diff --git a/controlplane/src/auth/api-token.ts b/controlplane/src/auth/api-token.ts index 1766543c..eaa9e326 100644 --- a/controlplane/src/auth/api-token.ts +++ b/controlplane/src/auth/api-token.ts @@ -111,6 +111,34 @@ export async function revokeApiToken(env: Env, userId: string, id: string): Prom return (res.meta?.changes ?? 0) > 0; } +/** + * Revoke the PAT presented as `Authorization: Bearer` — self-revoke, so the desktop + * app can invalidate its own cloud credential on logout (the normal token-management + * routes are method-gated against PATs). Presenting the token IS the authorization; + * it can only ever revoke itself. No-op (still 200) if unknown/already revoked. + */ +export async function revokeSelfApiToken(request: Request, env: Env): Promise { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); + const auth = request.headers.get('Authorization') || ''; + const m = auth.match(/^Bearer\s+(.+)$/i); + const token = m?.[1]?.trim(); + if (!token || !token.startsWith(PAT_PREFIX)) { + return json({ error: 'not_a_pat' }, 400); + } + const tokenHash = await hashToken(token); + await env.DB.prepare( + `UPDATE api_tokens SET revoked_at = datetime('now') + WHERE token_hash = ? AND revoked_at IS NULL` + ) + .bind(tokenHash) + .run(); + return json({ ok: true }); +} + /** * Resolve a bearer PAT to a user. Returns null if the token is malformed, * unknown, revoked, or expired. Fail-closed. Updates last_used_at on success. diff --git a/controlplane/src/auth/google.ts b/controlplane/src/auth/google.ts index cfe7708f..1f3c07f4 100644 --- a/controlplane/src/auth/google.ts +++ b/controlplane/src/auth/google.ts @@ -1,12 +1,13 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: popup-login-v1-oauth-popup -const moduleRevision = "popup-login-v1-oauth-popup"; +// REVISION: desktop-login-v1-google-nonce-poll +const moduleRevision = "desktop-login-v1-google-nonce-poll"; console.log(`[auth/google] REVISION: ${moduleRevision} loaded at ${new Date().toISOString()}`); import type { Env } from '../types'; import { buildSessionCookie, createUserSession } from './sessions'; +import { createApiToken } from './api-token'; import { processPendingInvitations } from '../members/handler'; const GOOGLE_LOGIN_SCOPE = [ @@ -201,11 +202,30 @@ export async function loginWithGoogle( } const requestUrl = new URL(request.url); - const isPopupMode = requestUrl.searchParams.get('mode') === 'popup'; + const mode = requestUrl.searchParams.get('mode'); + const isPopupMode = mode === 'popup'; + // Desktop login: the app opens this in the OS browser and polls for the result + // by a nonce (the OS browser can't hand a session back to the Tauri webview). + // The result is a PAT (full credential), so it's protected with PKCE: the app + // sends the code_challenge here and the matching verifier when it polls. Works on + // any deployment; web popup/redirect login is untouched. + // Desktop login uses a LOOPBACK redirect (RFC 8252): the app runs a temporary + // 127.0.0.1 listener and passes it as redirect_uri. After sign-in we redirect the + // browser to that loopback with a one-time code — so the credential is delivered + // only to a listener on the machine that started the flow. A phished victim's + // browser hits THEIR own loopback, not the attacker's, which closes the earlier + // pollable-rendezvous hole. `state` is the app's CSRF token for its listener. + const isDesktopMode = mode === 'desktop'; + const desktopLoopback = isDesktopMode ? requestUrl.searchParams.get('redirect_uri') : null; + const desktopState = isDesktopMode ? requestUrl.searchParams.get('state') : null; + // PKCE (S256): binds the one-time code to a verifier the native app never puts on + // the wire. Even a browser extension / local process that observes the loopback + // callback URL (which carries the code) can't exchange it without the verifier. + const desktopChallenge = isDesktopMode ? requestUrl.searchParams.get('challenge') : null; // Validate Turnstile bot verification token (skip if not configured, e.g. dev) - // Skip Turnstile in popup mode — Google OAuth consent screen provides bot protection - if (env.TURNSTILE_SECRET_KEY && !isPopupMode) { + // Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection + if (env.TURNSTILE_SECRET_KEY && !isPopupMode && !isDesktopMode) { const turnstileToken = requestUrl.searchParams.get('turnstile_token'); if (!turnstileToken) { return renderErrorPage('Bot verification required. Please try again.'); @@ -220,11 +240,27 @@ export async function loginWithGoogle( } } + if (isDesktopMode && (!desktopLoopback || !desktopState || !desktopChallenge || !isLoopbackRedirect(desktopLoopback))) { + return renderErrorPage('Desktop sign-in requires a loopback redirect and PKCE challenge.'); + } + const state = crypto.randomUUID(); const redirectUri = `${getRedirectBase(request, env)}/auth/google/callback`; - // In popup mode, store sentinel "popup" so callback knows to render completion page - const postLoginRedirect = isPopupMode ? 'popup' : resolvePostLoginRedirect(request, env); - + // Sentinel in the state's redirect slot tells the callback how to finish: + // - "popup" → postMessage completion page (web popup login) + // - "desktoplb:" → mint a one-time code, redirect to the app's + // loopback so it can exchange the code for a PAT + // - a URL → normal 302 redirect + session cookie + const postLoginRedirect = isPopupMode + ? 'popup' + : isDesktopMode + ? `desktoplb:${btoa(JSON.stringify({ uri: desktopLoopback, st: desktopState, ch: desktopChallenge }))}` + : resolvePostLoginRedirect(request, env); + + // Sweep abandoned desktop codes on every sign-in start (no scheduler needed). + if (isDesktopMode) { + await cleanupStaleDesktopCodes(env); + } await createAuthState(env, state, postLoginRedirect); const url = new URL('https://accounts.google.com/o/oauth2/v2/auth'); @@ -320,6 +356,50 @@ export async function callbackGoogle( // Process any pending dashboard invitations for this email await processPendingInvitations(env, userId, userInfo.email); + // Desktop login (loopback): mint a one-time code, stash the identity (short + // expiry), and redirect the browser to the app's 127.0.0.1 listener with the + // code. The PAT is minted only when the app exchanges the code (exchangeDesktopCode) + // — so an abandoned flow never creates a token, and the code reaches only a + // listener on the machine that started the flow (not a global poll an attacker + // could hit). Re-validate the loopback target before redirecting. + if (postLoginRedirect.startsWith('desktoplb:')) { + let uri: string; + let st: string; + let ch: string; + try { + const dec = JSON.parse(atob(postLoginRedirect.slice('desktoplb:'.length))) as { + uri: string; + st: string; + ch: string; + }; + uri = dec.uri; + st = dec.st; + ch = dec.ch; + } catch { + return renderErrorPage('Desktop sign-in state was corrupt.'); + } + if (!isLoopbackRedirect(uri)) { + return renderErrorPage('Invalid desktop redirect target.'); + } + const code = `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, ''); + // Store ONLY the opaque userId (+ PKCE challenge + expiry) — no email/name — so + // an abandoned/never-swept row on an inactive install holds no PII. The identity + // is fetched from the users table at exchange time. + await createAuthState( + env, + `desktopcode:${code}`, + JSON.stringify({ + userId, + challenge: ch, // PKCE S256 — exchanged only with the matching verifier + expiresAt: Date.now() + 5 * 60 * 1000, // 5 min to exchange + }) + ); + const dest = new URL(uri); + dest.searchParams.set('code', code); + dest.searchParams.set('state', st); + return Response.redirect(dest.toString(), 302); + } + const session = await createUserSession(env, userId); const cookie = buildSessionCookie(request, session.id, session.expiresAt); @@ -344,6 +424,122 @@ export async function callbackGoogle( }); } + +/** A redirect target usable by the desktop loopback flow: http on 127.0.0.1/::1/ + * localhost only. Enforced at login AND before the callback redirect so a crafted + * redirect_uri can't send a signed-in victim's code anywhere but their own machine. */ +function isLoopbackRedirect(uri: string): boolean { + try { + const u = new URL(uri); + if (u.protocol !== 'http:') return false; + return u.hostname === '127.0.0.1' || u.hostname === '::1' || u.hostname === 'localhost'; + } catch { + return false; + } +} + +/** Desktop-minted cloud PATs get a bounded lifetime so a leaked/orphaned one dies + * on its own even if logout's server-side revoke never runs (offline logout). */ +const DESKTOP_PAT_TTL_DAYS = 90; + +/** Best-effort sweep of abandoned desktop auth codes (cancelled sign-in / loopback + * timeout), which hold userId/email/name. The code TTL is 5 min; delete rows older + * than 15 min by created_at so PII doesn't linger indefinitely. Called on the hot + * paths (login start, exchange) so no scheduler is needed. */ +async function cleanupStaleDesktopCodes(env: Env): Promise { + try { + await env.DB.prepare( + "DELETE FROM auth_states WHERE state LIKE 'desktopcode:%' AND created_at < datetime('now','-15 minutes')" + ).run(); + } catch { + /* best-effort */ + } +} + +/** base64url(SHA-256(input)) — PKCE S256 challenge computation. */ +async function sha256Base64Url(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); + let bin = ''; + for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Desktop app exchanges the one-time `code` it received on its 127.0.0.1 listener + * (from the OAuth callback redirect) for its cloud PAT + identity. The code alone is + * NOT sufficient — the app must also present the PKCE `verifier` whose S256 hash + * matches the challenge sent at login, so a local observer of the loopback callback + * URL can't exchange the code it saw. Atomically single-use (DELETE … RETURNING) and + * short-lived; the PAT is bounded by a TTL. + */ +export async function exchangeDesktopCode(request: Request, env: Env): Promise { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); + + let body: { code?: string; verifier?: string }; + try { + body = (await request.json()) as { code?: string; verifier?: string }; + } catch { + return json({ error: 'bad_request' }, 400); + } + const { code, verifier } = body; + if (!code || !verifier) { + return json({ error: 'missing_code_or_verifier' }, 400); + } + await cleanupStaleDesktopCodes(env); + + const key = `desktopcode:${code}`; + // Peek first — do NOT consume on a bad verifier, or a local observer who saw the + // code could invalidate every legit sign-in by exchanging first with a bogus + // verifier. Only after PKCE passes do we atomically consume (compare-and-delete on + // the exact stored value, so concurrent success requests still can't double-mint). + const rec = await env.DB + .prepare('SELECT redirect_url as v FROM auth_states WHERE state = ?') + .bind(key) + .first<{ v: string }>(); + if (!rec) { + return json({ error: 'not_found' }, 404); + } + let parsed: { userId: string; challenge?: string; expiresAt?: number }; + try { + parsed = JSON.parse(rec.v); + } catch { + // Corrupt row — consume it so it can't wedge, then report. + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); + return json({ error: 'corrupt' }, 500); + } + if (parsed.expiresAt && Date.now() > parsed.expiresAt) { + await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run(); + return json({ error: 'expired' }, 410); + } + if (!parsed.challenge || (await sha256Base64Url(verifier)) !== parsed.challenge) { + return json({ error: 'forbidden' }, 403); // wrong verifier — leave the code intact + } + // PKCE verified — atomically consume this exact record. A concurrent request that + // already deleted it makes this affect 0 rows → treat as already used. + const consumed = await env.DB + .prepare('DELETE FROM auth_states WHERE state = ? AND redirect_url = ? RETURNING redirect_url as v') + .bind(key, rec.v) + .first<{ v: string }>(); + if (!consumed) { + return json({ error: 'not_found' }, 404); + } + const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop', DESKTOP_PAT_TTL_DAYS); + // Fetch the identity now (it wasn't stored in the temporary record). + const user = await env.DB + .prepare('SELECT email, name FROM users WHERE id = ?') + .bind(parsed.userId) + .first<{ email: string; name: string }>(); + return json({ + token, + email: user?.email ?? '', + name: user?.name || user?.email?.split('@')[0] || '', + }); +} + function renderLoginCompletePage( frontendOrigin: string, cookie: string, diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index 1694884b..9e22eaa4 100644 --- a/controlplane/src/auth/middleware.ts +++ b/controlplane/src/auth/middleware.ts @@ -77,7 +77,7 @@ export async function authenticate( * the host frontend (which sends the matching X-Orcabot-Surface header) may use * dev-auth. When unset (cloud / local dev / older builds), enforcement is off. */ -function devAuthSurfaceTrusted(request: Request, env: Env): boolean { +export function devAuthSurfaceTrusted(request: Request, env: Env): boolean { const expected = env.SURFACE_TOKEN; if (!expected) return true; // Header for fetch/XHR; query param for top-level browser navigations (OAuth diff --git a/controlplane/src/dashboards/handler.ts b/controlplane/src/dashboards/handler.ts index ee0a9a34..8f068387 100644 --- a/controlplane/src/dashboards/handler.ts +++ b/controlplane/src/dashboards/handler.ts @@ -28,6 +28,7 @@ function fоrmatDashbоard(row: Record): Dashboard & { secretsC updatedAt: row.updated_at as string, secretsCount: row.secrets_count !== undefined ? Number(row.secrets_count) : undefined, linkedCount: row.linked_count !== undefined ? Number(row.linked_count) : undefined, + cloudId: (row.cloud_id as string | null) ?? null, }; } @@ -177,18 +178,26 @@ export async function getDashbоard( export async function createDashbоard( env: Env, userId: string, - data: { name: string; templateId?: string }, + data: { name: string; templateId?: string; cloudId?: string }, ctx?: Pick, preferredRegion?: string, ): Promise { const id = generateId(); const now = new Date().toISOString(); - // Create dashboard + // Create dashboard. cloud_id (desktop downloads only) is set via a follow-up + // UPDATE rather than in this INSERT, so the INSERT never references the column — + // keeps working on a deployment whose schema migration hasn't added it yet, and + // the UPDATE only runs on desktop where the column exists. await env.DB.prepare(` INSERT INTO dashboards (id, name, owner_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `).bind(id, data.name, userId, now, now).run(); + if (data.cloudId) { + await env.DB.prepare(`UPDATE dashboards SET cloud_id = ? WHERE id = ?`) + .bind(data.cloudId, id) + .run(); + } // Add owner as member await env.DB.prepare(` diff --git a/controlplane/src/db/schema.ts b/controlplane/src/db/schema.ts index aa1aa28a..23a572b6 100644 --- a/controlplane/src/db/schema.ts +++ b/controlplane/src/db/schema.ts @@ -23,6 +23,10 @@ CREATE TABLE IF NOT EXISTS dashboards ( name TEXT NOT NULL, owner_id TEXT NOT NULL REFERENCES users(id), setup_guide TEXT, + -- Desktop: when this dashboard was downloaded from a cloud account, the id of + -- the source cloud dashboard (for the downloaded ✓ state + two-way sync). NULL + -- for purely-local dashboards. + cloud_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -1145,7 +1149,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_link_edge_b ON link_edge_map(link_id, edge `; // Initialize the database -const SCHEMA_REVISION = "schema-v15-api-tokens"; +const SCHEMA_REVISION = "schema-v16-dashboard-cloud-id"; export async function initializeDatabase(db: D1Database): Promise { console.log(`[schema] REVISION: ${SCHEMA_REVISION} loaded at ${new Date().toISOString()}`); @@ -1189,6 +1193,14 @@ export async function initializeDatabase(db: D1Database): Promise { // Column already exists. } + try { + await db.prepare(` + ALTER TABLE dashboards ADD COLUMN cloud_id TEXT + `).run(); + } catch { + // Column already exists. + } + try { await db.prepare(` ALTER TABLE egress_blocked_defaults ADD COLUMN revoked_at TEXT diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 24b228d0..85105b93 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -11,7 +11,7 @@ console.log(`[controlplane] REVISION: controlplane-v17-pty-ws-edge-debug loaded */ import type { Env, DashboardItem, RecipeStep, Session } from './types'; -import { authenticate, requireAuth, rejectPatAuth, requireInternalAuth, validateMcpAuth, type AuthContext } from './auth/middleware'; +import { authenticate, requireAuth, rejectPatAuth, requireInternalAuth, validateMcpAuth, devAuthSurfaceTrusted, type AuthContext } from './auth/middleware'; import { checkRateLimitIp, checkRateLimitUser } from './ratelimit/middleware'; import { initializeDatabase } from './db/schema'; import { ensureDb, type EnvWithDb } from './db/remote'; @@ -48,7 +48,7 @@ import { isAdminEmail } from './auth/admin'; import { getSubscriptionStatus, hasActiveAccess, isExemptEmail } from './subscriptions/check'; import * as subscriptions from './subscriptions/handler'; import { buildSessionCookie, createUserSession } from './auth/sessions'; -import { createApiToken, listApiTokens, revokeApiToken } from './auth/api-token'; +import { createApiToken, listApiTokens, revokeApiToken, revokeSelfApiToken } from './auth/api-token'; import { checkAndCacheSandbоxHealth, getCachedHealth } from './health/checker'; import { sendEmail, buildInterestThankYouEmail, buildInterestNotificationEmail, buildTemplateReviewEmail } from './email/resend'; import * as blog from './blog/handler'; @@ -1009,6 +1009,18 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick(); + let resolvedId = existing?.id; + if (!resolvedId) { + const now = new Date().toISOString(); + await env.DB + .prepare('INSERT INTO users (id, email, name, created_at, trial_started_at) VALUES (?, ?, ?, ?, ?)') + .bind(headerUserId, userEmail, userName, now, now) + .run(); + resolvedId = headerUserId; + } - const session = await createUserSession(env, auth.user!.id); + const session = await createUserSession(env, resolvedId); const cookie = buildSessionCookie(request, session.id, session.expiresAt); - return new Response(null, { - status: 204, + return new Response(JSON.stringify({ id: resolvedId, email: userEmail }), { + status: 200, headers: { + 'Content-Type': 'application/json', 'Set-Cookie': cookie, }, }); @@ -1389,7 +1430,7 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick String { + app.package_info().version.to_string() +} + +#[derive(Serialize, Clone)] +pub struct OrcabotAccount { + pub email: String, + pub name: String, +} + +/// Verify an orcabot.com personal access token and return its account identity. +/// Runs from the native layer (not the webview) so it isn't subject to browser +/// CORS, and the token is only ever sent to the FIXED cloud control-plane URL — +/// a compromised webview can't redirect it elsewhere. The desktop app keeps +/// running on the LOCAL control plane; this only confirms the account and reads +/// the email/name to use as the local identity. +/// +/// Async: the blocking HTTP call (up to 15s on a slow/offline network) runs on a +/// blocking thread so it never freezes the native UI/IPC event loop during sign-in. +#[tauri::command] +pub async fn verify_orcabot_account(token: String) -> Result { + tauri::async_runtime::spawn_blocking(move || verify_orcabot_account_blocking(&token)) + .await + .map_err(|e| format!("sign-in task failed: {e}"))? +} + +fn verify_orcabot_account_blocking(token: &str) -> Result { + let token = token.trim(); + if !token.starts_with("orca_pat_") { + return Err("That doesn't look like an Orcabot token (starts with orca_pat_).".into()); + } + // Fixed to the public cloud control plane on purpose (token exfil guard). + let url = "https://api.orcabot.com/users/me"; + match ureq::get(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(15)) + .call() + { + Ok(resp) => { + let body: serde_json::Value = resp + .into_json() + .map_err(|e| format!("unexpected response from orcabot.com: {e}"))?; + let email = body["user"]["email"].as_str().unwrap_or("").trim().to_string(); + if email.is_empty() { + return Err("That account has no email — can't sign in.".into()); + } + let name = body["user"]["name"] + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(&email) + .to_string(); + Ok(OrcabotAccount { email, name }) + } + Err(ureq::Error::Status(401, _)) => { + Err("That token was rejected. Create a fresh one on orcabot.com and try again.".into()) + } + Err(ureq::Error::Status(code, _)) => { + Err(format!("orcabot.com returned an error ({code}).")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +// ---- Cloud account credential (for dashboard sync) ------------------------- +// The signed-in cloud PAT + email, stored host-only (0600) so the app can list +// and download the user's cloud dashboards. A PAT is full account access, so it +// NEVER enters the sandbox VM or the webview beyond the initial sign-in. All +// cloud calls go through the native layer (no browser CORS, token stays in Rust). + +const CLOUD_API_BASE: &str = "https://api.orcabot.com"; + +fn cloud_credential_path(app: &tauri::AppHandle) -> Option { + use tauri::Manager; + app.path().app_data_dir().ok().map(|d| d.join("cloud-credential")) +} + +/// (token, email, origin). `origin` is "google" for a desktop-minted cloud PAT, +/// "pat" for a user-pasted token, or "" for legacy files. Only "google" tokens are +/// safe to revoke on logout (a pasted PAT may be shared with the CLI/automation). +fn read_cloud_credential_full(app: &tauri::AppHandle) -> Option<(String, String, String)> { + let path = cloud_credential_path(app)?; + let contents = std::fs::read_to_string(path).ok()?; + let mut lines = contents.lines(); + let token = lines.next()?.trim().to_string(); + let email = lines.next().unwrap_or("").trim().to_string(); + let origin = lines.next().unwrap_or("").trim().to_string(); + if token.is_empty() { + return None; + } + Some((token, email, origin)) +} + +fn read_cloud_credential(app: &tauri::AppHandle) -> Option<(String, String)> { + read_cloud_credential_full(app).map(|(t, e, _)| (t, e)) +} + +/// Remove the credential file, retrying a transient lock. Ok on success or NotFound; +/// Err otherwise — callers must NOT clear ownership state (COMMITTED_GEN) on Err, so +/// a later attempt can retry rather than losing track of a still-present credential. +fn remove_credential_file(app: &tauri::AppHandle) -> Result<(), String> { + let path = match cloud_credential_path(app) { + Some(p) => p, + None => return Ok(()), + }; + let mut last_err: Option = None; + for attempt in 0..3 { + match std::fs::remove_file(&path) { + Ok(()) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + last_err = Some(e); + if attempt < 2 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + } + } + Err(format!( + "failed to remove stored credential: {}", + last_err.map(|e| e.to_string()).unwrap_or_default() + )) +} + +#[derive(Serialize, Clone)] +pub struct CloudAccount { + pub email: String, +} + +/// Persist the cloud credential (PAT + email) host-only (0600), atomically. +/// Write to a temp file created 0600, then rename over the target — so the token is +/// never briefly world-readable (umask race) and any pre-existing loose-permission +/// file is replaced by a 0600 one. Permission failures are fatal. +fn write_cloud_credential( + app: &tauri::AppHandle, + token: &str, + email: &str, + origin: &str, +) -> Result<(), String> { + let path = cloud_credential_path(app).ok_or("no app data dir")?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let contents = format!("{}\n{}\n{}\n", token, email.trim(), origin); + let tmp = path.with_extension("tmp"); + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp) + .map_err(|e| format!("failed to store credential: {e}"))?; + f.write_all(contents.as_bytes()) + .map_err(|e| format!("failed to store credential: {e}"))?; + let _ = f.sync_all(); + } + #[cfg(not(unix))] + { + std::fs::write(&tmp, &contents) + .map_err(|e| format!("failed to store credential: {e}"))?; + } + std::fs::rename(&tmp, &path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("failed to store credential: {e}") + })?; + Ok(()) +} + +/// Persist the cloud credential (PAT + email) host-only (0600) for dashboard sync. +#[tauri::command] +pub fn set_cloud_credential(app: tauri::AppHandle, token: String, email: String) -> Result<(), String> { + let token = token.trim(); + if !token.starts_with("orca_pat_") { + return Err("Not an Orcabot token.".into()); + } + // Under the lock: claim a generation, write, and record it as the committing + // generation — so an in-flight Google flow (between its own check and write) + // can't overwrite this pasted token, and a stale Google rollback won't delete it. + { + let _guard = cred_lock(); + let g = SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + // "pat" origin — a user-pasted token, possibly shared with the CLI/automation, + // so logout must NOT revoke it server-side (only forget it locally). + write_cloud_credential(&app, token, &email, "pat")?; + COMMITTED_GEN.store(g, std::sync::atomic::Ordering::SeqCst); + } + Ok(()) +} + +#[derive(Serialize, Clone)] +pub struct CloudSignIn { + pub email: String, + pub name: String, + /// The attempt id (generation) that wrote this credential — the frontend passes + /// it back to rollback_sign_in if this attempt turns out to be stale/cancelled. + pub attempt: u64, +} + +/// Monotonic "current sign-in attempt" generation. Bumped when the user cancels, +/// starts another sign-in, or pastes a PAT — so an in-flight loopback sign-in can +/// tell it's been superseded and must NOT exchange or overwrite the credential. +static SIGN_IN_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Serializes every credential mutation (write / gen-check+write / clear) so the +/// generation check and the file write happen atomically — otherwise a cancel, +/// logout, or PAT paste could interleave between the check and the write and a +/// stale sign-in could restore or clobber a credential. No await is held across it. +static CRED_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn cred_lock() -> std::sync::MutexGuard<'static, ()> { + CRED_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The generation (attempt id) that wrote the CURRENT stored credential, or 0 if +/// none / it was cleared. Lets a superseded sign-in roll back ONLY its own write: +/// if a newer sign-in or a pasted PAT has since written, this won't match and the +/// rollback is a no-op (so it can't delete someone else's credential). +static COMMITTED_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn sign_in_current(my_gen: u64) -> bool { + SIGN_IN_GEN.load(std::sync::atomic::Ordering::SeqCst) == my_gen +} + +/// base64url (no padding) — matches the control plane's PKCE challenge encoding. +fn b64url(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(T[((n >> 6) & 63) as usize] as char); + } + if chunk.len() > 2 { + out.push(T[(n & 63) as usize] as char); + } + } + out +} + +/// PKCE S256 challenge: base64url(SHA-256(verifier)). +fn pkce_challenge(verifier: &str) -> String { + use sha2::{Digest, Sha256}; + b64url(&Sha256::digest(verifier.as_bytes())) +} + +/// Cryptographically-random hex token (OS RNG via /dev/urandom; the OS-seeded +/// RandomState as a fallback). Used as the loopback CSRF `state`. +fn random_hex(n: usize) -> String { + #[cfg(unix)] + { + use std::io::Read; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + let mut buf = vec![0u8; n]; + if f.read_exact(&mut buf).is_ok() { + return buf.iter().map(|b| format!("{b:02x}")).collect(); + } + } + } + use std::hash::{BuildHasher, Hasher}; + let mut s = String::new(); + while s.len() < n * 2 { + let h = std::collections::hash_map::RandomState::new() + .build_hasher() + .finish(); + s.push_str(&format!("{h:016x}")); + } + s.truncate(n * 2); + s +} + +/// Percent-encode a URL query value (unreserved chars pass through). +fn pct(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +fn open_in_browser(url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]); + c + }; + cmd.arg(url) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to open browser: {e}")) +} + +fn parse_query(path: &str) -> (Option, Option) { + let q = path.splitn(2, '?').nth(1).unwrap_or(""); + let mut code = None; + let mut state = None; + for kv in q.split('&') { + let mut it = kv.splitn(2, '='); + match (it.next(), it.next()) { + (Some("code"), Some(v)) => code = Some(v.to_string()), + (Some("state"), Some(v)) => state = Some(v.to_string()), + _ => {} + } + } + (code, state) +} + +/// Wait (bounded) for the OAuth callback on the loopback listener; return the +/// one-time `code` once a `/cb?code=…&state=…` request arrives with our state. +fn await_loopback_code( + listener: std::net::TcpListener, + expect_state: &str, + my_gen: u64, +) -> Result { + use std::io::{Read, Write}; + use std::time::{Duration, Instant}; + listener.set_nonblocking(true).ok(); + let deadline = Instant::now() + Duration::from_secs(180); + loop { + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } + if Instant::now() > deadline { + return Err("timed out waiting for the browser sign-in".into()); + } + match listener.accept() { + Ok((mut stream, _)) => { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]); + let path = req + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or(""); + let (code, state) = parse_query(path); + if !path.starts_with("/cb") || code.is_none() { + // Stray request (favicon, etc.) — brush it off and keep waiting. + let _ = + stream.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"); + continue; + } + let ok = state.as_deref() == Some(expect_state); + let page = if ok { + "Signed in

Signed in to Orcabot

You can close this tab and return to the app.

" + } else { + "Sign-in failed

Sign-in couldn't be verified

Please try again from the app.

" + }; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}", + page.len(), + page + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + if !ok { + return Err("sign-in verification failed (state mismatch)".into()); + } + return Ok(code.unwrap()); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(e) => return Err(format!("loopback listener error: {e}")), + } + } +} + +fn exchange_desktop_code(code: &str, verifier: &str) -> Result<(String, String, String), String> { + let url = format!("{CLOUD_API_BASE}/auth/desktop/exchange"); + match ureq::post(&url) + .timeout(std::time::Duration::from_secs(30)) + .send_json(serde_json::json!({ "code": code, "verifier": verifier })) + { + Ok(rp) => { + let v: serde_json::Value = rp.into_json().map_err(|e| e.to_string())?; + let token = v + .get("token") + .and_then(|x| x.as_str()) + .ok_or("sign-in response had no token")? + .to_string(); + let email = v.get("email").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + Ok((token, email, name)) + } + Err(ureq::Error::Status(c, rp)) => Err(format!( + "sign-in exchange failed ({c}): {}", + rp.into_string().unwrap_or_default().trim() + )), + Err(e) => Err(format!("couldn't reach orcabot.com: {e}")), + } +} + +/// Sign in to the cloud with Google via a LOOPBACK redirect (RFC 8252): run a +/// temporary 127.0.0.1 listener, open the browser to the cloud login pointing back +/// at it, receive a one-time code there, exchange it for a PAT, and store the PAT +/// host-only. The token never enters the webview. Returns {email,name} for the UI. +#[tauri::command] +pub async fn sign_in_google_loopback(app: tauri::AppHandle) -> Result { + // Claim a fresh attempt generation (under the lock, so it's part of the same + // serialized state machine as cancel/write). Any later cancel / sign-in / PAT + // paste bumps it, so this flow refuses to exchange or store once superseded. + let my_gen = { + let _guard = cred_lock(); + SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 + }; + + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("could not start local sign-in listener: {e}"))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let state = random_hex(16); + // PKCE: keep the verifier in-process; send only its S256 challenge in the URL. + let verifier = random_hex(32); + let challenge = pkce_challenge(&verifier); + let redirect = format!("http://127.0.0.1:{port}/cb"); + let login_url = format!( + "{CLOUD_API_BASE}/auth/google/login?mode=desktop&redirect_uri={}&state={}&challenge={}", + pct(&redirect), + pct(&state), + pct(&challenge) + ); + open_in_browser(&login_url)?; + + let (token, email, name) = tauri::async_runtime::spawn_blocking( + move || -> Result<(String, String, String), String> { + let code = await_loopback_code(listener, &state, my_gen)?; + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } + exchange_desktop_code(&code, &verifier) + }, + ) + .await + .map_err(|e| format!("sign-in task failed: {e}"))??; + + // Final guard: don't overwrite the credential if the attempt was cancelled or + // superseded (e.g. the user pasted a PAT for a different account meanwhile). + // Atomic gen-check + write: hold the lock across both so a cancel / PAT paste + // can't slip between them (it would bump the gen or write a different account). + { + let _guard = cred_lock(); + if !sign_in_current(my_gen) { + return Err("sign-in cancelled".into()); + } + // "google" origin — desktop-minted, so logout revokes it server-side. + write_cloud_credential(&app, &token, &email, "google")?; + COMMITTED_GEN.store(my_gen, std::sync::atomic::Ordering::SeqCst); + } + Ok(CloudSignIn { email, name, attempt: my_gen }) +} + +/// Roll back a specific sign-in attempt's credential (called by the frontend when a +/// resolved sign-in turns out to have been superseded/cancelled). Deletes + revokes +/// ONLY if that attempt still owns the stored credential; if a newer sign-in or a +/// pasted PAT wrote since, this is a no-op (can't clobber the current one). +#[tauri::command] +pub async fn rollback_sign_in(app: tauri::AppHandle, attempt: u64) -> Result<(), String> { + let (creds, delete_error) = { + let _guard = cred_lock(); + if attempt == 0 || COMMITTED_GEN.load(std::sync::atomic::Ordering::SeqCst) != attempt { + return Ok(()); // a newer write owns the credential — leave it + } + let creds = read_cloud_credential_full(&app); + // Only relinquish ownership after deletion succeeds. On failure, keep the + // mapping so the UI can retry this exact attempt without risking a newer + // credential. Still attempt to revoke the server token below, limiting the + // exposure of the leftover file whenever the cloud is reachable. + let delete_error = match remove_credential_file(&app) { + Ok(()) => { + COMMITTED_GEN.store(0, std::sync::atomic::Ordering::SeqCst); + None + } + Err(e) => Some(e), + }; + (creds, delete_error) + }; + if let Some((token, _email, origin)) = creds { + if origin == "google" { + let _ = tauri::async_runtime::spawn_blocking(move || { + let _ = ureq::post(&format!("{CLOUD_API_BASE}/auth/api-token/revoke-self")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(10)) + .call(); + }) + .await; + } + } + match delete_error { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// Cancel an in-flight loopback sign-in: bumps the attempt generation so the native +/// flow stops before exchanging the code or writing the credential. +#[tauri::command] +pub fn cancel_google_sign_in() { + // Under the lock so it's serialized with the sign-in's check+write. A cancel that + // still races an already-committed write is cleaned up by the frontend (it calls + // clear_cloud_credential when the resolved sign-in was cancelled). + let _guard = cred_lock(); + SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); +} + +/// The signed-in cloud account (email), or null if not signed in to the cloud. +#[tauri::command] +pub fn get_cloud_account(app: tauri::AppHandle) -> Option { + read_cloud_credential(&app).map(|(_, email)| CloudAccount { email }) +} + +/// Forget the stored cloud credential (sign out of cloud sync). Deletes the local +/// file FIRST (before any await) so a concurrent re-sign-in that writes a new +/// credential can't be clobbered by this logout's late deletion. Then, only for a +/// desktop-minted ("google") token, revokes it server-side (best-effort; the TTL is +/// the offline backstop). A user-pasted PAT is only forgotten locally — it may be +/// shared with the CLI/automation, so we must not revoke it globally. +#[tauri::command] +pub async fn clear_cloud_credential(app: tauri::AppHandle) -> Result<(), String> { + // Under the lock: supersede in-flight sign-ins, capture the token, and delete the + // file — all before any await, so a concurrent re-sign-in can't be clobbered. + let creds = { + let _guard = cred_lock(); + SIGN_IN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let creds = read_cloud_credential_full(&app); + // Only NotFound is fine; any other failure means the credential may still be + // on disk (it would sign the user back in on restart). Report it so the UI + // can surface it, and keep ownership state until the delete actually succeeds. + remove_credential_file(&app)?; + COMMITTED_GEN.store(0, std::sync::atomic::Ordering::SeqCst); + creds + }; + if let Some((token, _email, origin)) = creds { + if origin == "google" { + let _ = tauri::async_runtime::spawn_blocking(move || { + let _ = ureq::post(&format!("{CLOUD_API_BASE}/auth/api-token/revoke-self")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(10)) + .call(); + }) + .await; + } + } + Ok(()) +} + +/// List the signed-in user's CLOUD dashboards from api.orcabot.com using the +/// stored PAT. Native (no browser CORS; token never leaves Rust). Returns the raw +/// JSON so the frontend can render the list + mark which are downloaded locally. +#[tauri::command] +pub async fn list_cloud_dashboards(app: tauri::AppHandle) -> Result { + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + tauri::async_runtime::spawn_blocking(move || { + match ureq::get(&format!("{CLOUD_API_BASE}/dashboards")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(20)) + .call() + { + Ok(resp) => resp + .into_json::() + .map_err(|e| format!("unexpected response from orcabot.com: {e}")), + Err(ureq::Error::Status(401, _)) => { + Err("Cloud session expired — sign in again.".into()) + } + Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } + }) + .await + .map_err(|e| format!("list task failed: {e}"))? +} + +/// Fetch one cloud dashboard's full data (dashboard + items + edges) from +/// api.orcabot.com using the stored PAT, so the frontend can materialize it into +/// the local DB (the download). Native — no CORS, token stays in Rust. +#[tauri::command] +pub async fn get_cloud_dashboard( + app: tauri::AppHandle, + dashboard_id: String, +) -> Result { + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + tauri::async_runtime::spawn_blocking(move || { + match ureq::get(&format!("{CLOUD_API_BASE}/dashboards/{dashboard_id}")) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(30)) + .call() + { + Ok(resp) => resp + .into_json::() + .map_err(|e| format!("unexpected response from orcabot.com: {e}")), + Err(ureq::Error::Status(401, _)) => { + Err("Cloud session expired — sign in again.".into()) + } + Err(ureq::Error::Status(code, _)) => Err(format!("orcabot.com returned {code}.")), + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } + }) + .await + .map_err(|e| format!("fetch task failed: {e}"))? +} + +// ===== Cloud workspace download (per-dashboard file copy) ===== +// +// Downloading a cloud dashboard copies its canvas (frontend) AND its workspace +// files (this command). The desktop has ONE shared /workspace, so to keep two +// downloaded dashboards from colliding we write each dashboard's files into a +// per-dashboard subfolder `/workspace/` (subdir = the new local +// dashboard id); the recreated terminals get `workingDir=` so they open +// there. Mirrors the CLI `pull`: start/reuse a cloud session, list the workspace +// recursively, GET each file, write it locally with an O_NOFOLLOW-guarded walk. +// Secret values are redacted server-side on read, so secrets never transfer. + +#[derive(Serialize, Clone)] +pub struct WorkspaceDownloadResult { + pub written: u64, + pub skipped: u64, + /// false when the cloud dashboard has no terminal/session — nothing to pull + /// (not an error; a notes-only dashboard has no workspace files). + pub had_workspace: bool, +} + +/// Progress for a workspace download, emitted as `cloud-workspace-progress` so the +/// UI can show what's happening during a slow cold cloud-VM boot (otherwise a +/// legitimately slow pull looks like a hang). Keyed by `cloud_id`. +#[derive(Serialize, Clone)] +pub struct CloudWorkspaceProgress { + pub cloud_id: String, + /// "starting" | "booting" | "copying" + pub phase: String, + pub written: u64, +} + +/// GET a JSON body from the cloud with the PAT. Maps the paywall to a sentinel. +fn cloud_get_json(token: &str, url: &str) -> Result { + match ureq::get(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(30)) + .call() + { + Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + let b = rp.into_string().unwrap_or_default(); + if c == 403 && b.contains("SUBSCRIPTION_REQUIRED") { + return Err("SUBSCRIPTION_REQUIRED".into()); + } + Err(format!("orcabot.com returned {c}.")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +fn cloud_post_json( + token: &str, + url: &str, + body: serde_json::Value, + timeout_secs: u64, +) -> Result { + match ureq::post(url) + .set("Authorization", &format!("Bearer {token}")) + .timeout(std::time::Duration::from_secs(timeout_secs)) + .send_json(body) + { + Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + let b = rp.into_string().unwrap_or_default(); + if c == 403 && b.contains("SUBSCRIPTION_REQUIRED") { + return Err("SUBSCRIPTION_REQUIRED".into()); + } + Err(format!("orcabot.com returned {c}.")) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +/// List ONE directory's immediate children (non-recursive). We walk the tree +/// ourselves so we can prune excluded dirs (node_modules/.git/…) instead of a +/// server-side recursive walk that enumerates every file first — that blew the +/// request timeout (and the 100k-entry cap) on real projects. `dir` is a +/// workspace path like "/" or "/src". +fn cloud_dir_list(token: &str, sid: &str, dir: &str) -> Result, String> { + let url = format!("{CLOUD_API_BASE}/sessions/{sid}/files"); + match ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .query("path", dir) + .timeout(std::time::Duration::from_secs(30)) + .call() + { + Ok(rp) => { + let v: serde_json::Value = rp.into_json().unwrap_or(serde_json::Value::Null); + Ok(v.get("files").and_then(|x| x.as_array()).cloned().unwrap_or_default()) + } + Err(ureq::Error::Status(401, _)) => Err("Cloud session expired — sign in again.".into()), + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(format!("Couldn't reach orcabot.com: {e}")), + } +} + +/// Cap on a single downloaded file held in memory. The control plane already 413s +/// file reads over 50 MB; this is a defensive client-side bound so one huge +/// artifact can't exhaust desktop memory even if that cap changes. +const MAX_DOWNLOAD_FILE_BYTES: u64 = 64 * 1024 * 1024; + +fn cloud_file_get(token: &str, sid: &str, rel: &str) -> Result, String> { + use std::io::Read; + let url = format!("{CLOUD_API_BASE}/sessions/{sid}/file"); + match ureq::get(&url) + .set("Authorization", &format!("Bearer {token}")) + .query("path", rel) + .timeout(std::time::Duration::from_secs(120)) + .call() + { + Ok(rp) => { + let mut buf = Vec::new(); + rp.into_reader() + .take(MAX_DOWNLOAD_FILE_BYTES + 1) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.len() as u64 > MAX_DOWNLOAD_FILE_BYTES { + return Err("file exceeds size limit".into()); + } + Ok(buf) + } + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(e.to_string()), + } +} + +/// A transient/retryable failure: the cloud sandbox is provisioning (proxy 503/ +/// 502/504) or a connection blipped. The session can read "active" before the +/// sandbox HTTP is actually serving, so the first file calls need to be retried. +fn is_transient_err(e: &str) -> bool { + e.contains("HTTP 503") + || e.contains("HTTP 502") + || e.contains("HTTP 504") + || e.contains("Network Error") + || e.contains("reset") + || e.contains("timed out") +} + +/// List a directory, retrying transient failures (sandbox warming up) with a 3s +/// backoff. Use a large `attempts` for the first (root) list — that's the window +/// where the just-started sandbox may still be booting its HTTP server. +fn cloud_dir_list_ready( + token: &str, + sid: &str, + dir: &str, + attempts: u32, +) -> Result, String> { + let mut last = String::new(); + for i in 0..attempts.max(1) { + match cloud_dir_list(token, sid, dir) { + Ok(v) => return Ok(v), + Err(e) if is_transient_err(&e) => { + last = e; + if i + 1 < attempts { + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + Err(e) => return Err(e), + } + } + Err(last) +} + +/// GET a file, retrying transient failures a few times (2s backoff). +fn cloud_file_get_ready(token: &str, sid: &str, rel: &str) -> Result, String> { + let mut last = String::new(); + for i in 0..4 { + match cloud_file_get(token, sid, rel) { + Ok(v) => return Ok(v), + Err(e) if is_transient_err(&e) => { + last = e; + if i < 3 { + std::thread::sleep(std::time::Duration::from_secs(2)); + } + } + Err(e) => return Err(e), + } + } + Err(last) +} + +/// Get a live cloud session id for `dash` whose sandbox is actually running, so +/// the file API works. We do NOT trust a bare "active" DB status — that can point +/// at a reaped VM, and the file proxy then hangs forever trying to reach a dead +/// machine. Instead we always POST /session, which runs ensureDashboardSandbox on +/// the control plane: it restarts a stopped machine and reprovisions a dead +/// session. We never CREATE a terminal item (no phantom blocks); returns None when +/// the dashboard has no terminal item at all. `on_boot` fires each poll (progress). +fn cloud_ensure_session( + token: &str, + dash: &str, + on_boot: &dyn Fn(), +) -> Result, String> { + let dash_url = format!("{CLOUD_API_BASE}/dashboards/{dash}"); + let v = cloud_get_json(token, &dash_url)?; + + let item_id = v + .get("items") + .and_then(|x| x.as_array()) + .and_then(|items| { + items + .iter() + .find(|it| it.get("type").and_then(|x| x.as_str()) == Some("terminal")) + .and_then(|it| it.get("id").and_then(|x| x.as_str()).map(String::from)) + }); + let item_id = match item_id { + Some(i) => i, + None => return Ok(None), + }; + + // Always POST — ensureDashboardSandbox restarts a stopped machine / reprovisions + // a dead session. It cold-boots a Fly VM and may hold the request open until + // provisioned, so allow well past a cold boot (not 30s). Idempotent when the + // sandbox is already healthy. + eprintln!("[cloud-dl] ensuring sandbox for terminal {item_id}"); + cloud_post_json( + token, + &format!("{CLOUD_API_BASE}/dashboards/{dash}/items/{item_id}/session"), + serde_json::json!({}), + 180, + )?; + + // Poll for the session to go active (cloud spins up a VM — allow generous time). + for _ in 0..120 { + on_boot(); + std::thread::sleep(std::time::Duration::from_secs(2)); + let v = cloud_get_json(token, &dash_url)?; + if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { + for s in sessions { + if s.get("itemId").and_then(|x| x.as_str()) == Some(item_id.as_str()) + && s.get("status").and_then(|x| x.as_str()) == Some("active") + { + if let Some(id) = s.get("id").and_then(|x| x.as_str()) { + return Ok(Some(id.to_string())); + } + } + } + } + } + Err("timed out waiting for your cloud workspace to start".into()) +} + +/// Regenerable caches / transients / runtime state we never transfer (mirrors the +/// CLI's `ws_excluded`). +fn ws_excluded(rel: &str) -> bool { + let rel = rel.trim_start_matches('/'); + rel.starts_with(".browser") + || rel.starts_with(".npm") + || rel == ".orcabot" + || rel.starts_with(".orcabot/") + || rel.starts_with(".claude/cache") + || rel == ".git" + || rel.starts_with(".git/") + || rel.split('/').any(|seg| seg == "node_modules") +} + +/// Lexical/ancestor pre-filter for a remote-supplied workspace-relative path. +/// Rejects `..`, absolute paths, and writes through an in-workspace symlink whose +/// nearest existing ancestor escapes the root. The authoritative guard is the +/// O_NOFOLLOW walk in `safe_workspace_write`. (Mirrors the CLI helper.) +fn safe_workspace_dest(ws_canon: &Path, rel: &str) -> Option { + let rel_path = Path::new(rel); + for c in rel_path.components() { + if !matches!(c, Component::Normal(_) | Component::CurDir) { + return None; + } + } + let dest = ws_canon.join(rel_path); + let mut anc = dest.parent(); + while let Some(a) = anc { + if a.exists() { + match a.canonicalize() { + Ok(real) if real.starts_with(ws_canon) => break, + _ => return None, + } + } + anc = a.parent(); + } + Some(dest) +} + +/// Write `data` to `rel` under `ws_root`, walking every path component with +/// openat + O_NOFOLLOW so no component can be a symlink (race-safe against a +/// workspace-sharing process). (Mirrors the CLI helper.) +#[cfg(unix)] +fn safe_workspace_write(ws_root: &Path, rel: &str, data: &[u8]) -> std::io::Result<()> { + use std::ffi::CString; + use std::io::{Error, ErrorKind, Write}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::FromRawFd; + + fn cstr(bytes: &[u8]) -> std::io::Result { + CString::new(bytes).map_err(|_| Error::new(ErrorKind::InvalidInput, "NUL in path")) + } + + let root_c = cstr(ws_root.as_os_str().as_bytes())?; + let mut dirfd = unsafe { libc::open(root_c.as_ptr(), libc::O_DIRECTORY | libc::O_CLOEXEC) }; + if dirfd < 0 { + return Err(Error::last_os_error()); + } + + let comps: Vec<&str> = rel.split('/').filter(|c| !c.is_empty() && *c != ".").collect(); + let (file_name, dirs) = match comps.split_last() { + Some(x) => x, + None => { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "empty path")); + } + }; + + for comp in dirs { + if *comp == ".." { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "'..' in path")); + } + let c = cstr(comp.as_bytes())?; + let mk = unsafe { libc::mkdirat(dirfd, c.as_ptr(), 0o755) }; + if mk < 0 { + let err = Error::last_os_error(); + if err.raw_os_error() != Some(libc::EEXIST) { + unsafe { libc::close(dirfd) }; + return Err(err); + } + } + let next = unsafe { + libc::openat( + dirfd, + c.as_ptr(), + libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + unsafe { libc::close(dirfd) }; + if next < 0 { + return Err(Error::last_os_error()); + } + dirfd = next; + } + + if *file_name == ".." { + unsafe { libc::close(dirfd) }; + return Err(Error::new(ErrorKind::InvalidInput, "'..' in path")); + } + let fc = cstr(file_name.as_bytes())?; + let filefd = unsafe { + libc::openat( + dirfd, + fc.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o644, + ) + }; + unsafe { libc::close(dirfd) }; + if filefd < 0 { + return Err(Error::last_os_error()); + } + let mut f = unsafe { std::fs::File::from_raw_fd(filefd) }; + f.write_all(data) +} + +#[cfg(not(unix))] +fn safe_workspace_write(ws_root: &Path, rel: &str, data: &[u8]) -> std::io::Result<()> { + let dest = ws_root.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&dest, data) +} + +/// Copy a cloud dashboard's workspace files into the local per-dashboard subfolder +/// `/workspace/`. Best-effort per file; returns counts. Runs on a +/// blocking thread (ureq + a session-start poll that can take a minute+). +#[tauri::command] +pub async fn download_cloud_workspace( + app: tauri::AppHandle, + cloud_id: String, + subdir: String, +) -> Result { + use tauri::Manager; + let (token, _email) = read_cloud_credential(&app).ok_or("Not signed in to the cloud.")?; + + // subdir is the local dashboard id — must be a single safe path component. + let subdir = subdir.trim().trim_matches('/').to_string(); + if subdir.is_empty() || subdir.contains('/') || subdir.contains("..") { + return Err("invalid workspace subdir".into()); + } + let ws_root = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("workspace") + .join(&subdir); + std::fs::create_dir_all(&ws_root).map_err(|e| format!("create workspace dir: {e}"))?; + let ws_canon = ws_root + .canonicalize() + .map_err(|e| format!("resolve workspace dir: {e}"))?; + + let app2 = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + // Emit progress so a slow cold cloud-VM boot doesn't look like a hang. + let emit = |phase: &str, written: u64| { + let _ = app2.emit( + "cloud-workspace-progress", + CloudWorkspaceProgress { + cloud_id: cloud_id.clone(), + phase: phase.to_string(), + written, + }, + ); + }; + + emit("starting", 0); + let sid = match cloud_ensure_session(&token, &cloud_id, &|| emit("booting", 0)) { + Ok(Some(s)) => s, + Ok(None) => { + return Ok(WorkspaceDownloadResult { written: 0, skipped: 0, had_workspace: false }) + } + Err(e) if e == "SUBSCRIPTION_REQUIRED" => { + return Err( + "Starting your cloud workspace needs an active OrcaBot subscription.".into(), + ) + } + Err(e) => return Err(e), + }; + + eprintln!("[cloud-dl] session ready ({sid}); listing workspace"); + emit("copying", 0); + // Walk the workspace directory-by-directory, pruning excluded dirs so we + // never descend into node_modules/.git. Each list is one (bounded) dir. + let mut written = 0u64; + let mut skipped = 0u64; + let mut queue: Vec = vec![String::new()]; // "" = workspace root + let mut listed = 0u32; + while let Some(dir_rel) = queue.pop() { + listed += 1; + if listed > 50_000 { + // Pathological tree — stop, but count the unvisited dirs as skipped + // so the result reports the workspace as incomplete (not complete). + eprintln!("[cloud-dl] dir limit hit; {} dirs left unvisited", queue.len() + 1); + skipped += queue.len() as u64 + 1; + break; + } + let is_root = dir_rel.is_empty(); + let query_path = if is_root { + "/".to_string() + } else { + format!("/{dir_rel}") + }; + // The root list is the readiness gate — the just-started sandbox may + // still be booting its HTTP server (proxy 503s), so retry it for up to + // ~90s. Deeper dirs only need a light retry once it's serving. + let entries = match cloud_dir_list_ready(&token, &sid, &query_path, if is_root { 10 } else { 4 }) { + Ok(v) => v, + Err(e) if is_root => { + eprintln!("[cloud-dl] root list failed: {e}"); + return Err(format!( + "cloud workspace didn't become reachable ({}). Try again in a moment.", + e.trim() + )) + } + Err(e) => { + eprintln!("[cloud-dl] skip dir {query_path}: {e}"); + skipped += 1; // count it so the result reports incompleteness + continue; // a deeper dir stayed unreachable — skip it + } + }; + eprintln!("[cloud-dl] {} -> {} entries", query_path, entries.len()); + for e in &entries { + let rel = match e.get("path").and_then(|x| x.as_str()) { + Some(p) => p.trim_start_matches('/').to_string(), + None => continue, + }; + if rel.is_empty() || ws_excluded(&rel) { + continue; + } + if e.get("is_dir").and_then(|x| x.as_bool()).unwrap_or(false) { + queue.push(rel); // descend into non-excluded subdir + continue; + } + if safe_workspace_dest(&ws_canon, &rel).is_none() { + skipped += 1; + continue; + } + eprintln!("[cloud-dl] get {rel}"); + let data = match cloud_file_get_ready(&token, &sid, &rel) { + Ok(d) => d, + Err(e) => { + eprintln!("[cloud-dl] skip {rel}: {e}"); + skipped += 1; + continue; + } + }; + match safe_workspace_write(&ws_canon, &rel, &data) { + Ok(()) => { + written += 1; + if written % 5 == 0 { + emit("copying", written); + } + } + Err(e) => { + eprintln!("[cloud-dl] write {rel} failed: {e}"); + skipped += 1; + } + } + } + } + eprintln!("[cloud-dl] done: written={written} skipped={skipped}"); + Ok(WorkspaceDownloadResult { written, skipped, had_workspace: true }) + }) + .await + .map_err(|e| format!("workspace download task failed: {e}"))? +} + /// Return the per-boot surface token. The host frontend sends it as the /// `X-Orcabot-Surface` header so the control plane knows the request is from the /// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). @@ -750,3 +1889,24 @@ pub fn switch_to_cli(app: tauri::AppHandle) -> Result<(), String> { Err("switch_to_cli is only supported on macOS".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_challenge_matches_reference() { + // Must equal base64url(SHA-256("test")) exactly, or the control plane's PKCE + // check (sha256Base64Url in google.ts) rejects every desktop sign-in. + assert_eq!( + pkce_challenge("test"), + "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg" + ); + } + + #[test] + fn b64url_is_unpadded() { + assert_eq!(b64url(&[0x00]), "AA"); + assert_eq!(b64url(&[0xff, 0xff]), "__8"); + } +} diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 2b2252e9..d2fb38de 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -26,6 +26,19 @@ fn pid_file_path(data_dir: &Path) -> PathBuf { data_dir.join("desktop-services.pid") } +/// Progress of an in-flight auto-update, emitted to the GUI as `update-progress` +/// so the frontend can show a download bar (the native "Update available" dialog +/// otherwise gives no feedback between "Update & restart" and the relaunch). +#[derive(Clone, serde::Serialize)] +struct UpdateProgress { + /// "starting" | "downloading" | "installing" | "error" + phase: &'static str, + downloaded: u64, + total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + /// File recording the ports the stack actually bound to this boot (some may be /// dynamic when a default was busy). The `orcabot` CLI reads this so it connects /// to the right control plane / sandbox / frontend instead of the hardcoded @@ -143,10 +156,27 @@ fn passthrough_env(workerd_env: &mut Vec<(&'static str, String)>, key: &'static /// back to `preferred` if nothing is free in range (the later bind then fails /// loudly). Used so the app boots even when a default port is occupied (e.g. a /// stray `wrangler dev` on 8787) instead of silently failing to start. +fn port_is_free(port: u16) -> bool { + // Probe BOTH the IPv4 wildcard and the IPv6 wildcard, not just IPv4 loopback. + // Our consumers bind differently: workerd/d1-shim bind 127.0.0.1 (covered by + // 0.0.0.0, a superset), but the VM forwarder (vz-helper) binds the IPv6/IPv4 + // wildcard `*:port`. A 127.0.0.1-only probe misses a leftover on `*:port`, so + // the port looked free and the VM then collided on it ("Address already in + // use"). Require both families bindable so nothing slips past. + let v4 = std::net::TcpListener::bind(("0.0.0.0", port)).is_ok(); + let v6 = match std::net::TcpListener::bind(("::", port)) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => false, + // IPv6 unavailable/unsupported — don't treat as "in use" (avoids false skips). + Err(_) => true, + }; + v4 && v6 +} + fn pick_free_port(preferred: u16, used: &[u16]) -> u16 { let mut p = preferred; for _ in 0..200 { - if !used.contains(&p) && std::net::TcpListener::bind(("127.0.0.1", p)).is_ok() { + if !used.contains(&p) && port_is_free(p) { return p; } p = match p.checked_add(1) { @@ -981,6 +1011,17 @@ fn main() { commands::open_url, commands::reveal_workspace, commands::get_ports, + commands::get_app_version, + commands::verify_orcabot_account, + commands::set_cloud_credential, + commands::sign_in_google_loopback, + commands::cancel_google_sign_in, + commands::rollback_sign_in, + commands::get_cloud_account, + commands::clear_cloud_credential, + commands::list_cloud_dashboards, + commands::get_cloud_dashboard, + commands::download_cloud_workspace, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); @@ -1085,6 +1126,7 @@ fn main() { // prompt, because restarting tears down any running VM/terminal/agent // session (RunEvent::Exit shuts the services down). if !headless { + use tauri::Emitter; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_updater::UpdaterExt; let handle = app.handle().clone(); @@ -1115,9 +1157,49 @@ fn main() { eprintln!("[updater] update deferred by user"); return; } - match update.download_and_install(|_chunk, _total| {}, || {}).await { + + // Tell the GUI a download is starting so it can show a progress bar, + // then stream progress from the download closure. Emits are throttled + // to once per MB so a ~190MB download doesn't flood IPC. + let _ = handle.emit( + "update-progress", + UpdateProgress { phase: "starting", downloaded: 0, total: None, message: None }, + ); + let h_chunk = handle.clone(); + let h_done = handle.clone(); + let got = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let last_mb = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let got_c = got.clone(); + let result = update + .download_and_install( + move |chunk, total| { + use std::sync::atomic::Ordering::Relaxed; + let so_far = got_c.fetch_add(chunk as u64, Relaxed) + chunk as u64; + let mb = so_far / (1024 * 1024); + if mb != last_mb.swap(mb, Relaxed) { + let _ = h_chunk.emit( + "update-progress", + UpdateProgress { phase: "downloading", downloaded: so_far, total, message: None }, + ); + } + }, + move || { + let _ = h_done.emit( + "update-progress", + UpdateProgress { phase: "installing", downloaded: 0, total: None, message: None }, + ); + }, + ) + .await; + match result { Ok(_) => { eprintln!("[updater] installed; relaunching"); handle.restart(); } - Err(e) => eprintln!("[updater] install failed: {e}"), + Err(e) => { + eprintln!("[updater] install failed: {e}"); + let _ = handle.emit( + "update-progress", + UpdateProgress { phase: "error", downloaded: 0, total: None, message: Some(e.to_string()) }, + ); + } } }); } diff --git a/desktop/app/src-tauri/vz-helper/Sources/main.swift b/desktop/app/src-tauri/vz-helper/Sources/main.swift index 189cab47..de20d7ea 100644 --- a/desktop/app/src-tauri/vz-helper/Sources/main.swift +++ b/desktop/app/src-tauri/vz-helper/Sources/main.swift @@ -406,7 +406,15 @@ class TCPToVsockForwarder { throw NSError(domain: "TCPForwarder", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid port: \(hostPort)"]) } - listener = try NWListener(using: params, on: port) + // Bind LOOPBACK only. The forwarder is reached solely by host-local + // services (control plane, health checks), never the network, so exposing + // it on all interfaces (`*:port`) was needless attack surface. It also + // makes the bind match the Rust free-port probe (127.0.0.1): a busy port + // is now actually detected, so a dynamic one is chosen instead of the VM + // colliding on the wildcard. + params.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: port) + + listener = try NWListener(using: params) // Capture hostPort directly to avoid weak self issues in logging let capturedHostPort = hostPort diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index b023f3f1..9236ed7a 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -173,6 +173,16 @@ if [ -d "$FRONTEND_DIR" ]; then printf '%s\n' "Building frontend worker..." + # Step 0: clear stale build artifacts. Next.js's incremental cache (.next/cache) + # has silently served MONTHS-old compiled output before — a changed page didn't + # rebuild, shipping stale UI into the desktop app. This is a build step, not a + # hot dev loop, so correctness beats the incremental speedup: clean by default. + # Opt out with FRONTEND_CLEAN=0 when iterating and you trust the cache. + if [ "${FRONTEND_CLEAN:-1}" != "0" ]; then + printf '%s\n' "Clearing frontend build cache (.next, .open-next)..." + rm -rf "$FRONTEND_DIR/.next" "$FRONTEND_DIR/.open-next" + fi + # Step 1: Build with OpenNext (creates .open-next/) ( cd "$FRONTEND_DIR" @@ -287,8 +297,11 @@ ${WASM_MODULES} ], CAPNP_EOF printf '%s\n' " Generated: workerd.frontend.capnp" - # Copy assets directory + # Copy assets directory. Wipe the staged copy first so orphaned chunks from + # previous builds don't pile up (content-hashed filenames mean stale chunks are + # never overwritten, just accumulate — confusing to debug and bloats the app). if [ -d "$FRONTEND_DIR/.open-next/assets" ]; then + rm -rf "$FRONTEND_RES_DIR/assets" cp -r "$FRONTEND_DIR/.open-next/assets" "$FRONTEND_RES_DIR/" printf '%s\n' " Staged: frontend assets" fi @@ -353,8 +366,10 @@ fi TARGET_RES_DIR="$ROOT_DIR/app/src-tauri/target/release/resources" if [ -d "$TARGET_RES_DIR" ]; then if command -v rsync >/dev/null 2>&1; then - rsync -a "$TAURI_RESOURCES_DIR/" "$TARGET_RES_DIR/" + # --delete mirrors exactly, so stale frontend chunks don't linger here either. + rsync -a --delete "$TAURI_RESOURCES_DIR/" "$TARGET_RES_DIR/" else + rm -rf "$TARGET_RES_DIR/frontend/assets" cp -R "$TAURI_RESOURCES_DIR/." "$TARGET_RES_DIR/" fi printf '%s\n' " synced resources -> target/release/resources (dev binary picks these up)" diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index aa727deb..936cb1ab 100644 --- a/frontend/src/app/(app)/dashboards/[id]/page.tsx +++ b/frontend/src/app/(app)/dashboards/[id]/page.tsx @@ -76,6 +76,7 @@ import { ShareDashboardDialog } from "@/components/dialogs/ShareDashboardDialog" import { BugReportDialog } from "@/components/dialogs/BugReportDialog"; import { OnboardingDialog } from "@/components/dialogs/OnboardingDialog"; import { Canvas } from "@/components/canvas"; +import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; import { CursorOverlay, PresenceList } from "@/components/multiplayer"; import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; @@ -3605,6 +3606,7 @@ export default function DashboardPage() { OrcaBot +
diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 678a5a02..caa0e2a9 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: layout-v7-secret-input -const MODULE_REVISION = "layout-v7-secret-input"; +// REVISION: layout-v9-free-signin-btn +const MODULE_REVISION = "layout-v9-free-signin-btn"; console.log( `[dashboards] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -28,18 +28,14 @@ import { XCircle, Clock, BarChart3, - Link2, Terminal, + LogIn, } from "lucide-react"; import { toast } from "sonner"; import { Button, Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, Dialog, DialogContent, DialogHeader, @@ -53,8 +49,11 @@ import { Tooltip, } from "@/components/ui"; import { useAuthStore } from "@/stores/auth-store"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; +import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; +import { DashboardsTabs } from "@/components/desktop/DashboardsTabs"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -68,13 +67,18 @@ import { type UserSecret, } from "@/lib/api/cloudflare"; import { listTemplates, deleteTemplate, approveTemplate } from "@/lib/api/cloudflare/templates"; -import { formatRelativeTime, cn } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import type { Dashboard, TemplateCategory } from "@/types/dashboard"; export default function DashboardsPage() { const router = useRouter(); const queryClient = useQueryClient(); const { user, logout, isAuthenticated, isAuthResolved, isAdmin, setUser } = useAuthStore(); + // Desktop-only: a Free (local-only) account has nothing to "log out" of, so the + // header offers "Sign in" (→ welcome screen) instead of "Log out". A signed-in + // cloud account still gets a real "Log out". + const accountChoice = useDesktopAccountStore((s) => s.choice); + const isFreeDesktop = DESKTOP_MODE && accountChoice === "free"; const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [newDashboardName, setNewDashboardName] = React.useState(""); @@ -350,8 +354,14 @@ export default function DashboardsPage() { } catch { // Ignore logout errors and clear local state anyway. } + // logout() also resets the desktop account choice (centralized in the store), + // so the DesktopAuthGate shows the welcome screen. On desktop we therefore skip + // navigating to /login → "/" (the web-only marketing page, which is a dark + // screen in the app); the gate overlays the welcome screen in place. logout(); - router.push("/login"); + if (!DESKTOP_MODE) { + router.push("/login"); + } }; if (!isAuthResolved || !isAuthenticated) { @@ -371,6 +381,7 @@ export default function DashboardsPage() { className="w-7 h-7 object-contain" /> OrcaBot +
@@ -436,11 +447,24 @@ export default function DashboardsPage() { - - - + {isFreeDesktop ? ( + + + + ) : ( + + + + )}
@@ -528,59 +552,21 @@ export default function DashboardsPage() { {/* Two-column layout: Dashboards (left) + Environment Variables (right) */}
- {/* Your Dashboards Section */} -
-

- Your Dashboards -

- - {isLoading ? ( -
- {[1, 2, 3, 4].map((i) => ( - - ))} -
- ) : error ? ( -
-

- Failed to load dashboards. Please try again. -

- -
- ) : dashboards && dashboards.length > 0 ? ( -
- {dashboards.map((dashboard) => ( - router.push(`/dashboards/${dashboard.id}`)} - onDelete={() => setDeleteTarget(dashboard)} - /> - ))} -
- ) : ( -
-

- No dashboards yet. Create your first one! -

- -
- )} -
+ {/* Your Dashboards — Online / Local Storage tabs (desktop); local grid on web */} + + queryClient.invalidateQueries({ queryKey: ["dashboards"] }) + } + onOpen={(id) => router.push(`/dashboards/${id}`)} + onDelete={(dashboard) => setDeleteTarget(dashboard)} + onCreateFirst={() => setIsCreateOpen(true)} + onDownloaded={() => + queryClient.invalidateQueries({ queryKey: ["dashboards"] }) + } + /> {/* Secrets & Environment Variables Section */}
@@ -1014,45 +1000,3 @@ function NewDashboardCard({ ); } -interface DashboardCardProps { - dashboard: Dashboard; - onClick: () => void; - onDelete: () => void; -} - -function DashboardCard({ dashboard, onClick, onDelete }: DashboardCardProps) { - const isLinked = (dashboard.linkedCount ?? 0) > 0; - - return ( - -
- -
-
- {dashboard.name} - {isLinked && ( - - - - )} -
- -
-
- -

- Updated {formatRelativeTime(dashboard.updatedAt)} -

-
-
-
- ); -} diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx new file mode 100644 index 00000000..5186d5ad --- /dev/null +++ b/frontend/src/app/(app)/layout.tsx @@ -0,0 +1,19 @@ +import { UpdateProgressOverlay } from "@/components/UpdateProgressOverlay"; + +// REVISION: app-layout-v1-update-overlay +const MODULE_REVISION = "app-layout-v1-update-overlay"; +console.log(`[app-layout] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}`); + +/** + * Pass-through layout for the authenticated app routes. Its only job is to mount + * the desktop auto-update progress overlay once for every app page (dashboards, + * settings, admin, splash) so update feedback shows wherever the user is. + */ +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index c432b008..e1a00f66 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -// REVISION: providers-v6-analytics-init -const MODULE_REVISION = "providers-v6-analytics-init"; +// REVISION: providers-v8-machine-wide-local-identity +const MODULE_REVISION = "providers-v8-machine-wide-local-identity"; console.log( `[providers] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -15,6 +15,8 @@ import { Toaster } from "sonner"; import { API, DESKTOP_MODE } from "@/config/env"; import { ensureSurfaceToken } from "@/lib/tauri-bridge"; import { getAuthHeaders, useAuthStore } from "@/stores/auth-store"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; +import { DesktopWelcome } from "@/components/desktop/DesktopWelcome"; import type { User, SubscriptionInfo } from "@/types"; import { initAnalytics, stopAnalytics, resetQueue } from "@/lib/analytics"; @@ -49,9 +51,17 @@ interface ProvidersProps { function AuthBootstrapper() { const { isAuthenticated, setUser, setAuthResolved, loginDevMode } = useAuthStore(); const userId = useAuthStore((s) => s.user?.id ?? null); + // Desktop first-run account choice (free / signed-in). Drives whether/who we + // auto-login as; until it's set on a fresh install, the welcome gate is shown. + const accountChoice = useDesktopAccountStore((s) => s.choice); + const accountEmail = useDesktopAccountStore((s) => s.email); + // (account name/email are shown via the account store + CloudDashboardsSection; + // the LOCAL identity no longer depends on them — see bootstrap below.) + const chooseFree = useDesktopAccountStore((s) => s.chooseFree); // Track which user ID we last validated — re-runs on user switch, not just once per page const validatedUserRef = React.useRef(null); - const unauthBootstrappedRef = React.useRef(false); + // Which (mode/choice) we last bootstrapped for, so a first-run choice re-runs it. + const bootstrappedForRef = React.useRef(null); React.useEffect(() => { // If already authenticated (from localStorage hydration), just mark as resolved. @@ -62,6 +72,9 @@ function AuthBootstrapper() { if (validatedUserRef.current !== userId) { validatedUserRef.current = userId; if (DESKTOP_MODE) { + // Existing installs (already authed before the first-run choice existed) + // count as "free" so the welcome screen never appears for them. + if (!accountChoice) chooseFree(); void ensureSurfaceToken() .then(() => { const authHeaders = getAuthHeaders(); @@ -104,11 +117,22 @@ function AuthBootstrapper() { return; } - if (unauthBootstrappedRef.current) { + // Desktop: nothing to log in as until the first-run choice is made (the + // DesktopWelcome gate is rendered instead). Re-bootstrap if the choice changes + // (null → free/signed-in, or account switch). Web bootstraps exactly once. + if (DESKTOP_MODE && !accountChoice) { + // Logged out / reset to the welcome screen — clear the guard so the NEXT + // choice (even the same one) triggers a fresh login instead of being skipped. + bootstrappedForRef.current = null; return; } - - unauthBootstrappedRef.current = true; + const bootstrapKey = DESKTOP_MODE + ? `${accountChoice}:${accountEmail ?? ""}` + : "web"; + if (bootstrappedForRef.current === bootstrapKey) { + return; + } + bootstrappedForRef.current = bootstrapKey; // Reset validated user so post-login validation runs for the new session validatedUserRef.current = null; let isActive = true; @@ -121,6 +145,14 @@ function AuthBootstrapper() { // below carry X-Orcabot-Surface — the control plane requires it to honor // dev-auth, or /auth/dev/session and the /me ID-sync 401 (wrong user → // empty dashboards). + // + // Machine-wide local dashboards: the LOCAL dev-auth identity is ALWAYS the + // single machine user (desktop@localhost), regardless of Free vs signed-in. + // Signing in is purely additive — it attaches the cloud credential and + // surfaces your email (desktop-account-store + CloudDashboardsSection) — it + // must NOT switch the local owner, or your local dashboards would fork per + // identity and "disappear" when you sign in. Downloaded cloud dashboards + // land under this same machine user, alongside everything made while Free. loginDevMode("Desktop User", "desktop@localhost"); await Promise.race([ ensureSurfaceToken(), @@ -184,7 +216,16 @@ function AuthBootstrapper() { return () => { isActive = false; }; - }, [isAuthenticated, userId, setUser, setAuthResolved, loginDevMode]); + }, [ + isAuthenticated, + userId, + setUser, + setAuthResolved, + loginDevMode, + accountChoice, + accountEmail, + chooseFree, + ]); return null; } @@ -206,6 +247,22 @@ function AnalyticsBootstrapper() { return null; } +/** + * On the desktop app's first run, show the welcome screen (Free Desktop Use vs. + * sign in) instead of the app until a choice is made. Web is unaffected, and + * returning/authenticated desktop users skip straight through. + */ +function DesktopAuthGate({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const choice = useDesktopAccountStore((s) => s.choice); + const hydrated = useDesktopAccountStore((s) => s.hydrated); + + if (DESKTOP_MODE && hydrated && !isAuthenticated && choice === null) { + return ; + } + return <>{children}; +} + export function Providers({ children }: ProvidersProps) { const queryClient = getQueryClient(); @@ -214,7 +271,7 @@ export function Providers({ children }: ProvidersProps) { - {children} + {children} (null); + + useEffect(() => { + if (!DESKTOP_MODE) return; + let alive = true; + getAppVersion() + .then((v) => { + if (alive) setVersion(v); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + if (!DESKTOP_MODE || !version) return null; + + return ( + + v{version} + + ); +} diff --git a/frontend/src/components/UpdateProgressOverlay.tsx b/frontend/src/components/UpdateProgressOverlay.tsx new file mode 100644 index 00000000..d5462971 --- /dev/null +++ b/frontend/src/components/UpdateProgressOverlay.tsx @@ -0,0 +1,140 @@ +// REVISION: update-progress-overlay-v1 +"use client"; + +import { useEffect, useState } from "react"; +import { DESKTOP_MODE } from "@/config/env"; +import { onUpdateProgress, type UpdateProgress } from "@/lib/tauri-bridge"; + +const MODULE_REVISION = "update-progress-overlay-v1"; +if (typeof window !== "undefined") { + console.log( + `[update-overlay] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Fixed toast that appears while the desktop app is auto-updating. The native + * "Update available" dialog gives no feedback between the user accepting and the + * relaunch; this listens to the Rust `update-progress` events and shows a live + * download bar (then an "installing / restarting" state). No-op on web. + */ +export function UpdateProgressOverlay() { + const [progress, setProgress] = useState(null); + + useEffect(() => { + if (!DESKTOP_MODE) return; + let unlisten: (() => void) | null = null; + onUpdateProgress((p) => setProgress(p)).then((u) => { + unlisten = u; + }); + return () => { + if (unlisten) unlisten(); + }; + }, []); + + if (!DESKTOP_MODE || !progress) return null; + + const { phase, downloaded, total, message } = progress; + const pct = + total && total > 0 + ? Math.min(100, Math.round((downloaded / total) * 100)) + : null; + + const isError = phase === "error"; + const isInstalling = phase === "installing"; + const indeterminate = !isError && !isInstalling && pct === null; + + let title: string; + let detail: string; + if (isError) { + title = "Update failed"; + detail = message || "The update couldn't be installed. It'll retry next launch."; + } else if (isInstalling) { + title = "Installing update…"; + detail = "The app will restart in a moment."; + } else if (phase === "starting") { + title = "Downloading update…"; + detail = "Starting…"; + } else { + title = "Downloading update…"; + detail = + pct !== null + ? `${pct}% · ${fmtBytes(downloaded)}${total ? ` / ${fmtBytes(total)}` : ""}` + : fmtBytes(downloaded); + } + + return ( +
+
+ {!isError && ( + + )} + {title} +
+
{detail}
+ + {!isError && ( +
+
+
+ )} + + +
+ ); +} diff --git a/frontend/src/components/blocks/VncViewer.tsx b/frontend/src/components/blocks/VncViewer.tsx index 4a97d99a..322ab257 100644 --- a/frontend/src/components/blocks/VncViewer.tsx +++ b/frontend/src/components/blocks/VncViewer.tsx @@ -3,7 +3,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: vnc-viewer-v1-native-rfb +// REVISION: vnc-viewer-v2-zoom-screensize-margin-fix // // Native noVNC (RFB) viewer — no iframe. Renders the remote framebuffer onto a // canvas inside a plain
that lives directly in the React Flow node, so the @@ -20,7 +20,7 @@ import * as React from "react"; -const MODULE_REVISION = "vnc-viewer-v1-native-rfb"; +const MODULE_REVISION = "vnc-viewer-v2-zoom-screensize-margin-fix"; if (typeof console !== "undefined") { console.log(`[VncViewer] REVISION: ${MODULE_REVISION} loaded`); } @@ -112,6 +112,32 @@ export function VncViewer({ } catch { /* noVNC internals changed — leave input mapping as-is */ } + + // Fix the black margin that grows as the canvas is zoomed out. noVNC's + // `_screenSize()` measures the container with getBoundingClientRect, which + // INCLUDES React Flow's ancestor `transform: scale(zoom)`. Both the remote + // resize request and the autoscale use that value, so a browser opened while + // zoomed out asks for a framebuffer smaller than the block and paints the + // leftover with `background` (the black margin) — bigger the further you're + // zoomed out. clientWidth/Height are the true (untransformed) layout size and + // match noVNC's own `_currentClientSize()`, so the resize + scale target the + // real block at any zoom (no margin, no resize loop). Best-effort. + try { + const internals = r as unknown as { + _screen?: HTMLElement; + _screenSize?: () => { w: number; h: number }; + }; + const screenEl = internals._screen; + if (screenEl && typeof internals._screenSize === "function") { + internals._screenSize = () => ({ + w: screenEl.clientWidth, + h: screenEl.clientHeight, + }); + } + } catch { + /* noVNC internals changed — leave sizing as-is */ + } + const { viewOnly: vo, qualityLevel: q, compressionLevel: c } = optsRef.current; // Ask the server (x11vnc/Xvfb) to resize its framebuffer to the node so the // page FILLS the block. Falls back to scaleViewport (aspect-fit) when the diff --git a/frontend/src/components/desktop/DashboardsTabs.tsx b/frontend/src/components/desktop/DashboardsTabs.tsx new file mode 100644 index 00000000..0873ec6e --- /dev/null +++ b/frontend/src/components/desktop/DashboardsTabs.tsx @@ -0,0 +1,575 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: dashboards-tabs-v8-workspace-retry +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, HardDrive, Trash2, Link2, Plus, Loader2, Cloud, RefreshCw } from "lucide-react"; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from "@/components/ui"; +import { DESKTOP_MODE, CLOUD_SITE_URL } from "@/config/env"; +import { + getCloudAccount, + listCloudDashboards, + openExternalUrl, + onCloudWorkspaceProgress, + downloadCloudWorkspace, + type CloudWorkspaceProgress, +} from "@/lib/tauri-bridge"; +import { downloadCloudDashboard } from "@/lib/cloud-sync"; +import { formatRelativeTime, cn } from "@/lib/utils"; +import type { Dashboard } from "@/types/dashboard"; + +const MODULE_REVISION = "dashboards-tabs-v8-workspace-retry"; +if (typeof window !== "undefined") { + console.log( + `[dashboards-tabs] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +interface CloudDashboard { + id: string; + name: string; + updatedAt?: string; +} + +/** + * "Your Dashboards" with two tabs — **Online** (your cloud account's dashboards) + * and **Local Storage** (what's on this machine). Only shown when signed in to a + * cloud account; a Free/local-only session sees just the local grid (no tabs, no + * Online tab). Signing in defaults to the Online tab. + * + * A cloud dashboard shows a ⬇ Download until it's pulled local; once downloaded it + * reads "Local Storage" and appears in BOTH tabs (launchable from either) since it + * now exists as a local dashboard with a matching `cloudId`. + */ +export function DashboardsTabs({ + localDashboards, + isLoading, + error, + onRetry, + onOpen, + onDelete, + onCreateFirst, + onDownloaded, +}: { + localDashboards: Dashboard[]; + isLoading: boolean; + error: unknown; + onRetry: () => void; + onOpen: (localDashboardId: string) => void; + onDelete: (dashboard: Dashboard) => void; + onCreateFirst: () => void; + onDownloaded: () => void; +}) { + const [cloudEmail, setCloudEmail] = React.useState(null); + const [downloading, setDownloading] = React.useState>(new Set()); + const [downloadError, setDownloadError] = React.useState(null); + const [progress, setProgress] = React.useState>({}); + // Cloud ids whose last workspace copy was incomplete (persisted, so it survives + // reload) — those cards get a "Retry files" action. + const [incomplete, setIncomplete] = React.useState>(new Set()); + const [tab, setTab] = React.useState<"online" | "local">("local"); + const defaultedToOnline = React.useRef(false); + + const wsKey = (cloudId: string) => `orcabot:ws-incomplete:${cloudId}`; + const markIncomplete = React.useCallback((cloudId: string, yes: boolean) => { + try { + if (yes) localStorage.setItem(wsKey(cloudId), "1"); + else localStorage.removeItem(wsKey(cloudId)); + } catch { + /* localStorage unavailable — in-memory state still updates */ + } + setIncomplete((prev) => { + const next = new Set(prev); + if (yes) next.add(cloudId); + else next.delete(cloudId); + return next; + }); + }, []); + + // Live progress for in-flight downloads (so a slow cold cloud-VM boot doesn't + // look like a hang), keyed by cloud dashboard id. + React.useEffect(() => { + let unlisten: (() => void) | null = null; + let alive = true; + onCloudWorkspaceProgress((p) => { + setProgress((prev) => ({ ...prev, [p.cloud_id]: p })); + }).then((u) => { + if (alive) unlisten = u; + else u?.(); + }); + return () => { + alive = false; + unlisten?.(); + }; + }, []); + + React.useEffect(() => { + if (!DESKTOP_MODE) return; + let alive = true; + getCloudAccount() + .then((acc) => { + if (alive) setCloudEmail(acc?.email ?? null); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + const hasCloud = DESKTOP_MODE && !!cloudEmail; + + const { data: cloudDashboards, isLoading: cloudLoading } = useQuery({ + queryKey: ["cloud-dashboards"], + queryFn: async (): Promise => { + // /dashboards wraps the list as { dashboards: [...] }; tolerate a bare array + // too. Never return a non-array (guards the .map below). + const raw = await listCloudDashboards(); + if (Array.isArray(raw)) return raw as CloudDashboard[]; + const wrapped = (raw as { dashboards?: unknown })?.dashboards; + return Array.isArray(wrapped) ? (wrapped as CloudDashboard[]) : []; + }, + enabled: hasCloud, + staleTime: 30_000, + }); + + // Signing in defaults to the Online tab (once); logging out drops back to Local + // and re-arms the default so the next sign-in lands on Online again. + React.useEffect(() => { + if (hasCloud && !defaultedToOnline.current) { + defaultedToOnline.current = true; + setTab("online"); + } else if (!hasCloud) { + defaultedToOnline.current = false; + setTab("local"); + } + }, [hasCloud]); + + const localByCloudId = React.useMemo(() => { + const m = new Map(); + for (const d of localDashboards) if (d.cloudId) m.set(d.cloudId, d); + return m; + }, [localDashboards]); + + const cloudList = cloudDashboards ?? []; + + // Hydrate the incomplete set from localStorage once the cloud list is known. + React.useEffect(() => { + if (typeof window === "undefined" || !cloudDashboards) return; + const s = new Set(); + for (const cd of cloudDashboards) { + try { + if (localStorage.getItem(wsKey(cd.id)) === "1") s.add(cd.id); + } catch { + /* ignore */ + } + } + setIncomplete(s); + }, [cloudDashboards]); + + const download = async (cd: CloudDashboard) => { + setDownloadError(null); + setDownloading((s) => new Set(s).add(cd.id)); + try { + const res = await downloadCloudDashboard(cd.id, cd.name); + // Canvas downloaded; report incomplete workspace copies (hard error, or some + // files/dirs skipped) so it's not silently presented as complete, and remember + // the incomplete state so the card can offer a retry. + if (res.workspaceError) { + markIncomplete(cd.id, true); + setDownloadError( + `“${cd.name}” downloaded, but its files couldn't be copied: ${res.workspaceError}` + ); + } else if (res.workspace && res.workspace.skipped > 0) { + markIncomplete(cd.id, true); + setDownloadError( + `“${cd.name}” downloaded, but ${res.workspace.skipped} file(s)/folder(s) couldn't be copied — use “Retry files” on the card to try again.` + ); + } else if (res.workspace) { + markIncomplete(cd.id, false); + } + } catch (e) { + setDownloadError(e instanceof Error ? e.message : "Download failed."); + } finally { + // Always refresh — the canvas may have been created even if files failed, so + // the card should flip to "Local Storage". + onDownloaded(); + setDownloading((s) => { + const next = new Set(s); + next.delete(cd.id); + return next; + }); + setProgress((prev) => { + const next = { ...prev }; + delete next[cd.id]; + return next; + }); + } + }; + + // Workspace-only retry for an already-downloaded dashboard: re-pull the cloud + // files into the existing local dashboard's subfolder (no canvas re-create). + const retryWorkspace = async (cd: CloudDashboard, local: Dashboard) => { + setDownloadError(null); + setDownloading((s) => new Set(s).add(cd.id)); + try { + const ws = await downloadCloudWorkspace(cd.id, local.id); + if (ws.skipped > 0) { + markIncomplete(cd.id, true); + setDownloadError( + `Retried “${cd.name}”, but ${ws.skipped} item(s) still couldn't be copied.` + ); + } else { + markIncomplete(cd.id, false); + } + } catch (e) { + setDownloadError(e instanceof Error ? e.message : "Retry failed."); + } finally { + setDownloading((s) => { + const next = new Set(s); + next.delete(cd.id); + return next; + }); + setProgress((prev) => { + const next = { ...prev }; + delete next[cd.id]; + return next; + }); + } + }; + + const gridClass = "grid grid-cols-1 md:grid-cols-2 gap-4"; + + const localGrid = () => { + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ); + } + if (error) { + return ( +
+

+ Failed to load dashboards. Please try again. +

+ +
+ ); + } + if (localDashboards.length > 0) { + return ( +
+ {localDashboards.map((dashboard) => ( + onOpen(dashboard.id)} + onDelete={() => onDelete(dashboard)} + /> + ))} +
+ ); + } + return ( +
+

+ No dashboards yet. Create your first one! +

+ +
+ ); + }; + + const onlineGrid = () => { + if (cloudLoading && cloudList.length === 0) { + return ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ); + } + if (cloudList.length === 0) { + return ( +
+

+ No dashboards in your cloud account yet. +

+
+ ); + } + return ( +
+ {cloudList.map((cd) => ( + void download(cd)} + onRetry={(local) => void retryWorkspace(cd, local)} + onOpen={onOpen} + /> + ))} +
+ ); + }; + + return ( +
+

Your Dashboards

+ + {/* Desktop always shows the tab bar (Local Storage tab + count is always + present, even on Free); the Online tab only appears once signed in. Web + has no cloud/local split, so it renders just the grid below. */} + {DESKTOP_MODE && ( +
+ {hasCloud && ( + setTab("online")} + label="Online" + count={cloudList.length} + /> + )} + setTab("local")} + label="Local Storage" + count={localDashboards.length} + /> +
+ )} + + {downloadError && hasCloud && tab === "online" && ( +
+ {downloadError} +
+ )} + + {hasCloud && tab === "online" ? onlineGrid() : localGrid()} +
+ ); +} + +function TabButton({ + active, + onClick, + label, + count, +}: { + active: boolean; + onClick: () => void; + label: string; + count: number; +}) { + return ( + + ); +} + +/** Map download progress to a short status label shown on the card. */ +function downloadLabel(p?: CloudWorkspaceProgress): string { + switch (p?.phase) { + case "starting": + return "Starting…"; + case "booting": + return "Starting cloud workspace…"; + case "copying": + return p.written > 0 ? `Copying files… (${p.written})` : "Copying files…"; + default: + return "Downloading…"; + } +} + +function CloudDashboardCard({ + cd, + local, + isDownloading, + downloadingLabel, + incomplete, + onDownload, + onRetry, + onOpen, +}: { + cd: CloudDashboard; + local: Dashboard | undefined; + isDownloading: boolean; + downloadingLabel: string; + incomplete: boolean; + onDownload: () => void; + onRetry: (local: Dashboard) => void; + onOpen: (localDashboardId: string) => void; +}) { + const downloaded = !!local; + return ( + +
onOpen(local!.id) : undefined}> + +
+ {cd.name} + {/* Cloud link (top) — opens this dashboard on orcabot.com in the browser. */} + +
+
+ + {/* Updated time (left) + Download / Local Storage action pinned to the + bottom of the card (right). */} +
+

+ {cd.updatedAt + ? `Updated ${formatRelativeTime(cd.updatedAt)}` + : "In your cloud account"} +

+ {downloaded ? ( +
+ + Local Storage + + {incomplete && + (isDownloading ? ( + + {downloadingLabel} + + ) : ( + + ))} +
+ ) : ( + + )} +
+
+
+
+ ); +} + +function DashboardCard({ + dashboard, + onClick, + onDelete, +}: { + dashboard: Dashboard; + onClick: () => void; + onDelete: () => void; +}) { + const isLinked = (dashboard.linkedCount ?? 0) > 0; + + return ( + +
+ +
+
+ {dashboard.name} + {isLinked && ( + + + + )} +
+ +
+
+ +

+ Updated {formatRelativeTime(dashboard.updatedAt)} +

+
+
+
+ ); +} + +export default DashboardsTabs; diff --git a/frontend/src/components/desktop/DesktopWelcome.tsx b/frontend/src/components/desktop/DesktopWelcome.tsx new file mode 100644 index 00000000..477e70a3 --- /dev/null +++ b/frontend/src/components/desktop/DesktopWelcome.tsx @@ -0,0 +1,273 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: desktop-welcome-v8-rollback-retry +"use client"; + +import * as React from "react"; +import { toast } from "sonner"; +import { CLOUD_SITE_URL } from "@/config/env"; +import { + openExternalUrl, + signInGoogleLoopback, + cancelGoogleSignIn, + rollbackSignIn, + setCloudCredential, + verifyOrcabotAccount, +} from "@/lib/tauri-bridge"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; + +const MODULE_REVISION = "desktop-welcome-v8-rollback-retry"; +if (typeof window !== "undefined") { + console.log( + `[desktop-welcome] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +/** + * First-run screen for the desktop app. Three equally-weighted choices — use it + * free (local, no account), sign in with Google, or paste an Orcabot token — all + * of which keep the app running on the LOCAL control plane + VM. Signing in only + * attaches the real identity (data stays local). Shown until a choice is made. + */ +export function DesktopWelcome() { + const chooseFree = useDesktopAccountStore((s) => s.chooseFree); + const chooseSignedIn = useDesktopAccountStore((s) => s.chooseSignedIn); + + // "token" opens the paste panel; "google" shows the waiting-for-browser state. + const [panel, setPanel] = React.useState(null); + + // Token (PAT) flow + const [token, setToken] = React.useState(""); + const [tokenBusy, setTokenBusy] = React.useState(false); + const [tokenError, setTokenError] = React.useState(null); + + // Google flow. Each sign-in attempt gets a unique token; `currentAttemptRef` + // holds the one we'll accept. Cancelling, starting another attempt, or pasting a + // PAT changes/clears it, so a late-resolving attempt knows it's stale and rolls + // back ONLY its own credential (never a newer sign-in or a pasted token). + const [googleError, setGoogleError] = React.useState(null); + const attemptCounterRef = React.useRef(0); + const currentAttemptRef = React.useRef(null); + React.useEffect( + () => () => { + currentAttemptRef.current = null; + }, + [] + ); + + async function rollbackCancelledAttempt(attempt: number): Promise { + const toastId = `desktop-signin-rollback-${attempt}`; + try { + await rollbackSignIn(attempt); + toast.success("Cancelled sign-in credential removed.", { id: toastId }); + } catch (e) { + console.error("[welcome] failed to roll back cancelled sign-in:", e); + toast.error( + "The cancelled sign-in credential couldn't be removed locally. Keep the app open and retry cleanup.", + { + id: toastId, + duration: Infinity, + action: { + label: "Retry cleanup", + onClick: () => void rollbackCancelledAttempt(attempt), + }, + } + ); + } + } + + const connectToken = async () => { + const t = token.trim(); + if (!t) return; + setTokenBusy(true); + setTokenError(null); + // Pasting a PAT supersedes any in-flight Google attempt so its late resolve + // won't override this choice (the native side also guards the credential). + currentAttemptRef.current = null; + try { + const account = await verifyOrcabotAccount(t); + // Keep the token as the cloud credential so we can list/download the user's + // cloud dashboards (the app still runs locally; this is only for sync). + await setCloudCredential(t, account.email); + chooseSignedIn(account.email, account.name || account.email); + } catch (e) { + setTokenError(e instanceof Error ? e.message : String(e)); + } finally { + setTokenBusy(false); + } + }; + + const startGoogle = async () => { + const myAttempt = ++attemptCounterRef.current; + currentAttemptRef.current = myAttempt; + setGoogleError(null); + setPanel("google"); + try { + // Loopback sign-in (RFC 8252): the native layer opens the OS browser to the + // cloud login, receives the result on a local 127.0.0.1 listener, and stores + // the PAT host-only — the token never enters this webview. Resolves with the + // identity once done. Google needs a real browser (blocks embedded webviews), + // and this authenticates against the CLOUD so we get a credential for sync. + const account = await signInGoogleLoopback(); + if (currentAttemptRef.current !== myAttempt) { + // Superseded (cancelled / another attempt / PAT pasted): roll back ONLY this + // attempt's credential — a no-op natively if something newer now owns it. + // Keep the attempt id alive in a persistent retry action if local deletion + // fails; restarting would discard the native ownership mapping. + void rollbackCancelledAttempt(account.attempt); + return; + } + chooseSignedIn(account.email, account.name || account.email); + // chooseSignedIn unmounts this component. + } catch (e) { + if (currentAttemptRef.current !== myAttempt) return; // stale attempt — ignore + setGoogleError( + e instanceof Error ? e.message : "Couldn't complete Google sign-in." + ); + setPanel(null); + } + }; + + const cancelGoogle = () => { + // Drop the current attempt so a late resolve rolls itself back instead of + // signing in, and stop the native flow (it keeps waiting/exchanging otherwise). + currentAttemptRef.current = null; + void cancelGoogleSignIn(); + setPanel(null); + }; + + const optionClass = + "w-full rounded-xl px-5 py-4 text-left border border-[var(--border)] " + + "hover:bg-[var(--background-elevated)] hover:border-[var(--foreground)]/30 " + + "transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent,#5b8cff)]"; + + return ( +
+
+ Orcabot { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> +

Welcome to Orcabot

+

+ Everything runs locally on this machine — pick how you sign in. +

+ + {panel === "google" ? ( +
+
+
Waiting for Google sign-in…
+
+ Finish signing in in your browser, then return here. +
+ + +
+ ) : ( +
+ {/* 1. Local / no account */} + + + {/* 2. Google */} + + + {/* 3. Token */} + + + {googleError && ( +
{googleError}
+ )} + + {panel === "token" && ( +
+
    +
  1. Open orcabot.com and sign in.
  2. +
  3. In Settings, create a personal access token.
  4. +
  5. Paste it below.
  6. +
+ + { + setToken(e.target.value); + if (tokenError) setTokenError(null); + }} + placeholder="orca_pat_…" + autoComplete="off" + spellCheck={false} + className="mt-3 w-full rounded-lg bg-[var(--background)] border border-[var(--border)] px-3 py-2 text-sm outline-none focus:border-[var(--accent,#5b8cff)]" + /> + {tokenError && ( +
+ {tokenError} +
+ )} + +
+ )} +
+ )} +
+
+ ); +} + +export default DesktopWelcome; diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index e26f0cf8..436fbc46 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: desktop-env-v8-dynamic-cp-port -const MODULE_REVISION = "desktop-env-v8-dynamic-cp-port"; +// REVISION: desktop-env-v9-prod-api-host +const MODULE_REVISION = "desktop-env-v9-prod-api-host"; console.log( `[env] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -29,7 +29,10 @@ const FRONTEND_TARGET = resolveFrontendTarget(); const API_URL_BY_TARGET: Record = { localhost: "http://localhost:8787", dev: "https://api.dev.orcabot.com", - production: "https://orcabot-controlplane.orcabot.workers.dev", + // The prod control plane is api.orcabot.com. (The old workers.dev subdomain is + // dead — the prod web build overrides this via NEXT_PUBLIC_API_URL, but + // CLOUD_API_URL reads this default directly, so it must be correct.) + production: "https://api.orcabot.com", }; const SITE_URL_BY_TARGET: Record = { @@ -105,6 +108,15 @@ export const CLOUDFLARE_API_URL = resolveApiUrl(); export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL_BY_TARGET[FRONTEND_TARGET]; +// The PUBLIC cloud control plane + site, regardless of build target. On desktop +// CLOUDFLARE_API_URL points at the LOCAL control plane, so these give the desktop +// app a way to reach orcabot.com for optional "sign in with your account" — the +// app itself keeps running on the local control plane. +export const CLOUD_API_URL = + process.env.NEXT_PUBLIC_CLOUD_API_URL || API_URL_BY_TARGET.production; +export const CLOUD_SITE_URL = + process.env.NEXT_PUBLIC_CLOUD_SITE_URL || SITE_URL_BY_TARGET.production; + export const DEV_MODE_ENABLED = process.env.NEXT_PUBLIC_DEV_MODE_ENABLED === "true"; diff --git a/frontend/src/lib/api/cloudflare/dashboards.ts b/frontend/src/lib/api/cloudflare/dashboards.ts index 8f699c21..ce0517f2 100644 --- a/frontend/src/lib/api/cloudflare/dashboards.ts +++ b/frontend/src/lib/api/cloudflare/dashboards.ts @@ -111,12 +111,15 @@ export async function getDashboard(id: string): Promise<{ */ export async function createDashboard( name: string, - templateId?: string + templateId?: string, + cloudId?: string ): Promise<{ dashboard: Dashboard; viewport?: { x: number; y: number; zoom: number }; hasSetupGuide?: boolean }> { const response = await apiPost(API.cloudflare.dashboards, { name, templateId, - } as DashboardCreateRequest); + // Set when materializing a downloaded cloud dashboard (desktop sync). + cloudId, + } as DashboardCreateRequest & { cloudId?: string }); return { dashboard: response.dashboard, viewport: response.viewport, hasSetupGuide: response.hasSetupGuide }; } diff --git a/frontend/src/lib/cloud-sync.ts b/frontend/src/lib/cloud-sync.ts new file mode 100644 index 00000000..96c87652 --- /dev/null +++ b/frontend/src/lib/cloud-sync.ts @@ -0,0 +1,148 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: cloud-sync-v3-pin-legacy-atomic +"use client"; + +import { + getCloudDashboard, + downloadCloudWorkspace, + type WorkspaceDownloadResult, +} from "@/lib/tauri-bridge"; +import { + createDashboard, + createItem, + createEdge, + deleteDashboard, +} from "@/lib/api/cloudflare/dashboards"; +import type { Dashboard, DashboardItem, DashboardEdge } from "@/types"; + +if (typeof window !== "undefined") { + console.log( + `[cloud-sync] REVISION: cloud-sync-v3-pin-legacy-atomic loaded at ${new Date().toISOString()}` + ); +} + +interface CloudDashboardData { + dashboard: { name: string }; + items: DashboardItem[]; + edges: DashboardEdge[]; +} + +export interface DownloadResult { + dashboard: Dashboard; + /** Workspace file-copy result, or null if it couldn't run. */ + workspace: WorkspaceDownloadResult | null; + /** Set if the workspace file copy failed (the canvas still downloaded). */ + workspaceError?: string; +} + +/** + * Sanitize a workspace-relative path to plain segments: strip leading slashes and + * `.`/empty segments, and REJECT any `..` (returns "" so the caller falls back to + * the bare subfolder). Without this, a terminal whose content has + * `workingDir: "../other"` would become `/../other` and the sandbox would + * normalize it OUT of the dashboard's isolated subfolder. + */ +function sanitizeRel(p: string): string { + const parts = p + .replace(/^\/+/, "") + .split("/") + .filter((seg) => seg !== "" && seg !== "."); + if (parts.some((seg) => seg === "..")) return ""; + return parts.join("/"); +} + +function pinTerminalToSubdir(content: string, subdir: string): string { + const trimmed = content.trim(); + // Legacy / malformed terminal content is a plain string (the parser treats it as + // the terminal's display name). Wrap it into valid JSON so it, too, is pinned to + // this dashboard's subfolder instead of starting at the shared workspace root. + if (!trimmed.startsWith("{")) { + return JSON.stringify({ name: trimmed || "Terminal", workingDir: subdir }); + } + let obj: Record; + try { + obj = JSON.parse(content) as Record; + } catch { + return JSON.stringify({ name: trimmed, workingDir: subdir }); + } + const existing = + typeof obj.workingDir === "string" ? sanitizeRel(obj.workingDir) : ""; + obj.workingDir = existing ? `${subdir}/${existing}` : subdir; + return JSON.stringify(obj); +} + +/** + * Download a cloud dashboard into the LOCAL control-plane DB and copy its + * workspace files. Fetches the cloud dashboard's structure (items + edges) via the + * native layer (PAT) and recreates it locally with `cloudId` set (so it shows as + * downloaded and Phase-2 sync can map it back), then pulls the cloud workspace + * files into a per-dashboard subfolder. The local copy runs on the local VM. + * + * The workspace copy is best-effort: if it fails (cloud VM won't start, no + * subscription, timeout), the canvas is still downloaded and `workspaceError` is + * set so the caller can tell the user. + */ +export async function downloadCloudDashboard( + cloudId: string, + fallbackName: string +): Promise { + const data = (await getCloudDashboard(cloudId)) as CloudDashboardData; + const name = data.dashboard?.name || fallbackName; + + const { dashboard } = await createDashboard(name, undefined, cloudId); + const subdir = dashboard.id; + + // Recreate the canvas atomically: if any item/edge fails, delete the partial + // dashboard so it doesn't linger marked "downloaded" (with a cloudId) but broken. + try { + // Recreate items, mapping each cloud item id → the new local id (edges use it). + // Terminal items are pinned to this dashboard's workspace subfolder. + const idMap = new Map(); + for (const item of data.items || []) { + const content = + item.type === "terminal" ? pinTerminalToSubdir(item.content, subdir) : item.content; + const local = await createItem(dashboard.id, { + type: item.type, + content, + position: item.position, + size: item.size, + metadata: item.metadata, + }); + idMap.set(item.id, local.id); + } + + for (const edge of data.edges || []) { + const sourceItemId = idMap.get(edge.sourceItemId); + const targetItemId = idMap.get(edge.targetItemId); + if (!sourceItemId || !targetItemId) continue; // endpoint didn't survive (e.g. filtered item) + await createEdge(dashboard.id, { + sourceItemId, + targetItemId, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + }); + } + } catch (e) { + // Roll back the partial dashboard (best-effort) and surface the failure. + try { + await deleteDashboard(dashboard.id); + } catch { + /* leave it; the canvas error below is the real signal */ + } + throw e; + } + + // Pull the workspace files into /. Best-effort — the canvas + // is already created, so a failure here doesn't undo the download. + let workspace: WorkspaceDownloadResult | null = null; + let workspaceError: string | undefined; + try { + workspace = await downloadCloudWorkspace(cloudId, subdir); + } catch (e) { + workspaceError = e instanceof Error ? e.message : String(e); + } + + return { dashboard, workspace, workspaceError }; +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index c45b81d8..b5d375d6 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: tauri-bridge-v5-global-invoke -const MODULE_REVISION = "tauri-bridge-v5-global-invoke"; +// REVISION: tauri-bridge-v7-global-event-listen +const MODULE_REVISION = "tauri-bridge-v7-global-event-listen"; console.log( `[tauri-bridge] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -68,6 +68,13 @@ export interface ImportProgress { phase: "scanning" | "copying" | "done" | "error"; } +export interface UpdateProgress { + phase: "starting" | "downloading" | "installing" | "error"; + downloaded: number; + total: number | null; + message?: string; +} + // ---- Commands ---- /** Get the host workspace directory path. */ @@ -77,6 +84,142 @@ export async function getWorkspacePath(): Promise { return invoke("get_workspace_path") as Promise; } +/** The running app version (e.g. "0.5.0"); null on web/Cloudflare builds. */ +export async function getAppVersion(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return null; + try { + const v = (await invoke("get_app_version")) as string; + return typeof v === "string" && v ? v : null; + } catch { + return null; + } +} + +export interface OrcabotAccount { + email: string; + name: string; +} + +/** + * Verify an orcabot.com personal access token via the native layer (no browser + * CORS; the token is only sent to the fixed cloud URL). Resolves to the account + * identity, or throws with a user-facing message. Desktop-only. + */ +export async function verifyOrcabotAccount(token: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Sign-in is only available in the desktop app."); + return invoke("verify_orcabot_account", { token }) as Promise; +} + +// ---- Cloud account credential (dashboard sync) ---- + +export interface CloudAccount { + email: string; +} + +/** Persist the cloud PAT + email natively (host-only) for dashboard sync. Throws + * (rather than silently no-op'ing) if the native bridge is unavailable, so a + * sign-in can't appear to succeed without actually storing the credential. */ +export async function setCloudCredential(token: string, email: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Couldn't reach the desktop app to store the sign-in."); + await invoke("set_cloud_credential", { token, email }); +} + +/** The signed-in cloud account, or null if not connected. */ +export async function getCloudAccount(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return null; + try { + return (await invoke("get_cloud_account")) as CloudAccount | null; + } catch { + return null; + } +} + +/** Forget the stored cloud credential (and revoke it if desktop-minted). Rejects if + * the native command fails (e.g. the file couldn't be removed) so callers can + * surface it — a swallowed error would leave the credential able to sign the user + * back in. No-op off desktop. */ +export async function clearCloudCredential(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + await invoke("clear_cloud_credential"); +} + +/** List the signed-in user's cloud dashboards (raw JSON from api.orcabot.com). */ +export async function listCloudDashboards(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("list_cloud_dashboards"); +} + +/** Fetch one cloud dashboard's full data (dashboard + items + edges) for download. */ +export async function getCloudDashboard(dashboardId: string): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("get_cloud_dashboard", { dashboardId }); +} + +export interface WorkspaceDownloadResult { + written: number; + skipped: number; + /** false when the cloud dashboard has no terminal/session (no files to pull). */ + had_workspace: boolean; +} + +/** + * Copy a cloud dashboard's workspace files into the local per-dashboard subfolder + * (`/`). Starts/reuses a cloud session, so it can take a minute + * while the cloud VM boots. Native — the PAT never leaves Rust. + */ +export async function downloadCloudWorkspace( + cloudId: string, + subdir: string +): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Cloud dashboards are only available in the desktop app."); + return invoke("download_cloud_workspace", { cloudId, subdir }) as Promise; +} + +export interface CloudSignIn { + email: string; + name: string; + /** Attempt id — pass to rollbackSignIn if this resolved sign-in was superseded. */ + attempt: number; +} + +/** + * Sign in to the cloud with Google via a LOOPBACK redirect (RFC 8252). The native + * layer runs a temporary 127.0.0.1 listener, opens the browser to the cloud login + * pointing back at it, receives a one-time code there, exchanges it for a PAT, and + * stores the PAT host-only — the token never enters the webview. Resolves with + * {email,name} once sign-in completes (rejects on timeout/cancel). Desktop only. + */ +export async function signInGoogleLoopback(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) throw new Error("Sign-in is only available in the desktop app."); + return invoke("sign_in_google_loopback") as Promise; +} + +/** Cancel an in-flight loopback Google sign-in so the native flow stops before it + * exchanges the code or writes the credential. No-op off desktop. */ +export async function cancelGoogleSignIn(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + await invoke("cancel_google_sign_in"); +} + +/** Roll back a specific sign-in attempt's credential (only if that attempt still + * owns the stored credential — a no-op otherwise, so it can't delete a newer + * sign-in or a pasted PAT). No-op off desktop. */ +export async function rollbackSignIn(attempt: number): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return; + await invoke("rollback_sign_in", { attempt }); +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); @@ -247,23 +390,75 @@ export async function pickFolder(): Promise { // ---- Events ---- -/** Listen for import progress events emitted from the Rust backend. */ -export async function onImportProgress( - callback: (progress: ImportProgress) => void +/** + * Subscribe to a Rust-emitted (`app.emit`) event. Prefers the runtime-injected + * `window.__TAURI__.event.listen` (present via withGlobalTauri) — the same global + * onAppFocus uses — because the bare `import("@tauri-apps/api/webview")` specifier + * does NOT resolve from the remote-origin packaged webview (it throws, the error + * is swallowed, and the listener silently never fires). Falls back to the dynamic + * import for dev builds where the specifier does resolve. No-op off desktop. + */ +async function listenGlobal( + event: string, + callback: (payload: T) => void ): Promise<(() => void) | null> { - if (!DESKTOP_MODE) return null; + if (!DESKTOP_MODE || typeof window === "undefined") return null; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listen = (window as any).__TAURI__?.event?.listen; + if (typeof listen === "function") { + const unlisten = await listen(event, (e: { payload: T }) => callback(e.payload)); + return typeof unlisten === "function" ? unlisten : null; + } + } catch { + /* fall through to the dynamic-import path */ + } try { const mod = await import(/* webpackIgnore: true */ TAURI_WEBVIEW); - const unlisten = await mod.getCurrentWebview().listen( - "folder-import-progress", - (event: { payload: ImportProgress }) => callback(event.payload) - ); + const unlisten = await mod + .getCurrentWebview() + .listen(event, (e: { payload: T }) => callback(e.payload)); return unlisten; } catch { return null; } } +/** Listen for import progress events emitted from the Rust backend. */ +export async function onImportProgress( + callback: (progress: ImportProgress) => void +): Promise<(() => void) | null> { + return listenGlobal("folder-import-progress", callback); +} + +export interface CloudWorkspaceProgress { + cloud_id: string; + /** "starting" | "booting" | "copying" */ + phase: string; + written: number; +} + +/** + * Listen for cloud workspace-download progress (`cloud-workspace-progress`) so the + * UI can show "Starting…/Booting…/Copying files" during a slow cold cloud-VM boot. + */ +export async function onCloudWorkspaceProgress( + callback: (progress: CloudWorkspaceProgress) => void +): Promise<(() => void) | null> { + return listenGlobal("cloud-workspace-progress", callback); +} + +/** + * Listen for auto-update progress emitted from Rust (`update-progress`). Fires + * once the user accepts the update (download start → per-MB progress → install), + * so the UI can show a download bar. No-op off desktop. + */ +export async function onUpdateProgress( + callback: (progress: UpdateProgress) => void +): Promise<(() => void) | null> { + return listenGlobal("update-progress", callback); +} + /** Listen for native drag-drop events on the Tauri webview. */ export async function onDragDrop( callback: (event: { diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts index 26bffcfe..1412cabf 100644 --- a/frontend/src/stores/auth-store.ts +++ b/frontend/src/stores/auth-store.ts @@ -7,7 +7,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { User, SubscriptionInfo } from "@/types"; import { generateId } from "@/lib/utils"; -import { getCachedSurfaceToken } from "@/lib/tauri-bridge"; +import { getCachedSurfaceToken, clearCloudCredential } from "@/lib/tauri-bridge"; +import { useDesktopAccountStore } from "@/stores/desktop-account-store"; /** On desktop, the control plane only honors dev-auth for requests carrying the * per-boot surface token, so include it when present (blocks VM-origin spoofing). */ @@ -105,6 +106,28 @@ export const useAuthStore = create()( isAuthResolved: true, subscription: null, }); + // Desktop: also clear the first-run account choice so logout returns to the + // welcome screen (Free / Google / token) instead of the auto-login + // re-signing the user straight back in. Centralized here so every logout + // path (dashboards header, PaywallDialog, …) behaves the same. No-op on web. + try { + useDesktopAccountStore.getState().reset(); + // Fully disconnect the cloud account: forget the stored PAT (and revoke it + // server-side if it was desktop-minted) so a later sign-in re-establishes + // it cleanly. If the credential file couldn't be removed it may sign the + // user back in on restart — surface that as a user-visible error (not just + // a console log). No-op on web. + clearCloudCredential().catch((e) => { + console.error("[auth] failed to clear cloud credential on logout:", e); + void import("sonner").then(({ toast }) => { + toast.error( + "Signed out, but the stored cloud credential couldn't be removed from this device. Restart the app and sign out again to fully disconnect." + ); + }); + }); + } catch { + /* store unavailable (SSR / very early) — nothing to reset */ + } }, setLoading: (loading: boolean) => { diff --git a/frontend/src/stores/desktop-account-store.ts b/frontend/src/stores/desktop-account-store.ts new file mode 100644 index 00000000..789840f0 --- /dev/null +++ b/frontend/src/stores/desktop-account-store.ts @@ -0,0 +1,79 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: desktop-account-store-v2-hydration-fix +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +const MODULE_REVISION = "desktop-account-store-v2-hydration-fix"; +if (typeof window !== "undefined") { + console.log( + `[desktop-account-store] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` + ); +} + +/** + * First-run account choice for the DESKTOP app. The desktop stack always runs on + * the LOCAL control plane + local VM; this only records whether the user chose to + * stay anonymous ("free") or connect their orcabot.com identity ("signed-in"). + * Signing in is additive — it sets the local account's identity to the real + * email/name and (later) enables cloud sync; it does NOT move execution to cloud. + * + * We intentionally do NOT persist any cloud secret (PAT) here — that will live in + * the Tauri backend / OS keychain when cloud sync lands. This store holds only the + * choice and the display identity. + */ +export type DesktopAccountChoice = "free" | "signed-in"; + +interface DesktopAccountState { + /** null until the user has made a first-run choice. */ + choice: DesktopAccountChoice | null; + /** Real account identity when signed-in (used as the local dev-auth identity). */ + email: string | null; + name: string | null; + /** True once the persisted value has hydrated from storage (client-only). */ + hydrated: boolean; + + chooseFree: () => void; + chooseSignedIn: (email: string, name: string) => void; + /** Return to the first-run choice (e.g. "sign out" / "use a different account"). */ + reset: () => void; +} + +export const useDesktopAccountStore = create()( + persist( + (set) => ({ + choice: null, + email: null, + name: null, + hydrated: false, + + chooseFree: () => set({ choice: "free", email: null, name: null }), + chooseSignedIn: (email: string, name: string) => + set({ choice: "signed-in", email, name }), + reset: () => set({ choice: null, email: null, name: null }), + }), + { + name: "orcabot-desktop-account", + // Persist only the choice + identity, never a transient flag. + partialize: (s) => ({ choice: s.choice, email: s.email, name: s.name }), + } + ) +); + +// Flip `hydrated` once the persisted value has loaded, so the gate doesn't flash +// the welcome screen before we know the choice. This MUST be done after the store +// is created — referencing `useDesktopAccountStore` inside persist's +// onRehydrateStorage hits the const's temporal dead zone (sync localStorage +// hydrates during create()), which threw a ReferenceError and crashed the app. +if (typeof window !== "undefined") { + const markHydrated = () => + useDesktopAccountStore.setState({ hydrated: true }); + if (useDesktopAccountStore.persist.hasHydrated()) { + markHydrated(); + } else { + useDesktopAccountStore.persist.onFinishHydration(markHydrated); + } +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 39a0aced..c49a4598 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -14,6 +14,9 @@ export interface Dashboard { secretsCount?: number; /** Number of active links to/from this dashboard */ linkedCount?: number; + /** Desktop: the source cloud dashboard id if this was downloaded from a cloud + * account (drives the "downloaded" state + sync); null/absent for local ones. */ + cloudId?: string | null; } /** diff --git a/sandbox/internal/sessions/session.go b/sandbox/internal/sessions/session.go index 0366ac92..5f757871 100644 --- a/sandbox/internal/sessions/session.go +++ b/sandbox/internal/sessions/session.go @@ -668,6 +668,12 @@ func (s *Session) CreatePTYWithOptions(opts CreatePTYOptions) (*PTYInfo, error) // reconnect logic turns into a PTY restart loop. if agentType == mcp.AgentTypeClaude { envVars["IS_SANDBOX"] = "1" + // REVISION: claude-no-altscreen-v1 + // Force Claude Code's classic inline renderer instead of the full-screen + // alternate-screen TUI (and skip its one-time "use full screen?" prompt). + // The alt-screen takeover is disruptive inside the xterm.js terminal block; + // this env forces classic mode regardless of any saved `tui` setting. + envVars["CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"] = "1" } // HOME resolves to a dedicated dir INSIDE the workspace, not the workspace root // itself. Keeping ~ under the workspace keeps agent home-dir files self-contained