From 96a2abe7e0e4a44a6ccc8b5ecfffe214d8cc6be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Wed, 28 Oct 2026 04:55:03 +0000 Subject: [PATCH] One window, the app's own page; the server through /api/v2 The two webviews (local title bar, remote web UI) become one WebviewWindow on ui/index.html. The window refuses every navigation but the app's page and its sealed frames; the capability still names only main and no remote URL. - server.rs: discovery refuses a server older than 2.0 (protocol 3 and major version 2) and an app API newer than this app knows; the app's own device login for the app scope, the token kept in account.json (0600) and never given to the page; a proxy limited to /api/v2 paths; the SSE stream. - files.rs: list and read the agent's working directory, resolved (links and .. included) and refused outside the agent's folders. - sealed.rs: HTML for the viewer on its own scheme under the server's sealed policy: no bridge, no network, no navigation. - watch.rs + tauri-plugin-notification: a notification when a reply or an agent chat finishes out of sight. - A failed start and panics go to desktop.log; a missing tray no longer stops the app; the DMA-BUF renderer is off under NVIDIA's driver. - The unstable multiwebview feature, the gtk dependency and the theme report are gone. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 101 ++- src-tauri/Cargo.toml | 9 +- src-tauri/build.rs | 6 +- src-tauri/capabilities/default.json | 6 +- src-tauri/src/files.rs | 246 ++++++ src-tauri/src/lib.rs | 1082 +++++++++++++++++---------- src-tauri/src/sealed.rs | 131 ++++ src-tauri/src/server.rs | 674 +++++++++++++++++ src-tauri/src/settings.rs | 35 +- src-tauri/src/theme_report.js | 44 -- src-tauri/src/watch.rs | 139 ++++ src-tauri/tauri.conf.json | 9 +- 12 files changed, 2008 insertions(+), 474 deletions(-) create mode 100644 src-tauri/src/files.rs create mode 100644 src-tauri/src/sealed.rs create mode 100644 src-tauri/src/server.rs delete mode 100644 src-tauri/src/theme_report.js create mode 100644 src-tauri/src/watch.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0df3e1d..4c478ca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -469,6 +469,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -569,6 +580,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1296,6 +1316,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -2066,13 +2087,13 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" name = "llembas-desktop" version = "0.7.2" dependencies = [ - "gtk", "libc", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -2094,6 +2115,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -2220,6 +2255,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2842,6 +2891,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3429,7 +3495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3925,6 +3991,26 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "time", + "url", + "zbus", +] + [[package]] name = "tauri-plugin-opener" version = "2.7.1" @@ -4097,6 +4183,17 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6bff0c1..b2eb3f1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "llembas-desktop" version = "0.7.2" -description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" +description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own" authors = ["Jaroslav Beneš"] license = "MIT" repository = "https://github.com/LLeMbas/LLeMbas-Desktop" @@ -17,8 +17,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.12", features = ["tray-icon", "unstable"] } +tauri = { version = "2.12", features = ["tray-icon"] } tauri-plugin-dialog = "2" +tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" @@ -29,10 +30,6 @@ ureq = { version = "2", default-features = false, features = ["tls", "native-cer [target.'cfg(unix)'.dependencies] libc = "0.2" -[target.'cfg(target_os = "linux")'.dependencies] -# The window's GTK box: Tauri packs every webview into it with an equal share on Linux. -gtk = "0.18" - [profile.release] codegen-units = 1 lto = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 2a50830..d90d948 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,5 +1,5 @@ -//! Besides Tauri's own build step: the two themes' colours the window itself paints, read out -//! of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into +//! Besides Tauri's own build step: the two themes' page colour, which the window itself paints +//! before the page has (so a dark theme does not start white), read out of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into //! `$OUT_DIR/tokens.rs`, so no colour is written in Rust. `lib.rs` includes that file. use std::env; @@ -10,7 +10,7 @@ use std::path::Path; /// Rust names the constants get. const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")]; /// The tokens taken from each theme, by the custom property and the constant's suffix. -const TOKENS: [(&str, &str); 2] = [("--bg", "BG"), ("--border", "BORDER")]; +const TOKENS: [(&str, &str); 1] = [("--bg", "BG")]; fn main() { let tokens = Path::new("../ui/tokens.css"); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8b91ffa..3f4f016 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,10 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).", - "windows": ["main"], + "description": "The app's own page (ui/), the only content with the bridge. It names the main window and no remote URL: no server page is ever loaded, and HTML the viewer shows is a sealed frame on its own scheme, which no capability names. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).", + "windows": [ + "main" + ], "permissions": [ "core:default", "core:window:allow-start-dragging", diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs new file mode 100644 index 0000000..b5c89ab --- /dev/null +++ b/src-tauri/src/files.rs @@ -0,0 +1,246 @@ +//! The agent's working directory, for the explorer and the viewer: list a folder, read a file. +//! +//! Only inside the folders the agent may work in (`lembas remote` roots, as the CLI last said): +//! the page names a folder and a path inside it, and both are resolved here -- symbolic links and +//! `..` included -- before anything is opened. A path that ends up outside every root is refused, +//! whatever the page asked. Reading only: the app never writes in a project, the agent does. + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::Serialize; + +/// The most of a folder listed at once. +pub const MAX_ENTRIES: usize = 5000; +/// The most of a file read for the viewer; a bigger one is offered by name only. +pub const MAX_READ: u64 = 2 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Entry { + pub name: String, + /// From the folder the page named, with `/` between parts. + pub path: String, + /// `dir` or `file` (a link counts as what it points to; a broken one as a file). + pub kind: &'static str, + pub size: u64, + /// Seconds since 1970, when the system says. + pub modified: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Listing { + pub root: String, + pub path: String, + pub entries: Vec, + /// More entries than `MAX_ENTRIES`: the rest are not listed. + pub more: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Opened { + pub root: String, + pub path: String, + pub name: String, + pub size: u64, + /// The text, when the file is text and small enough. + pub text: Option, + /// Why there is no text: `binary` or `too_big`. + pub withheld: Option<&'static str>, +} + +/// `root` (a folder the agent may use, or one inside it) and `path` inside it, resolved, and +/// refused unless the result is inside one of `roots`. The canonical root and target. +pub fn resolve(roots: &[String], root: &str, path: &str) -> Result<(PathBuf, PathBuf), String> { + let base = fs::canonicalize(root).map_err(|e| format!("{root}: {e}"))?; + let allowed = roots + .iter() + .filter_map(|r| fs::canonicalize(r).ok()) + .any(|r| base.starts_with(&r)); + if !allowed { + return Err(format!( + "{root} is not a folder the agent may work in. Add it under Settings, Project folders." + )); + } + let rel = Path::new(path.trim_start_matches('/')); + if rel.components().any(|c| matches!(c, Component::Prefix(_))) { + return Err(format!("Not a path inside {root}: {path}")); + } + let target = fs::canonicalize(base.join(rel)).map_err(|e| format!("{path}: {e}"))?; + if !target.starts_with(&base) { + return Err(format!("{path} is outside {root}.")); + } + Ok((base, target)) +} + +/// `target` from `base`, with `/` between parts ("" for the base itself). +fn relative(base: &Path, target: &Path) -> String { + target + .strip_prefix(base) + .map(|p| { + p.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") + }) + .unwrap_or_default() +} + +pub fn list(roots: &[String], root: &str, path: &str) -> Result { + let (base, dir) = resolve(roots, root, path)?; + if !dir.is_dir() { + return Err(format!("{path} is not a folder.")); + } + let mut entries = Vec::new(); + let mut more = false; + for item in fs::read_dir(&dir).map_err(|e| format!("{path}: {e}"))? { + let Ok(item) = item else { continue }; + if entries.len() >= MAX_ENTRIES { + more = true; + break; + } + let name = item.file_name().to_string_lossy().into_owned(); + // A link is shown as what it points to; following it is `resolve`'s to allow or not. + let meta = fs::metadata(item.path()).or_else(|_| item.metadata()); + let (kind, size, modified) = match meta { + Ok(m) => ( + if m.is_dir() { "dir" } else { "file" }, + if m.is_dir() { 0 } else { m.len() }, + m.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()), + ), + Err(_) => ("file", 0, None), + }; + let at = relative(&base, &dir); + entries.push(Entry { + path: if at.is_empty() { + name.clone() + } else { + format!("{at}/{name}") + }, + name, + kind, + size, + modified, + }); + } + entries.sort_by(|a, b| { + (a.kind != "dir") + .cmp(&(b.kind != "dir")) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(Listing { + root: base.to_string_lossy().into_owned(), + path: relative(&base, &dir), + entries, + more, + }) +} + +/// Text, as the CLI's `show` decides it: no NUL byte, and valid UTF-8. +fn as_text(data: Vec) -> Option { + if data.contains(&0) { + return None; + } + String::from_utf8(data).ok() +} + +pub fn read(roots: &[String], root: &str, path: &str) -> Result { + let (base, file) = resolve(roots, root, path)?; + let meta = fs::metadata(&file).map_err(|e| format!("{path}: {e}"))?; + if !meta.is_file() { + return Err(format!("{path} is not a file.")); + } + let name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut opened = Opened { + root: base.to_string_lossy().into_owned(), + path: relative(&base, &file), + name, + size: meta.len(), + text: None, + withheld: None, + }; + if meta.len() > MAX_READ { + opened.withheld = Some("too_big"); + return Ok(opened); + } + let data = fs::read(&file).map_err(|e| format!("{path}: {e}"))?; + match as_text(data) { + Some(t) => opened.text = Some(t), + None => opened.withheld = Some("binary"), + } + Ok(opened) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("llembas-files-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(d.join("project/src")).unwrap(); + fs::create_dir_all(d.join("outside")).unwrap(); + fs::write(d.join("project/README.md"), "# Waybread\n").unwrap(); + fs::write(d.join("project/src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(d.join("project/blob.bin"), [0u8, 1, 2]).unwrap(); + fs::write(d.join("outside/secret.txt"), "no").unwrap(); + d + } + + fn roots(d: &Path) -> Vec { + vec![d.join("project").to_string_lossy().into_owned()] + } + + #[test] + fn a_root_lists_folders_first() { + let d = scratch("list"); + let root = d.join("project").to_string_lossy().into_owned(); + let l = list(&roots(&d), &root, "").unwrap(); + let names: Vec<_> = l.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["src", "blob.bin", "README.md"]); + assert_eq!(l.entries[0].kind, "dir"); + let inner = list(&roots(&d), &root, "src").unwrap(); + assert_eq!(inner.entries[0].path, "src/main.rs"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn nothing_outside_the_roots_is_reached() { + let d = scratch("outside"); + let root = d.join("project").to_string_lossy().into_owned(); + let other = d.join("outside").to_string_lossy().into_owned(); + assert!(list(&roots(&d), &other, "").is_err()); + assert!(read(&roots(&d), &root, "../outside/secret.txt").is_err()); + assert!(read(&roots(&d), &root, "/../outside/secret.txt").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink(d.join("outside"), d.join("project/escape")).unwrap(); + assert!(read(&roots(&d), &root, "escape/secret.txt").is_err()); + // A folder inside a root is a root of its own for the pane (an agent chat's folder). + let sub = d.join("project/src").to_string_lossy().into_owned(); + assert!(list(&roots(&d), &sub, "").is_ok()); + } + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn text_is_read_and_the_rest_is_withheld() { + let d = scratch("read"); + let root = d.join("project").to_string_lossy().into_owned(); + let md = read(&roots(&d), &root, "README.md").unwrap(); + assert_eq!(md.text.as_deref(), Some("# Waybread\n")); + assert_eq!(md.path, "README.md"); + let bin = read(&roots(&d), &root, "blob.bin").unwrap(); + assert_eq!(bin.text, None); + assert_eq!(bin.withheld, Some("binary")); + assert!(read(&roots(&d), &root, "src").is_err()); + fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e1512f..b333f67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,47 +1,51 @@ -//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent -//! running on this computer, so agent chats can work in its projects without a terminal. +//! LLeMbas Desktop: the agent on this computer, and the chats on your LLeMbas server, in an app +//! of its own. //! -//! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a -//! local page, `ui/titlebar.html`) on top and the content below it — before a server is chosen -//! the first-run page (`ui/index.html`), after it the server itself. Two webviews in one window, -//! so the server's pages never share a document with the app's controls. Closing hides the -//! window; the tray, the title bar's menu or starting the app again bring it back. -//! - **The server's pages get no IPC.** They are remote content, and the one capability lists no -//! remote URL. Links that leave the server open in the system browser. -//! - **The window follows the server's theme** (Moria or Shire, the account's choice, not the -//! desktop's): a script in the content view reports the page's `data-theme` by trying to -//! navigate to `llembas-theme:`, which the navigation handler notes and refuses (see -//! `THEME_SCRIPT`). The frame, the title bar and the first-run page are painted in it, and it -//! is remembered for the next start. No IPC is granted for this: a navigation attempt is all -//! the page can make, and the only thing it can say is one of two theme names. +//! - **One window, `main`, one page: the app's own** (`ui/`). It draws its own title bar, the +//! chats, the two explorers and the viewer. No server page is ever loaded into it: the page +//! talks to the server's `/api/v2` through the commands here (`server.rs`), and the token that +//! takes stays in Rust. +//! - **Only that page gets the bridge.** The one capability names the `main` window and no remote +//! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows +//! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`). //! - **The agent is the CLI's**: `lembas login --json` links this computer, and -//! `lembas service run` is the device (see `agent.rs`). +//! `lembas service run` is the device (`agent.rs`). The app reads the CLI's state and runs its +//! commands; it never writes the CLI's files. +//! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`). mod agent; mod cli; +mod files; mod remote; +mod sealed; +mod server; mod settings; +mod watch; +use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Command, Stdio}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use serde::Serialize; +use serde_json::Value; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::webview::{PermissionKind, PermissionResponse, WebviewBuilder}; -use tauri::window::{Color, WindowBuilder}; +use tauri::window::Color; use tauri::{ - AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl, - Window, WindowEvent, Wry, + AppHandle, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, Window, WindowEvent, Wry, }; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; +use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::UpdaterExt; use agent::{Agent, State as AgentState}; use remote::Remote; +use server::{Account, ApiError, Discovery}; use settings::Settings; /// The colours the window paints itself, read out of `ui/tokens.css` by `build.rs`. @@ -50,20 +54,9 @@ mod tokens { } const MAIN: &str = "main"; -const TITLEBAR: &str = "titlebar"; -const CONTENT: &str = "content"; -/// The title bar's height, in logical pixels: `--titlebar-h` in `ui/titlebar.css`. -const TITLE_H: f64 = 36.0; -/// The frame left around the webviews while the window is not maximized: the window's own -/// surface, where the pointer resizes it (a webview over the edge would take the events). -const FRAME: f64 = 4.0; -/// The scheme the content page's theme report is a navigation to: `llembas-theme:shire`. -const THEME_SCHEME: &str = "llembas-theme"; -/// Run in the content view on every page: reports the page's theme (see the module's notes). -const THEME_SCRIPT: &str = include_str!("theme_report.js"); /// The two looks the server's tokens define, named as `tokens.css` selects them (`data-theme`). -/// A custom theme on the server reports its base, which is one of these. +/// The account's theme, as `/api/v2/me` says; a custom theme counts as Moria, the default. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Theme { Moria, @@ -86,17 +79,8 @@ impl Theme { } } - /// The frame's colour: the theme's `--border`, which the pages draw their edges in. - fn frame(self) -> Color { - let (r, g, b) = match self { - Theme::Moria => tokens::MORIA_BORDER, - Theme::Shire => tokens::SHIRE_BORDER, - }; - Color(r, g, b, 0xFF) - } - - /// A webview's own colour, `--bg`: what shows where a page has not painted yet, so a - /// server page loading in the dark theme is not a white flash first. + /// The window's own colour, `--bg`: what shows before the page has painted, so a dark + /// theme does not start with a white flash. fn background(self) -> Color { let (r, g, b) = match self { Theme::Moria => tokens::MORIA_BG, @@ -116,8 +100,19 @@ struct App { update: Mutex>, /// The agent's folders, as `lembas remote` last answered. remote: Mutex>, - /// What the window is painted in: the server page's theme, as last reported. theme: Mutex, + /// The chosen server, once discovery has passed. + server: Mutex>, + /// Why the chosen server was refused, or could not be reached, at the last look. + refused: Mutex>, + /// The app's own sign-in to that server. + account: Mutex>, + /// Set to cancel the device login in progress. + signing_in: Mutex>>, + streams: Mutex>>, + next_stream: AtomicU64, + sealed: sealed::Pages, + watch: watch::Watch, } struct TrayItems { @@ -141,13 +136,39 @@ impl App { fn theme(&self) -> Theme { *self.theme.lock().unwrap() } + + /// The app door and the token, when the app is signed in to a server that passed. + fn door(&self) -> Option<(String, String)> { + let server = self.server.lock().unwrap().clone()?; + let account = self.account.lock().unwrap().clone()?; + Some((server.app, account.token)) + } + + /// The folders the agent may use: what the explorer and the viewer may read. + fn roots(&self) -> Vec { + self.remote + .lock() + .unwrap() + .as_ref() + .filter(|r| r.usable()) + .map(|r| r.roots.clone()) + .unwrap_or_default() + } } -/// What the first-run page shows. +/// What the app's page draws from: everything but the token. #[derive(Clone, Serialize)] struct Status { version: &'static str, + /// The chosen server's address. server: Option, + /// What discovery said about it, once it passed. + discovery: Option, + refused: Option, + /// Discovery has answered (or failed) for the chosen server. + checked: bool, + account: Option, + signing_in: bool, linked: bool, agent: AgentState, agent_wanted: bool, @@ -155,71 +176,375 @@ struct Status { update: Option, /// Which folders the agent may open sessions in (None: not known yet). remote: Option, - /// The theme the app's own pages are painted in: `moria` or `shire`, the server page's. theme: &'static str, + /// This computer's name: the device an agent chat on it runs on says this `host`. + host: Option, + notify: bool, } fn status(app: &App) -> Status { let s = app.settings(); + let discovery = app.server.lock().unwrap().clone(); + let refused = app.refused.lock().unwrap().clone(); Status { version: env!("CARGO_PKG_VERSION"), linked: s.server.as_deref().is_some_and(cli::linked_to), + checked: s.server.is_none() || discovery.is_some() || refused.is_some(), server: s.server, + discovery, + refused, + account: app.account.lock().unwrap().as_ref().map(Account::public), + signing_in: app.signing_in.lock().unwrap().is_some(), agent: app.agent.state(), agent_wanted: app.agent.wanted(), update: app.update.lock().unwrap().clone(), remote: app.remote.lock().unwrap().clone(), theme: app.theme().name(), + host: cli::hostname(), + notify: s.notify, } } +fn status_of(handle: &AppHandle) -> Status { + status(&handle.state::()) +} + +fn announce(handle: &AppHandle) { + let _ = handle.emit("status", status_of(handle)); +} + #[tauri::command] fn get_status(app: tauri::State<'_, App>) -> Status { status(&app) } -/// Check that `address` is a LLeMbas server, remember it, and open it. +// --- The server ------------------------------------------------------------------------------ + +/// Look at the chosen server again (at start, and when the page asks): discovery, and the +/// sign-in kept for it. +fn check_server(handle: &AppHandle) { + let app = handle.state::(); + let Some(base) = app.settings().server else { + return; + }; + let found = server::discover(&base); + match found { + Ok(d) => { + *app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base); + *app.server.lock().unwrap() = Some(d); + *app.refused.lock().unwrap() = None; + } + Err(why) => { + *app.server.lock().unwrap() = None; + *app.refused.lock().unwrap() = Some(why); + } + } + announce(handle); +} + #[tauri::command] -async fn set_server(handle: AppHandle, address: String) -> Result { +async fn recheck_server(handle: AppHandle) -> Result { + let h = handle.clone(); + tauri::async_runtime::spawn_blocking(move || check_server(&h)) + .await + .map_err(|e| e.to_string())?; + Ok(status_of(&handle)) +} + +/// Check that `address` is a LLeMbas 2.0 server and remember it. A refused one is not kept. +#[tauri::command] +async fn set_server(handle: AppHandle, address: String) -> Result { let base = settings::normalise(&address)?; let probe = base.clone(); - let name = tauri::async_runtime::spawn_blocking(move || discover(&probe)) + let d = tauri::async_runtime::spawn_blocking(move || server::discover(&probe)) .await .map_err(|e| e.to_string())??; let app = handle.state::(); let before = app.settings().server; - app.update(|s| s.server = Some(base.clone()))?; - if before.as_deref() != Some(base.as_str()) { + app.update(|s| s.server = Some(d.base.clone()))?; + if before.as_deref() != Some(d.base.as_str()) { app.agent.stop(); + app.watch.reset(); } - open_main(&handle, Some(&base))?; - Ok(name) + *app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base); + *app.server.lock().unwrap() = Some(d); + *app.refused.lock().unwrap() = None; + announce(&handle); + Ok(status_of(&handle)) } -/// `/.well-known/lembas.json`: the same check `lembas login` makes first. -fn discover(base: &str) -> Result { - let url = format!("{base}/.well-known/lembas.json"); - let res = ureq::get(&url) - .timeout(std::time::Duration::from_secs(10)) - .call() - .map_err(|e| match e { - ureq::Error::Status(404, _) => format!("{base} answers, but is not a LLeMbas server."), - ureq::Error::Status(code, _) => format!("{base} answered {code}."), - ureq::Error::Transport(t) => format!("Could not reach {base}: {t}"), - })?; - let body: serde_json::Value = res - .into_json() - .map_err(|_| format!("{base} is not a LLeMbas server."))?; - if body.get("service").and_then(|s| s.as_str()) != Some("lembas") { - return Err(format!("{base} is not a LLeMbas server.")); - } - Ok(body - .get("name") - .and_then(|n| n.as_str()) - .unwrap_or("LLeMbas") - .to_string()) +/// What the page hears while signing in: the code, then the outcome. +#[derive(Clone, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum SignIn { + Code { code: server::Code }, + Done, + Failed { message: String }, } +/// Sign the app in to the server: a device login for the `app` scope, approved in the browser. +#[tauri::command] +fn sign_in(handle: AppHandle) -> Result<(), String> { + let app = handle.state::(); + let Some(d) = app.server.lock().unwrap().clone() else { + return Err("Choose a LLeMbas 2.0 server first.".into()); + }; + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut busy = app.signing_in.lock().unwrap(); + if let Some(old) = busy.take() { + old.store(true, Ordering::SeqCst); + } + *busy = Some(cancel.clone()); + } + announce(&handle); + std::thread::spawn(move || { + let host = cli::hostname().unwrap_or_default(); + let result = server::start_sign_in(&d, &host).and_then(|code| { + let _ = handle + .opener() + .open_url(&code.verification_uri_complete, None::<&str>); + let _ = handle.emit("signin", SignIn::Code { code: code.clone() }); + server::await_sign_in(&d, &code, &cancel) + }); + let app = handle.state::(); + { + let mut busy = app.signing_in.lock().unwrap(); + if busy.as_ref().is_some_and(|c| Arc::ptr_eq(c, &cancel)) { + *busy = None; + } + } + let outcome = match result { + Ok(account) => match server::save_account(&app.config_dir, &account) { + Ok(()) => { + *app.account.lock().unwrap() = Some(account); + app.watch.reset(); + SignIn::Done + } + Err(e) => SignIn::Failed { + message: format!("The sign-in could not be saved: {e}"), + }, + }, + Err(message) => SignIn::Failed { message }, + }; + let _ = handle.emit("signin", outcome); + announce(&handle); + }); + Ok(()) +} + +#[tauri::command] +fn sign_in_cancel(handle: AppHandle) { + if let Some(c) = handle.state::().signing_in.lock().unwrap().take() { + c.store(true, Ordering::SeqCst); + } + announce(&handle); +} + +/// Sign the app out: the token is given up on the server and forgotten here. The agent's own +/// link (the CLI's) is not touched. +#[tauri::command] +fn sign_out(handle: AppHandle) { + let app = handle.state::(); + let account = app.account.lock().unwrap().take(); + server::forget_account(&app.config_dir); + app.watch.reset(); + if let Some(a) = account { + std::thread::spawn(move || server::sign_out(&a.server, &a.token)); + } + announce(&handle); +} + +/// The token was refused: it was revoked, or expired. Forget it, so the page asks to sign in. +fn token_refused(handle: &AppHandle) { + let app = handle.state::(); + if app.account.lock().unwrap().take().is_some() { + server::forget_account(&app.config_dir); + announce(handle); + } +} + +/// One request to the server's `/api/v2`, as the signed-in account: `path` from there on +/// (`/chats?kind=agent`). The answer's JSON, or the server's error. +#[tauri::command] +async fn api( + handle: AppHandle, + method: String, + path: String, + body: Option, +) -> Result { + let Some((app_url, token)) = handle.state::().door() else { + return Err(ApiError::new(401, "Not signed in.")); + }; + let result = tauri::async_runtime::spawn_blocking(move || { + server::request(&app_url, &token, &method, &path, body.as_ref()) + }) + .await + .map_err(|e| ApiError::new(0, e.to_string()))?; + if matches!(&result, Err(e) if e.status == 401) { + token_refused(&handle); + } + result +} + +/// A frame of a reply being written, as the page hears it. +#[derive(Clone, Serialize)] +struct Frame { + id: u64, + event: String, + data: Value, +} + +/// Follow a reply as it is written: the frames come as `stream` events with this id, and a last +/// one named `end` (its data the error, if it broke off). +#[tauri::command] +fn stream_open(handle: AppHandle, path: String) -> Result { + let app = handle.state::(); + let Some((app_url, token)) = app.door() else { + return Err(ApiError::new(401, "Not signed in.")); + }; + server::checked_path(&path)?; + let id = app.next_stream.fetch_add(1, Ordering::SeqCst) + 1; + let stop = Arc::new(AtomicBool::new(false)); + app.streams.lock().unwrap().insert(id, stop.clone()); + std::thread::spawn(move || { + let emit = |event: &str, data: Value| { + let _ = handle.emit( + "stream", + Frame { + id, + event: event.to_string(), + data, + }, + ); + }; + let result = server::stream(&app_url, &token, &path, &stop, |event, data| { + let value = serde_json::from_str(data).unwrap_or(Value::String(data.to_string())); + emit(event, value); + !stop.load(Ordering::SeqCst) + }); + let end = match result { + Ok(()) => Value::Null, + Err(e) => { + if e.status == 401 { + token_refused(&handle); + } + serde_json::to_value(e).unwrap_or(Value::Null) + } + }; + handle.state::().streams.lock().unwrap().remove(&id); + emit("end", end); + }); + Ok(id) +} + +#[tauri::command] +fn stream_close(app: tauri::State<'_, App>, id: u64) { + if let Some(stop) = app.streams.lock().unwrap().remove(&id) { + stop.store(true, Ordering::SeqCst); + } +} + +// --- This computer's files ---------------------------------------------------------------------- + +/// A folder of the agent's, for the explorer: `root` one of the agent's folders (or a folder +/// inside one), `path` inside it. +#[tauri::command] +async fn local_list( + handle: AppHandle, + root: String, + path: String, +) -> Result { + let roots = handle.state::().roots(); + tauri::async_runtime::spawn_blocking(move || files::list(&roots, &root, &path)) + .await + .map_err(|e| e.to_string())? +} + +/// A file of the agent's, for the viewer: as text, or why not. +#[tauri::command] +async fn local_read( + handle: AppHandle, + root: String, + path: String, +) -> Result { + let roots = handle.state::().roots(); + tauri::async_runtime::spawn_blocking(move || files::read(&roots, &root, &path)) + .await + .map_err(|e| e.to_string())? +} + +/// Hold HTML for the viewer's sealed frame; the address the frame loads. +#[tauri::command] +fn sealed_open(app: tauri::State<'_, App>, html: String) -> String { + sealed::url(&app.sealed.hold(html)) +} + +#[tauri::command] +fn sealed_close(app: tauri::State<'_, App>, url: String) { + let path = Url::parse(&url) + .map(|u| u.path().to_string()) + .unwrap_or_default(); + app.sealed.forget(sealed::id_of(&path)); +} + +// --- The page's own settings --------------------------------------------------------------------- + +/// The account's theme, as the page read it from the server: the window is painted in it, and +/// it is remembered for the next start. +#[tauri::command] +fn set_theme(handle: AppHandle, name: String) { + let Some(theme) = Theme::parse(&name) else { + return; + }; + let app = handle.state::(); + { + let mut current = app.theme.lock().unwrap(); + if *current == theme { + return; + } + *current = theme; + } + let _ = app.update(|s| s.theme = Some(theme.name().into())); + if let Some(w) = handle.get_webview_window(MAIN) { + let _ = w.set_background_color(Some(theme.background())); + } + announce(&handle); +} + +#[tauri::command] +fn set_notify(handle: AppHandle, on: bool) -> Result<(), String> { + handle.state::().update(|s| s.notify = on)?; + announce(&handle); + Ok(()) +} + +/// Which chat the page shows (none: `null`), and what notifications say in its language. +#[tauri::command] +fn set_open_chat(app: tauri::State<'_, App>, chat: Option) { + *app.watch.open.lock().unwrap() = chat; +} + +#[tauri::command] +fn set_words(app: tauri::State<'_, App>, words: watch::Words) { + *app.watch.words.lock().unwrap() = words; +} + +/// A link the page was asked to follow: in the system's browser, and only a web or mail link. +#[tauri::command] +fn open_external(handle: AppHandle, url: String) -> Result<(), String> { + let parsed = Url::parse(&url).map_err(|_| format!("Not a link: {url}"))?; + if !matches!(parsed.scheme(), "http" | "https" | "mailto") { + return Err(format!("Not a web link: {url}")); + } + handle + .opener() + .open_url(parsed.as_str(), None::<&str>) + .map_err(|e| e.to_string()) +} + +// --- The agent ---------------------------------------------------------------------------------- + #[tauri::command] fn set_agent(handle: AppHandle, on: bool) -> Result<(), String> { set_agent_wanted(&handle, on) @@ -238,18 +563,7 @@ fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> { app.agent.start(server); let h = handle.clone(); std::thread::spawn(move || { - // Connected but allowed into no folder is the state a new computer starts in: - // say so where the folders are chosen, rather than showing a green light. - if let Some(r) = refresh_remote(&h) { - if !r.usable() { - let _ = h.run_on_main_thread({ - let h = h.clone(); - move || { - let _ = open_main(&h, None); - } - }); - } - } + refresh_remote(&h); }); } _ => app.agent.stop(), @@ -261,7 +575,7 @@ fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> { fn refresh_remote(handle: &AppHandle) -> Option { let r = remote::run(&["list"]).ok(); *handle.state::().remote.lock().unwrap() = r.clone(); - let _ = handle.emit("status", status_of(handle)); + announce(handle); r } @@ -275,7 +589,7 @@ fn remote_changed(handle: &AppHandle, r: Remote) { app.agent.start(server); } } - let _ = handle.emit("status", status_of(handle)); + announce(handle); } /// Run a `lembas remote` change off the main thread; the page hears the outcome as `status`, @@ -338,240 +652,17 @@ fn remote_terminal(handle: AppHandle, on: bool) { ); } -/// The server's pages are told they are in LLeMbas Desktop, and on which computer: it shows -/// the device that is this computer as "Local". A label only — it grants nothing. -fn user_agent() -> String { - let platform = if cfg!(target_os = "linux") { - "X11; Linux x86_64" - } else if cfg!(target_os = "macos") { - "Macintosh; Intel Mac OS X 10_15_7" - } else { - "Windows NT 10.0; Win64; x64" - }; - let host = cli::hostname() - .filter(|h| { - h.len() <= 253 - && h.chars() - .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c)) - }) - .map(|h| format!(" (host={h})")) - .unwrap_or_default(); - format!( - "Mozilla/5.0 ({platform}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 LLeMbasDesktop/{}{host}", - env!("CARGO_PKG_VERSION") - ) +/// What the page hears while the computer is linked: the code, then the outcome. +#[derive(Clone, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +enum Linking { + Code { user_code: String, uri: String }, + Done, + Failed { message: String }, } -/// The app's own first-run page, as the webview addresses it on this platform. -fn local_page() -> Url { - let base = if cfg!(windows) { - "http://tauri.localhost/index.html" - } else { - "tauri://localhost/index.html" - }; - Url::parse(base).expect("a fixed URL") -} - -fn content(handle: &AppHandle) -> Option { - handle.get_webview(CONTENT) -} - -fn main_window(handle: &AppHandle) -> Option { - handle.get_window(MAIN) -} - -/// Show the main window on `server`, or on the first-run page; made when it does not exist. -fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> { - let target = match server { - Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?), - None => None, - }; - if let (Some(w), Some(c)) = (main_window(handle), content(handle)) { - c.navigate(target.unwrap_or_else(local_page)) - .map_err(|e| e.to_string())?; - let _ = w.show(); - let _ = w.unminimize(); - let _ = w.set_focus(); - return Ok(()); - } - build_main(handle, target).map_err(|e| e.to_string()) -} - -fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { - let theme = handle.state::().theme(); - let window = WindowBuilder::new(handle, MAIN) - .title("LLeMbas") - .inner_size(1280.0, 840.0) - .min_inner_size(420.0, 560.0) - .decorations(false) - // The frame's colour: the theme's border, as the pages draw theirs. - .background_color(theme.frame()) - .build()?; - let size = window - .inner_size()? - .to_logical::(window.scale_factor()?); - window.add_child( - WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())) - .background_color(theme.background()), - LogicalPosition::new(FRAME, FRAME), - LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H), - )?; - let url = match target { - Some(u) => WebviewUrl::External(u), - None => WebviewUrl::App("index.html".into()), - }; - #[cfg(target_os = "linux")] - if let Some(bar) = handle.get_webview(TITLEBAR) { - pin_titlebar(&bar); - } - let nav = handle.clone(); - let perm = handle.clone(); - window.add_child( - WebviewBuilder::new(CONTENT, url) - .user_agent(&user_agent()) - .background_color(theme.background()) - .initialization_script(THEME_SCRIPT) - .on_navigation(move |url| allowed(&nav, url)) - .on_permission_request(move |view, kind| permission(&perm, &view, kind)), - LogicalPosition::new(FRAME, FRAME + TITLE_H), - LogicalSize::new( - size.width - 2.0 * FRAME, - (size.height - TITLE_H - 2.0 * FRAME).max(0.0), - ), - )?; - layout(&window); - Ok(()) -} - -/// The server's dictation asks for the microphone, and a webview answers a permission request -/// only if its app does: WebKitGTK refuses anything it is not answered on, and has no prompt of -/// its own -- so the microphone simply said "Permission may be blocked" with no way to allow it. -/// Granted for the microphone, in the content view, once a server is chosen, and for nothing -/// else (a camera or a screen gets the platform's default, which on Linux is no). The content view -/// never shows another origin: `allowed` sends every other site to the system browser. -fn permission(handle: &AppHandle, view: &Webview, kind: PermissionKind) -> PermissionResponse { - if kind != PermissionKind::Microphone || view.label() != CONTENT { - return PermissionResponse::Default; - } - if handle.state::().settings().server.is_some() { - PermissionResponse::Allow - } else { - PermissionResponse::Deny - } -} - -/// On Linux, Tauri packs every webview of a window into the window's GTK box, each with an equal -/// share of the height, and positions and sizes set on a webview are not applied there. So the -/// title bar is told directly: a fixed height, no share of what is left (the content takes it). -#[cfg(target_os = "linux")] -fn pin_titlebar(bar: &Webview) { - let _ = bar.with_webview(|pv| { - use gtk::prelude::*; - let view = pv.inner(); - view.set_size_request(-1, TITLE_H as i32); - if let Some(parent) = view.parent().and_then(|p| p.downcast::().ok()) { - parent.set_child_packing(&view, false, false, 0, gtk::PackType::Start); - } - }); -} - -/// Lay the two webviews out in the window: the title bar on top, the content under it, and the -/// frame around both unless the window is maximized (then there is nothing to resize). -fn layout(window: &Window) { - let framed = - !(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)); - #[cfg(target_os = "linux")] - { - use gtk::prelude::*; - // The box's border is the frame: the window's own surface, which GTK resizes by. - if let Ok(vbox) = window.default_vbox() { - vbox.set_border_width(if framed { FRAME as u32 } else { 0 }); - } - } - #[cfg(not(target_os = "linux"))] - place(window, framed); -} - -#[cfg(not(target_os = "linux"))] -fn place(window: &Window, framed: bool) { - use tauri::{PhysicalPosition, PhysicalSize}; - let handle = window.app_handle(); - let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT)) - else { - return; - }; - let (Ok(size), Ok(scale)) = (window.inner_size(), window.scale_factor()) else { - return; - }; - let frame = if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) - { - 0 - } else { - (FRAME * scale).round() as u32 - }; - let title = (TITLE_H * scale).round() as u32; - let width = size.width.saturating_sub(2 * frame); - let _ = bar.set_position(PhysicalPosition::new(frame, frame)); - let _ = bar.set_size(PhysicalSize::new(width, title)); - let _ = body.set_position(PhysicalPosition::new(frame, frame + title)); - let _ = body.set_size(PhysicalSize::new( - width, - size.height.saturating_sub(title + 2 * frame), - )); -} - -/// The window may show the app's own pages and the server; anything else opens in the browser. -/// A `llembas-theme:` "navigation" is the content page reporting its theme: noted, never loaded. -fn allowed(handle: &AppHandle, url: &Url) -> bool { - if url.scheme() == THEME_SCHEME { - if let Some(theme) = Theme::parse(url.path()) { - set_theme(handle, theme); - } - return false; - } - let local = matches!(url.scheme(), "tauri" | "asset") - || url.host_str() == Some("tauri.localhost") - || url.scheme() == "about" - || url.scheme() == "data"; - if local { - return true; - } - let server = handle.state::().settings().server; - let same = server - .as_deref() - .and_then(|s| Url::parse(s).ok()) - .is_some_and(|s| s.origin() == url.origin()); - if !same && matches!(url.scheme(), "http" | "https" | "mailto") { - let _ = handle.opener().open_url(url.as_str(), None::<&str>); - } - same -} - -/// The server page shows `theme`: paint the frame and the webviews in it, remember it for the -/// next start, and tell the app's own pages (they hear it as `status`). -fn set_theme(handle: &AppHandle, theme: Theme) { - let app = handle.state::(); - { - let mut current = app.theme.lock().unwrap(); - if *current == theme { - return; - } - *current = theme; - } - let _ = app.update(|s| s.theme = Some(theme.name().into())); - if let Some(w) = main_window(handle) { - let _ = w.set_background_color(Some(theme.frame())); - } - for label in [TITLEBAR, CONTENT] { - if let Some(v) = handle.get_webview(label) { - let _ = v.set_background_color(Some(theme.background())); - } - } - let _ = handle.emit("status", status_of(handle)); -} - -/// Link this computer: `lembas login --json`, with the approval shown in the window -/// (where the user is already signed in to the server), then the agent started. +/// Link this computer: `lembas login --json`, the approval opened in the system's +/// browser (where the person is signed in to the server), then the agent started. fn start_link(handle: &AppHandle) -> Result<(), String> { let app = handle.state::(); let Some(server) = app.settings().server else { @@ -605,28 +696,28 @@ fn start_link(handle: &AppHandle) -> Result<(), String> { let mut done = false; if let Some(out) = child.stdout.take() { for line in BufReader::new(out).lines().map_while(Result::ok) { - let Ok(ev) = serde_json::from_str::(&line) else { + let Ok(ev) = serde_json::from_str::(&line) else { continue; }; - match ev.get("event").and_then(|e| e.as_str()) { + match ev.get("event").and_then(Value::as_str) { Some("code") => { - let link = ev - .get("verification_uri_complete") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if let Ok(url) = Url::parse(link) { - if let Some(c) = content(&handle) { - let _ = c.navigate(url); - } - if let Some(w) = main_window(&handle) { - let _ = w.show(); - let _ = w.set_focus(); - } + let field = + |k: &str| ev.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let uri = field("verification_uri_complete"); + if Url::parse(&uri).is_ok() { + let _ = handle.opener().open_url(&uri, None::<&str>); } + let _ = handle.emit( + "linking", + Linking::Code { + user_code: field("user_code"), + uri, + }, + ); } Some("done") => done = true, Some("error") => { - failure = ev.get("message").and_then(|m| m.as_str()).map(String::from) + failure = ev.get("message").and_then(Value::as_str).map(String::from) } _ => {} } @@ -644,42 +735,65 @@ fn start_link(handle: &AppHandle) -> Result<(), String> { Err(e) => e.to_string(), }); } - let app = handle.state::(); - *app.linking.lock().unwrap() = false; + *handle.state::().linking.lock().unwrap() = false; match failure { None => { - let _ = open_main(&handle, Some(&server)); + let _ = handle.emit("linking", Linking::Done); let _ = set_agent_wanted(&handle, true); } - Some(why) => { - handle - .dialog() - .message(why) - .title("LLeMbas: linking failed") - .kind(MessageDialogKind::Error) - .show(|_| {}); + Some(message) => { + let _ = handle.emit("linking", Linking::Failed { message }); } } - let _ = handle.emit("status", status_of(&handle)); + announce(&handle); }); Ok(()) } -fn status_of(handle: &AppHandle) -> Status { - status(&handle.state::()) +// --- The window, the tray and the menu ------------------------------------------------------------ + +fn main_window(handle: &AppHandle) -> Option { + handle.get_webview_window(MAIN) } +/// Bring the window to the front, made if it is not there. fn show(handle: &AppHandle) { - let server = handle.state::().settings().server; if let Some(w) = main_window(handle) { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); - } else { - let _ = open_main(handle, server.as_deref()); + } else if let Err(e) = build_main(handle) { + log::write(&format!("could not open the window: {e}")); } } +fn build_main(handle: &AppHandle) -> tauri::Result { + let theme = handle.state::().theme(); + WebviewWindowBuilder::new(handle, MAIN, WebviewUrl::App("index.html".into())) + .title("LLeMbas") + .inner_size(1280.0, 840.0) + .min_inner_size(360.0, 480.0) + // The app draws its own title bar; Tauri makes the edges resize an undecorated window. + .decorations(false) + .background_color(theme.background()) + .on_navigation(allowed) + .build() +} + +/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever. +/// A link the page wants followed goes to the system's browser through `open_external`; a +/// navigation it did not ask for -- a link or a script in a sealed frame -- simply goes nowhere. +fn allowed(url: &Url) -> bool { + let local_app = url.scheme() == "tauri" + || (url.scheme() == "http" && url.host_str() == Some("tauri.localhost")); + let sealed = url.scheme() == sealed::SCHEME + || (url.scheme() == "http" + && url + .host_str() + .is_some_and(|h| h == format!("{}.localhost", sealed::SCHEME))); + local_app || sealed || url.as_str() == "about:blank" +} + fn build_tray(handle: &AppHandle) -> tauri::Result { let open = MenuItem::with_id(handle, "open", "Open LLeMbas", true, None::<&str>)?; let status = MenuItem::with_id( @@ -691,7 +805,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { )?; let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?; let link = MenuItem::with_id(handle, "link", "Link this computer…", true, None::<&str>)?; - let server = MenuItem::with_id(handle, "server", "Settings…", true, None::<&str>)?; + let settings = MenuItem::with_id(handle, "settings", "Settings…", true, None::<&str>)?; let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?; let menu = Menu::with_items( handle, @@ -702,7 +816,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { &toggle, &link, &PredefinedMenuItem::separator(handle)?, - &server, + &settings, &quit, ], )?; @@ -731,7 +845,7 @@ async fn check_update(handle: &AppHandle) -> Result, String> { .map_err(|e| e.to_string())? .map(|u| u.version); *handle.state::().update.lock().unwrap() = found.clone(); - let _ = handle.emit("status", status_of(handle)); + announce(handle); Ok(found) } @@ -834,6 +948,7 @@ fn on_menu(h: &AppHandle, id: &str) { } } "link" => { + show(h); if let Err(e) = start_link(h) { h.dialog() .message(e) @@ -843,14 +958,13 @@ fn on_menu(h: &AppHandle, id: &str) { } "update" => offer_update(h), "reload" => { - if let Some(c) = content(h) { - if let Ok(url) = c.url() { - let _ = c.navigate(url); - } + if let Some(w) = main_window(h) { + let _ = w.reload(); } } - "server" => { - let _ = open_main(h, None); + "settings" => { + show(h); + let _ = h.emit("open-settings", ()); } "quit" => { h.state::().agent.stop(); @@ -860,11 +974,10 @@ fn on_menu(h: &AppHandle, id: &str) { } } -/// The title bar's menu: the tray's items, where the window is (a desktop without a tray — -/// GNOME without the AppIndicator extension — has no other way to reach them). -/// The title bar's menu, opened under the button that asked for it: `x`, `y` are the button's -/// bottom-left corner in the title bar page. (A plain popup opens at the pointer, which a Wayland -/// compositor does not tell the app — it landed in the middle of the window.) +/// The title bar's menu: the tray's items, where the window is (a desktop without a tray -- +/// GNOME without the AppIndicator extension -- has no other way to reach them). Opened under the +/// button that asked for it: `x`, `y` are its bottom-left corner in the page. (A plain popup +/// opens at the pointer, which a Wayland compositor does not tell the app.) #[tauri::command] fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> { let h = window.app_handle(); @@ -887,7 +1000,7 @@ fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> { None => m("update", "Check for updates…", true)?, }; let reload = m("reload", "Reload", true)?; - let server = m("server", "Settings…", true)?; + let settings = m("settings", "Settings…", true)?; let quit = m("quit", "Quit LLeMbas", true)?; Menu::with_items( h, @@ -898,19 +1011,15 @@ fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> { &PredefinedMenuItem::separator(h)?, &reload, &update, - &server, + &settings, &PredefinedMenuItem::separator(h)?, &quit, ], ) }; let menu = build().map_err(|e| e.to_string())?; - // The title bar sits inside the frame while the window is not maximized. - let framed = - !(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)); - let inset = if framed { FRAME } else { 0.0 }; window - .popup_menu_at(&menu, LogicalPosition::new(x + inset, y + inset)) + .popup_menu_at(&menu, LogicalPosition::new(x, y)) .map_err(|e| e.to_string()) } @@ -924,18 +1033,144 @@ fn on_agent_state(handle: &AppHandle, state: &AgentState) { "Start the agent" }); } - let _ = handle.emit("status", status_of(handle)); + announce(handle); +} + +/// Look at the chat list every so often, and say when a reply finished out of sight. +fn watch_chats(handle: AppHandle) { + std::thread::spawn(move || loop { + std::thread::sleep(watch::EVERY); + let app = handle.state::(); + let Some((app_url, token)) = app.door() else { + continue; + }; + let list = match server::request(&app_url, &token, "GET", "/chats?limit=50", None) { + Ok(v) => v, + Err(e) => { + if e.status == 401 { + token_refused(&handle); + } + continue; + } + }; + let done = app.watch.look(&list); + if done.is_empty() || !app.settings().notify { + continue; + } + let in_front = main_window(&handle) + .is_some_and(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false)); + let open = app.watch.open.lock().unwrap().clone(); + let words = app.watch.words.lock().unwrap().clone(); + for d in done { + if in_front && open.as_deref() == Some(d.id.as_str()) { + continue; + } + let title = if d.title.is_empty() { + "LLeMbas".to_string() + } else { + d.title.clone() + }; + let _ = handle + .notification() + .builder() + .title(title) + .body(if d.agent { &words.agent } else { &words.reply }) + .show(); + } + }); +} + +/// Where the app writes what it could not show: a start that fails before the window is up +/// leaves its reason here, not only on a terminal nobody sees. +mod log { + use std::io::Write; + use std::path::PathBuf; + + /// The app's log directory as Tauri names it on Linux (`$XDG_DATA_HOME//logs`), + /// worked out without the app, which may not exist yet. + pub fn file() -> Option { + let data = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?; + Some(data.join("eu.llembas.desktop/logs/desktop.log")) + } + + pub fn write(line: &str) { + eprintln!("llembas-desktop: {line}"); + let Some(path) = file() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let _ = writeln!(f, "[{secs}] {line}"); + } + } + + /// A panic -- the release build aborts on one -- is written down before it ends the app. + pub fn on_panic() { + let before = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + write(&format!("stopped: {info}")); + before(info); + })); + } +} + +/// Settings for WebKitGTK that must be in place before it starts. A blank or crashing window +/// with NVIDIA's own driver (on Wayland most of all) is WebKitGTK's DMA-BUF renderer; without it, +/// the window draws. Only when that driver is loaded and the person has not chosen otherwise. +#[cfg(target_os = "linux")] +fn webkit_environment() { + let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists(); + if nvidia && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } } pub fn run() { - tauri::Builder::default() + log::on_panic(); + #[cfg(target_os = "linux")] + webkit_environment(); + let built = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h))) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .register_uri_scheme_protocol(sealed::SCHEME, |ctx, request| { + sealed::respond( + &ctx.app_handle().state::().sealed, + request.uri().path(), + ) + }) .invoke_handler(tauri::generate_handler![ get_status, + recheck_server, set_server, + sign_in, + sign_in_cancel, + sign_out, + api, + stream_open, + stream_close, + local_list, + local_read, + sealed_open, + sealed_close, + set_theme, + set_notify, + set_open_chat, + set_words, + open_external, set_agent, link_computer, titlebar_menu, @@ -953,16 +1188,16 @@ pub fn run() { if let Ok(dir) = app.path().resource_dir() { cli::set_bundled(&dir); } - let log = app.path().app_log_dir()?.join("agent.log"); + let agent_log = app.path().app_log_dir()?.join("agent.log"); let notify_handle = handle.clone(); - let agent = Agent::new(log, move |s| { + let agent = Agent::new(agent_log, move |s| { // The notifier runs on the agent's thread; the tray is touched on the main one. let h = notify_handle.clone(); let s = s.clone(); let _ = notify_handle.run_on_main_thread(move || on_agent_state(&h, &s)); }); let loaded = settings::load(&config_dir); - // Until a server page has said, the server's own default: Moria. + // Until the account has said, the server's own default: Moria. let theme = loaded .theme .as_deref() @@ -977,14 +1212,28 @@ pub fn run() { update: Mutex::new(None), remote: Mutex::new(None), theme: Mutex::new(theme), + server: Mutex::new(None), + refused: Mutex::new(None), + account: Mutex::new(None), + signing_in: Mutex::new(None), + streams: Mutex::new(HashMap::new()), + next_stream: AtomicU64::new(0), + sealed: sealed::Pages::default(), + watch: watch::Watch::default(), }); - let items = build_tray(&handle)?; - *handle.state::().tray.lock().unwrap() = Some(items); - open_main(&handle, loaded.server.as_deref())?; + // Without a tray (no AppIndicator library, no StatusNotifier host) the app still + // starts: the title bar's menu has every item the tray has. + match build_tray(&handle) { + Ok(items) => *handle.state::().tray.lock().unwrap() = Some(items), + Err(e) => log::write(&format!("no tray icon: {e}")), + } + build_main(&handle)?; watch_updates(handle.clone()); + watch_chats(handle.clone()); { let h = handle.clone(); std::thread::spawn(move || { + check_server(&h); refresh_remote(&h); }); } @@ -999,25 +1248,24 @@ pub fn run() { let _ = window.hide(); api.prevent_close(); } - WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } - if window.label() == MAIN => - { - layout(window); - let _ = window.emit_to( - TITLEBAR, - "maximized", - window.is_maximized().unwrap_or(false), - ); + WindowEvent::Resized(_) if window.label() == MAIN => { + let _ = window.emit("maximized", window.is_maximized().unwrap_or(false)); } _ => {} }) - .build(tauri::generate_context!()) - .expect("LLeMbas Desktop could not start") - .run(|handle, event| { - if let RunEvent::Exit = event { - handle.state::().agent.stop(); - } - }); + .build(tauri::generate_context!()); + let app = match built { + Ok(app) => app, + Err(e) => { + log::write(&format!("could not start: {e}")); + std::process::exit(1); + } + }; + app.run(|handle, event| { + if let RunEvent::Exit = event { + handle.state::().agent.stop(); + } + }); } #[cfg(test)] @@ -1037,19 +1285,37 @@ mod tests { #[test] fn the_themes_come_out_of_tokens_css_as_two_different_looks() { - // build.rs read these out of ui/tokens.css: a light and a dark theme cannot share - // either colour, and the frame is not the page. - assert_ne!(Theme::Moria.frame(), Theme::Shire.frame()); + // build.rs read these out of ui/tokens.css: a light and a dark theme cannot share a page + // colour. assert_ne!(Theme::Moria.background(), Theme::Shire.background()); - assert_ne!(Theme::Moria.frame(), Theme::Moria.background()); - assert_ne!(Theme::Shire.frame(), Theme::Shire.background()); } #[test] - fn the_theme_report_is_a_navigation_the_handler_can_read() { - let url = Url::parse("llembas-theme:shire").unwrap(); - assert_eq!(url.scheme(), THEME_SCHEME); - assert_eq!(Theme::parse(url.path()), Some(Theme::Shire)); - assert!(THEME_SCRIPT.contains(THEME_SCHEME)); + fn the_window_shows_the_app_and_its_sealed_frames_only() { + for ok in [ + "tauri://localhost/index.html", + "http://tauri.localhost/index.html", + "llembas-sealed://localhost/p1", + "http://llembas-sealed.localhost/p1", + "about:blank", + ] { + assert!(allowed(&Url::parse(ok).unwrap()), "{ok}"); + } + for no in [ + "https://ai.example/", + "http://localhost:8080/", + "http://tauri.localhost.evil.example/", + "file:///etc/passwd", + "data:text/html,

