From 8ee164e9f49bde55ca0d72e585cccfa27ce00cb0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 13 May 2026 17:09:25 +1000 Subject: [PATCH 1/5] feat(staged): add browser-accessible web mode with transport abstraction Restore the full Axum HTTPS web server (previously stubbed) and implement the frontend transport layer for running Staged in a browser. This enables phone and desktop browser access to a running Staged instance. Key changes: - Unstub web_server::start() with TLS listener, static file serving, and auth-protected API routes - Add HTTP transport in invokeCommand() that POSTs to /api/invoke/{command} with automatic 401 redirect to login - Add WebSocket singleton for server-sent events in web mode - Add WebLogin.svelte token entry screen with /api/auth session cookie flow - Implement localStorage backend for persistentStore in web mode - Expose web access token via Tauri command and settings UI with copy button - Add `just dev-web` recipe requiring HTTPS cert/key env vars - Add writeClipboardText/readClipboardText web fallbacks in transport layer Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/justfile | 29 ++++ apps/staged/package.json | 1 + apps/staged/src-tauri/src/lib.rs | 19 ++- apps/staged/src-tauri/src/web_server.rs | 74 ++++++++-- apps/staged/src/App.svelte | 31 +++- apps/staged/src/lib/commands.ts | 9 ++ .../src/lib/features/layout/WebLogin.svelte | 137 ++++++++++++++++++ .../lib/features/settings/SettingsPage.svelte | 80 +++++++++- apps/staged/src/lib/transport.ts | 48 +++++- apps/staged/vite.config.ts | 30 ++++ 10 files changed, 436 insertions(+), 22 deletions(-) create mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte diff --git a/apps/staged/justfile b/apps/staged/justfile index 10a662a30..2b664d926 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -58,6 +58,35 @@ dev repo="": {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} pnpm exec tauri dev --config "$TAURI_CONFIG" +# Run with the HTTPS web server enabled for phone/browser access. +# Requires PEM cert/key files and a hostname covered by the certificate. +dev-web repo="": + #!/usr/bin/env bash + set -euo pipefail + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" || -z "${STAGED_WEB_HOST:-}" ]]; then + printf '%s\n' \ + 'Error: `just dev-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files and a hostname covered by the certificate:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' STAGED_WEB_HOST=hostname.example.com \' \ + ' just dev-web' >&2 + exit 1 + fi + + VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)") + export VITE_PORT + export STAGED_WEB_SERVER=1 + TAURI_CONFIG="{\"build\":{\"devUrl\":\"https://${STAGED_WEB_HOST}:${VITE_PORT}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort --host 0.0.0.0\"}}" + + echo "Starting on https://${STAGED_WEB_HOST}:${VITE_PORT} (HTTPS web server on :5175)" + {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} + pnpm exec tauri dev --config "$TAURI_CONFIG" + # Build the app for production build: pnpm run tauri:build diff --git a/apps/staged/package.json b/apps/staged/package.json index 4a5811845..4a33decf0 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings && tsc -p tsconfig.node.json", diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 44968f501..fa17a9959 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -67,6 +67,10 @@ struct DbState { needs_reset: Mutex>, } +/// Holds the bearer token for web server authentication so it can be +/// retrieved by the frontend (Tauri command) and shown to the user. +struct WebAccessToken(String); + #[derive(Default)] struct ShutdownState { quit_in_progress: AtomicBool, @@ -294,6 +298,12 @@ fn start_store_services( // Store status commands // ============================================================================= +/// Returns the bearer token used to authenticate web browser clients. +#[tauri::command] +fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String { + token.0.clone() +} + /// Returns null if the store is ready, or version info if a reset is needed. #[tauri::command] fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option { @@ -2158,14 +2168,16 @@ pub fn run() { let (event_tx, _) = tokio::sync::broadcast::channel::(256); app.manage(event_tx.clone()); - // Web server startup is stubbed out in this build. - // TODO(web): restore web server startup from the `mobile-web` branch. + // Start the Axum web server only when opted-in via environment variable. + // This avoids exposing an HTTP server on all interfaces for users who + // don't need browser-based access. let web_server_enabled = std::env::var("STAGED_WEB_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if web_server_enabled { let auth_token = web_server::generate_token(); + app.manage(WebAccessToken(auth_token.clone())); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, @@ -2174,6 +2186,8 @@ pub fn run() { std::collections::HashSet::new(), )), }); + } else { + app.manage(WebAccessToken(String::new())); } if cfg!(debug_assertions) { @@ -2240,6 +2254,7 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + get_web_access_token, get_store_status, confirm_reset_store, // Windows diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 2c76aaafa..8b8fb2b3f 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -11,10 +11,6 @@ //! All `/api/*` routes (except `/api/auth`) require authentication via either //! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -// The full implementation is preserved here but start() is currently stubbed out, -// so most items appear unused to the compiler. -#![allow(dead_code, unused_imports)] - use std::collections::HashSet; use std::io; use std::net::SocketAddr; @@ -204,14 +200,68 @@ impl Listener for TlsListener { /// Start the Axum web server in a background tokio task. /// -/// Stubbed — logs a warning and returns. The full implementation (TLS listener, -/// Axum router with static file serving) is intentionally disabled in this build. -/// All route handlers, auth middleware, and the `dispatch()` match block are kept -/// compiling so they stay in sync with the rest of the codebase. -/// -/// TODO(web): restore full web server startup from the `mobile-web` branch. -pub fn start(_state: WebAppState) { - log::warn!("Web server requested but this build has the web server stubbed out"); +/// This should be called from the Tauri `setup` hook after all managed state +/// has been registered. +pub fn start(state: WebAppState) { + let token = state.auth_token.clone(); + tauri::async_runtime::spawn(async move { + let dist_dir = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + // In dev, the exe is in src-tauri/target/debug; dist is at ../../dist relative to src-tauri + .map(|p| { + // Try multiple candidate paths for the built frontend + let candidates = vec![ + p.join("../dist"), // production bundle + p.join("../../../../dist"), // dev (target/debug -> src-tauri -> apps/staged -> dist) + PathBuf::from("../dist"), // relative to cwd + ]; + candidates + .into_iter() + .find(|c| c.exists()) + .unwrap_or_else(|| PathBuf::from("../dist")) + }) + .unwrap_or_else(|| PathBuf::from("../dist")); + + // Protected API routes require auth (Bearer token or session cookie) + let api_routes = Router::new() + .route("/api/invoke/{command}", post(invoke_command)) + .route("/api/events", get(ws_events)) + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); + + // Auth endpoint is public (it's where you submit the token) + let auth_route = Router::new().route("/api/auth", post(authenticate)); + + let app = api_routes + .merge(auth_route) + .fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true)) + .layer(CorsLayer::permissive()) + .with_state(state); + + let addr = "0.0.0.0:5175"; + let tls_acceptor = match load_tls_acceptor() { + Ok(acceptor) => acceptor, + Err(e) => { + log::error!("[web_server] {e}"); + return; + } + }; + log::info!( + "[web_server] starting HTTPS on {addr}, serving static files from {}", + dist_dir.display() + ); + log::info!("[web_server] web access token: {token}"); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("[web_server] failed to bind {addr}: {e}"); + return; + } + }; + if let Err(e) = axum::serve(TlsListener::new(listener, tls_acceptor), app).await { + log::error!("[web_server] server error: {e}"); + } + }); } // ============================================================================= diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 4d680c14d..4771cafcf 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -13,6 +13,7 @@ listenToEvent, type UnlistenFn, } from './lib/transport'; + import WebLogin from './lib/features/layout/WebLogin.svelte'; import * as commands from './lib/api/commands'; import TopBar from './lib/features/layout/TopBar.svelte'; import ProjectHome from './lib/features/projects/ProjectHome.svelte'; @@ -70,6 +71,8 @@ const updaterCheckIntervalMs = 15 * 60 * 1000; let showSessionLab = $state(false); + let currentHash = $state(window.location.hash); + const showLogin = $derived(!isTauri && currentHash === '#/login'); let unlistenMenu: UnlistenFn | undefined; let unlistenSessionStatus: UnlistenFn | undefined; let unlistenCacheInvalidation: UnlistenFn | undefined; @@ -322,11 +325,34 @@ void ensureUpdaterLoopStarted(); } + function onHashChange() { + currentHash = window.location.hash; + } + onMount(async () => { darkMode.init(); // Wire up PR-polling interest hints (window focus + backend lifecycle events). prPollingService.init(); document.addEventListener('keydown', handleKonamiKey); + window.addEventListener('hashchange', onHashChange); + + // In web mode, verify we have a valid session before loading the app. + // This shows the login page immediately rather than after the first + // failed API call triggers a 401 redirect. + if (!isTauri) { + try { + const resp = await fetch('/api/invoke/get_store_status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + if (resp.status === 401) { + window.location.hash = '#/login'; + } + } catch { + // Server unreachable — login page won't help, continue loading + } + } // Web resume accelerator: the restored project id is available synchronously // (navigation seeds it from localStorage at module load). Warm that project's @@ -560,6 +586,7 @@ destroyed = true; prPollingService.dispose(); document.removeEventListener('keydown', handleKonamiKey); + window.removeEventListener('hashchange', onHashChange); unregisterShortcuts?.(); unlistenMenu?.(); unlistenSessionStatus?.(); @@ -591,7 +618,9 @@ } -{#if preferences.loaded} +{#if showLogin} + +{:else if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 2dedb910a..d579df2dc 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -54,6 +54,15 @@ export interface WorktreeChangesPreview { conflictedPaths: string[]; } +// ============================================================================= +// Web access +// ============================================================================= + +/** Returns the bearer token for web server authentication (Tauri-only). */ +export function getWebAccessToken(): Promise { + return invokeCommand('get_web_access_token'); +} + // ============================================================================= // Store status // ============================================================================= diff --git a/apps/staged/src/lib/features/layout/WebLogin.svelte b/apps/staged/src/lib/features/layout/WebLogin.svelte new file mode 100644 index 000000000..ea10a83e0 --- /dev/null +++ b/apps/staged/src/lib/features/layout/WebLogin.svelte @@ -0,0 +1,137 @@ + + + + + + diff --git a/apps/staged/src/lib/features/settings/SettingsPage.svelte b/apps/staged/src/lib/features/settings/SettingsPage.svelte index d60243766..3e0797155 100644 --- a/apps/staged/src/lib/features/settings/SettingsPage.svelte +++ b/apps/staged/src/lib/features/settings/SettingsPage.svelte @@ -10,9 +10,12 @@ import DoctorSettingsPanel from './DoctorSettingsPanel.svelte'; import GeneralSettingsPanel from './GeneralSettingsPanel.svelte'; import KeyboardSettingsPanel from './KeyboardSettingsPanel.svelte'; - import { isTauri } from '../../transport'; + import { isTauri, writeClipboardText } from '../../transport'; + import * as commands from '../../commands'; let appVersion = $state(__APP_VERSION__); + let webToken = $state(null); + let tokenCopied = $state(false); onMount(async () => { if (!isTauri) return; @@ -23,7 +26,20 @@ } catch (error) { console.warn('[Settings] Could not load runtime app version', error); } + + try { + webToken = await commands.getWebAccessToken(); + } catch { + // web server may not be running + } }); + + async function copyToken() { + if (!webToken) return; + await writeClipboardText(webToken); + tokenCopied = true; + setTimeout(() => (tokenCopied = false), 2000); + } @@ -85,6 +101,18 @@
+ + {#if webToken} +
+ Web Access Token +
+ {webToken.slice(0, 8)}... + +
+
+ {/if}
@@ -268,5 +296,55 @@ .nav-meta { display: none; } + + .web-token-section { + display: none; + } + } + + .web-token-section { + margin-top: auto; + padding: 12px; + border-top: 1px solid var(--border-subtle); + } + + .web-token-label { + font-size: var(--size-xs); + color: var(--text-muted); + display: block; + margin-bottom: 6px; + } + + .web-token-row { + display: flex; + align-items: center; + gap: 8px; + } + + .web-token-value { + font-size: var(--size-xs); + color: var(--text-faint); + background: var(--bg-deepest); + padding: 2px 6px; + border-radius: 3px; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } + + .web-token-copy { + background: none; + border: 1px solid var(--border-muted); + border-radius: 4px; + color: var(--text-muted); + font-size: var(--size-xs); + padding: 2px 8px; + cursor: pointer; + white-space: nowrap; + } + + .web-token-copy:hover { + color: var(--text-primary); + border-color: var(--border-emphasis); } diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index 54d6c6944..3e8780bb2 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -6,7 +6,6 @@ * - Event listening (Tauri events vs WebSocket) * - Window management (Tauri window vs no-op) * - Clipboard (Tauri plugin vs navigator.clipboard) - * */ // --------------------------------------------------------------------------- @@ -102,6 +101,11 @@ export async function invokeCommand( body: JSON.stringify(args ?? {}), }); + if (response.status === 401) { + redirectToLogin(); + throw new Error('Authentication required'); + } + if (!response.ok) { const text = await response.text(); let message = text; @@ -119,6 +123,35 @@ export async function invokeCommand( return (await response.json()) as T; } +// --------------------------------------------------------------------------- +// Web authentication +// --------------------------------------------------------------------------- + +let loginRedirectPending = false; + +function redirectToLogin(): void { + if (loginRedirectPending) return; + loginRedirectPending = true; + // Use a small delay to batch multiple 401s that fire simultaneously + setTimeout(() => { + window.location.hash = '#/login'; + loginRedirectPending = false; + }, 50); +} + +/** + * Submit a bearer token to the web server's auth endpoint. + * On success the server sets a session cookie and subsequent requests are authenticated. + */ +export async function submitWebToken(token: string): Promise { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + return response.ok; +} + // --------------------------------------------------------------------------- // Event listening // --------------------------------------------------------------------------- @@ -130,10 +163,10 @@ export type UnlistenFn = () => void; * API; in web mode it connects to the shared WebSocket event stream. * * Returns a synchronous unlisten function. Registration happens asynchronously - * in the background; if the unlisten is called before registration finishes, - * the eventual listener is torn down on arrival. This makes the helper safe to - * use directly in `onMount` cleanup blocks without an intermediate - * `Promise` reference that could race the unmount. + * in the background for Tauri; if the unlisten is called before registration + * finishes, the eventual listener is torn down on arrival. This makes the + * helper safe to use directly in `onMount` cleanup blocks without an + * intermediate `Promise` reference that could race the unmount. */ export function listenToEvent(event: string, callback: (payload: T) => void): UnlistenFn { if (!isTauri) { @@ -405,6 +438,7 @@ interface WindowHandle { const noopWindow: WindowHandle = { show: async () => {}, close: async () => { + // In browser mode, just close the tab/window window.close(); }, startDragging: async () => {}, @@ -418,7 +452,7 @@ const noopWindow: WindowHandle = { /** * Get a handle to the current window. In Tauri mode this returns the real - * Tauri window; in web mode it returns a no-op implementation. + * Tauri window; in web mode it returns a no-op (or limited) implementation. */ export async function getWindow(): Promise { if (isTauri) { @@ -436,6 +470,7 @@ export async function getWindow(): Promise { export function getWindowSync(): WindowHandle { if (!isTauri) return noopWindow; + // Return a proxy that lazily imports the Tauri window API return { show: async () => { const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -507,5 +542,6 @@ export async function onDragDropEvent( // eslint-disable-next-line @typescript-eslint/no-explicit-any return getCurrentWebview().onDragDropEvent(callback as any); } + // No-op in web mode — native file drag is a Tauri-only feature return () => {}; } diff --git a/apps/staged/vite.config.ts b/apps/staged/vite.config.ts index ae91f011f..1d4b3b25f 100644 --- a/apps/staged/vite.config.ts +++ b/apps/staged/vite.config.ts @@ -14,6 +14,24 @@ const serviceWorkerCacheHashLength = 12; const packageJson = JSON.parse( readFileSync(resolve(rootDir, 'package.json'), 'utf8') ) as { version: string }; +const webCertPath = process.env.STAGED_WEB_CERT_PATH; +const webKeyPath = process.env.STAGED_WEB_KEY_PATH; +const webHost = process.env.STAGED_WEB_HOST; + +function requireWebPath(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`${name} must be set to enable HTTPS web mode`); + } + return resolve(value); +} + +const webHttps = + webCertPath || webKeyPath + ? { + cert: readFileSync(requireWebPath('STAGED_WEB_CERT_PATH', webCertPath)), + key: readFileSync(requireWebPath('STAGED_WEB_KEY_PATH', webKeyPath)), + } + : undefined; type HashInput = { contents: string | Uint8Array; @@ -123,7 +141,19 @@ export default defineConfig({ }, }, server: { + // Network access (0.0.0.0) is enabled via `--host` in `just dev-web`. + // Default `dev` stays on localhost to avoid exposing the dev server. port, strictPort: true, + https: webHttps, + allowedHosts: webHost ? [webHost] : undefined, + proxy: { + '/api': { + target: `${webHttps ? 'https' : 'http'}://localhost:5175`, + changeOrigin: true, + secure: false, + ws: true, // WebSocket proxy for /api/events + }, + }, }, }); From 150dfa27ec7ea7875bbb47379202f6ddddaea7dc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 22 Jun 2026 17:09:31 +1000 Subject: [PATCH 2/5] feat(staged): add resilient web resume cache Cherry-pick 6606433087b8048c09457523ec1923b8bf97ed01 to add the web resume cache and page lifecycle refresh behavior. Cache command results with stale-while-revalidate semantics, register lifecycle and invalidation listeners, and add tests covering the browser resume path. Signed-off-by: Matt Toohey --- apps/staged/src/lib/commands.test.ts | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index d1ede235c..a0b0cc4c0 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -632,3 +632,146 @@ describe('cached mutation command wrappers', () => { }); }); }); + +describe('cached mutation command wrappers', () => { + function deferred() { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + } + + let invokeCommand: ReturnType; + let cachedCommand: ReturnType; + let invalidateCache: ReturnType; + let invalidateCacheByCommand: ReturnType; + + beforeEach(() => { + vi.resetModules(); + invokeCommand = vi.fn(); + cachedCommand = vi.fn(); + invalidateCache = vi.fn(); + invalidateCacheByCommand = vi.fn(); + + vi.doMock('./transport', () => ({ + isTauri: false, + invokeCommand, + })); + vi.doMock('./cache', () => ({ + cachedCommand, + cachedInvoke: vi.fn(), + invalidateCache, + invalidateCacheByCommand, + })); + }); + + afterEach(() => { + vi.doUnmock('./transport'); + vi.doUnmock('./cache'); + }); + + it('waits for repo list invalidation before resolving addProjectRepo', async () => { + const repo = { id: 'repo-1' }; + const invalidated = deferred(); + invokeCommand.mockResolvedValue(repo); + invalidateCache.mockReturnValue(invalidated.promise); + + const { addProjectRepo } = await import('./commands'); + + let settled = false; + const result = addProjectRepo('project-1', 'block/builderbot').then((value) => { + settled = true; + return value; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCache).toHaveBeenCalledWith('list_project_repos', { projectId: 'project-1' }); + expect(settled).toBe(false); + + invalidated.resolve(); + + await expect(result).resolves.toBe(repo); + }); + + it('waits for all project cache invalidations before resolving deleteProject', async () => { + const projectsInvalidated = deferred(); + const branchesInvalidated = deferred(); + const reposInvalidated = deferred(); + invokeCommand.mockResolvedValue(undefined); + invalidateCacheByCommand + .mockReturnValueOnce(projectsInvalidated.promise) + .mockReturnValueOnce(branchesInvalidated.promise) + .mockReturnValueOnce(reposInvalidated.promise); + + const { deleteProject } = await import('./commands'); + + let settled = false; + const result = deleteProject('project-1').then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(invalidateCacheByCommand.mock.calls).toEqual([ + ['list_projects'], + ['list_branches_for_project'], + ['list_project_repos'], + ]); + expect(settled).toBe(false); + + projectsInvalidated.resolve(); + branchesInvalidated.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + reposInvalidated.resolve(); + + await expect(result).resolves.toBeUndefined(); + }); + + it('bypasses the SWR cache when fetching fresh session messages', async () => { + const messages = [{ id: 1, sessionId: 'session-1', role: 'assistant', content: 'done' }]; + invokeCommand.mockResolvedValue(messages); + + const { getFreshSessionMessages } = await import('./commands'); + + await expect(getFreshSessionMessages('session-1')).resolves.toBe(messages); + expect(invokeCommand).toHaveBeenCalledWith('get_session_messages', { + sessionId: 'session-1', + }); + expect(cachedCommand).not.toHaveBeenCalled(); + }); + + it('uses the standard provider discovery cache by default', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + cachedCommand.mockResolvedValue({ data: providers, revalidating: null }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders()).resolves.toEqual({ + data: providers, + revalidating: null, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { + ttl: 30 * 60_000, + }); + }); + + it('forces provider discovery revalidation without bypassing the cached value', async () => { + const providers = [{ id: 'goose', label: 'Goose' }]; + const revalidating = Promise.resolve([{ id: 'codex', label: 'Codex' }]); + cachedCommand.mockResolvedValue({ data: providers, revalidating }); + + const { discoverAcpProviders } = await import('./commands'); + + await expect(discoverAcpProviders({ force: true })).resolves.toEqual({ + data: providers, + revalidating, + }); + expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { ttl: 0 }); + }); +}); From 551688949ce72a86540cafb8d8b2c45d65ed8aa1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 3 Jul 2026 19:08:44 +1000 Subject: [PATCH 3/5] fix(staged): expose web access token in web dispatch Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/web_server.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 8b8fb2b3f..85f1ce55e 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -584,6 +584,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Ok(serde_json::to_value(&state.auth_token).unwrap()), "get_store_status" => { // We don't have DbState in web context — return null (store ready) Ok(Value::Null) From 13e947dd329318b173a0e58fc6ff1c15971c3b4d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 10:48:50 +1000 Subject: [PATCH 4/5] feat(staged): remove web access token auth from web mode Unauthenticated HTTPS is enough for browser access, so drop the bearer token / session cookie layer entirely: - Remove the require_auth middleware, /api/auth endpoint, session registry, and token generation from the Axum web server - Remove the WebAccessToken managed state and get_web_access_token Tauri command - Remove the WebLogin token entry screen, #/login hash routing, and 401-redirect handling from the frontend transport layer - Remove the token display/copy section from the settings page - Drop the now-unused axum-extra, rand, hex, subtle, and time crates Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/Cargo.lock | 28 ---- apps/staged/src-tauri/Cargo.toml | 9 +- apps/staged/src-tauri/src/lib.rs | 19 --- apps/staged/src-tauri/src/web_server.rs | 119 +-------------- apps/staged/src/App.svelte | 31 +--- apps/staged/src/lib/commands.ts | 9 -- .../src/lib/features/layout/WebLogin.svelte | 137 ------------------ .../lib/features/settings/SettingsPage.svelte | 80 +--------- apps/staged/src/lib/transport.ts | 34 ----- 9 files changed, 7 insertions(+), 459 deletions(-) delete mode 100644 apps/staged/src/lib/features/layout/WebLogin.svelte diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 2c24a1bce..0b81373c3 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -11,7 +11,6 @@ dependencies = [ "anyhow", "async-trait", "axum", - "axum-extra", "base64 0.22.1", "blox-cli", "builderbot-actions", @@ -24,7 +23,6 @@ dependencies = [ "libc", "log", "pikchr", - "rand 0.9.5", "regex", "reqwest", "resvg", @@ -36,7 +34,6 @@ dependencies = [ "serde_json", "sha2 0.11.0", "strip-ansi-escapes", - "subtle", "tar", "tauri", "tauri-build", @@ -50,7 +47,6 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "thiserror 2.0.20", - "time", "tiny-skia", "tokio", "tokio-rustls", @@ -473,29 +469,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" -dependencies = [ - "axum", - "axum-core", - "bytes", - "cookie", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde_core", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.21.7" @@ -873,7 +846,6 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ - "percent-encoding", "time", "version_check", ] diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 8852bd54f..466a56004 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -65,14 +65,9 @@ tauri-plugin-store = "2.4.2" # MCP server for project sessions rmcp = { version = "0.17", features = ["server", "transport-streamable-http-server"] } axum = { version = "0.8", features = ["ws"] } -axum-extra = { version = "0.10", features = ["cookie"] } tower-http = { version = "0.6", features = ["fs", "cors"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } -rand = "0.9" -hex = "0.4" -subtle = "2.6" -time = "0.3" pikchr = "0.1.4" # Pikchr preview MCP tool: render Pikchr source to SVG (same C engine as the @@ -81,9 +76,11 @@ resvg = "0.47" usvg = "0.47" tiny-skia = "0.12" -# Managed Node.js runtime: tarball extraction for runtime installs +# Managed Node.js runtime: tarball extraction and checksum verification for +# runtime installs tar = "0.4" flate2 = "1" +hex = "0.4" [dev-dependencies] # test-util: `#[tokio::test(start_paused = true)]` for the store-events diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index fa17a9959..50d397ff8 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -67,10 +67,6 @@ struct DbState { needs_reset: Mutex>, } -/// Holds the bearer token for web server authentication so it can be -/// retrieved by the frontend (Tauri command) and shown to the user. -struct WebAccessToken(String); - #[derive(Default)] struct ShutdownState { quit_in_progress: AtomicBool, @@ -298,12 +294,6 @@ fn start_store_services( // Store status commands // ============================================================================= -/// Returns the bearer token used to authenticate web browser clients. -#[tauri::command] -fn get_web_access_token(token: tauri::State<'_, WebAccessToken>) -> String { - token.0.clone() -} - /// Returns null if the store is ready, or version info if a reset is needed. #[tauri::command] fn get_store_status(db_state: tauri::State<'_, DbState>) -> Option { @@ -2176,18 +2166,10 @@ pub fn run() { .unwrap_or(false); if web_server_enabled { - let auth_token = web_server::generate_token(); - app.manage(WebAccessToken(auth_token.clone())); web_server::start(web_server::WebAppState { app_handle: app.handle().clone(), event_tx, - auth_token, - sessions: std::sync::Arc::new(std::sync::Mutex::new( - std::collections::HashSet::new(), - )), }); - } else { - app.manage(WebAccessToken(String::new())); } if cfg!(debug_assertions) { @@ -2254,7 +2236,6 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ - get_web_access_token, get_store_status, confirm_reset_store, // Windows diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 85f1ce55e..b3930e92b 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -5,13 +5,8 @@ //! //! - `POST /api/invoke/{command}` — dispatches to the same logic as Tauri commands //! - `GET /api/events` — WebSocket that broadcasts Tauri events as JSON -//! - `POST /api/auth` — accepts bearer token and sets session cookie //! - `GET /*` — static files from `../dist` (the built Svelte frontend) -//! -//! All `/api/*` routes (except `/api/auth`) require authentication via either -//! an `Authorization: Bearer ` header or a valid `staged_session` cookie. -use std::collections::HashSet; use std::io; use std::net::SocketAddr; use std::path::PathBuf; @@ -19,19 +14,15 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::extract::ws::{Message, WebSocket}; -use axum::extract::{Path, Query, Request, State, WebSocketUpgrade}; +use axum::extract::{Path, Query, State, WebSocketUpgrade}; use axum::http::StatusCode; -use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::serve::Listener; use axum::Router; -use axum_extra::extract::cookie::{Cookie, CookieJar}; -use rand::Rng; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use serde_json::Value; -use subtle::ConstantTimeEq; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::broadcast; use tokio_rustls::server::TlsStream; @@ -58,10 +49,6 @@ use crate::store::{self, Store}; pub struct WebAppState { pub app_handle: tauri::AppHandle, pub event_tx: broadcast::Sender, - /// Hex-encoded 256-bit token required to authenticate web clients. - pub auth_token: String, - /// Set of valid session IDs, one per authenticated client. - pub sessions: Arc>>, } /// A serialized event for WebSocket broadcast. @@ -121,12 +108,6 @@ pub fn emit_to_all( // Server startup // ============================================================================= -/// Generate a cryptographically random hex-encoded token (256-bit). -pub fn generate_token() -> String { - let bytes: [u8; 32] = rand::rng().random(); - hex::encode(bytes) -} - const CERT_PATH_ENV: &str = "STAGED_WEB_CERT_PATH"; const KEY_PATH_ENV: &str = "STAGED_WEB_KEY_PATH"; @@ -203,7 +184,6 @@ impl Listener for TlsListener { /// This should be called from the Tauri `setup` hook after all managed state /// has been registered. pub fn start(state: WebAppState) { - let token = state.auth_token.clone(); tauri::async_runtime::spawn(async move { let dist_dir = std::env::current_exe() .ok() @@ -223,17 +203,9 @@ pub fn start(state: WebAppState) { }) .unwrap_or_else(|| PathBuf::from("../dist")); - // Protected API routes require auth (Bearer token or session cookie) - let api_routes = Router::new() + let app = Router::new() .route("/api/invoke/{command}", post(invoke_command)) .route("/api/events", get(ws_events)) - .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); - - // Auth endpoint is public (it's where you submit the token) - let auth_route = Router::new().route("/api/auth", post(authenticate)); - - let app = api_routes - .merge(auth_route) .fallback_service(ServeDir::new(&dist_dir).append_index_html_on_directories(true)) .layer(CorsLayer::permissive()) .with_state(state); @@ -250,7 +222,6 @@ pub fn start(state: WebAppState) { "[web_server] starting HTTPS on {addr}, serving static files from {}", dist_dir.display() ); - log::info!("[web_server] web access token: {token}"); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -264,91 +235,6 @@ pub fn start(state: WebAppState) { }); } -// ============================================================================= -// Authentication -// ============================================================================= - -const SESSION_COOKIE_NAME: &str = "staged_session"; -const SESSION_MAX_AGE_DAYS: i64 = 7; - -/// Constant-time string comparison to prevent timing side-channel attacks. -fn constant_time_eq(a: &str, b: &str) -> bool { - a.as_bytes().ct_eq(b.as_bytes()).into() -} - -/// Middleware that rejects unauthenticated requests to protected routes. -/// -/// Accepts either: -/// - `Authorization: Bearer ` header matching the server's auth token -/// - `staged_session` cookie matching the server's session ID -async fn require_auth( - State(state): State, - jar: CookieJar, - request: Request, - next: Next, -) -> Response { - // Check Authorization header (constant-time comparison to prevent timing attacks) - if let Some(auth_header) = request.headers().get("authorization") { - if let Ok(value) = auth_header.to_str() { - if let Some(token) = value.strip_prefix("Bearer ") { - if constant_time_eq(token, &state.auth_token) { - return next.run(request).await; - } - } - } - } - - // Check session cookie against the set of valid sessions - if let Some(cookie) = jar.get(SESSION_COOKIE_NAME) { - let is_valid = { - let sessions = state.sessions.lock().unwrap_or_else(|e| e.into_inner()); - let cookie_val = cookie.value(); - sessions.iter().any(|s| constant_time_eq(cookie_val, s)) - }; - if is_valid { - return next.run(request).await; - } - } - - (StatusCode::UNAUTHORIZED, "Authentication required").into_response() -} - -/// POST /api/auth — validate the bearer token and issue a session cookie. -/// -/// Expects JSON body: `{ "token": "" }` -async fn authenticate( - State(state): State, - jar: CookieJar, - Json(body): Json, -) -> Response { - let token = body - .get("token") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - - if !constant_time_eq(token, &state.auth_token) { - return (StatusCode::UNAUTHORIZED, "Invalid token").into_response(); - } - - // Generate a unique session ID for this client and register it. - let new_session_id = generate_token(); - state - .sessions - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(new_session_id.clone()); - - let cookie = Cookie::build((SESSION_COOKIE_NAME, new_session_id)) - .path("/") - .http_only(true) - .secure(true) - .max_age(time::Duration::days(SESSION_MAX_AGE_DAYS)) - .same_site(axum_extra::extract::cookie::SameSite::Lax) - .build(); - - (jar.add(cookie), Json(serde_json::json!({ "ok": true }))).into_response() -} - // ============================================================================= // WebSocket endpoint — /api/events // ============================================================================= @@ -584,7 +470,6 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Ok(serde_json::to_value(&state.auth_token).unwrap()), "get_store_status" => { // We don't have DbState in web context — return null (store ready) Ok(Value::Null) diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 4771cafcf..4d680c14d 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -13,7 +13,6 @@ listenToEvent, type UnlistenFn, } from './lib/transport'; - import WebLogin from './lib/features/layout/WebLogin.svelte'; import * as commands from './lib/api/commands'; import TopBar from './lib/features/layout/TopBar.svelte'; import ProjectHome from './lib/features/projects/ProjectHome.svelte'; @@ -71,8 +70,6 @@ const updaterCheckIntervalMs = 15 * 60 * 1000; let showSessionLab = $state(false); - let currentHash = $state(window.location.hash); - const showLogin = $derived(!isTauri && currentHash === '#/login'); let unlistenMenu: UnlistenFn | undefined; let unlistenSessionStatus: UnlistenFn | undefined; let unlistenCacheInvalidation: UnlistenFn | undefined; @@ -325,34 +322,11 @@ void ensureUpdaterLoopStarted(); } - function onHashChange() { - currentHash = window.location.hash; - } - onMount(async () => { darkMode.init(); // Wire up PR-polling interest hints (window focus + backend lifecycle events). prPollingService.init(); document.addEventListener('keydown', handleKonamiKey); - window.addEventListener('hashchange', onHashChange); - - // In web mode, verify we have a valid session before loading the app. - // This shows the login page immediately rather than after the first - // failed API call triggers a 401 redirect. - if (!isTauri) { - try { - const resp = await fetch('/api/invoke/get_store_status', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }); - if (resp.status === 401) { - window.location.hash = '#/login'; - } - } catch { - // Server unreachable — login page won't help, continue loading - } - } // Web resume accelerator: the restored project id is available synchronously // (navigation seeds it from localStorage at module load). Warm that project's @@ -586,7 +560,6 @@ destroyed = true; prPollingService.dispose(); document.removeEventListener('keydown', handleKonamiKey); - window.removeEventListener('hashchange', onHashChange); unregisterShortcuts?.(); unlistenMenu?.(); unlistenSessionStatus?.(); @@ -618,9 +591,7 @@ } -{#if showLogin} - -{:else if preferences.loaded} +{#if preferences.loaded} {#if storeIncompat && storeIncompat.kind === 'needs_reset'}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index d579df2dc..2dedb910a 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -54,15 +54,6 @@ export interface WorktreeChangesPreview { conflictedPaths: string[]; } -// ============================================================================= -// Web access -// ============================================================================= - -/** Returns the bearer token for web server authentication (Tauri-only). */ -export function getWebAccessToken(): Promise { - return invokeCommand('get_web_access_token'); -} - // ============================================================================= // Store status // ============================================================================= diff --git a/apps/staged/src/lib/features/layout/WebLogin.svelte b/apps/staged/src/lib/features/layout/WebLogin.svelte deleted file mode 100644 index ea10a83e0..000000000 --- a/apps/staged/src/lib/features/layout/WebLogin.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - diff --git a/apps/staged/src/lib/features/settings/SettingsPage.svelte b/apps/staged/src/lib/features/settings/SettingsPage.svelte index 3e0797155..d60243766 100644 --- a/apps/staged/src/lib/features/settings/SettingsPage.svelte +++ b/apps/staged/src/lib/features/settings/SettingsPage.svelte @@ -10,12 +10,9 @@ import DoctorSettingsPanel from './DoctorSettingsPanel.svelte'; import GeneralSettingsPanel from './GeneralSettingsPanel.svelte'; import KeyboardSettingsPanel from './KeyboardSettingsPanel.svelte'; - import { isTauri, writeClipboardText } from '../../transport'; - import * as commands from '../../commands'; + import { isTauri } from '../../transport'; let appVersion = $state(__APP_VERSION__); - let webToken = $state(null); - let tokenCopied = $state(false); onMount(async () => { if (!isTauri) return; @@ -26,20 +23,7 @@ } catch (error) { console.warn('[Settings] Could not load runtime app version', error); } - - try { - webToken = await commands.getWebAccessToken(); - } catch { - // web server may not be running - } }); - - async function copyToken() { - if (!webToken) return; - await writeClipboardText(webToken); - tokenCopied = true; - setTimeout(() => (tokenCopied = false), 2000); - } @@ -101,18 +85,6 @@
- - {#if webToken} -
- Web Access Token -
- {webToken.slice(0, 8)}... - -
-
- {/if}
@@ -296,55 +268,5 @@ .nav-meta { display: none; } - - .web-token-section { - display: none; - } - } - - .web-token-section { - margin-top: auto; - padding: 12px; - border-top: 1px solid var(--border-subtle); - } - - .web-token-label { - font-size: var(--size-xs); - color: var(--text-muted); - display: block; - margin-bottom: 6px; - } - - .web-token-row { - display: flex; - align-items: center; - gap: 8px; - } - - .web-token-value { - font-size: var(--size-xs); - color: var(--text-faint); - background: var(--bg-deepest); - padding: 2px 6px; - border-radius: 3px; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - } - - .web-token-copy { - background: none; - border: 1px solid var(--border-muted); - border-radius: 4px; - color: var(--text-muted); - font-size: var(--size-xs); - padding: 2px 8px; - cursor: pointer; - white-space: nowrap; - } - - .web-token-copy:hover { - color: var(--text-primary); - border-color: var(--border-emphasis); } diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index 3e8780bb2..69d9abd8e 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -101,11 +101,6 @@ export async function invokeCommand( body: JSON.stringify(args ?? {}), }); - if (response.status === 401) { - redirectToLogin(); - throw new Error('Authentication required'); - } - if (!response.ok) { const text = await response.text(); let message = text; @@ -123,35 +118,6 @@ export async function invokeCommand( return (await response.json()) as T; } -// --------------------------------------------------------------------------- -// Web authentication -// --------------------------------------------------------------------------- - -let loginRedirectPending = false; - -function redirectToLogin(): void { - if (loginRedirectPending) return; - loginRedirectPending = true; - // Use a small delay to batch multiple 401s that fire simultaneously - setTimeout(() => { - window.location.hash = '#/login'; - loginRedirectPending = false; - }, 50); -} - -/** - * Submit a bearer token to the web server's auth endpoint. - * On success the server sets a session cookie and subsequent requests are authenticated. - */ -export async function submitWebToken(token: string): Promise { - const response = await fetch('/api/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - return response.ok; -} - // --------------------------------------------------------------------------- // Event listening // --------------------------------------------------------------------------- From d4a44ead3bd9e4d09420f8a1e6b7876bfa17bbae Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 28 Aug 2026 15:36:06 +1000 Subject: [PATCH 5/5] feat(staged): add run-web recipe for release web server flow Encode the "release build + web server" flow from the note so its two traps aren't rediscovered each time: - Guard on STAGED_WEB_CERT_PATH/STAGED_WEB_KEY_PATH before building, using the same error style as `dev-web` (STAGED_WEB_HOST isn't needed here) - Build with `tauri build --bundles app`, which regenerates dist/ and skips the DMG, keeping the window's embedded assets and the server's on-disk dist/ in sync - Resolve the bundle under CARGO_TARGET_DIR and launch from src-tauri, since only the cwd-relative `../dist` candidate resolves for a release binary - Probe https://localhost:5175/sw.js after launch and report cert/port versus missing-dist failures, which release builds otherwise swallow (no logger) Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- apps/staged/justfile | 84 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/apps/staged/justfile b/apps/staged/justfile index 2b664d926..bc6d5be5d 100644 --- a/apps/staged/justfile +++ b/apps/staged/justfile @@ -87,6 +87,90 @@ dev-web repo="": {{ if repo != "" { "export STAGED_REPO=" + repo } else { "" } }} pnpm exec tauri dev --config "$TAURI_CONFIG" +# Build and run the release app with the HTTPS web server. Requires PEM cert/key files. +run-web: + #!/usr/bin/env bash + set -euo pipefail + + # Unlike `dev-web` there is no Vite/HMR: the desktop window and the phone + # get the same built bundle, so this is the path that exercises the service + # worker and the production frontend. One Staged process at a time — a + # `just dev`/`dev-web` instance elsewhere shares ~/.staged and the port. + + [[ -d node_modules ]] || pnpm install + + if [[ -z "${STAGED_WEB_CERT_PATH:-}" || -z "${STAGED_WEB_KEY_PATH:-}" ]]; then + printf '%s\n' \ + 'Error: `just run-web` serves browser access over HTTPS.' \ + 'Provide PEM certificate/key files covering the hostname you browse to:' \ + '' \ + ' STAGED_WEB_CERT_PATH=/path/to/cert.pem \' \ + ' STAGED_WEB_KEY_PATH=/path/to/key.pem \' \ + ' just run-web' >&2 + exit 1 + fi + + # `tauri build` runs `pnpm run build` first, so dist/ is always regenerated + # here. The window's assets are compiled into the binary while the server + # reads dist/ from disk — rebuilding both together keeps them in sync. + # `--bundles app` skips the DMG that `just build` would also produce. + pnpm exec tauri build --bundles app + + # Cargo resolves a relative CARGO_TARGET_DIR against its own cwd (src-tauri) + TARGET_DIR="$(pwd)/src-tauri/target" + if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then + case "$CARGO_TARGET_DIR" in + /*) TARGET_DIR="$CARGO_TARGET_DIR" ;; + *) TARGET_DIR="$(pwd)/src-tauri/$CARGO_TARGET_DIR" ;; + esac + fi + + APP_BIN="$TARGET_DIR/release/bundle/macos/Staged.app/Contents/MacOS/Staged" + if [[ ! -x "$APP_BIN" ]]; then + echo "Error: no app bundle at $APP_BIN" >&2 + exit 1 + fi + + export STAGED_WEB_SERVER=1 + + # The server locates the frontend by trying /../dist, then + # /../../../../dist, then ../dist relative to the cwd. Only the last + # one resolves for a bundled release binary, so launch from src-tauri to + # make ../dist mean apps/staged/dist. Anywhere else serves 404s. + cd src-tauri + + "$APP_BIN" & + APP_PID=$! + trap 'kill "$APP_PID" 2>/dev/null || true' EXIT + + # Release builds install no logger, so web server failures (bad cert path, + # :5175 already bound by another Staged instance) are completely silent. + # Probe the server rather than trusting that the app launched. + STATUS=000 + for _ in $(seq 1 30); do + kill -0 "$APP_PID" 2>/dev/null || break + STATUS=$(curl -sk --max-time 2 -o /dev/null -w '%{http_code}' https://localhost:5175/sw.js) || true + [[ "$STATUS" == "000" ]] || break + sleep 1 + done + + case "$STATUS" in + 2*|3*) + echo "Web server ready on https://${STAGED_WEB_HOST:-$(hostname)}:5175" + ;; + 404) + echo "Web server is up but dist/ did not resolve — it is serving 404s." >&2 + ;; + *) + printf '%s\n' \ + 'Web server never came up on :5175. Release builds log nothing, so check:' \ + ' - STAGED_WEB_CERT_PATH / STAGED_WEB_KEY_PATH point at readable PEM files' \ + ' - no other Staged instance already holds the port' >&2 + ;; + esac + + wait "$APP_PID" || true + # Build the app for production build: pnpm run tauri:build