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>
);
}
Loading
Loading