x

", + "javascript:alert(1)", + ] { + assert!(!allowed(&Url::parse(no).unwrap()), "{no}"); + } + } + + #[test] + fn the_log_lives_in_the_apps_own_directory() { + let f = log::file().unwrap(); + assert!(f.ends_with("eu.llembas.desktop/logs/desktop.log")); } } diff --git a/src-tauri/src/sealed.rs b/src-tauri/src/sealed.rs new file mode 100644 index 0000000..56894b8 --- /dev/null +++ b/src-tauri/src/sealed.rs @@ -0,0 +1,131 @@ +//! HTML to look at, sealed: an HTML file in the viewer, whether it is the agent's (`show`), the +//! server's tree's or this computer's, is a page a model may have written. It is drawn in a frame +//! from an address of its own (`llembas-sealed://`), never as part of the app's page: +//! +//! - **No bridge.** The capability names only the app's own page; Tauri gives the IPC script to +//! the main frame only, and this scheme is not the app's origin. Nothing in the frame can call +//! the app. +//! - **No network, no app.** It is served under the server's sealed policy (its +//! `security/sealed.py`): `sandbox allow-scripts` without `allow-same-origin` makes it an opaque +//! origin, nothing loads from anywhere (inline and `data:`/`blob:` only), no form is sent. +//! - **No way out.** The app refuses every navigation it did not make (see `lib.rs`), so a link or +//! a script in the frame goes nowhere. +//! +//! The page holds the HTML until it is closed (`forget`); at most `KEEP` are kept. + +use std::collections::VecDeque; +use std::sync::Mutex; + +pub const SCHEME: &str = "llembas-sealed"; +/// The most sealed pages held at once; the oldest goes first. +const KEEP: usize = 16; + +/// The policy a sealed page is served under -- the server's `sealed.policy()`, minus +/// `frame-ancestors`, which would have to name the app's own scheme. +pub const POLICY: &str = "sandbox allow-scripts allow-forms; default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'unsafe-inline' data: blob:; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'"; + +#[derive(Default)] +pub struct Pages { + next: Mutex, + held: Mutex>, +} + +impl Pages { + /// Hold `html`; the id it is served under. + pub fn hold(&self, html: String) -> String { + let id = { + let mut n = self.next.lock().unwrap(); + *n += 1; + format!("p{}", *n) + }; + let mut held = self.held.lock().unwrap(); + while held.len() >= KEEP { + held.pop_front(); + } + held.push_back((id.clone(), html)); + id + } + + pub fn forget(&self, id: &str) { + self.held.lock().unwrap().retain(|(i, _)| i != id); + } + + pub fn get(&self, id: &str) -> Option { + self.held + .lock() + .unwrap() + .iter() + .find(|(i, _)| i == id) + .map(|(_, h)| h.clone()) + } +} + +/// Where a held page is served: the address form differs by platform, as Tauri's own does. +pub fn url(id: &str) -> String { + if cfg!(windows) { + format!("http://{SCHEME}.localhost/{id}") + } else { + format!("{SCHEME}://localhost/{id}") + } +} + +/// The id in a request's path: `/p3` -> `p3`. +pub fn id_of(path: &str) -> &str { + path.trim_start_matches('/') + .split(['?', '/']) + .next() + .unwrap_or("") +} + +/// The response for a request to the scheme. +pub fn respond(pages: &Pages, path: &str) -> tauri::http::Response> { + let builder = tauri::http::Response::builder() + .header("Content-Security-Policy", POLICY) + .header("X-Content-Type-Options", "nosniff") + .header("Referrer-Policy", "no-referrer") + .header("Cache-Control", "no-store"); + let built = match pages.get(id_of(path)) { + Some(html) => builder + .status(200) + .header("Content-Type", "text/html; charset=utf-8") + .body(html.into_bytes()), + None => builder + .status(404) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Vec::new()), + }; + built.unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pages_are_held_by_id_and_the_oldest_goes() { + let p = Pages::default(); + let first = p.hold("

one

".into()); + assert_eq!(p.get(&first).as_deref(), Some("

one

")); + for i in 0..KEEP { + p.hold(format!("

{i}

")); + } + assert_eq!(p.get(&first), None); + let last = p.hold("

last

".into()); + p.forget(&last); + assert_eq!(p.get(&last), None); + } + + #[test] + fn a_sealed_page_is_served_sealed() { + let p = Pages::default(); + let id = p.hold("".into()); + let res = respond(&p, &format!("/{id}")); + assert_eq!(res.status(), 200); + let csp = res.headers()["Content-Security-Policy"].to_str().unwrap(); + assert!(csp.starts_with("sandbox allow-scripts")); + assert!(!csp.contains("allow-same-origin")); + assert!(csp.contains("connect-src 'none'")); + assert_eq!(respond(&p, "/nope").status(), 404); + assert_eq!(id_of("/p7?x=1"), "p7"); + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs new file mode 100644 index 0000000..f24f61c --- /dev/null +++ b/src-tauri/src/server.rs @@ -0,0 +1,674 @@ +//! The server this app is a front end to: whether it is one this app can talk to, the app's own +//! sign-in to it, and every request the app's pages make to it. +//! +//! - **Discovery** (`/.well-known/lembas.json`) decides first. LLeMbas Desktop 1.0 needs a +//! LLeMbas 2.0 server: one that speaks link protocol 3 and has the app's door (`/api/v2`) in a +//! version this app knows. An older server is refused with a sentence that says which side to +//! update, before anything is signed in. +//! - **The app's sign-in** is the server's device login (RFC 8628), the same one `lembas login` +//! uses, for one scope: `app`, "read and write your chats, as the web interface does". The +//! approval page opens in the system's browser, where the person is signed in already. The +//! token is kept in the app's config directory, readable by this user only. +//! - **The token stays in Rust.** The pages ask for `/api/v2/...` through a command and get the +//! JSON back; they never hold the token, so no page -- and nothing a page shows -- can send it +//! anywhere. A request can only reach the server's `/api/v2`: the path is checked here. +//! +//! The agent's own link is a different token, the CLI's (`lembas login`), which this app never +//! reads: a token that may accept work as a device is not one the chat window needs. + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The link protocol a 2.0 server speaks (harness spec `acp.md`, *Versions*). +pub const NEEDS_PROTOCOL: i64 = 3; +/// The server's major version this app is written for. +pub const NEEDS_MAJOR: u64 = 2; +/// The `/api/v2` contract this app is written against: discovery's `app_api`. A higher number is +/// a change an existing client would misread, so this app says it is too old rather than guess. +pub const APP_API: i64 = 1; + +/// What discovery said, once it has passed every check. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Discovery { + pub base: String, + pub name: String, + pub version: String, + /// The app's door: `https://host/api/v2`. + #[serde(skip)] + pub app: String, + #[serde(skip)] + pub device_code: String, + #[serde(skip)] + pub device_token: String, +} + +fn agent(read: Duration) -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(read) + .timeout_write(Duration::from_secs(30)) + .user_agent(&format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION"))) + .build() +} + +/// An error a page can show and act on: the HTTP status (0: the server was not reached) and the +/// server's own sentence (`detail`), or ours. +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct ApiError { + pub status: u16, + pub detail: String, +} + +impl ApiError { + pub fn new(status: u16, detail: impl Into) -> Self { + ApiError { + status, + detail: detail.into(), + } + } +} + +impl From for ApiError { + fn from(e: ureq::Error) -> Self { + match e { + ureq::Error::Status(code, res) => { + let text = res.into_string().unwrap_or_default(); + let detail = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("detail").cloned()) + .map(|d| match d { + Value::String(s) => s, + other => other.to_string(), + }) + .unwrap_or_else(|| format!("The server answered {code}.")); + ApiError::new(code, detail) + } + ureq::Error::Transport(t) => { + ApiError::new(0, format!("Could not reach the server: {t}")) + } + } + } +} + +/// Ask `base` who it is, and refuse what this app cannot talk to. +pub fn discover(base: &str) -> Result { + let url = format!("{base}/.well-known/lembas.json"); + let res = agent(Duration::from_secs(15)) + .get(&url) + .call() + .map_err(|e| match e { + ureq::Error::Status(404, _) => format!("{base} answers, but is not a LLeMbas server."), + ureq::Error::Status(code, _) => format!("{base} answered {code}."), + ureq::Error::Transport(t) => format!("Could not reach {base}: {t}"), + })?; + let body: Value = res + .into_json() + .map_err(|_| format!("{base} is not a LLeMbas server."))?; + check(&body, base) +} + +/// The leading number of a version: `2` of `2.0.0-beta.1`, `1` of `1.7.3`. +fn major(version: &str) -> Option { + let digits: String = version + .trim_start_matches('v') + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + digits.parse().ok() +} + +/// The checks, on discovery's answer: a LLeMbas server, a 2.0 one (protocol 3, version 2 or +/// later), with an app door whose version this app knows. +pub fn check(body: &Value, base: &str) -> Result { + if body.get("service").and_then(Value::as_str) != Some("lembas") { + return Err(format!("{base} is not a LLeMbas server.")); + } + let version = body + .get("version") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + // `protocols` is a 2.0 server's list; a 1.x one has only `protocol`, frozen at 1. + let protocols: Vec = match body.get("protocols").and_then(Value::as_array) { + Some(list) => list.iter().filter_map(Value::as_i64).collect(), + None => body + .get("protocol") + .and_then(Value::as_i64) + .into_iter() + .collect(), + }; + let new_enough = + protocols.contains(&NEEDS_PROTOCOL) && major(&version).is_some_and(|m| m >= NEEDS_MAJOR); + if !new_enough { + let runs = if version.is_empty() { + "an older LLeMbas".to_string() + } else { + format!("LLeMbas {version}") + }; + return Err(format!( + "{base} runs {runs}. LLeMbas Desktop {} needs a LLeMbas 2.0 server or later: update the server, or keep using LLeMbas Desktop 0.7 with this one.", + env!("CARGO_PKG_VERSION") + )); + } + let app_api = body.get("app_api").and_then(Value::as_i64).unwrap_or(0); + if app_api < 1 { + return Err(format!( + "{base} has no app interface (/api/v2). Update the server to LLeMbas 2.0 or later." + )); + } + if app_api > APP_API { + return Err(format!( + "{base} speaks a newer app interface (version {app_api}) than LLeMbas Desktop {} knows (version {APP_API}). Update LLeMbas Desktop.", + env!("CARGO_PKG_VERSION") + )); + } + let api = body.get("api"); + let app = api + .and_then(|a| a.get("app")) + .and_then(Value::as_str) + .map(|s| s.trim_end_matches('/').to_string()) + .unwrap_or_else(|| format!("{base}/api/v2")); + let device = body.get("login").and_then(|l| l.get("device")); + let endpoint = |key: &str| { + device + .and_then(|d| d.get(key)) + .and_then(Value::as_str) + .map(String::from) + }; + let (Some(device_code), Some(device_token)) = (endpoint("code"), endpoint("token")) else { + return Err(format!("{base} offers no device sign-in.")); + }; + Ok(Discovery { + base: base.to_string(), + name: body + .get("name") + .and_then(Value::as_str) + .unwrap_or("LLeMbas") + .to_string(), + version, + app, + device_code, + device_token, + }) +} + +// --- The app's sign-in ---------------------------------------------------------------------- + +/// What the app keeps of its sign-in: which server, the token, and whose it is. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Account { + pub server: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub token: String, + pub email: String, + pub name: String, +} + +impl Account { + /// The same account without its token: what a page may see. + pub fn public(&self) -> Account { + Account { + token: String::new(), + ..self.clone() + } + } +} + +fn account_file(config_dir: &Path) -> PathBuf { + config_dir.join("account.json") +} + +/// The sign-in for `server`, if there is one (a sign-in to another server is not this one's). +pub fn load_account(config_dir: &Path, server: &str) -> Option { + let text = fs::read_to_string(account_file(config_dir)).ok()?; + let a: Account = serde_json::from_str(&text).ok()?; + (a.server == server && !a.token.is_empty()).then_some(a) +} + +/// Written to a new file readable by this user only, then moved over the old one. +pub fn save_account(config_dir: &Path, a: &Account) -> std::io::Result<()> { + fs::create_dir_all(config_dir)?; + let tmp = config_dir.join("account.json.tmp"); + let _ = fs::remove_file(&tmp); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut f = options.open(&tmp)?; + f.write_all((serde_json::to_string_pretty(a)? + "\n").as_bytes())?; + f.sync_all()?; + drop(f); + fs::rename(tmp, account_file(config_dir)) +} + +pub fn forget_account(config_dir: &Path) { + let _ = fs::remove_file(account_file(config_dir)); +} + +/// A device login in progress: the code to approve and where. +#[derive(Clone, Debug, Serialize)] +pub struct Code { + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, + #[serde(skip)] + pub interval: u64, + #[serde(skip)] + pub device_code: String, +} + +/// Open a device login for the `app` scope. +pub fn start_sign_in(d: &Discovery, host: &str) -> Result { + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let device_name = if host.is_empty() { + "LLeMbas Desktop".to_string() + } else { + format!("LLeMbas Desktop on {host}") + }; + let res = agent(Duration::from_secs(15)) + .post(&d.device_code) + .set("accept", "application/json") + .send_json(serde_json::json!({ + "client_id": format!("llembas-desktop {}", env!("CARGO_PKG_VERSION")), + "device_name": device_name, + // No host on purpose: the host names the device an agent chat runs on, and that is + // the CLI's link, not this sign-in. + "device_platform": platform, + "scope": "app", + })) + .map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?; + let v: Value = res + .into_json() + .map_err(|e| format!("The server's answer was not understood: {e}"))?; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let n = |k: &str, or: u64| v.get(k).and_then(Value::as_u64).unwrap_or(or); + let code = Code { + user_code: s("user_code"), + verification_uri: s("verification_uri"), + verification_uri_complete: match s("verification_uri_complete") { + c if c.is_empty() => s("verification_uri"), + c => c, + }, + expires_in: n("expires_in", 600), + interval: n("interval", 5).max(1), + device_code: s("device_code"), + }; + if code.device_code.is_empty() || code.verification_uri.is_empty() { + return Err("The server's answer was not understood: no code.".into()); + } + Ok(code) +} + +/// RFC 8628's error, in words. +fn oauth_error(e: ureq::Error, what: &str) -> String { + match e { + ureq::Error::Status(code, res) => { + let v: Value = res.into_json().unwrap_or(Value::Null); + let said = v + .get("error_description") + .or_else(|| v.get("error")) + .and_then(Value::as_str) + .map(String::from) + .unwrap_or_else(|| format!("{code}")); + format!("{what}: {said}") + } + ureq::Error::Transport(t) => format!("Could not reach the server: {t}"), + } +} + +/// Wait for the person to approve the code (or not), checking `cancelled` while waiting. +pub fn await_sign_in( + d: &Discovery, + code: &Code, + cancelled: &AtomicBool, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(code.expires_in.max(1)); + let mut interval = Duration::from_secs(code.interval); + let http = agent(Duration::from_secs(15)); + while Instant::now() < deadline { + let until = Instant::now() + interval; + while Instant::now() < until { + if cancelled.load(Ordering::SeqCst) { + return Err("The sign-in was cancelled.".into()); + } + std::thread::sleep(Duration::from_millis(250)); + } + let res = http + .post(&d.device_token) + .set("accept", "application/json") + .send_json(serde_json::json!({ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": code.device_code, + })); + let v: Value = match res { + Ok(r) => r.into_json().unwrap_or(Value::Null), + Err(ureq::Error::Status(_, r)) => r.into_json().unwrap_or(Value::Null), + Err(ureq::Error::Transport(t)) => { + return Err(format!("Could not reach the server: {t}")) + } + }; + if let Some(token) = v.get("access_token").and_then(Value::as_str) { + let account = v.get("account"); + let field = |k: &str| { + account + .and_then(|a| a.get(k)) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + return Ok(Account { + server: d.base.clone(), + token: token.to_string(), + email: field("email"), + name: field("name"), + }); + } + match v.get("error").and_then(Value::as_str) { + Some("authorization_pending") => {} + Some("slow_down") => interval += Duration::from_secs(5), + Some("access_denied") => return Err("The sign-in was denied on the server.".into()), + Some("expired_token") => { + return Err("The code expired before it was approved. Sign in again.".into()) + } + Some(other) => return Err(format!("The server refused the sign-in: {other}")), + None => return Err("The server's answer was not understood.".into()), + } + } + Err("The code expired before it was approved. Sign in again.".into()) +} + +/// Give the token up on the server (it is gone from the server's device list then). Best effort: +/// the app forgets it either way. +pub fn sign_out(base: &str, token: &str) { + let _ = agent(Duration::from_secs(10)) + .delete(&format!("{base}/api/v1/token")) + .set("authorization", &format!("Bearer {token}")) + .call(); +} + +// --- The app's door ------------------------------------------------------------------------- + +/// A path a page may ask for: on the app's door, and nothing that could leave it. +pub fn checked_path(path: &str) -> Result<&str, ApiError> { + let ok = path.starts_with('/') + && !path.starts_with("//") + && !path.contains("://") + && !path.contains('#') + && !path.contains('\\') + && !path.split(['/', '?']).any(|part| part == "..") + && path.chars().all(|c| !c.is_control() && c != ' '); + if ok { + Ok(path) + } else { + Err(ApiError::new( + 400, + format!("Not a path on the server: {path}"), + )) + } +} + +/// One request to `/api/v2`, as the signed-in account. The JSON answer, or `null` for none. +pub fn request( + app: &str, + token: &str, + method: &str, + path: &str, + body: Option<&Value>, +) -> Result { + let path = checked_path(path)?; + let method = method.to_ascii_uppercase(); + if !["GET", "POST", "PATCH", "PUT", "DELETE"].contains(&method.as_str()) { + return Err(ApiError::new(400, format!("No such method: {method}"))); + } + let req = agent(Duration::from_secs(60)) + .request(&method, &format!("{app}{path}")) + .set("authorization", &format!("Bearer {token}")) + .set("accept", "application/json"); + let res = match body { + Some(b) => req.send_json(b), + None => req.call(), + }?; + if res.status() == 204 { + return Ok(Value::Null); + } + let text = res + .into_string() + .map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?; + if text.trim().is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&text) + .map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string())) +} + +/// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame. +#[derive(Default)] +pub struct Sse { + event: String, + data: Vec, +} + +impl Sse { + /// Feed one line (without its newline); a blank line ends a frame and returns it. + pub fn line(&mut self, line: &str) -> Option<(String, String)> { + let line = line.strip_suffix('\r').unwrap_or(line); + if line.is_empty() { + if self.data.is_empty() && self.event.is_empty() { + return None; + } + let event = std::mem::take(&mut self.event); + let data = std::mem::take(&mut self.data).join("\n"); + return Some(( + if event.is_empty() { + "message".into() + } else { + event + }, + data, + )); + } + if line.starts_with(':') { + return None; // a keepalive + } + let (field, value) = match line.split_once(':') { + Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)), + None => (line, ""), + }; + match field { + "event" => self.event = value.to_string(), + "data" => self.data.push(value.to_string()), + _ => {} + } + None + } +} + +/// Follow a reply as it is written (`GET …/stream`), handing each frame to `frame` until the +/// server closes it, `frame` says stop (false), or `stop` is set. Keepalives arrive often enough +/// that `stop` is noticed within seconds. +pub fn stream( + app: &str, + token: &str, + path: &str, + stop: &AtomicBool, + mut frame: impl FnMut(&str, &str) -> bool, +) -> Result<(), ApiError> { + let path = checked_path(path)?; + let res = agent(Duration::from_secs(90)) + .get(&format!("{app}{path}")) + .set("authorization", &format!("Bearer {token}")) + .set("accept", "text/event-stream") + .call()?; + let mut sse = Sse::default(); + for line in BufReader::new(res.into_reader()).lines() { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + let line = line.map_err(|e| ApiError::new(0, format!("The stream broke off: {e}")))?; + if let Some((event, data)) = sse.line(&line) { + if !frame(&event, &data) || event == "close" { + return Ok(()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn two_oh() -> Value { + json!({ + "service": "lembas", + "version": "2.0.0-beta.1", + "name": "Example", + "protocol": 3, + "protocols": [3], + "app_api": 1, + "api": {"app": "https://ai.example/api/v2"}, + "login": {"device": { + "code": "https://ai.example/api/device/code", + "token": "https://ai.example/api/device/token" + }} + }) + } + + #[test] + fn a_2_0_server_passes() { + let d = check(&two_oh(), "https://ai.example").unwrap(); + assert_eq!(d.app, "https://ai.example/api/v2"); + assert_eq!(d.name, "Example"); + assert_eq!(d.device_token, "https://ai.example/api/device/token"); + } + + #[test] + fn an_older_server_is_refused_and_told_which_side_to_update() { + // 1.x: protocol frozen at 1, no `protocols`, no app door. + let old = json!({"service": "lembas", "version": "1.7.3", "protocol": 1}); + let why = check(&old, "https://ai.example").unwrap_err(); + assert!(why.contains("LLeMbas 1.7.3"), "{why}"); + assert!(why.contains("needs a LLeMbas 2.0 server"), "{why}"); + // Protocol 3 alone is not enough: the version must say 2. + let mut early = two_oh(); + early["version"] = json!("1.7.2"); + assert!(check(&early, "https://ai.example").is_err()); + // Version 2 without protocol 3 is not one either. + let mut odd = two_oh(); + odd["protocols"] = json!([1, 2]); + assert!(check(&odd, "https://ai.example").is_err()); + } + + #[test] + fn an_app_interface_this_app_does_not_know_is_refused() { + let mut newer = two_oh(); + newer["app_api"] = json!(APP_API + 1); + assert!(check(&newer, "https://ai.example") + .unwrap_err() + .contains("Update LLeMbas Desktop")); + let mut none = two_oh(); + none.as_object_mut().unwrap().remove("app_api"); + assert!(check(&none, "https://ai.example").is_err()); + let not_ours = json!({"service": "other"}); + assert!(check(¬_ours, "https://ai.example").is_err()); + } + + #[test] + fn versions_read_their_major() { + assert_eq!(major("2.0.0-beta.1"), Some(2)); + assert_eq!(major("v10.1.0"), Some(10)); + assert_eq!(major("1.7.3"), Some(1)); + assert_eq!(major(""), None); + } + + #[test] + fn a_page_reaches_only_the_app_door() { + assert!(checked_path("/chats?kind=agent&limit=50").is_ok()); + assert!(checked_path("/chats/abc/messages/def/stream").is_ok()); + for bad in [ + "chats", + "//evil.example/x", + "/x://y", + "/../v1/admin", + "/chats/../../v1", + "/a#b", + "/a b", + "/a\\b", + ] { + assert!(checked_path(bad).is_err(), "{bad}"); + } + } + + #[test] + fn server_sent_events_come_out_whole() { + let mut sse = Sse::default(); + let mut got = vec![]; + for line in [ + ": keepalive", + "", + "event: tail", + "data: {\"text\":\"a\"}", + "", + "event: steps", + "data: one", + "data: two", + "\r", + "data: plain", + "", + ] { + if let Some(f) = sse.line(line) { + got.push(f); + } + } + assert_eq!( + got, + vec![ + ("tail".to_string(), "{\"text\":\"a\"}".to_string()), + ("steps".to_string(), "one\ntwo".to_string()), + ("message".to_string(), "plain".to_string()), + ] + ); + } + + #[test] + fn the_account_file_keeps_its_server_and_hides_its_token() { + let dir = std::env::temp_dir().join(format!("llembas-account-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let a = Account { + server: "https://ai.example".into(), + token: "secret".into(), + email: "a@example.org".into(), + name: "A".into(), + }; + save_account(&dir, &a).unwrap(); + assert_eq!(load_account(&dir, "https://ai.example"), Some(a.clone())); + assert_eq!(load_account(&dir, "https://other.example"), None); + assert!(!serde_json::to_string(&a.public()) + .unwrap() + .contains("secret")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(account_file(&dir)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + forget_account(&dir); + assert_eq!(load_account(&dir, "https://ai.example"), None); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ec6d951..d8ef95e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,22 +1,35 @@ -//! What the app remembers: which server it opens, and whether the agent should run. One small -//! JSON file in the app's config directory; nothing secret is in it (the device's key is the +//! What the app remembers: which server it talks to, whether the agent should run, the theme and +//! whether to notify. One small JSON file in the app's config directory; nothing secret is in it +//! (the app's sign-in is `account.json`, readable by this user only; the device's key is the //! LLeMbas CLI's, in its own config). use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct Settings { /// The server's base address, `https://host[:port]`, with no trailing slash. pub server: Option, /// Run the agent while the app runs (once this computer is linked to the server). pub agent: bool, - /// The server page's theme the last time it said (`moria` or `shire`): what the window's - /// frame, the title bar and the first-run page are painted in until the server says again. - /// None until a server page has been shown. + /// The account's theme the last time the server said (`moria` or `shire`): what the window + /// is painted in until it says again. None until it has. pub theme: Option, + /// Say with a notification when a reply or an agent's work finishes out of sight. + pub notify: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + server: None, + agent: false, + theme: None, + notify: true, + } + } } pub fn file(config_dir: &Path) -> PathBuf { @@ -85,10 +98,20 @@ mod tests { let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id())); let _ = fs::remove_dir_all(&dir); assert_eq!(load(&dir), Settings::default()); + assert!(Settings::default().notify); + // A file from before 1.0 has no `notify`: it is on. + fs::create_dir_all(&dir).unwrap(); + fs::write( + file(&dir), + r#"{"server":"https://ai.example","agent":true}"#, + ) + .unwrap(); + assert!(load(&dir).notify); let s = Settings { server: Some("https://ai.example".into()), agent: true, theme: Some("shire".into()), + notify: false, }; save(&dir, &s).unwrap(); assert_eq!(load(&dir), s); diff --git a/src-tauri/src/theme_report.js b/src-tauri/src/theme_report.js deleted file mode 100644 index 47e840a..0000000 --- a/src-tauri/src/theme_report.js +++ /dev/null @@ -1,44 +0,0 @@ -// Run in the content view on every page it shows (an initialization script, before the page's -// own scripts). It reports the theme the page is in -- the server sets `data-theme` and -// `data-base` on (base.html, then app.js:applyTheme when the reader switches) -- so the -// app can paint the frame and the title bar in the same look. -// -// The report is a navigation to `llembas-theme:`, which the app's navigation handler -// (lib.rs:allowed) reads and refuses, so nothing loads and the page is not left. That is the -// whole channel: the server's pages get no IPC, and this gives them none -- a page can say one -// of two theme names, and nothing else. The base, not the theme's id: a custom theme on the -// server is a handful of colours over `moria` or `shire`, and the base is the look the app has. -(function () { - "use strict"; - // Only the server's pages: the app's own first-run page is painted from the app's side. - if (window.top !== window) return; - if (location.protocol !== "http:" && location.protocol !== "https:") return; - if (location.hostname === "tauri.localhost") return; - - var last = ""; - function report() { - var root = document.documentElement; - if (!root) return; - var base = root.getAttribute("data-base") || root.getAttribute("data-theme") || ""; - if (base !== "moria" && base !== "shire") return; - if (base === last) return; - last = base; - try { - location.assign("llembas-theme:" + base); - } catch (e) { /* refused, as intended */ } - } - function watch() { - var root = document.documentElement; - if (!root) return; - new MutationObserver(report).observe(root, { - attributes: true, - attributeFilter: ["data-theme", "data-base"] - }); - report(); - } - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", watch); - } else { - watch(); - } -})(); diff --git a/src-tauri/src/watch.rs b/src-tauri/src/watch.rs new file mode 100644 index 0000000..061013f --- /dev/null +++ b/src-tauri/src/watch.rs @@ -0,0 +1,139 @@ +//! Native notifications: a reply finished, or an agent chat's work did, while the person was not +//! looking at it. The app's own, so the server's Web Push can stay off. +//! +//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are +//! being answered; a chat that was and no longer is has finished. The notification is shown +//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in +//! the page's language; the chat's title is the person's own. Nothing from the reply itself is +//! put on screen outside the app. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +pub const EVERY: Duration = Duration::from_secs(15); + +/// What a notification says, in the page's language. +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct Words { + /// A chat's reply is ready. + pub reply: String, + /// An agent chat has finished its work. + pub agent: String, +} + +impl Default for Words { + fn default() -> Self { + Words { + reply: "The reply is ready.".into(), + agent: "The agent has finished.".into(), + } + } +} + +/// A chat that has just finished. +#[derive(Clone, Debug, PartialEq)] +pub struct Done { + pub id: String, + pub title: String, + pub agent: bool, +} + +#[derive(Default)] +pub struct Watch { + /// The chat the page shows, if any. + pub open: Mutex>, + pub words: Mutex, + /// Which chats were being answered at the last look (None before the first look). + running: Mutex>>, +} + +impl Watch { + /// Note the chat list as the server gave it; the chats that finished since the last look. + /// The first look only notes: what finished before the app looked is not news. + pub fn look(&self, list: &Value) -> Vec { + let rows = list + .get("chats") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut now = HashMap::new(); + let mut done = Vec::new(); + let mut running = self.running.lock().unwrap(); + for row in &rows { + let Some(id) = row.get("id").and_then(Value::as_str) else { + continue; + }; + let busy = row.get("running").and_then(Value::as_bool).unwrap_or(false); + let was = running + .as_ref() + .and_then(|r| r.get(id).copied()) + .unwrap_or(false); + if was && !busy { + done.push(Done { + id: id.to_string(), + title: row + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + agent: row.get("kind").and_then(Value::as_str) == Some("agent"), + }); + } + now.insert(id.to_string(), busy); + } + *running = Some(now); + done + } + + /// Start again from nothing: another server, or signed out. + pub fn reset(&self) { + *self.running.lock().unwrap() = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn list(rows: &[(&str, bool, &str)]) -> Value { + json!({"chats": rows.iter().map(|(id, running, kind)| json!({ + "id": id, "title": format!("Chat {id}"), "running": running, "kind": kind + })).collect::>()}) + } + + #[test] + fn a_chat_that_stops_running_has_finished_and_the_first_look_is_quiet() { + let w = Watch::default(); + assert!(w + .look(&list(&[("a", true, "chat"), ("b", false, "agent")])) + .is_empty()); + assert!(w + .look(&list(&[("a", true, "chat"), ("b", true, "agent")])) + .is_empty()); + let done = w.look(&list(&[("a", false, "chat"), ("b", false, "agent")])); + assert_eq!( + done, + vec![ + Done { + id: "a".into(), + title: "Chat a".into(), + agent: false + }, + Done { + id: "b".into(), + title: "Chat b".into(), + agent: true + }, + ] + ); + // Said once. + assert!(w.look(&list(&[("a", false, "chat")])).is_empty()); + w.reset(); + assert!(w.look(&list(&[("a", false, "chat")])).is_empty()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 707a08c..a6afe9c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,7 +10,10 @@ "withGlobalTauri": true, "windows": [], "security": { - "csp": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src ipc: http://ipc.localhost" + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'", + "dangerousDisableAssetCspModification": [ + "style-src" + ] } }, "bundle": { @@ -20,8 +23,8 @@ "appimage" ], "category": "Productivity", - "shortDescription": "Your LLeMbas server in a window, with the agent on this computer", - "longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.", + "shortDescription": "The LLeMbas agent on this computer, and your server's chats", + "longDescription": "LLeMbas Desktop runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal, and shows them, and the chats on your LLeMbas 2.0 server, in an app of its own: the agent's working directory and the server's files side by side, and a viewer for what the agent shows you.", "license": "MIT", "homepage": "https://llembas.eu", "publisher": "LLeMbas",