diff --git a/iris/Cargo.lock b/iris/Cargo.lock index d37cdf1..d39c8fb 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -922,6 +922,19 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "desktop-app" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "serde_json", + "tempfile", + "transcript-ui", + "winit", +] + [[package]] name = "dispatch" version = "0.2.0" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index f251bbd..ddb4445 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -73,7 +73,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui", "transcript-ui"] +members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate diff --git a/iris/desktop-app/Cargo.toml b/iris/desktop-app/Cargo.toml new file mode 100644 index 0000000..36547a5 --- /dev/null +++ b/iris/desktop-app/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "desktop-app" +version.workspace = true +edition.workspace = true + +# RUST.md's E4: the same transcript-ui screen (I5) in a winit window on the +# desktop, beside a session list, talking to a real `ai-server` through +# `client-core`'s REST + SSE clients. Enrolment reuses the phone's own +# `aiapp://enroll?...` link (`client-core::config`) rather than inventing a +# second format -- see DECISIONS.md's 2026-09-05 entry. An ordinary +# workspace member (unlike `android-app`): nothing here needs the NDK, so +# `cargo build --workspace --all-targets` at the host stays clean with it +# included. + +[dependencies] +iris = { path = ".." } +transcript-ui = { path = "../transcript-ui" } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +# Already pulled in transitively through client-core; used directly here +# only to persist `EnrolledServer` as the app's own tiny config file (see +# `config.rs`) -- no new dependency. +serde_json = { version = "1", features = ["float_roundtrip"] } +winit = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/iris/desktop-app/src/app.rs b/iris/desktop-app/src/app.rs new file mode 100644 index 0000000..28e86d2 --- /dev/null +++ b/iris/desktop-app/src/app.rs @@ -0,0 +1,538 @@ +//! RUST.md's E4: a session list on the left, `transcript-ui`'s screen (I5) +//! filling the rest, both against a real `ai-server` reached through +//! `client-core`. The layout is the simplest thing that shows both at +//! once -- a fixed-width column and `rest(1)` for everything else, using +//! `iris::widget::{Span, WidgetPtr}` the way `tabs-ui` already switches +//! panes, rather than anything desktop-specific: +//! +//! ```text +//! +-----------+--------------------------------------+ +//! | session | transcript_ui::TranscriptScreen | +//! | list | (List of folded rows + composer) | +//! | (WidgetPtr| | +//! | swapped | (WidgetPtr swapped whole on session | +//! | on data) | switch or a new transcript event) | +//! +-----------+--------------------------------------+ +//! ``` +//! +//! **Deliberately left simple, and why**: every incoming SSE event refolds +//! the *entire* transcript (`client_core::transcript_fold::fold_event` is +//! already `O(items)` and a desktop session's conversation is small) and +//! rebuilds the whole right-hand widget tree from scratch, rather than +//! reaching for `TranscriptScreen::push_row`'s incremental append. +//! `push_row` cannot update a row already on screen -- only append a new +//! one -- and a streaming assistant reply is exactly a row whose *text* +//! keeps changing after it first appears (see `transcript-ui`'s own doc on +//! `fold_event` folding deltas into one growing item). A full rebuild +//! shows that growth correctly at the cost of redrawing everything each +//! time; fine for this proof, wrong for a long, fast-streaming transcript +//! -- the incremental path that fixes it needs `transcript-ui` to expose +//! updating a row in place, which it does not yet. The composer's +//! in-progress text survives a rebuild (`rebuild_transcript`'s +//! `in_progress` local) since the user typing a followup while a reply +//! streams in is the one case a naive rebuild would otherwise lose data +//! on. +//! +//! Background network I/O (`client_core::api`/`event_stream`, both +//! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain +//! `std::thread`s that report back through `winit`'s `EventLoopProxy` +//! (`Proxy`), rather than through iris's own `Tasks`/`task_on`: +//! `Tasks` only requests a redraw once, after its whole async closure +//! finishes, which fits a single request-then-update but not a live SSE +//! loop that needs to be seen redrawing after *each* event it relays. +//! `Proxy::send_event` wakes the window's event loop immediately, once per +//! event, which is what a stream wants. + +use client_core::api::{ApiClient, SessionSummary, UreqTransport}; +use client_core::event_stream::{StreamItem, follow_session_events}; +use client_core::transcript_fold::{TranscriptItem, fold_event, group_tool_runs}; +use event_model::SeqEvent; +use iris::prelude::*; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// The session list column's width -- a fixed size for the simplest +/// layout that shows both panels at once (UI_RULES's text-truncation and +/// no-shrink rules apply to what's drawn inside it, not to this choice of +/// column width itself). +const LIST_WIDTH: f32 = 260.0; + +/// Everything a background thread hands back to the window's event loop. +/// `generation` on the session-scoped variants is the generation +/// `select_session` was on when the thread started (`Client::generation`) +/// -- compared back against the current one before being applied, so a +/// slow response from a session the reader has since clicked away from +/// can't overwrite what replaced it. +enum AppEvent { + Sessions(Result, String>), + TranscriptLoaded { + session_id: String, + generation: u64, + result: Result, String>, + }, + StreamEvent { + session_id: String, + generation: u64, + event: SeqEvent, + }, + StreamEnded { + session_id: String, + generation: u64, + message: Option, + }, + SendFailed(String), +} + +pub fn run() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +struct Client { + ui_state: DefaultUiState, + api: Arc>, + /// A second, independent `UreqTransport` to the same server, used only + /// by `select_session`'s live-follow loop. `ApiClient` keeps its + /// transport private (rightly -- nothing outside it should reach past + /// the typed calls), so a caller that also needs the raw + /// `Transport::stream` for SSE, as this one does, builds its own + /// rather than the crate growing a getter whose only purpose would be + /// letting one caller reach around its own abstraction. + stream_transport: Arc, + proxy: Proxy, + sessions: Vec, + selected: Option, + items: Vec, + list_ptr: WeakWidget, + transcript_ptr: WeakWidget, + screen: Option, + /// Bumped every time the selected session changes; see `AppEvent`'s + /// doc for what it guards against. + generation: Arc, +} + +impl DefaultAppState for Client { + type Event = AppEvent; + + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + proxy: Proxy, + ) -> Self { + // Re-validated here rather than threaded through from `main` -- + // `DefaultApp::run()` takes no payload, so there is no other way + // to get `main`'s parsed CLI/config into this constructor. `main` + // already called this once to fail fast before a window opens; + // this call only fails if the filesystem changed underneath the + // process in between, which is not a case worth a nicer message. + let (server, ca_pem) = crate::load_startup_config().unwrap_or_else(|e| { + eprintln!("desktop-app: {e}"); + std::process::exit(2); + }); + let build_transport = + || UreqTransport::new(server.base_url(), server.token.clone(), &ca_pem); + let (rest_transport, stream_transport) = build_transport() + .and_then(|rest| build_transport().map(|stream| (rest, stream))) + .unwrap_or_else(|e| { + eprintln!( + "desktop-app: couldn't set up TLS to {}: {e}", + server.base_url() + ); + std::process::exit(1); + }); + let api = Arc::new(ApiClient::new(rest_transport)); + let stream_transport = Arc::new(stream_transport); + + let list_ptr = WidgetPtr::new().add(rsc); + let transcript_ptr = WidgetPtr::new().add(rsc); + let loading = placeholder(rsc, "Loading sessions..."); + transcript_ptr(rsc).set(loading); + + (list_ptr.width(LIST_WIDTH), transcript_ptr.width(rest(1))) + .span(Dir::RIGHT) + .set_root(rsc, &mut ui_state); + + let client = Self { + ui_state, + api, + stream_transport, + proxy, + sessions: Vec::new(), + selected: None, + items: Vec::new(), + list_ptr, + transcript_ptr, + screen: None, + generation: Arc::new(AtomicU64::new(0)), + }; + client.spawn_fetch_sessions(); + client + } + + fn event(&mut self, event: AppEvent, rsc: &mut DefaultRsc, _render: &mut UiRenderState) { + match event { + AppEvent::Sessions(Ok(sessions)) => { + self.sessions = sessions; + self.rebuild_list(rsc); + if self.selected.is_none() { + self.show_message(rsc, "Select a session."); + } + } + AppEvent::Sessions(Err(message)) => { + self.show_message(rsc, &format!("Couldn't list sessions: {message}")); + } + AppEvent::TranscriptLoaded { + session_id, + generation, + result, + } => { + if self.current(&session_id, generation) { + match result { + Ok(items) => { + self.items = items; + self.rebuild_transcript(rsc); + } + Err(message) => { + self.show_message( + rsc, + &format!("Couldn't load {session_id}: {message}"), + ); + } + } + } + } + AppEvent::StreamEvent { + session_id, + generation, + event, + } => { + if self.current(&session_id, generation) { + self.items = fold_event(&self.items, &event); + self.rebuild_transcript(rsc); + } + } + AppEvent::StreamEnded { + session_id, + generation, + message: Some(message), + } => { + if self.current(&session_id, generation) { + eprintln!("desktop-app: {session_id}'s live connection ended: {message}"); + } + } + AppEvent::StreamEnded { .. } => {} + AppEvent::SendFailed(message) => { + eprintln!("desktop-app: couldn't send: {message}"); + } + } + self.ui_state.window.request_redraw(); + } +} + +impl Client { + fn current(&self, session_id: &str, generation: u64) -> bool { + self.selected.as_deref() == Some(session_id) + && self.generation.load(Ordering::SeqCst) == generation + } + + /// Replaces the right-hand panel with a line of text -- built before + /// `transcript_ptr` is reached for, since building the message and + /// swapping it in both need `rsc` and can't overlap as one borrow. + fn show_message(&mut self, rsc: &mut DefaultRsc, message: &str) { + let widget = placeholder(rsc, message); + (self.transcript_ptr)(rsc).set(widget); + } + + fn spawn_fetch_sessions(&self) { + let api = self.api.clone(); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let result = api.fetch_sessions().map_err(|e| e.to_string()); + let _ = proxy.send_event(AppEvent::Sessions(result)); + }); + } + + fn rebuild_list(&mut self, rsc: &mut DefaultRsc) { + let list = Span::empty(Dir::DOWN).gap(2).add(rsc); + for session in &self.sessions { + let selected = self.selected.as_deref() == Some(session.id.as_str()); + let row = session_row(rsc, session, selected); + list(rsc).push(row); + } + let tree = list + .background(rect(Color::rgb(24, 24, 28))) + .add_strong(rsc) + .any(); + (self.list_ptr)(rsc).set(tree); + } + + /// Selecting a session starts a fresh generation: any thread still + /// working for the previous one checks `Client::current` before + /// touching state, so a slow response for a session the reader has + /// clicked away from is silently dropped rather than overwriting what + /// replaced it. + fn select_session(&mut self, rsc: &mut DefaultRsc, session_id: String) { + let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.selected = Some(session_id.clone()); + self.items.clear(); + self.screen = None; + self.rebuild_list(rsc); + self.show_message(rsc, "Loading transcript..."); + + let api = self.api.clone(); + let stream_transport = self.stream_transport.clone(); + let proxy = self.proxy.clone(); + let live_generation = self.generation.clone(); + std::thread::spawn(move || { + // The most recent 200 events, coalesced -- plenty for a + // desktop proof; RUST.md's I3/history-paging work is what a + // real scrollback would reuse, out of scope here (E4 is only + // "the same screen runs in a window"). + let page: Result, String> = api + .fetch_transcript_page(&session_id, None, 200, true) + .map_err(|e| e.to_string()); + // The raw wire `seq` of the last line fetched -- not the seq of + // the last *folded item*. A `TranscriptItem::AssistantMsg` keeps + // the seq of the first delta it accumulated (`fold_event`'s own + // doc: "a row whose identity changed with every delta would be + // a new row every frame"), so resuming the live stream from + // that seq re-delivers every delta already folded into it, + // duplicating the tail of whatever reply was mid-stream when + // the page was fetched. Found by screenshotting a real reply + // through `run-headless.sh`: the assistant's line read "You + // said: ... testsaid: ... test", the back half being deltas 2 + // through N replayed onto an already-complete message. + let after = page + .as_ref() + .ok() + .and_then(|values| raw_seq(values.last()?)) + .unwrap_or(0); + let result = page.and_then(|values| fold_page(&values)); + let _ = proxy.send_event(AppEvent::TranscriptLoaded { + session_id: session_id.clone(), + generation, + result, + }); + + // Follows live from here in the same thread -- sequential + // rather than a second thread, since there is nothing to do + // with the stream until the page above has been sent anyway. + let stop = || live_generation.load(Ordering::SeqCst) != generation; + if stop() { + return; + } + let outcome = + follow_session_events(&*stream_transport, &session_id, after, |item| match item { + StreamItem::Open | StreamItem::Reset => !stop(), + StreamItem::Event { event, .. } => { + if stop() { + return false; + } + let _ = proxy.send_event(AppEvent::StreamEvent { + session_id: session_id.clone(), + generation, + event, + }); + true + } + }); + let _ = proxy.send_event(AppEvent::StreamEnded { + session_id, + generation, + message: outcome.err().map(|e| e.to_string()), + }); + }); + } + + fn send_message(&mut self, session_id: String, text: String) { + let api = self.api.clone(); + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + if let Err(e) = api.send_message(&session_id, &text, &[]) { + let _ = proxy.send_event(AppEvent::SendFailed(e.to_string())); + } + }); + } + + fn rebuild_transcript(&mut self, rsc: &mut DefaultRsc) { + let in_progress = self + .screen + .as_ref() + .map(|screen| screen.composer.field.edit(rsc).text.text().to_string()) + .filter(|t| !t.is_empty()); + + let rows = group_tool_runs(&self.items); + let (screen, tree) = transcript_ui::build_tree(rsc, rows); + + if let Some(text) = in_progress { + screen.composer.field.edit(rsc).set(&text); + } + if let Some(session_id) = self.selected.clone() { + let field = screen.composer.field; + rsc.register_event(field, Submit, move |ctx, rsc| { + let text = field.edit(rsc).take(); + let text = text.trim().to_string(); + if !text.is_empty() { + ctx.state.send_message(session_id.clone(), text); + } + }); + } + + (self.transcript_ptr)(rsc).set(tree); + self.screen = Some(screen); + } +} + +/// One row in the session list: title on top, status below, highlighted +/// when it's the one currently shown. +fn session_row( + rsc: &mut DefaultRsc, + session: &SessionSummary, + selected: bool, +) -> StrongWidget { + let bg = if selected { + Color::rgb(58, 90, 138) + } else { + Color::rgb(38, 38, 44) + }; + let id = session.id.clone(); + let label = format!("{}\n{}", session.title, session.status); + wtext(label) + .color(Color::WHITE) + .wrap(true) + .pad(10) + .width(rest(1)) + .background(rect(bg)) + .on( + CursorSense::click(), + move |ctx, rsc: &mut DefaultRsc| { + ctx.state.select_session(rsc, id.clone()); + }, + ) + .add_strong(rsc) + .any() +} + +fn placeholder(rsc: &mut DefaultRsc, message: &str) -> StrongWidget { + wtext(message.to_string()) + .color(Color::WHITE) + .wrap(true) + .pad(16) + .add_strong(rsc) + .any() +} + +/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s +/// `Vec`) into the flat item list `client_core::transcript_fold` +/// works over. A line this build can't parse fails the whole page rather +/// than being skipped -- CODE_RULES's "an enumeration must be able to say +/// 'it broke'" -- since silently dropping one event could hide, say, the +/// user message the composer is about to look like it never sent. +fn fold_page(values: &[serde_json::Value]) -> Result, String> { + let mut items = Vec::new(); + for value in values { + let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| { + format!("the server sent a transcript line this build couldn't parse: {e}") + })?; + items = fold_event(&items, &event); + } + Ok(items) +} + +/// The wire `seq` a raw transcript line carries -- see `select_session`'s +/// comment on why the live-stream cursor has to be this, not a folded +/// item's `seq()`. +fn raw_seq(value: &serde_json::Value) -> Option { + value.get("seq")?.as_u64() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(seq: u64, json: serde_json::Value) -> serde_json::Value { + let mut obj = json; + obj["seq"] = serde_json::json!(seq); + obj["ts"] = serde_json::json!(1.0); + obj + } + + /// The regression for the bug a real `run-headless.sh` screenshot + /// found (see `select_session`'s comment): resuming the live stream + /// from the last *item's* seq re-delivers the deltas already folded + /// into a still-open assistant message, doubling its tail. `raw_seq` + /// of the last wire line must be the true high-water mark instead, + /// which for a run of deltas is higher than every item's own `seq()`. + #[test] + fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() { + let values = vec![ + line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), + line( + 2, + serde_json::json!({"type": "assistantText", "delta": "a"}), + ), + line( + 3, + serde_json::json!({"type": "assistantText", "delta": "b"}), + ), + line( + 4, + serde_json::json!({"type": "assistantText", "delta": "c"}), + ), + ]; + let after = raw_seq(values.last().unwrap()).unwrap(); + assert_eq!(after, 4); + + let items = fold_page(&values).unwrap(); + // The folded item keeps the *first* delta's seq (2), which is + // exactly the value that must not be used as the resume cursor. + let assistant_seq = items + .iter() + .find(|i| matches!(i, TranscriptItem::AssistantMsg { .. })) + .unwrap() + .seq(); + assert_eq!(assistant_seq, 2); + assert_ne!( + after, assistant_seq, + "the fixed bug: these must differ here" + ); + } + + #[test] + fn a_page_folds_into_one_settled_assistant_message() { + let values = vec![ + line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), + line( + 2, + serde_json::json!({"type": "assistantText", "delta": "hel"}), + ), + line( + 3, + serde_json::json!({"type": "assistantText", "delta": "lo"}), + ), + ]; + let items = fold_page(&values).unwrap(); + assert_eq!( + items, + vec![ + TranscriptItem::UserMsg { + seq: 1, + text: "hi".to_string(), + attachments: Vec::new(), + }, + TranscriptItem::AssistantMsg { + seq: 2, + text: "hello".to_string(), + settled: false, + }, + ] + ); + } + + #[test] + fn an_unparseable_line_fails_the_whole_page() { + let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})]; + let err = fold_page(&values).unwrap_err(); + assert!(err.contains("couldn't parse")); + } +} diff --git a/iris/desktop-app/src/config.rs b/iris/desktop-app/src/config.rs new file mode 100644 index 0000000..5a1228f --- /dev/null +++ b/iris/desktop-app/src/config.rs @@ -0,0 +1,134 @@ +//! Where the desktop app keeps the enrollment it should not have to be +//! told about a second time: `client_core::config::EnrolledServer`, +//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`, +//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer +//! token, and the reason `client_core::config`'s own doc comment leaves +//! persistence and file mode to the caller. +//! +//! JSON rather than the project's usual RON: `wg-app-link`'s RON house +//! rules (`format`) are for configs a person hand-edits, and this file +//! never is one -- only this program ever writes or reads it, and +//! `serde_json` is already in the dependency graph through `client-core`, +//! so nothing new is added to reach for it. + +use client_core::config::EnrolledServer; +use std::io; +use std::path::{Path, PathBuf}; + +/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way +/// the XDG basedir spec says to when the variable is unset -- the same +/// fallback `wg_app_link::xdg::config_home` uses, reimplemented here +/// rather than depended on: that helper lives in the `wg-app-link` +/// submodule, which `server/` needs but this desktop-only crate does not, +/// and pulling in a git submodule for one path join would cost more than +/// it saves. +pub fn config_dir() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME").expect("HOME must be set"); + PathBuf::from(home).join(".config") + }); + base.join("ai-app-desktop") +} + +fn enrollment_file(dir: &Path) -> PathBuf { + dir.join("enrollment.json") +} + +/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in +/// the tests below), creating it if needed, and sets the file owner-only -- +/// it carries a bearer token, the same reason `server/`'s own token store +/// is 0600. +pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> { + std::fs::create_dir_all(dir)?; + let path = enrollment_file(dir); + let json = serde_json::to_vec_pretty(server) + .expect("EnrolledServer holds nothing that fails to serialise"); + std::fs::write(&path, json)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// `Ok(None)` when nothing has been enrolled yet, rather than an error -- +/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES' +/// "a deliberate choice is not a problem to report" applies just as well +/// to a file that simply hasn't been written yet). +pub fn load_enrollment_in(dir: &Path) -> io::Result> { + let path = enrollment_file(dir); + match std::fs::read(&path) { + Ok(bytes) => { + let server = serde_json::from_slice(&bytes).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{} is not a valid enrollment ({e})", path.display()), + ) + })?; + Ok(Some(server)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> { + save_enrollment_in(&config_dir(), server) +} + +pub fn load_enrollment() -> io::Result> { + load_enrollment_in(&config_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_saved_enrollment_reads_back_the_same() { + let dir = tempfile::tempdir().unwrap(); + let server = EnrolledServer { + host: "127.0.0.1".to_string(), + port: 8547, + token: "tok".to_string(), + }; + save_enrollment_in(dir.path(), &server).unwrap(); + let read_back = load_enrollment_in(dir.path()).unwrap(); + assert_eq!(read_back, Some(server)); + } + + #[test] + fn nothing_saved_yet_is_none_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(load_enrollment_in(dir.path()).unwrap(), None); + } + + #[test] + #[cfg(unix)] + fn the_saved_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let server = EnrolledServer { + host: "h".to_string(), + port: 1, + token: "t".to_string(), + }; + save_enrollment_in(dir.path(), &server).unwrap(); + let mode = std::fs::metadata(enrollment_file(dir.path())) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn a_corrupt_file_is_named_in_the_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(enrollment_file(dir.path()), b"not json").unwrap(); + let err = load_enrollment_in(dir.path()).unwrap_err(); + assert!(err.to_string().contains("enrollment.json")); + } +} diff --git a/iris/desktop-app/src/main.rs b/iris/desktop-app/src/main.rs new file mode 100644 index 0000000..a9c9cc9 --- /dev/null +++ b/iris/desktop-app/src/main.rs @@ -0,0 +1,95 @@ +//! RUST.md's E4: the transcript screen (`transcript-ui`, I5) in a real +//! winit window on the desktop, with a session list beside it, talking to +//! a real `ai-server` over `client-core`'s REST + SSE clients. See +//! `app.rs`'s module doc for the widget tree and the event flow. +//! +//! Usage: +//! +//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T' +//! desktop-app --ca /path/to/ca.pem # after the first run above +//! +//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a +//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather +//! than scanned, since a desktop has no camera to assume. It is parsed and +//! saved to `config::save_enrollment` once; later runs read it back and +//! `--link` is only needed again to enrol against a different server. The +//! CA is never persisted -- it is a public certificate whose path a +//! caller is expected to already know (`AGENTS.md`'s "prefer exercising +//! the server directly": the same `certs/ca.pem` a `curl --cacert` call +//! uses). + +mod app; +mod config; + +use client_core::config::EnrolledServer; + +struct Args { + ca_path: std::path::PathBuf, + link: Option, +} + +fn parse_args() -> Result { + let mut ca_path = None; + let mut link = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--ca" => { + ca_path = Some(std::path::PathBuf::from( + args.next().ok_or("--ca needs a path")?, + )) + } + "--link" => link = Some(args.next().ok_or("--link needs a value")?), + other => return Err(format!("unrecognised argument '{other}'")), + } + } + Ok(Args { + ca_path: ca_path.ok_or( + "--ca PATH is required (the pinned CA's certificate, e.g. \ + ~/.config/ai-app/certs/ca.pem)", + )?, + link, + }) +} + +/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled +/// server (freshly parsed from `--link`, or read back from last time) and +/// the CA's PEM bytes. Loading is a pure function of the process's own +/// argv and config file, so it is safe to call again from `Client::new` -- +/// see that call site's comment for why it is not threaded through some +/// other way (`DefaultApp::run()` takes no payload). +fn load_startup_config() -> Result<(EnrolledServer, Vec), String> { + let args = parse_args()?; + let server = match args.link { + Some(link) => { + let server = EnrolledServer::parse_link(&link)?; + config::save_enrollment(&server) + .map_err(|e| format!("couldn't save the enrollment: {e}"))?; + server + } + None => config::load_enrollment() + .map_err(|e| format!("couldn't read the saved enrollment: {e}"))? + .ok_or_else(|| { + format!( + "no server enrolled yet under {} -- pass --link 'aiapp://enroll?...' \ + once (app/ui-sandbox.sh's start banner prints one)", + config::config_dir().display() + ) + })?, + }; + let ca_pem = std::fs::read(&args.ca_path) + .map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?; + Ok((server, ca_pem)) +} + +fn main() { + // Validated once here so a bad `--ca`/`--link` is reported on stderr + // before any window opens; `Client::new` calls this same function + // again once the window exists, so this first call is a fast-fail + // rather than the only place the values come from. + if let Err(e) = load_startup_config() { + eprintln!("desktop-app: {e}"); + std::process::exit(2); + } + app::run(); +}