Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ cargo test --manifest-path src-tauri/Cargo.toml
or an agent in Boite). It never reads Boite's files beyond `core.json`.
- Tests and manual runs use a throwaway core on a temporary data folder, never
the data of a Boite someone uses.
- Native commands check which webview calls them (`only_bots`,
`only_companion`), and every command is listed in
- Native commands check which webview calls them (`only_companion`: the
companion's window is Bots's only page), and every command is listed in
`src-tauri/permissions/bots.toml` and covered by `src-tauri/src/acl.rs`.
- Quit exits with code 0; any other exit tells Boite to start Bots again.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ A pomodoro or a timer sits beside it.

### Settings

Everything else is in **Settings**, from the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French.
Everything else is in **Settings**, a panel that grows out of the open island under its bar, from the bar's gear or the tray icon: where the robots sit and on which screen, the shortcut, what the pill shows, the mascot and the agents on screen, quotas, pomodoro, missions, sounds and reminders. In English and French.

<p align="center">
<img src="docs/media/settings.png" width="400" alt="The B.O.T.S settings: where the robots sit, open on hover, pill at rest, screen, shortcut and the mascot">
<img src="docs/media/settings.png" width="640" alt="The B.O.T.S settings under the open island's bar: a rail of sections, and Place with where the robots sit, the screen, the shortcut, open on hover, the pill at rest and the bar's edit mode">
</p>

## Install
Expand Down Expand Up @@ -173,7 +173,7 @@ cd src-tauri && cargo test && cargo clippy --all-targets -- -D warnings

The demo runs the robots on Boite's in-memory fake core: no Boite needed.

- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or open `?view=settings&fake=1` for the settings).
- In a browser: `bun run dev`, then open `http://localhost:5174/?fake=1` (add `&hud=1` for busy agents and quotas, or `&pose=settings&section=crew` for the settings panel on a section).
- In the desktop window: `BOTS_DEMO=1 bun run tauri dev` (`BOTS_DEMO=hud` for the busy version). Development builds only.

In the browser, a few more parameters set a scene for captures: `pose=collapsed|peek|open|deck|desktop|away`, `tab=threads|today|chat|stats|music|apps|clipboard|wallpaper|system`, `calm=1` to answer the starting requests, `music=playing&lyrics=1`, `rest=live|quota|music|timer|robot`, `timer=1`, `todo=1`, `mission=ask|list`, `source=engine|recent|folder` for the wallpapers, `bar=custom` and `edit=1` for the bar, and more. [`src/lib/island/pose.ts`](src/lib/island/pose.ts) lists them all. The pictures of this README were taken that way, with the English interface.
Expand Down
Binary file modified docs/media/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 0 additions & 7 deletions src-tauri/capabilities/settings.json

This file was deleted.

