diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index 610c13d..cbe1aa5 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -45,8 +45,9 @@ pub enum State { /// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link, /// the server cannot be reached, the link dropped). Waiting(String), - /// The CLI will not try again by itself: the server refused this computer's token, or the - /// login is missing. Linking again is the way out. + /// The server refused this computer's link, in its words (a token it no longer takes, or a + /// protocol it does not speak). The CLI will not try again by itself; linking again is the + /// way out. Only the server's refusal: a missing login is `NotLinked`. Refused(String), /// Another `lembas service` on this computer holds the link. Elsewhere(Elsewhere), @@ -108,6 +109,32 @@ pub fn from_link(link: &cli::Link, before: &State) -> State { } } +/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status +/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any +/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which +/// is the server's word only. None when it said nothing. +fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option { + if let Some(link) = stopped { + let why = link.detail.clone().unwrap_or_default(); + // The CLI's advice to its terminal user ("sign in again: lembas login") is the app's + // Link button here. + let why = why + .split(" — sign in again") + .next() + .unwrap_or("") + .trim() + .to_string(); + if !why.is_empty() && why != "stopped" { + return Some(State::Refused(why)); + } + } + let said = strip_prefix(said?); + if said.starts_with("not logged in") { + return Some(State::NotLinked); + } + Some(State::Failed(said)) +} + /// What a running process has said before its status file says anything: the one line the /// CLI's service writes while a terminal on this computer holds the link for `/remote`. fn from_output(line: &str) -> Option { @@ -252,15 +279,12 @@ impl Agent { Some(RESTART_FOR_UPDATE) => continue, Some(0) => { // It ended by itself without an error: the CLI's word for "starting again - // will not help" -- the server refused the token, or there is no login. - // Waiting a long while (linking again starts it at once). - let why = cli::link_of(pid) - .filter(|l| l.state == "stopped") - .and_then(|l| l.detail) - .or_else(|| said.map(|s| strip_prefix(&s))); - match why { - Some(why) => { - if !self.set(generation, State::Refused(why)) { + // will not help". Waiting a long while (linking again starts it at once). + let stopped = cli::link_of(pid).filter(|l| l.state == "stopped"); + let next = ended_cleanly(stopped.as_ref(), said.as_deref()); + match next { + Some(state) => { + if !self.set(generation, state) { return; } self.pause(generation, REFUSED_WAIT); @@ -436,6 +460,31 @@ fn strip_prefix(line: &str) -> String { mod tests { use super::*; + #[test] + fn refused_is_only_the_servers_word() { + let refused = link( + "stopped", + Some("not allowed to link — sign in again: lembas login"), + 1, + ); + assert_eq!( + ended_cleanly(Some(&refused), None), + Some(State::Refused("not allowed to link".into())) + ); + assert_eq!( + ended_cleanly( + None, + Some("lembas service: not logged in to any LLeMbas instance — lembas login
") + ), + Some(State::NotLinked) + ); + assert_eq!( + ended_cleanly(None, Some("lembas service: logged in to several instances")), + Some(State::Failed("logged in to several instances".into())) + ); + assert_eq!(ended_cleanly(None, None), None); + } + fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link { cli::Link { state: state.into(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e74037..fbd543b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,9 +9,10 @@ //! URL; the window refuses every navigation away from the app's page, and HTML the viewer shows //! is a sealed frame on a scheme of its own with no IPC and no network (`sealed.rs`). //! - **The agent is the CLI's**: the app's own sign-in also gives the agent's token, in the same -//! approval, and `lembas login --token-stdin` takes it (a server without that: `lembas login -//! --json`, a second approval); `lembas service run` is the device (`agent.rs`). The app reads -//! the CLI's state and runs its commands; it never writes the CLI's files. +//! approval, and `lembas login --token-stdin` takes it; *Link this computer* is a login for the +//! agent's token alone, handed over the same way. `lembas service run` is the device +//! (`agent.rs`). The app reads the CLI's state and runs its commands; it never writes the +//! CLI's files. //! - **A 2.0 server only.** Discovery is checked before anything else (`server::check`). mod agent; @@ -97,6 +98,8 @@ struct App { agent: Agent, tray: Mutex>, linking: Mutex, + /// Set to stop waiting for the approval of a link in progress. + link_cancel: Mutex>>, /// The newer release the last check found, if any. update: Mutex>, /// The agent's folders, as `lembas remote` last answered. @@ -286,9 +289,12 @@ enum SignIn { } /// Sign the app in to the server: a device login for the app's scopes, approved in the browser. -/// Where the server can (`one_approval`) and this computer is not linked to it yet, the same -/// approval links it: the agent's token goes to the CLI. A computer linked already keeps its -/// link; "Link again" is the way to replace it. +/// Where the server can (`one_approval`), the same approval asks for the agent's token too, unless +/// the agent's link works right now (the CLI says it is linked). Whether the CLI has a login for +/// this server on file is not asked: a login the server refuses is a file all the same, and +/// asking that left a computer with a refused link and no way to a new one from the sign-in. +/// After the approval the agent's token goes to the CLI -- whose `--token-stdin` login replaces +/// the one it had for this server -- or, when the link came up meanwhile, is given up. #[tauri::command] fn sign_in(handle: AppHandle) -> Result<(), String> { let app = handle.state::(); @@ -306,7 +312,7 @@ fn sign_in(handle: AppHandle) -> Result<(), String> { announce(&handle); std::thread::spawn(move || { let host = cli::hostname().unwrap_or_default(); - let with_agent = d.one_approval && !cli::linked_to(&d.base); + let with_agent = d.one_approval && !link_works(&handle.state::()); let result = server::start_sign_in(&d, &host, with_agent).and_then(|code| { let _ = handle .opener() @@ -346,7 +352,10 @@ fn sign_in(handle: AppHandle) -> Result<(), String> { let _ = handle.emit("signin", outcome); announce(&handle); if let Some(token) = agent_token { - if let Err(message) = hand_to_cli(&handle, &d.base, token) { + if link_works(&app) { + // Linked meanwhile (a service started by hand, say): this token is nobody's. + server::sign_out(&d.base, &token); + } else if let Err(message) = hand_to_cli(&handle, &d.base, token) { let _ = handle.emit("linking", Linking::Failed { message }); announce(&handle); } @@ -779,6 +788,24 @@ fn link_computer(handle: AppHandle) -> Result<(), String> { start_link(&handle) } +/// Stop waiting for the approval of a link in progress. +#[tauri::command] +fn link_cancel(handle: AppHandle) { + if let Some(c) = handle.state::().link_cancel.lock().unwrap().take() { + c.store(true, Ordering::SeqCst); + } +} + +/// Whether the agent's link works right now: the CLI says it is linked -- the app's own service, +/// or one started by hand. A login on file is not a link that works. +fn link_works(app: &App) -> bool { + match app.agent.state() { + AgentState::Running => true, + AgentState::Elsewhere(e) => e.linked, + _ => false, + } +} + fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> { let app = handle.state::(); let s = app.update(|s| s.agent = on)?; @@ -885,31 +912,49 @@ enum Linking { Failed { message: String }, } -/// Link this computer: `lembas login --json`, the approval opened in the system's -/// browser (where the person is signed in to the server), then the agent started. The way to -/// link again, and the only way on a server that gives no agent token with the app's sign-in. +/// Link this computer (again): a device login for the agent's scopes alone, the approval opened +/// in the system's browser (where the person is signed in to the server), the token handed to +/// the CLI (`lembas login --token-stdin`, which replaces a login it had for this server), then the +/// agent started. Not `lembas login `: for a server the CLI is logged in to already, that +/// only refreshes the login it has, so a token the server refused would stay refused. fn start_link(handle: &AppHandle) -> Result<(), String> { let app = handle.state::(); - let Some(server) = app.settings().server else { - return Err("Choose a server first.".into()); + let Some(d) = app.server.lock().unwrap().clone() else { + return Err("Choose a LLeMbas 2.0 server first.".into()); }; claim_linking(&app)?; - let child = match Command::new(cli::binary()) - .arg("login") - .arg(&server) - .arg("--json") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - *app.linking.lock().unwrap() = false; - return Err(format!("Could not start {}: {e}", cli::binary().display())); + let cancel = Arc::new(AtomicBool::new(false)); + *app.link_cancel.lock().unwrap() = Some(cancel.clone()); + let handle = handle.clone(); + std::thread::spawn(move || { + let host = cli::hostname().unwrap_or_default(); + let result = server::start_link(&d, &host).and_then(|code| { + let _ = handle + .opener() + .open_url(&code.verification_uri_complete, None::<&str>); + let _ = handle.emit( + "linking", + Linking::Code { + user_code: code.user_code.clone(), + uri: code.verification_uri_complete.clone(), + }, + ); + server::await_sign_in(&d, &code, &cancel) + }); + let app = handle.state::(); + app.link_cancel.lock().unwrap().take(); + let given = match result { + Ok(granted) => give_to_cli(&handle, &d.base, granted.account.token), + Err(message) => { + *app.linking.lock().unwrap() = false; + Err(message) + } + }; + if let Err(message) = given { + let _ = handle.emit("linking", Linking::Failed { message }); } - }; - follow_login(handle, child, None); + announce(&handle); + }); Ok(()) } @@ -924,6 +969,12 @@ fn hand_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), Stri server::sign_out(base, &token); return Err(e); } + give_to_cli(handle, base, token) +} + +/// `hand_to_cli` once linking is claimed: the CLI takes the token, or it is given up. +fn give_to_cli(handle: &AppHandle, base: &str, token: String) -> Result<(), String> { + let app = handle.state::(); let spawned = Command::new(cli::binary()) .args(["login", "--token-stdin", "--server", base, "--json"]) .stdin(Stdio::piped()) @@ -1460,6 +1511,7 @@ pub fn run() { open_external, set_agent, link_computer, + link_cancel, titlebar_menu, update_app, remote_refresh, @@ -1496,6 +1548,7 @@ pub fn run() { agent, tray: Mutex::new(None), linking: Mutex::new(false), + link_cancel: Mutex::new(None), update: Mutex::new(None), remote: Mutex::new(None), theme: Mutex::new(theme), diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 943eba2..7fc70ef 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -271,6 +271,18 @@ pub struct Code { /// What a device login asks the server for, as the form it posts. fn code_request(host: &str, with_agent: bool) -> Value { + code_request_for(host, Ask::App { with_agent }) +} + +/// Which login: the app's own (and the agent's beside it, in the same approval), or the agent's +/// alone -- linking this computer again, which needs no second token for the app. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Ask { + App { with_agent: bool }, + Agent, +} + +fn code_request_for(host: &str, ask: Ask) -> Value { let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); let device_name = if host.is_empty() { "LLeMbas Desktop".to_string() @@ -283,7 +295,13 @@ fn code_request(host: &str, with_agent: bool) -> Value { "device_platform": platform, "scope": APP_SCOPE, }); - if with_agent { + if ask == Ask::Agent { + // The agent's token alone: the device an agent chat runs on, named by its host. + body["scope"] = Value::String(AGENT_SCOPE.to_string()); + body["device_host"] = Value::String(host.to_string()); + return body; + } + if matches!(ask, Ask::App { with_agent: true }) { // The agent's token is the device an agent chat runs on: it carries this computer's // host, as `lembas login` sends it, so the app knows which device is this one. The // app's own token alone names no host: it is not a device. @@ -296,10 +314,22 @@ fn code_request(host: &str, with_agent: bool) -> Value { /// Open a device login for the app's scopes, and the agent's too when `with_agent` (only where /// discovery offered it). pub fn start_sign_in(d: &Discovery, host: &str, with_agent: bool) -> Result { + start_code(d, code_request(host, with_agent && d.one_approval)) +} + +/// Open a device login for the agent's scopes alone: linking this computer (again), with the +/// token handed to the CLI as the app's own approval hands it. A login the CLI made itself +/// (`lembas login `) would not do here: for a server it is logged in to already, that +/// only refreshes the login it has -- a token the server refuses stays refused. +pub fn start_link(d: &Discovery, host: &str) -> Result { + start_code(d, code_request_for(host, Ask::Agent)) +} + +fn start_code(d: &Discovery, form: Value) -> Result { let res = agent(Duration::from_secs(15)) .post(&d.device_code) .set("accept", "application/json") - .send_json(code_request(host, with_agent && d.one_approval)) + .send_json(form) .map_err(|e| oauth_error(e, "The server refused to start a sign-in"))?; let v: Value = res .into_json() @@ -793,6 +823,14 @@ mod tests { assert!(app_only.get("device_host").is_none()); } + #[test] + fn linking_again_asks_for_the_agents_token_alone_and_names_the_host() { + let link = code_request_for("box", Ask::Agent); + assert_eq!(link["scope"], "models memory link"); + assert_eq!(link["device_host"], "box"); + assert!(link.get("agent_scope").is_none()); + } + #[test] fn an_approval_gives_the_app_its_token_and_the_agent_its_own() { let v = json!({