diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 5befee12..2277ad8f 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -608,6 +608,14 @@ pub fn quit_app(app: tauri::AppHandle) { app.exit(0); } +/// The running app's version (from tauri.conf.json / Cargo.toml), e.g. "0.5.0". +/// Shown in the desktop header so users can see what they're running — the +/// version is otherwise invisible in a packaged build. +#[tauri::command] +pub fn get_app_version(app: tauri::AppHandle) -> String { + app.package_info().version.to_string() +} + /// 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). diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 2b2252e9..2c7d3d8b 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 @@ -981,6 +994,7 @@ fn main() { commands::open_url, commands::reveal_workspace, commands::get_ports, + commands::get_app_version, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); @@ -1085,6 +1099,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 +1130,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/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 8c3bf441..0b23b290 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -54,6 +54,7 @@ import { import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; +import { DesktopVersionBadge } from "@/components/DesktopVersionBadge"; import { API, DESKTOP_MODE } from "@/config/env"; import { switchToCli } from "@/lib/tauri-bridge"; import { @@ -370,6 +371,7 @@ export default function DashboardsPage() { className="w-7 h-7 object-contain" /> OrcaBot +
diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx new file mode 100644 index 00000000..08b15df8 --- /dev/null +++ b/frontend/src/app/(app)/layout.tsx @@ -0,0 +1,15 @@ +import { UpdateProgressOverlay } from "@/components/UpdateProgressOverlay"; + +/** + * 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/components/DesktopVersionBadge.tsx b/frontend/src/components/DesktopVersionBadge.tsx new file mode 100644 index 00000000..f09ec515 --- /dev/null +++ b/frontend/src/components/DesktopVersionBadge.tsx @@ -0,0 +1,39 @@ +// REVISION: desktop-version-badge-v1 +"use client"; + +import { useEffect, useState } from "react"; +import { DESKTOP_MODE } from "@/config/env"; +import { getAppVersion } from "@/lib/tauri-bridge"; + +/** + * Small "v0.5.0" tag shown next to the Orcabot wordmark in the desktop app so + * users can see which version they're running (invisible otherwise in a packaged + * build). Renders nothing on web/Cloudflare builds or until the version resolves. + */ +export function DesktopVersionBadge({ className = "" }: { className?: string }) { + const [version, setVersion] = useState(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/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index c45b81d8..92bdd215 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-v6-version-and-update-progress +const MODULE_REVISION = "tauri-bridge-v6-version-and-update-progress"; 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,18 @@ 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; + } +} + /** Reveal the host workspace directory in Finder/Explorer (desktop only). */ export async function revealWorkspace(): Promise { const invoke = await getTauriInvoke(); @@ -264,6 +283,27 @@ export async function onImportProgress( } } +/** + * 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> { + if (!DESKTOP_MODE) return null; + try { + const mod = await import(/* webpackIgnore: true */ TAURI_WEBVIEW); + const unlisten = await mod.getCurrentWebview().listen( + "update-progress", + (event: { payload: UpdateProgress }) => callback(event.payload) + ); + return unlisten; + } catch { + return null; + } +} + /** Listen for native drag-drop events on the Tauri webview. */ export async function onDragDrop( callback: (event: { 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