diff --git a/Cargo.lock b/Cargo.lock index 914a1e0a4..e8824be77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4257,6 +4257,7 @@ dependencies = [ "puffer-observability", "puffer-provider-openai", "puffer-provider-registry", + "puffer-provider-worldrouter", "puffer-resources", "puffer-runner-api", "puffer-runner-grpc", @@ -4566,12 +4567,27 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml", "sha2 0.10.9", "tempfile", "toml", "uuid", ] +[[package]] +name = "puffer-provider-worldrouter" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "rand 0.9.4", + "reqwest", + "serde", + "serde_json", + "url", + "uuid", +] + [[package]] name = "puffer-resources" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d651127c9..fbc32cdf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/puffer-observability", "crates/puffer-provider-openai", "crates/puffer-provider-registry", + "crates/puffer-provider-worldrouter", "crates/puffer-resources", "crates/puffer-runner-api", "crates/puffer-runner-local", diff --git a/apps/puffer-desktop/src-tauri/src/backend.rs b/apps/puffer-desktop/src-tauri/src/backend.rs index a618a1504..b1b050120 100644 --- a/apps/puffer-desktop/src-tauri/src/backend.rs +++ b/apps/puffer-desktop/src-tauri/src/backend.rs @@ -42,10 +42,22 @@ pub(crate) struct BackendState { fs_watches: Arc, browsers: browser::BrowserRegistry, turns: Mutex>>, + /// Optional handle to the puffer daemon child so we can forward + /// catalog-style RPCs (live `/v1/models`, etc.) to the daemon that owns + /// the real ProviderRegistry. None during plain unit tests where no + /// daemon was spawned — those code paths fall back to the hardcoded + /// descriptors instead. + daemon_launcher: Option>, } impl BackendState { pub(crate) fn new() -> Self { + Self::new_with_launcher(None) + } + + pub(crate) fn new_with_launcher( + daemon_launcher: Option>, + ) -> Self { let ptys = Arc::new(pty::PtyRegistry::new()); ptys.spawn_idle_pruner(); let browser_profile_root = app_home() @@ -56,6 +68,7 @@ impl BackendState { fs_watches: Arc::new(fs_watch::FsWatchRegistry::new()), browsers: browser::BrowserRegistry::new(browser_profile_root), turns: Mutex::new(HashMap::new()), + daemon_launcher, } } @@ -107,16 +120,50 @@ impl BackendState { )) } "load_settings_snapshot" => serde_value(self.load_settings_snapshot()?), - "login_with_oauth" => serde_value(self.load_settings_snapshot()?), + "login_with_oauth" => { + let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; + if provider_id == "worldrouter" { + // Detach: OAuth blocks up to 120 s on the localhost + // callback listener, which would freeze the GUI. The + // event emitter lets the spawned reaper thread fire + // `worldrouter:oauth-completed` so the GUI can swap + // the spinner for the "Connected" affordance the + // moment the puffer subprocess exits. + self.run_puffer_cli_auth_subcommand( + &["auth", "login", "worldrouter"], + false, + Some(events.clone()), + )?; + } + // For other providers (puffer/codex/claude), the existing behavior was a + // no-op — keep it as such; native CLI integrations handle their own login. + serde_value(self.load_settings_snapshot()?) + } "login_with_api_key" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; let api_key = string_param(¶ms, &["apiKey", "api_key"])?; - self.store_api_key(&provider_id, &api_key)?; + if provider_id == "worldrouter" { + self.run_puffer_cli_auth_subcommand( + &["auth", "set-api-key", "worldrouter", &api_key], + true, + None, + )?; + } else { + self.store_api_key(&provider_id, &api_key)?; + } serde_value(self.load_settings_snapshot()?) } "logout_provider" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; - self.remove_api_key(&provider_id)?; + if provider_id == "worldrouter" { + self.run_puffer_cli_auth_subcommand( + &["auth", "clear", "worldrouter"], + true, + None, + )?; + } else { + self.remove_api_key(&provider_id)?; + } serde_value(self.load_settings_snapshot()?) } "list_external_credentials" => serde_value(self.list_external_credentials()?), @@ -197,9 +244,10 @@ impl BackendState { "add_mcp_server" => serde_value(json!({"servers": self.list_mcp_servers()?})), "list_provider_models" => { let provider_id = string_param(¶ms, &["providerId", "provider_id"])?; + let models = self.resolve_provider_models(&provider_id); serde_value(json!({ "providerId": provider_id, - "models": self.provider_models(&provider_id), + "models": models, })) } "list_permissions" => serde_value(json!({ @@ -515,10 +563,48 @@ impl BackendState { }) } - fn provider_models(&self, provider_id: &str) -> Vec { + /// Returns the model catalog for `provider_id`. For most providers this + /// is just the hardcoded list in the free-function `provider_models`, + /// but providers whose catalog is too large to hardcode (currently + /// worldrouter — the relay exposes ~66 models discovered via live + /// `/v1/models`) are forwarded to the running puffer daemon's + /// `list_provider_models` RPC, which holds the merged ProviderRegistry. + /// Falls back to the hardcoded seed if the daemon isn't running or the + /// call fails — better to show the two seed models than a spinner that + /// never resolves. + fn resolve_provider_models(&self, provider_id: &str) -> Vec { + let canonical = canonical_backend_provider_id(provider_id); + if canonical == "worldrouter" { + if let Some(models) = self.fetch_daemon_provider_models(&canonical) { + return models; + } + } provider_models(provider_id) } + fn fetch_daemon_provider_models(&self, provider_id: &str) -> Option> { + let launcher = self.daemon_launcher.as_ref()?; + let handshake = launcher.current_handshake()?; + match crate::daemon_launcher::query_daemon_rpc( + &handshake, + "list_provider_models", + json!({ "providerId": provider_id }), + std::time::Duration::from_secs(8), + ) { + Ok(result) => result + .get("models") + .and_then(Value::as_array) + .cloned(), + Err(error) => { + eprintln!( + "corbina: daemon list_provider_models({provider_id}) failed: {error}; \ + falling back to descriptor seed" + ); + None + } + } + } + fn provider_auth_statuses(&self) -> Result> { let credentials = self.load_credentials()?; let mut out = Vec::new(); @@ -562,6 +648,31 @@ impl BackendState { }); } } + // worldrouter lives in puffer-cli's AuthStore (~/.puffer/auth.json), + // not in corbina's own credentials file. Surface it so the GUI shows + // "Connected" after a successful `puffer auth login worldrouter`. + // + // We override `kind` to "oauth" when the stored key is an + // `sk-worldrouter-…` token (minted via the /auth/exchange flow), + // even though AuthStore tags it as `api_key`. This lets the + // LoginView hide the "Paste API key" affordance for OAuth users + // and only show "Disconnect". + if let Some(entry) = read_puffer_cli_credential("worldrouter") { + let surfaced_kind = if entry.oauth_derived { + "oauth".to_string() + } else { + entry.kind + }; + out.push(AuthProviderStatusDto { + provider_id: "worldrouter".to_string(), + kind: surfaced_kind, + email: None, + expires_at_ms: None, + scopes: Vec::new(), + plan_type: Some(entry.summary), + organization_name: None, + }); + } Ok(out) } @@ -1020,6 +1131,110 @@ impl BackendState { fn save_sessions(&self, sessions: &[SessionRecord]) -> Result<()> { write_json(&sessions_file()?, sessions) } + + /// Runs a `puffer auth …` subcommand using the same binary + /// `daemon_launcher::resolve_puffer_binary()` would spawn. + /// + /// `wait = true` blocks until the subcommand exits (with a 30 s + /// cap). Inherits stdio so the user sees output in the dev + /// terminal. Only safe for fast commands (`set-api-key`, `clear`). + /// + /// `wait = false` detaches: spawn, return immediately, and tail + /// the child's stdout in a background thread. When the child + /// prints a `https://…` URL on its own line (puffer's `Open this + /// URL in your browser:` block), corbina itself opens the URL via + /// macOS `open`. The CLI's own `open` invocation runs in a + /// non-GUI context (subprocess of a Tauri host) and on macOS that + /// path silently fails to route through LaunchServices; corbina + /// itself is a proper GUI app so its `open` call works. + /// + /// Required for `auth login`, which sits on a localhost listener + /// for up to 120 s waiting for the browser callback; blocking the + /// Tauri command handler that long freezes the GUI. When `events` + /// is supplied with `wait = false`, the reaper thread fires + /// `worldrouter:oauth-completed` once the child exits so the + /// frontend can clear its "Opening browser…" spinner without + /// having to poll `load_settings_snapshot`. + fn run_puffer_cli_auth_subcommand( + &self, + args: &[&str], + wait: bool, + events: Option, + ) -> Result<()> { + let binary = crate::daemon_launcher::resolve_puffer_binary() + .context("failed to resolve puffer binary for auth subcommand")?; + let stdio_stdout = if wait { + std::process::Stdio::inherit() + } else { + std::process::Stdio::piped() + }; + let mut child = Command::new(&binary) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(stdio_stdout) + .stderr(std::process::Stdio::inherit()) + .spawn() + .with_context(|| format!("failed to spawn {} {:?}", binary.display(), args))?; + if !wait { + if let Some(stdout) = child.stdout.take() { + std::thread::spawn(move || tail_auth_subcommand_stdout(stdout)); + } + // Reap the child in the background so we don't leave a + // zombie, and (when we have an emitter) fan out the exit + // status as a Tauri event so the GUI can react. + let args_label = args.join(" "); + std::thread::spawn(move || { + let outcome = child.wait(); + if let Some(events) = events { + let (success, error) = match outcome { + Ok(status) if status.success() => (true, None), + Ok(status) => ( + false, + Some(format!("puffer {} exited with {}", args_label, status)), + ), + Err(err) => ( + false, + Some(format!("wait failed for puffer {}: {}", args_label, err)), + ), + }; + events.emit( + "worldrouter:oauth-completed", + json!({"success": success, "error": error}), + ); + } + }); + return Ok(()); + } + let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(30); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + anyhow::bail!("`puffer {}` exited with {}", args.join(" "), status); + } + return Ok(()); + } + Ok(None) => { + if started.elapsed() >= timeout { + let _ = child.kill(); + anyhow::bail!( + "`puffer {}` did not finish within {:?}", + args.join(" "), + timeout + ); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(error) => { + return Err(anyhow::Error::new(error).context(format!( + "wait failed for `puffer {}`", + args.join(" ") + ))); + } + } + } + } } fn ensure_session_cwd(cwd: &Path) -> Result<()> { @@ -1036,6 +1251,34 @@ fn ensure_session_cwd(cwd: &Path) -> Result<()> { .with_context(|| format!("failed to create session cwd {}", cwd.display())) } +/// Reads the detached `puffer auth login` child's stdout line by +/// line and, when it sees a URL (puffer's `Open this URL in your +/// browser:` block emits one `https://…` line followed by an empty +/// line), opens that URL via macOS `open` from corbina's own +/// process. Other stdout lines are forwarded to corbina's stderr so +/// they remain visible in dev logs without contending with the +/// detached child's own ttyless context. +fn tail_auth_subcommand_stdout(stdout: std::process::ChildStdout) { + let reader = BufReader::new(stdout); + let mut opened = false; + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if !opened && trimmed.starts_with("https://") { + opened = true; + let target = trimmed.to_string(); + eprintln!("[worldrouter oauth] opening browser at {target}"); + match Command::new("open").arg(&target).spawn() { + Ok(_) => {} + Err(error) => { + eprintln!("[worldrouter oauth] failed to launch `open`: {error}"); + } + } + continue; + } + eprintln!("[worldrouter oauth] {line}"); + } +} + fn run_agent_turn_thread( events: EventEmitter, browsers: browser::BrowserRegistry, @@ -1458,6 +1701,20 @@ fn build_provider_command( json_stream: false, }) } + "worldrouter" => { + let command = ensure_provider_command("puffer")?; + Ok(ProviderLaunch { + label: "WorldRouter".to_string(), + command, + args: vec![ + "--no-alt-screen".to_string(), + "--provider".to_string(), + "worldrouter".to_string(), + message.to_string(), + ], + json_stream: false, + }) + } other => bail!("unknown provider `{other}`"), } } @@ -2200,6 +2457,16 @@ fn provider_summaries() -> Vec { source_kind: "builtin".to_string(), source_path: None, }, + ProviderSummaryDto { + id: "worldrouter".to_string(), + display_name: "WorldRouter".to_string(), + base_url: "https://inference-api.worldrouter.ai".to_string(), + default_api: "openai-completions".to_string(), + model_count: provider_models("worldrouter").len(), + auth_modes: vec!["oauth".to_string(), "api_key".to_string()], + source_kind: "builtin".to_string(), + source_path: None, + }, ] } @@ -2207,6 +2474,10 @@ fn provider_models(provider_id: &str) -> Vec { match canonical_backend_provider_id(provider_id).as_str() { "puffer" => vec![model("default", "Default", "puffer", false)], "claude" => claude_models(), + "worldrouter" => vec![ + model("kimi-k2.6", "Kimi K2.6", "worldrouter", true), + model("qwen3.5-flash", "Qwen 3.5 Flash", "worldrouter", true), + ], _ => codex_app_server_models().unwrap_or_default(), } } @@ -2355,6 +2626,7 @@ fn default_model_for(provider: &str) -> Option { match canonical_backend_provider_id(provider).as_str() { "claude" => Some(DEFAULT_CLAUDE_MODEL.to_string()), "puffer" => Some(DEFAULT_PUFFER_MODEL.to_string()), + "worldrouter" => Some("kimi-k2.6".to_string()), _ => codex_app_server_catalog() .ok() .and_then(|catalog| catalog.default_model), @@ -2363,7 +2635,7 @@ fn default_model_for(provider: &str) -> Option { fn validate_provider_id(provider: &str) -> Result<()> { match canonical_backend_provider_id(provider).as_str() { - "puffer" | "codex" | "claude" => Ok(()), + "puffer" | "codex" | "claude" | "worldrouter" => Ok(()), other => bail!("unknown provider `{other}`"), } } @@ -2374,6 +2646,7 @@ fn canonical_backend_provider_id(provider: &str) -> String { "openai" | "codex" => "codex".to_string(), "anthropic" | "claude" => "claude".to_string(), "puffer" => "puffer".to_string(), + "worldrouter" => "worldrouter".to_string(), _ => trimmed.to_string(), } } @@ -2472,6 +2745,21 @@ fn provider_command(provider: &str) -> String { return trimmed.to_string(); } } + // For `puffer`, prefer the sibling-aware resolver used by the auth + // subcommand path. Packaged corbina ships `puffer` alongside the Tauri + // host but typically without adding it to PATH; without this fallback, + // chat-turn spawns would error even though OAuth login (which goes + // through `resolve_puffer_binary` directly) works. Only use the resolver + // result when it points at an actually-existing file, otherwise fall + // through to the PATH-based default so the error message in + // `ensure_provider_command` stays informative. + if provider == "puffer" { + if let Ok(path) = crate::daemon_launcher::resolve_puffer_binary() { + if path.exists() { + return path.display().to_string(); + } + } + } match provider { "claude" => "claude".to_string(), "puffer" => "puffer".to_string(), @@ -2910,3 +3198,57 @@ enum ProcessLine { Stdout(String), Stderr(String), } + +struct PufferCliCredentialSummary { + /// Raw `kind` field from puffer-cli's AuthStore (`"api_key"` or + /// `"oauth"` today). Preserved verbatim for callers that want the + /// untouched on-disk value. + kind: String, + /// Human-friendly one-liner suitable for the GUI's plan-type slot. + summary: String, + /// True when the stored credential is an `sk-worldrouter-…` token + /// minted by the corbina OAuth exchange flow (worldrouter writes + /// these into the same `api_key` slot as a manually pasted key). + oauth_derived: bool, +} + +fn read_puffer_cli_credential(provider_id: &str) -> Option { + let path = home_dir().join(".puffer/auth.json"); + let raw = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + let provider = parsed.get("providers")?.get(provider_id)?; + let kind = provider.get("kind")?.as_str()?.to_string(); + let mut oauth_derived = kind == "oauth"; + let summary = match kind.as_str() { + "api_key" => { + let key = provider.get("key").and_then(|v| v.as_str()).unwrap_or(""); + // worldrouter mints OAuth-exchanged tokens with the + // `sk-worldrouter-` prefix and persists them through the + // same `api_key` slot as manually pasted keys. Tagging them + // as OAuth lets the GUI render the right affordance. + if key.starts_with("sk-worldrouter-") { + oauth_derived = true; + } + let tail = key + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + if oauth_derived { + format!("oauth \u{2026}{}", tail) + } else { + format!("api_key \u{2026}{}", tail) + } + } + "oauth" => "oauth credential stored".to_string(), + other => other.to_string(), + }; + Some(PufferCliCredentialSummary { + kind, + summary, + oauth_derived, + }) +} diff --git a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs index 691064ab1..8a53387c3 100644 --- a/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs +++ b/apps/puffer-desktop/src-tauri/src/daemon_launcher.rs @@ -6,14 +6,17 @@ //! opens a local port-forward to the remote WebSocket so the frontend can //! continue to connect to `ws://127.0.0.1:/ws` transparently. -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::io::{BufRead, BufReader}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use tungstenite::{connect, Message}; +use url::Url; #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -83,6 +86,15 @@ impl DaemonLauncher { Self::default() } + /// Returns the handshake for the currently-running local daemon, if any. + /// Unlike `ensure_started`, this never spawns a new daemon — callers that + /// just want to talk to the existing daemon (e.g. forwarding a one-off + /// RPC) shouldn't trigger a fresh spawn as a side effect. + pub(crate) fn current_handshake(&self) -> Option { + let guard = self.child.lock().unwrap(); + guard.as_ref().map(|child| child.handshake.clone()) + } + /// Returns the handshake for the local daemon, starting it if needed. #[allow(dead_code)] pub(crate) fn ensure_started(&self) -> Result { @@ -220,6 +232,97 @@ check that sshd allows TCP forwarding and that the remote daemon really bound" } } +/// Synchronously runs a single JSON-RPC round trip against the puffer +/// daemon's WebSocket endpoint described by `handshake`. The daemon's wire +/// protocol is one JSON object per text frame; we send a `{ id, method, +/// params }` request and wait for the matching `{ id, ok, result | error }` +/// response (event frames in between are skipped). +/// +/// This is intentionally narrow: it's used by corbina to forward specific +/// catalog-style lookups (e.g. worldrouter's live `/v1/models` list) to the +/// daemon that already holds the live registry, without us having to +/// re-implement discovery here. Anything more chatty should use the +/// frontend's persistent DaemonClient instead. +pub(crate) fn query_daemon_rpc( + handshake: &DaemonHandshake, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + // Bolt the auth token onto the URL the way the frontend client does + // (`?token=…`). Without this the daemon returns 401 on upgrade. + let mut url = Url::parse(&handshake.url).context("parsing daemon handshake URL")?; + if !handshake.token.is_empty() { + let has_token = url + .query_pairs() + .any(|(name, _)| name == "token"); + if !has_token { + url.query_pairs_mut().append_pair("token", &handshake.token); + } + } + + let (mut socket, _response) = + connect(url.as_str()).with_context(|| format!("connecting to daemon at {}", handshake.url))?; + + // Constrain the underlying TCP socket so a hung daemon can't lock the + // model picker forever. tungstenite::connect returns a MaybeTlsStream + // wrapping the raw stream — best-effort apply read/write timeouts. + if let tungstenite::stream::MaybeTlsStream::Plain(stream) = socket.get_ref() { + let _ = stream.set_read_timeout(Some(timeout)); + let _ = stream.set_write_timeout(Some(timeout)); + } + + let id = uuid::Uuid::new_v4().to_string(); + let request = json!({ + "id": id, + "method": method, + "params": params, + }); + socket + .send(Message::Text(request.to_string())) + .with_context(|| format!("sending {method} to daemon"))?; + + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + let _ = socket.close(None); + return Err(anyhow!("daemon {method} timed out after {:?}", timeout)); + } + let message = socket + .read() + .with_context(|| format!("reading daemon response for {method}"))?; + let text = match message { + Message::Text(text) => text, + // Pings/Pongs/Binary aren't part of our request/response protocol — + // just keep reading until we see our text reply. + Message::Ping(payload) => { + let _ = socket.send(Message::Pong(payload)); + continue; + } + Message::Pong(_) | Message::Binary(_) | Message::Frame(_) => continue, + Message::Close(_) => { + return Err(anyhow!("daemon closed the WebSocket before responding")) + } + }; + let value: Value = serde_json::from_str(&text) + .with_context(|| format!("parsing daemon response: {text}"))?; + // Server-pushed events have an `event` field and no `id`; skip them. + let response_id = value.get("id").and_then(Value::as_str); + if response_id != Some(id.as_str()) { + continue; + } + let _ = socket.close(None); + if value.get("ok").and_then(Value::as_bool) == Some(false) { + let err = value + .get("error") + .map(|e| e.to_string()) + .unwrap_or_else(|| "(no error message)".to_string()); + return Err(anyhow!("daemon {method} failed: {err}")); + } + return Ok(value.get("result").cloned().unwrap_or(Value::Null)); + } +} + // try_wait returns Result> — a thin wrapper that ignores // ECHILD on platforms where the subprocess has already been reaped. #[allow(dead_code)] @@ -465,7 +568,7 @@ fn shell_quote(s: &str) -> String { /// sibling `puffer` binary next to the Tauri process (i.e. `cargo run`'s /// target directory); in release builds we fall back to the first `puffer` /// on `PATH`. -fn resolve_puffer_binary() -> Result { +pub(crate) fn resolve_puffer_binary() -> Result { let bin_name = if cfg!(windows) { "puffer.exe" } else { "puffer" }; if let Ok(explicit) = std::env::var("PUFFER_BINARY") { let path = PathBuf::from(explicit); diff --git a/apps/puffer-desktop/src-tauri/src/lib.rs b/apps/puffer-desktop/src-tauri/src/lib.rs index 2a1d53b27..48384074b 100644 --- a/apps/puffer-desktop/src-tauri/src/lib.rs +++ b/apps/puffer-desktop/src-tauri/src/lib.rs @@ -382,8 +382,11 @@ fn cancel_turn( } pub fn run() { - let backend = Arc::new(BackendState::new()); let launcher = Arc::new(DaemonLauncher::new()); + // Give the backend a handle to the launcher so it can forward + // catalog-style RPCs (e.g. worldrouter's live `/v1/models` list) to + // the daemon that already holds the merged provider registry. + let backend = Arc::new(BackendState::new_with_launcher(Some(launcher.clone()))); websocket::start_backend_ws(backend.clone()); Builder::default() diff --git a/apps/puffer-desktop/src/App.svelte b/apps/puffer-desktop/src/App.svelte index 83670e43c..2d34ea423 100644 --- a/apps/puffer-desktop/src/App.svelte +++ b/apps/puffer-desktop/src/App.svelte @@ -3,6 +3,7 @@ import TitleBar from "./lib/shell/TitleBar.svelte"; import Sidebar, { type ActiveAgent, type UserChip } from "./lib/shell/Sidebar.svelte"; + import { isTauri } from "./lib/shell/platform"; import { applyTweaksToDocument, defaultTweaks, @@ -185,6 +186,7 @@ let turnQuestionLookup = $state>({}); let replayTextByTurn: Record = {}; let sessionEventUnlisten: UnlistenFn | null = null; + let worldrouterOauthUnsubscribe: (() => void) | null = null; let subscribedSessionId: string | null = null; let sessionSubscriptionGeneration = 0; let liveSidebarSessionEventUnlisteners: Record = {}; @@ -1044,6 +1046,10 @@ window.removeEventListener("blur", armRecapBlurTimer); window.removeEventListener("focus", cancelRecapBlurTimer); window.removeEventListener("keydown", handleShellKeydown, true); + if (worldrouterOauthUnsubscribe) { + worldrouterOauthUnsubscribe(); + worldrouterOauthUnsubscribe = null; + } }; }); @@ -1067,6 +1073,18 @@ void ensureLocalDaemonClient() .then((client) => { attachDaemonClient(client); + // Fired by the Rust reaper thread when `puffer auth login + // worldrouter` exits. The handler that kicked off the flow + // (`handleOauthLogin`) keeps `authBusyProviderId` set so the + // GUI shows "Waiting for browser login…" until this event + // arrives. + if (worldrouterOauthUnsubscribe) worldrouterOauthUnsubscribe(); + worldrouterOauthUnsubscribe = client.on<{ success?: boolean; error?: string | null }>( + "worldrouter:oauth-completed", + (payload) => { + void handleWorldrouterOauthCompleted(payload); + } + ); }) .catch(() => { /* connection may be unavailable (web preview); stay idle */ @@ -1171,6 +1189,16 @@ authBusyProviderId = providerId; authError = null; try { + if (providerId === "worldrouter" && isTauri()) { + // Tauri: the host returns immediately after spawning the + // detached `puffer auth login worldrouter` subprocess and emits + // `worldrouter:oauth-completed` when the child exits. + statusMessage = "Opening browser — finish the login to continue."; + await loginWithOauth(providerId, remoteConnection); + // Intentionally leave `authBusyProviderId` set; the event + // handler will clear it. + return; + } settingsSnapshot = await loginWithOauth(providerId, remoteConnection); onboardingCompleted = hasAvailableAgentProvider(settingsSnapshot); onboarding = shouldShowOnboarding(settingsSnapshot); @@ -1182,8 +1210,49 @@ } catch (error) { authError = String(error); statusMessage = authError; + if (providerId === "worldrouter" && isTauri()) { + authBusyProviderId = null; + } } finally { - authBusyProviderId = null; + if (!(providerId === "worldrouter" && isTauri())) { + authBusyProviderId = null; + } + } + } + + async function handleWorldrouterOauthCompleted(payload: { + success?: boolean; + error?: string | null; + }) { + try { + if (payload?.success) { + statusMessage = "WorldRouter connected."; + await refreshSnapshot(); + if ((settingsSnapshot?.auth?.length ?? 0) > 0) { + onboarding = false; + } + await refreshGroups(); + } else { + const reason = payload?.error?.trim(); + authError = reason && reason.length > 0 + ? reason + : "WorldRouter login failed. Please try again."; + statusMessage = authError; + } + } finally { + // The OAuth flow has reached a terminal state regardless of + // success — clear the spinner so the user can retry. + if (authBusyProviderId === "worldrouter") { + authBusyProviderId = null; + } + } + } + + async function refreshSnapshot() { + try { + settingsSnapshot = await loadSettingsSnapshot(remoteConnection); + } catch (error) { + statusMessage = `Failed to refresh settings: ${error}`; } } diff --git a/apps/puffer-desktop/src/lib/api/daemonClient.ts b/apps/puffer-desktop/src/lib/api/daemonClient.ts index c5c847b9c..18d05d480 100644 --- a/apps/puffer-desktop/src/lib/api/daemonClient.ts +++ b/apps/puffer-desktop/src/lib/api/daemonClient.ts @@ -152,39 +152,50 @@ export class DaemonClient { } on(event: string, handler: (payload: T) => void): () => void { + const cleanups: (() => void)[] = []; + if (this.useWebSocket) { const wrapped = handler as (payload: unknown) => void; const listeners = this.eventListeners.get(event) ?? new Set(); listeners.add(wrapped); this.eventListeners.set(event, listeners); void this.connect().catch(() => {}); - return () => { + cleanups.push(() => { listeners.delete(wrapped); if (listeners.size === 0) this.eventListeners.delete(event); - }; + }); } - let active = true; - let unlisten: UnlistenFn | null = null; - const pending = listen("corbina:event", (nativeEvent) => { - if (!active) return; - const payload = nativeEvent.payload; - if (payload?.event === event) { - handler(payload.payload as T); - } - }); - void pending.then((next) => { - unlisten = next; - if (!active) unlisten(); - }); + // Tauri host events (e.g. corbina-emitted `worldrouter:oauth-completed`) + // arrive on the `corbina:event` channel and never traverse the daemon's + // WebSocket. Subscribe regardless of `useWebSocket` so Tauri-only events + // still reach handlers when the daemon happens to be a ws:// endpoint. + if (canInvokeTauri()) { + let active = true; + let unlisten: UnlistenFn | null = null; + const pending = listen("corbina:event", (nativeEvent) => { + if (!active) return; + const payload = nativeEvent.payload; + if (payload?.event === event) { + handler(payload.payload as T); + } + }); + void pending.then((next) => { + unlisten = next; + if (!active) unlisten(); + }); + cleanups.push(() => { + active = false; + if (unlisten) { + unlisten(); + } else { + void pending.then((next) => next()); + } + }); + } return () => { - active = false; - if (unlisten) { - unlisten(); - } else { - void pending.then((next) => next()); - } + for (const cleanup of cleanups) cleanup(); }; } diff --git a/apps/puffer-desktop/src/lib/components/LoginView.svelte b/apps/puffer-desktop/src/lib/components/LoginView.svelte index a304b90cf..1e1960e46 100644 --- a/apps/puffer-desktop/src/lib/components/LoginView.svelte +++ b/apps/puffer-desktop/src/lib/components/LoginView.svelte @@ -21,6 +21,7 @@ export let onLoginOauth: (providerId: string) => void = () => {}; export let onLoginApiKey: (providerId: string, apiKey: string) => void = () => {}; export let onImportExternal: (providerId: string, source: "claude" | "codex") => void = () => {}; + export let onLogout: (providerId: string) => void = () => {}; export let onRefresh: () => void = () => {}; let apiKeys: Record = {}; @@ -47,6 +48,11 @@ onLoginOauth(providerId); } + function submitLogout(providerId: string) { + if (busyProviderId) return; + onLogout(providerId); + } + function supports(provider: ProviderSummary, mode: string): boolean { return provider.authModes.includes(mode); } @@ -206,6 +212,8 @@ {@const candidates = importsByProvider[provider.id] ?? []} {@const auth = authForProvider(provider.id)} {@const authFree = providerRunsWithoutAuth(provider)} + {@const isBusy = busyProviderId === provider.id} + {@const pendingKey = (apiKeys[provider.id] ?? "").trim()}