Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions desktop/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
59 changes: 57 additions & 2 deletions desktop/app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}

/// 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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()) },
);
}
}
});
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/(app)/dashboards/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3605,6 +3606,7 @@ export default function DashboardPage() {
<span className="text-lg font-bold text-[var(--foreground)]">
OrcaBot
</span>
<DesktopVersionBadge />
</div>

<div className="flex items-center gap-2 justify-center min-w-0">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/(app)/dashboards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -370,6 +371,7 @@ export default function DashboardsPage() {
className="w-7 h-7 object-contain"
/>
<span className="text-lg font-bold text-[var(--foreground)]">OrcaBot</span>
<DesktopVersionBadge />
</div>
<div className="flex items-center gap-4">
<TrialBanner />
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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}
<UpdateProgressOverlay />
</>
);
}
39 changes: 39 additions & 0 deletions frontend/src/components/DesktopVersionBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<span
className={`text-xs font-medium text-[var(--foreground)] opacity-40 tabular-nums ${className}`}
title={`Orcabot desktop v${version}`}
>
v{version}
</span>
);
}
140 changes: 140 additions & 0 deletions frontend/src/components/UpdateProgressOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<UpdateProgress | null>(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 (
<div
role="status"
aria-live="polite"
style={{
position: "fixed",
bottom: 16,
right: 16,
zIndex: 2147483000,
width: 320,
maxWidth: "calc(100vw - 32px)",
padding: "12px 14px",
borderRadius: 12,
background: "var(--card, #12161f)",
color: "var(--foreground, #eef2f8)",
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 8px 30px rgba(0,0,0,0.35)",
fontSize: 13,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
{!isError && (
<span
aria-hidden
style={{
width: 12,
height: 12,
flex: "none",
borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.25)",
borderTopColor: isInstalling ? "#34d399" : "#5b8cff",
animation: "orcaSpin 0.8s linear infinite",
}}
/>
)}
<span>{title}</span>
</div>
<div style={{ marginTop: 4, opacity: 0.7, fontSize: 12 }}>{detail}</div>

{!isError && (
<div
style={{
marginTop: 10,
height: 6,
borderRadius: 999,
overflow: "hidden",
background: "rgba(255,255,255,0.10)",
}}
>
<div
style={{
height: "100%",
borderRadius: 999,
background: isInstalling ? "#34d399" : "#5b8cff",
width: indeterminate || isInstalling ? "100%" : `${pct}%`,
transition: "width 0.25s ease",
animation: indeterminate ? "orcaPulse 1.2s ease-in-out infinite" : undefined,
}}
/>
</div>
)}

<style>{`
@keyframes orcaSpin { to { transform: rotate(360deg); } }
@keyframes orcaPulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }
`}</style>
</div>
);
}
30 changes: 28 additions & 2 deletions frontend/src/components/blocks/VncViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div> that lives directly in the React Flow node, so the
Expand All @@ -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`);
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading