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 <noreply@anthropic.com>
This commit is contained in:
12 files changed
+2008
-474
No files matched your search
Generated
+99
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Listing {
|
||||
pub root: String,
|
||||
pub path: String,
|
||||
pub entries: Vec<Entry>,
|
||||
/// 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<String>,
|
||||
/// 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::<Vec<_>>()
|
||||
.join("/")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn list(roots: &[String], root: &str, path: &str) -> Result<Listing, String> {
|
||||
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<u8>) -> Option<String> {
|
||||
if data.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(data).ok()
|
||||
}
|
||||
|
||||
pub fn read(roots: &[String], root: &str, path: &str) -> Result<Opened, String> {
|
||||
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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
+674
-408
File diff suppressed because it is too large.
Load diff
@@ -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<u64>,
|
||||
held: Mutex<VecDeque<(String, String)>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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("<p>one</p>".into());
|
||||
assert_eq!(p.get(&first).as_deref(), Some("<p>one</p>"));
|
||||
for i in 0..KEEP {
|
||||
p.hold(format!("<p>{i}</p>"));
|
||||
}
|
||||
assert_eq!(p.get(&first), None);
|
||||
let last = p.hold("<p>last</p>".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("<script>alert(1)</script>".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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Self {
|
||||
ApiError {
|
||||
status,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ureq::Error> 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::<Value>(&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<Discovery, String> {
|
||||
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<u64> {
|
||||
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<Discovery, String> {
|
||||
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<i64> = 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<Account> {
|
||||
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<Code, String> {
|
||||
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<Account, String> {
|
||||
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<Value, ApiError> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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);
|
||||
|
||||
@@ -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 <html> (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:<base>`, 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();
|
||||
}
|
||||
})();
|
||||
@@ -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<Option<String>>,
|
||||
pub words: Mutex<Words>,
|
||||
/// Which chats were being answered at the last look (None before the first look).
|
||||
running: Mutex<Option<HashMap<String, bool>>>,
|
||||
}
|
||||
|
||||
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<Done> {
|
||||
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::<Vec<_>>()})
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user