The pass condition was the same screen, from the same crate, running in a window with only the layout differing. desktop-app is a new workspace member: a session list (plain iris::widget::Span, rebuilt on selection) beside transcript_ui::build_tree's screen, talking to a real ai-server through client-core's ApiClient/UreqTransport/follow_session_events, with background network I/O on plain std::threads reporting back through winit's EventLoopProxy rather than iris's Tasks (which only redraws once per async closure, not once per SSE event). Both pass-condition proofs held against app/ui-sandbox.sh's real server: the list showed a spawned session, selecting it loaded its transcript, and a message sent from the composer streamed its reply back live. Along the way, a real bug: resuming the SSE stream from a folded item's seq (which for a still-open assistant message is its *first* delta's seq by design) replayed already-folded deltas and duplicated the tail of the reply -- found by a run-headless.sh screenshot, fixed by resuming from the raw wire seq instead, and covered by a regression test. Deliberately simple and said so in app.rs's module doc: every SSE event refolds the whole transcript and rebuilds the right-hand tree from scratch rather than reaching for TranscriptScreen::push_row's incremental append, since a streaming reply is a row whose text keeps changing after it appears and push_row can only add a new one. Fine at a desktop session's scale; the real fix needs transcript-ui to expose updating a row in place. Android is untouched by this step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
96 lines
3.7 KiB
Rust
96 lines
3.7 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
fn parse_args() -> Result<Args, String> {
|
|
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<u8>), 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();
|
|
}
|