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:
HomerandClaude Opus 5.5 committed 2026-10-28 04:55:03 +00:00
1 parent af48856fcd
commit 96a2abe7e0
12 files changed
+2008 -474

No files matched your search

+246
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+131
View File
@@ -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");
}
}
+674
View File
@@ -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(&not_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();
}
}
+29 -6
View File
@@ -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);
-44
View File
@@ -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();
}
})();
+139
View File
@@ -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());
}
}