6 changes: 1 addition & 5 deletions src-tauri/permissions/bots.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[[permission]]
identifier = "allow-companion-ui"
description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there."
description = "Allows the companion to connect to the core, place its window on the desktop or as the island, take clicks where it draws, read the music playing, register its shortcut, see the screen when asked, take the keyboard when files are dropped on it, open its settings panel and name the tray; and, for the deck's computer tabs, read the system's numbers, close a process, lock, sleep, restart or shut down the computer, list and open the Start menu's apps, keep a clipboard history in memory and copy an entry back, list and set wallpapers, and drive Wallpaper Engine; and, for the island's bar, open a web link the user added there."
commands.allow = [
"core_endpoint",
"companion_monitors",
Expand Down Expand Up @@ -42,7 +42,3 @@ commands.allow = [
"computer_engine_control",
]

[[permission]]
identifier = "allow-settings-ui"
description = "Allows the settings window to connect to the core and list the screens the companion can sit on."
commands.allow = ["core_endpoint", "companion_monitors"]
9 changes: 3 additions & 6 deletions src-tauri/src/acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,11 @@ mod tests {
}

#[test]
fn the_settings_window_connects_and_lists_screens_but_does_not_drive_the_companion() {
fn the_settings_live_in_the_companion_and_no_settings_webview_is_granted_anything() {
let mut context: tauri::Context<tauri::test::MockRuntime> = crate::bots_context();
let authority = context.runtime_authority_mut();
for command in ["core_endpoint", "companion_monitors", "plugin:event|listen", "plugin:event|unlisten"] {
assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_some(), "settings cannot invoke {command}");
}
for command in ["companion_place", "island_place", "companion_capture", "companion_keep", "companion_configure", "bots_tray_labels", "bots_settings"] {
assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "settings can invoke {command}");
for command in COMPANION {
assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "a settings webview can invoke {command}");
}
}
}
14 changes: 10 additions & 4 deletions src-tauri/src/companion_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ fn place<R: Runtime>(window: &WebviewWindow<R>, monitor: Option<&str>, anchor: A

/// The page's query: `BOTS_DEMO=1` runs a development build on the fake core,
/// `BOTS_DEMO=hud` with the HUD demo seeded too. A release build ignores it.
pub fn demo_query() -> &'static str {
fn demo_query() -> &'static str {
if !cfg!(debug_assertions) { return ""; }
match std::env::var("BOTS_DEMO").as_deref() {
Ok("hud") => "fake=1&hud=1",
Expand Down Expand Up @@ -428,12 +428,18 @@ fn watch<R: Runtime>(app: AppHandle<R>, shared: Shared, mine: u64) {
/// The shortcut, or Bots launched again: the companion comes forward, over a
/// full-screen app too, and the page opens its question bar.
pub fn summon<R: Runtime>(app: &AppHandle<R>) {
let Some(window) = app.get_webview_window(LABEL) else { return };
if let Some(window) = bring_forward(app) { let _ = window.emit("companion://summon", ()); }
}

/// The companion's window over the others, with the keyboard, back from
/// hiding for a full-screen app: for the shortcut and for Settings.
pub fn bring_forward<R: Runtime>(app: &AppHandle<R>) -> Option<WebviewWindow<R>> {
let window = app.get_webview_window(LABEL)?;
app.state::<CompanionState>().shared.hidden.store(false, Ordering::Release);
let _ = window.show();
crate::platform::keep_on_top(&window);
let _ = window.set_focus();
let _ = window.emit("companion://summon", ());
Some(window)
}

/// Opens the companion, at start. It stays until Bots quits: closing it is
Expand Down Expand Up @@ -465,7 +471,7 @@ fn window_of(app: &AppHandle) -> Result<WebviewWindow, String> {
/// The screens connected now, for the setting that picks one.
#[tauri::command]
pub async fn companion_monitors(app: AppHandle, webview: Webview) -> Result<Vec<MonitorInfo>, String> {
crate::only_bots(&webview)?;
only_companion(&webview)?;
let primary = app.primary_monitor().map_err(|error| error.to_string())?.map(|monitor| monitor_name(&monitor));
Ok(app.available_monitors().map_err(|error| error.to_string())?.into_iter().map(|monitor| {
let name = monitor_name(&monitor);
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,11 @@ fn find() -> Option<CoreEndpoint> {
listening(&endpoint.url).then_some(endpoint)
}

/// The core to connect to, or none yet. Only Bots's own pages may ask: the
/// answer carries the owner's token.
/// The core to connect to, or none yet. Only the companion's page may ask:
/// the answer carries the owner's token.
#[tauri::command]
pub async fn core_endpoint(webview: Webview) -> Result<Option<CoreEndpoint>, String> {
crate::only_bots(&webview)?;
crate::companion_window::only_companion(&webview)?;
tauri::async_runtime::spawn_blocking(find).await.map_err(|error| error.to_string())
}

Expand Down
14 changes: 1 addition & 13 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,6 @@ mod tray;
#[link(name = "resource", kind = "static", modifiers = "-bundle")]
unsafe extern "C" {}

use tauri::Webview;

/// Bots's own pages, the companion and its settings: the only ones that may
/// ask for the core's token or list the screens.
pub(crate) fn only_bots(webview: &Webview) -> Result<(), String> {
match webview.label() {
companion_window::LABEL | tray::SETTINGS_LABEL => Ok(()),
other => Err(format!("the webview {other:?} may not invoke this command")),
}
}

// One expansion: on macOS generate_context also embeds the application plist.
fn bots_context<R: tauri::Runtime>() -> tauri::Context<R> {
tauri::generate_context!()
Expand Down Expand Up @@ -102,8 +91,7 @@ pub fn run() {
}
};
app.run(|_app, event| {
// The settings window closing leaves the companion: Bots stops on Quit
// alone, or when Boite stops it.
// Bots stops on Quit alone, or when Boite stops it.
if let tauri::RunEvent::ExitRequested { code: None, api, .. } = event { api.prevent_exit(); }
});
}
81 changes: 47 additions & 34 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
//! The tray icon, its menu, the settings window and quitting.
//! The tray icon, its menu, opening the settings and quitting.
//!
//! The settings have no window of their own: they are a panel of the island,
//! in the companion's window (`components/island/settings/`). Settings brings
//! that window forward and asks its page for the panel (`companion://settings`).

use std::sync::{Mutex, PoisonError};
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, Runtime, Webview, WebviewUrl,
AppHandle, Emitter, Manager, Runtime, Webview,
};

pub const SETTINGS_LABEL: &str = "settings";
pub const TRAY_ID: &str = "bots";
/// The longest section name the page may ask the panel to open on.
const SECTION_MAX: usize = 24;

/// The tray menu's items and the settings window's title, kept so the page
/// can name them in its language.
/// The tray menu's items, kept so the page can name them in its language.
struct Labels<R: Runtime> {
tray: tauri::tray::TrayIcon<R>,
settings: MenuItem<R>,
quit: MenuItem<R>,
title: Mutex<String>,
}

/// Quit: Bots stops with code 0, which Boite reads as stopped on purpose,
Expand All @@ -25,47 +27,43 @@ pub fn quit<R: Runtime>(app: &AppHandle<R>) {
app.exit(0);
}

/// Opens the settings window, or brings it forward when it is open.
pub fn open_settings<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window(SETTINGS_LABEL) {
let _ = window.unminimize();
window.show()?;
return window.set_focus();
}
let title = app.try_state::<Labels<R>>()
.map(|labels| labels.title.lock().unwrap_or_else(PoisonError::into_inner).clone())
.unwrap_or_else(|| "B.O.T.S settings".into());
let query = crate::companion_window::demo_query();
let page = if query.is_empty() { "index.html?view=settings".to_string() } else { format!("index.html?view=settings&{query}") };
let window = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, WebviewUrl::App(page.into()))
.title(title).inner_size(720.0, 760.0).min_inner_size(520.0, 480.0)
.on_navigation(|url| matches!(url.scheme(), "tauri" | "http" | "https") && matches!(url.host_str(), Some("tauri.localhost") | Some("localhost")))
.build()?;
window.set_focus()
/// A section the page may name: a short lowercase word. The page knows its
/// sections (`lib/island/settings-panel.svelte.ts`) and opens the last one
/// shown on any name it does not.
fn is_section(name: &str) -> bool {
!name.is_empty() && name.len() <= SECTION_MAX && name.bytes().all(|byte| byte.is_ascii_lowercase())
}

/// The companion's window comes forward with the keyboard and opens its
/// settings panel, on `section` when one is named.
pub fn open_settings<R: Runtime>(app: &AppHandle<R>, section: Option<&str>) -> tauri::Result<()> {
let Some(window) = crate::companion_window::bring_forward(app) else { return Ok(()) };
window.emit("companion://settings", section)
}

/// `companion://settings` from the companion: its settings, in their own window.
/// The companion asks for its settings panel, on a section or the last one shown.
#[tauri::command]
pub async fn bots_settings(app: AppHandle, webview: Webview) -> Result<(), String> {
pub async fn bots_settings(app: AppHandle, webview: Webview, section: Option<String>) -> Result<(), String> {
crate::companion_window::only_companion(&webview)?;
open_settings(&app).map_err(|error| error.to_string())
if let Some(name) = section.as_deref().filter(|name| !is_section(name)) {
return Err(format!("{name:?} is not a settings section"));
}
open_settings(&app, section.as_deref()).map_err(|error| error.to_string())
}

/// The tray is native and the sentences live in the page's catalogue: the
/// companion sends them at start and on every language change. English until
/// then.
#[tauri::command]
pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String, title: String) -> Result<(), String> {
pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String) -> Result<(), String> {
crate::companion_window::only_companion(&webview)?;
if [&tooltip, &settings, &quit, &title].iter().any(|label| label.trim().is_empty()) {
return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}, title {title:?}"));
if [&tooltip, &settings, &quit].iter().any(|label| label.trim().is_empty()) {
return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}"));
}
let Some(labels) = app.try_state::<Labels<tauri::Wry>>() else { return Ok(()) };
labels.tray.set_tooltip(Some(tooltip)).map_err(|error| format!("the tray kept its tooltip: {error}"))?;
labels.settings.set_text(settings).map_err(|error| format!("the tray's Settings item kept its label: {error}"))?;
labels.quit.set_text(quit).map_err(|error| format!("the tray's Quit item kept its label: {error}"))?;
*labels.title.lock().unwrap_or_else(PoisonError::into_inner) = title.clone();
if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { let _ = window.set_title(&title); }
Ok(())
}

Expand All @@ -85,7 +83,7 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
})
.on_menu_event(|app, event| match event.id().as_ref() {
"settings" => {
if let Err(error) = open_settings(app) { eprintln!("[bots] the settings window did not open: {error}"); }
if let Err(error) = open_settings(app, None) { eprintln!("[bots] the settings did not open: {error}"); }
}
"quit" => quit(app),
_ => {}
Expand All @@ -94,6 +92,21 @@ pub fn build_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
builder = builder.icon(icon);
}
let tray = builder.build(app)?;
app.manage(Labels { tray, settings, quit: leave, title: Mutex::new("B.O.T.S settings".into()) });
app.manage(Labels { tray, settings, quit: leave });
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_section_is_a_short_lowercase_word() {
for name in ["place", "crew", "permissions"] {
assert!(is_section(name), "{name}");
}
for name in ["", "Crew", "crew settings", "../crew", "brain-model", "a".repeat(SECTION_MAX + 1).as_str()] {
assert!(!is_section(name), "{name:?}");
}
}
}
Loading
Loading