@@ -90,7 +89,21 @@ account while existing threads stay pinned to the account that started them.
## Quick start
-### Desktop app (beta)
+### Personal install (CLI)
+
+```bash
+npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
+ocx start # proxy + dashboard on localhost:10100
+```
+
+Use `ocx service` to run it in the background.
+
+Open **http://localhost:10100** and configure everything in the web dashboard — add providers
+(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui`
+re-opens the dashboard at any time.
+
+
+Desktop app (beta)
The desktop app is the same proxy and dashboard in a native window, with a tray and bundled `ocx`.
It attaches to a proxy that is already running, or starts its bundled one, and the dashboard stays
@@ -113,18 +126,7 @@ step needs macOS). The [Desktop App guide](https://opencodex.me/guides/desktop-a
[macOS Menu Bar App guide](https://opencodex.me/guides/macos-menu-bar/) cover first launch, and
[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed) lists everything written to disk.
-### Personal install (CLI)
-
-```bash
-npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically
-ocx start # proxy + dashboard on localhost:10100
-```
-
-Use `ocx service` to run it in the background.
-
-Open **http://localhost:10100** and configure everything in the web dashboard — add providers
-(40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui`
-re-opens the dashboard at any time.
+
### ChatGPT account pool
@@ -306,14 +308,15 @@ see the [installation docs](https://opencodex.me/getting-started/installation/).
Memory ownership details
-OpenCodex tracks 36 categories of process-retained state. Each has a documented bound:
+OpenCodex tracks process-retained state in the categories below. Each has a documented bound:
-- **12 retained stores** (request log, debug rings, image cache, model cache, vision
+- **14 retained stores** (request log, debug rings, image cache, model cache, vision
descriptions, cursor blobs, responses continuation, etc.) are byte-accounted and
- evicted by the app-owned memory budget (default 256 MiB).
+ evicted by the app-owned memory budget (default 256 MiB), except the native control replay
+ store, which is pinned and never evicted.
- **4 observed buffers** (translator accumulators, image/OAuth/Grok tails) are
monitored for in-flight byte pressure without eviction.
-- **24 state-store registrations** handle expiry sweeps (60 s interval) and
+- **28 state-store registrations** handle expiry sweeps (60 s interval) and
config-generation reconciliation so stale provider/account keys are removed.
- **Path and fingerprint memos** (workspace metadata, hardened identities, installation
salts, mode-hint capabilities) use insertion-order LRU caps (8–128 entries).
diff --git a/app/Sources/NativeTray/Popover.swift b/app/Sources/NativeTray/Popover.swift
index 5659aa858ad..e3d930af7c2 100644
--- a/app/Sources/NativeTray/Popover.swift
+++ b/app/Sources/NativeTray/Popover.swift
@@ -33,6 +33,78 @@ private final class NativeTrayPopover: NSObject {
}
+@MainActor
+private final class UpdateDotView: NSView {
+ weak var statusButton: NSStatusBarButton?
+
+ init(button: NSStatusBarButton) {
+ statusButton = button
+ super.init(frame: button.bounds)
+ autoresizingMask = [.width, .height]
+ // AppKit keeps the template image and its highlighted tint. This view draws only
+ // the independent accent, without making the status button layer-backed.
+ wantsLayer = false
+ }
+
+ required init?(coder: NSCoder) { nil }
+ override var isOpaque: Bool { false }
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+
+ override func layout() {
+ super.layout()
+ needsDisplay = true
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let button = statusButton else { return }
+ let imageRect = button.cell?.imageRect(forBounds: button.bounds) ?? button.bounds
+ let image = imageRect.isEmpty ? button.bounds : imageRect
+ let diameter: CGFloat = 7
+ let dot = NSRect(x: min(bounds.maxX - diameter, image.maxX - 4),
+ y: max(bounds.minY, image.minY + 1),
+ width: diameter, height: diameter)
+ NSColor.windowBackgroundColor.setFill()
+ NSBezierPath(ovalIn: dot.insetBy(dx: -1.25, dy: -1.25)).fill()
+ NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.97, alpha: 1).setFill()
+ NSBezierPath(ovalIn: dot).fill()
+ }
+}
+
+@MainActor
+private enum UpdateDot {
+ static weak var button: NSStatusBarButton?
+ static var view: UpdateDotView?
+
+ static func set(_ item: NSStatusItem, visible: Bool) {
+ guard let next = item.button else { return }
+ if button !== next {
+ view?.removeFromSuperview()
+ view = nil
+ button = next
+ }
+ guard visible else {
+ view?.removeFromSuperview()
+ view = nil
+ return
+ }
+ if view == nil {
+ let overlay = UpdateDotView(button: next)
+ next.addSubview(overlay)
+ view = overlay
+ }
+ view?.frame = next.bounds
+ view?.needsDisplay = true
+ }
+}
+
+@_cdecl("ocx_native_tray_update_dot")
+@MainActor
+public func nativeTrayUpdateDot(_ item: UnsafeMutableRawPointer?, _ show: Int32) {
+ guard Thread.isMainThread, let item else { return }
+ let statusItem = Unmanaged.fromOpaque(item).takeUnretainedValue()
+ UpdateDot.set(statusItem, visible: show != 0)
+}
+
@_cdecl("ocx_native_tray_show")
@MainActor
public func nativeTrayShow(_ item: UnsafeMutableRawPointer?, _ toggle: Int32, _ callback: @escaping @convention(c) (Int32) -> Void) {
diff --git a/assets/download-linux.svg b/assets/download-linux.svg
deleted file mode 100644
index b2555c9a92f..00000000000
--- a/assets/download-linux.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/assets/download-macos.svg b/assets/download-macos.svg
deleted file mode 100644
index db9427bed9f..00000000000
--- a/assets/download-macos.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/assets/download-windows.svg b/assets/download-windows.svg
deleted file mode 100644
index 28e2b1c6455..00000000000
--- a/assets/download-windows.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/bin/ocx.mjs b/bin/ocx.mjs
index 55729b02c3f..c7f847542d7 100755
--- a/bin/ocx.mjs
+++ b/bin/ocx.mjs
@@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url";
import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs";
import { npmInvocation } from "../src/update/npm-invocation.mjs";
import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs";
-import { detectInstallFromPath } from "../src/update/install-detection.mjs";
+import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs";
import {
pnpmOwnerInvocation,
resolvePnpmGlobalOwner,
@@ -71,7 +71,8 @@ try {
}
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
-const installMethod = detectInstallFromPath(here, { exists: existsSync });
+const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync });
+const installMethod = installOwnership.installer;
const cliPath = join(here, "..", "src", "cli", "index.ts");
const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT";
const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof=";
@@ -924,6 +925,19 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") {
process.exit(1);
}
+if (process.argv[2] === "update" && installMethod === "mise") {
+ if (installOwnership.owner) {
+ console.error(
+ `opencodex: this installation is externally managed by mise; update it with: mise upgrade ${installOwnership.owner.tool}`,
+ );
+ } else {
+ console.error(
+ "opencodex: this installation appears to be managed by mise, but its ownership metadata is unreadable or inconsistent; repair the mise installation metadata before updating.",
+ );
+ }
+ process.exit(1);
+}
+
if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) {
if (installMethod === "npm") runNpmSelfUpdate();
if (installMethod === "pnpm") runPnpmSelfUpdate();
diff --git a/desktop/package.json b/desktop/package.json
index 5966c9235f2..7e469873090 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -5,6 +5,7 @@
"dev": "tauri dev",
"build": "tauri build",
"build:local": "bun scripts/build-local.ts",
+ "e2e:linux-packaged": "bun scripts/linux-packaged-e2e.ts",
"icons": "bun scripts/generate-icons.ts",
"icons:check": "bun scripts/generate-icons.ts --check",
"prepare-sidecar": "bun scripts/prepare-sidecar.ts",
diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py
index 4c7e73cd930..63e78ef7416 100644
--- a/desktop/scripts/appimage-patchelf.py
+++ b/desktop/scripts/appimage-patchelf.py
@@ -5,17 +5,51 @@
import sys
+APPDIR_SIDECAR_TAIL = (
+ "release",
+ "bundle",
+ "appimage",
+ "OpenCodex.AppDir",
+ "usr",
+ "bin",
+ "ocx",
+)
+
+
+def prepared_sidecar(root, candidate, target_root):
+ """Return the one prepared Linux CLI that the AppDir sidecar exactly mirrors."""
+ try:
+ relative = candidate.resolve().relative_to(target_root.resolve())
+ except ValueError:
+ return None
+ if tuple(relative.parts[-len(APPDIR_SIDECAR_TAIL):]) != APPDIR_SIDECAR_TAIL:
+ return None
+ prefix = relative.parts[:-len(APPDIR_SIDECAR_TAIL)]
+ if len(prefix) > 1:
+ return None
+
+ binaries = root / "desktop/src-tauri/binaries"
+ candidates = sorted(path for path in binaries.glob("ocx-*-linux-gnu") if path.is_file())
+ if prefix:
+ candidates = [path for path in candidates if path.name == f"ocx-{prefix[0]}"]
+ matches = [path for path in candidates if path.read_bytes() == candidate.read_bytes()]
+ return matches[0] if len(matches) == 1 else None
+
+
def main(args):
root = Path(__file__).resolve().parents[2]
- triple = "x86_64-unknown-linux-gnu"
- original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}"
- sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx"
- if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve():
+ target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "desktop/src-tauri/target"))
+ sidecar = Path(args[2]) if len(args) == 3 else None
+ if (
+ sidecar is not None
+ and args[:2] == ["--set-rpath", "$ORIGIN/../lib"]
+ and prepared_sidecar(root, sidecar, target_root) is not None
+ ):
# linuxdeploy's nested GTK pass runs ldd again after patching. Its
# patchelf rewrite breaks the compiled Bun ELF. This sidecar depends
# only on host glibc libraries; it needs no AppDir library search path.
# Never bless an already-modified binary or a different executable.
- if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes():
+ if sidecar.is_symlink():
raise RuntimeError("AppImage sidecar differs from the prepared CLI")
print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr)
return
diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts
index 2c97de39d44..1d1266283e0 100644
--- a/desktop/scripts/collect-release-assets.ts
+++ b/desktop/scripts/collect-release-assets.ts
@@ -42,6 +42,7 @@ export interface CollectReleaseAssetsOptions {
target: string;
out: string;
repoRoot?: string;
+ bundleRoot?: string;
}
function findBundle(directory: string, kind: BundleKind): string {
@@ -61,13 +62,17 @@ export function collectReleaseAssets(options: CollectReleaseAssetsOptions): stri
const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../.."));
const bundles = bundlesByTarget[options.target];
if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`);
+ const bundleRoot = resolve(
+ options.bundleRoot
+ ?? join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle"),
+ );
const output = resolve(options.out);
mkdirSync(output, { recursive: true });
const written: string[] = [];
for (const bundle of bundles) {
const source = findBundle(
- join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir),
+ join(bundleRoot, bundle.dir),
bundle.kind,
);
const destinationName = `OpenCodex-${options.version}-${bundle.name}`;
@@ -98,8 +103,11 @@ if (import.meta.main) {
const version = argument("--version");
const target = argument("--target");
const out = argument("--out");
+ const bundleRoot = argument("--bundle-root");
if (!version || !target || !out) {
throw new Error("Usage: collect-release-assets.ts --version --target --out ");
}
- for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`);
+ const options: CollectReleaseAssetsOptions = { version, target, out };
+ if (bundleRoot) options.bundleRoot = bundleRoot;
+ for (const path of collectReleaseAssets(options)) console.log(`Wrote ${path}`);
}
diff --git a/desktop/scripts/generate-icons.ts b/desktop/scripts/generate-icons.ts
index 3acfdc8bb92..6ce6a00c264 100644
--- a/desktop/scripts/generate-icons.ts
+++ b/desktop/scripts/generate-icons.ts
@@ -70,6 +70,17 @@ const ICO_SIZES = [16, 32, 48, 64, 128, 256];
const TRAY_OUTPUT = "tray/icon.png";
const TRAY_SIZE = 44;
const traySource = join(iconsDir, "tray", "icon.svg");
+const DOTTED_TRAY_OUTPUT = "tray/icon-update.png";
+const DOTTED_TRAY_SVG = '';
+
+function renderDottedTray(target: string): void {
+ const dottedSvg = join(target, ".tray-update.svg");
+ const base = readFileSync(traySource, "utf8");
+ if (!base.includes("")) throw new Error("tray icon source is not SVG");
+ writeFileSync(dottedSvg, base.replace("", DOTTED_TRAY_SVG + ""));
+ try { render(TRAY_SIZE, join(target, DOTTED_TRAY_OUTPUT), dottedSvg); }
+ finally { rmSync(dottedSvg, { force: true }); }
+}
/** Render at `size` from `from`, defaulting to the app icon vector. */
function render(size: number, out: string, from: string = source): void {
@@ -112,6 +123,8 @@ function generateInto(target: string): { produced: string[]; icnsSkipped: boolea
mkdirSync(join(target, "tray"), { recursive: true });
render(TRAY_SIZE, join(target, TRAY_OUTPUT), traySource);
produced.push(TRAY_OUTPUT);
+ renderDottedTray(target);
+ produced.push(DOTTED_TRAY_OUTPUT);
return { produced, icnsSkipped };
}
diff --git a/desktop/scripts/linux-packaged-e2e.ts b/desktop/scripts/linux-packaged-e2e.ts
new file mode 100644
index 00000000000..9ca92352668
--- /dev/null
+++ b/desktop/scripts/linux-packaged-e2e.ts
@@ -0,0 +1,566 @@
+#!/usr/bin/env bun
+/**
+ * Hosted Linux packaged-shell acceptance.
+ *
+ * This is deliberately narrower than installed-gate.ts. It extracts, rather than
+ * installs, the AppImage and deb payloads so a hosted runner never mutates its package
+ * database or the runner account's real OpenCodex home. What it proves is the common
+ * packaged path: the real application executable and bundled resources can show a
+ * window in a session with no tray host, start their bundled sidecar, identify that
+ * runtime, and drain both processes when the only window closes.
+ *
+ * Real dpkg/AppImage installation, elevation, takeover, and in-place updates remain the
+ * responsibility of installed-gate.ts on an approved disposable GUI runner.
+ */
+import { spawn, spawnSync, type ChildProcess } from "node:child_process";
+import {
+ closeSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ openSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import { createServer } from "node:net";
+
+export type LinuxBundleFormat = "appimage" | "deb";
+
+export interface LinuxE2eOptions {
+ bundleRoot: string;
+ reportPath: string;
+ version: string;
+}
+
+export interface BundleArtifacts {
+ appimage: string;
+ deb: string;
+}
+
+interface RuntimeRecord {
+ pid: number;
+ port: number;
+}
+
+interface HealthObservation {
+ status: number;
+ body: Record;
+}
+
+interface ReservedLoopbackPort {
+ port: number;
+ release: () => Promise;
+}
+
+interface FormatReport {
+ format: LinuxBundleFormat;
+ artifact: string;
+ ok: boolean;
+ durationMs: number;
+ windowId?: string;
+ appPid?: number;
+ appExitCode?: number | null;
+ appExitSignal?: string | null;
+ runtimePid?: number;
+ runtimeVersion?: string;
+ configuredPort?: number;
+ readyMs?: number;
+ processTreeRssKiB?: number;
+ error?: string;
+ stdoutTail?: string[];
+ stderrTail?: string[];
+}
+
+interface AcceptanceReport {
+ schema: "opencodex-linux-packaged-e2e/1";
+ version: string;
+ startedAt: string;
+ finishedAt: string;
+ ok: boolean;
+ formats: FormatReport[];
+}
+
+const READY_DEADLINE_MS = 45_000;
+const EXIT_DEADLINE_MS = 30_000;
+const POLL_MS = 200;
+const LOG_TAIL_LINES = 80;
+const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
+
+function argument(argv: string[], name: string): string | undefined {
+ const index = argv.indexOf(name);
+ return index >= 0 ? argv[index + 1] : undefined;
+}
+
+export function parseArguments(argv: string[]): LinuxE2eOptions {
+ const bundleRoot = argument(argv, "--bundle-root");
+ const reportPath = argument(argv, "--report");
+ const version = argument(argv, "--version");
+ if (!bundleRoot || !reportPath || !version) {
+ throw new Error("--bundle-root, --report and --version are required");
+ }
+ if (!VERSION.test(version)) throw new Error("--version must be a strict semver");
+ return {
+ bundleRoot: resolve(bundleRoot),
+ reportPath: resolve(reportPath),
+ version,
+ };
+}
+
+function files(directory: string): string[] {
+ if (!existsSync(directory)) return [];
+ return readdirSync(directory)
+ .map(name => join(directory, name))
+ .filter(path => statSync(path).isFile());
+}
+
+function exactlyOne(paths: string[], label: string): string {
+ if (paths.length !== 1) {
+ throw new Error(`expected exactly one ${label}, found ${paths.length}`);
+ }
+ return paths[0]!;
+}
+
+export function locateArtifacts(bundleRoot: string): BundleArtifacts {
+ return {
+ appimage: exactlyOne(
+ files(join(bundleRoot, "appimage")).filter(path => path.endsWith(".AppImage")),
+ "AppImage",
+ ),
+ deb: exactlyOne(
+ files(join(bundleRoot, "deb")).filter(path => path.endsWith(".deb")),
+ "deb",
+ ),
+ };
+}
+
+function command(
+ file: string,
+ args: string[],
+ options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
+): void {
+ const result = spawnSync(file, args, {
+ cwd: options.cwd,
+ env: options.env,
+ encoding: "utf8",
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ if (result.status !== 0) {
+ const detail = (result.stderr || result.stdout || "no output").trim();
+ throw new Error(`${basename(file)} exited ${result.status ?? "without a status"}: ${detail}`);
+ }
+}
+
+function executableFiles(directory: string): string[] {
+ if (!existsSync(directory)) return [];
+ return readdirSync(directory)
+ .map(name => join(directory, name))
+ .filter(path => {
+ const stat = statSync(path);
+ return stat.isFile() && (stat.mode & 0o111) !== 0;
+ });
+}
+
+export function extractedExecutable(
+ format: LinuxBundleFormat,
+ artifact: string,
+ destination: string,
+): string {
+ mkdirSync(destination, { recursive: true });
+ if (format === "appimage") {
+ command(artifact, ["--appimage-extract"], { cwd: destination });
+ const appRun = join(destination, "squashfs-root", "AppRun");
+ if (!existsSync(appRun)) throw new Error("AppImage extraction did not produce AppRun");
+ return appRun;
+ }
+
+ command("dpkg-deb", ["--extract", artifact, destination]);
+ const candidates = executableFiles(join(destination, "usr", "bin"));
+ return selectDebExecutable(candidates);
+}
+
+export function selectDebExecutable(candidates: string[]): string {
+ // The package contains the desktop host and its `ocx` sidecar. The sidecar is deliberately
+ // executable, but it is not the process whose WebView/window lifecycle this acceptance owns.
+ return exactlyOne(
+ candidates.filter(candidate => basename(candidate) !== "ocx"),
+ "deb desktop executable under usr/bin",
+ );
+}
+
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function reserveLoopbackPort(): Promise {
+ return await new Promise((resolvePort, reject) => {
+ const server = createServer();
+ server.unref();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("could not reserve a temporary loopback port"));
+ return;
+ }
+ let released = false;
+ resolvePort({
+ port: address.port,
+ release: async () => {
+ if (released) return;
+ released = true;
+ await new Promise((resolveClose, rejectClose) => {
+ server.close(error => error ? rejectClose(error) : resolveClose());
+ });
+ },
+ });
+ });
+ });
+}
+
+async function waitFor(read: () => T | undefined | Promise, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const value = await read();
+ if (value !== undefined) return value;
+ await sleep(POLL_MS);
+ }
+ throw new Error(`condition did not settle within ${timeoutMs}ms`);
+}
+
+function positiveInteger(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
+}
+
+export function readRuntimeRecord(path: string): RuntimeRecord | undefined {
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record;
+ const pid = positiveInteger(parsed.pid);
+ const port = positiveInteger(parsed.port);
+ if (pid === undefined || port === undefined || port > 65_535) return undefined;
+ return { pid, port };
+ } catch {
+ return undefined;
+ }
+}
+
+export function assertRuntimeRecordPort(record: RuntimeRecord, configuredPort: number): RuntimeRecord {
+ if (record.port !== configuredPort) {
+ throw new Error(
+ `packaged runtime recorded port ${record.port}, expected isolated port ${configuredPort}`,
+ );
+ }
+ return record;
+}
+
+function processAlive(pid: number | undefined): boolean {
+ if (pid === undefined) return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM";
+ }
+}
+
+function processRows(): Array<{ pid: number; ppid: number; rssKiB: number }> {
+ const result = spawnSync("ps", ["-e", "-o", "pid=,ppid=,rss="], { encoding: "utf8" });
+ if (result.status !== 0) return [];
+ return result.stdout
+ .trim()
+ .split(/\r?\n/u)
+ .map(line => line.trim().split(/\s+/u).map(Number))
+ .filter(parts => parts.length === 3 && parts.every(Number.isFinite))
+ .map(parts => ({ pid: parts[0]!, ppid: parts[1]!, rssKiB: parts[2]! }));
+}
+
+export interface AppExit {
+ code: number | null;
+ signal: string | null;
+}
+
+/**
+ * The close request goes through the window manager (EWMH _NET_CLOSE_WINDOW), the same path a
+ * person's close button takes. xdotool's windowclose destroys the X window instead, which can end
+ * the process without ever running Tauri's close/drain handling and still look like a clean exit.
+ */
+export function windowManagerCloseArgs(windowId: string): string[] {
+ const id = Number(windowId);
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`invalid X11 window id: ${windowId}`);
+ return ["-i", "-c", `0x${id.toString(16)}`];
+}
+
+/** A graceful close exits 0 on its own; a signal or a nonzero code is a crash, not a drain. */
+export function assertCleanExit(exit: AppExit | undefined): AppExit {
+ if (!exit) throw new Error("desktop app did not exit after the close request");
+ if (exit.signal !== null || exit.code !== 0) {
+ throw new Error(`desktop app exited with code ${exit.code ?? "none"} and signal ${exit.signal ?? "none"} instead of a clean close`);
+ }
+ return exit;
+}
+
+export function processTreeRssKiB(rootPid: number, rows = processRows()): number {
+ const selected = new Set([rootPid]);
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const row of rows) {
+ if (selected.has(row.ppid) && !selected.has(row.pid)) {
+ selected.add(row.pid);
+ changed = true;
+ }
+ }
+ }
+ return rows.filter(row => selected.has(row.pid)).reduce((sum, row) => sum + row.rssKiB, 0);
+}
+
+function xdotoolWindow(): string | undefined {
+ // WebKit exposes an auxiliary `opencodex-desktop` X11 window before the titled top-level
+ // `OpenCodex` window. A loose match selected that helper and `windowclose` merely destroyed the
+ // web process surface, never exercising Tauri's close/drain path.
+ const result = spawnSync(
+ "xdotool",
+ ["search", "--onlyvisible", "--name", "^OpenCodex$"],
+ { encoding: "utf8" },
+ );
+ if (result.status !== 0) return undefined;
+ return result.stdout.trim().split(/\r?\n/u).find(Boolean);
+}
+
+async function health(record: RuntimeRecord): Promise {
+ try {
+ const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, {
+ signal: AbortSignal.timeout(1_000),
+ cache: "no-store",
+ });
+ const body = await response.json();
+ return typeof body === "object" && body !== null
+ ? { status: response.status, body: body as Record }
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function tail(path: string): string[] {
+ try {
+ return readFileSync(path, "utf8").split(/\r?\n/u).filter(Boolean).slice(-LOG_TAIL_LINES);
+ } catch {
+ return [];
+ }
+}
+
+async function stopGroup(child: ChildProcess): Promise {
+ if (!child.pid || !processAlive(child.pid)) return;
+ try {
+ process.kill(-child.pid, "SIGTERM");
+ } catch {
+ child.kill("SIGTERM");
+ }
+ try {
+ await waitFor(() => processAlive(child.pid) ? undefined : true, 5_000);
+ return;
+ } catch {
+ // Escalate only inside the detached process group this test created.
+ }
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ child.kill("SIGKILL");
+ }
+}
+
+async function runFormat(
+ format: LinuxBundleFormat,
+ artifact: string,
+ version: string,
+ root: string,
+): Promise {
+ const started = Date.now();
+ const directory = join(root, format);
+ const extracted = join(directory, "payload");
+ const home = join(directory, "home");
+ const opencodexHome = join(home, ".opencodex");
+ const codexHome = join(home, ".codex");
+ const configHome = join(home, ".config");
+ const cacheHome = join(home, ".cache");
+ const dataHome = join(home, ".local", "share");
+ for (const path of [home, opencodexHome, codexHome, configHome, cacheHome, dataHome]) {
+ mkdirSync(path, { recursive: true, mode: 0o700 });
+ }
+ const stdoutPath = join(directory, "stdout.log");
+ const stderrPath = join(directory, "stderr.log");
+ mkdirSync(directory, { recursive: true });
+ const stdout = openSync(stdoutPath, "w", 0o600);
+ const stderr = openSync(stderrPath, "w", 0o600);
+ let child: ChildProcess | undefined;
+ let runtimePid: number | undefined;
+ let configuredPort: number | undefined;
+ let reservedPort: ReservedLoopbackPort | undefined;
+ try {
+ const executable = extractedExecutable(format, artifact, extracted);
+ reservedPort = await reserveLoopbackPort();
+ configuredPort = reservedPort.port;
+ writeFileSync(
+ join(opencodexHome, "config.json"),
+ `${JSON.stringify({ port: configuredPort }, null, 2)}\n`,
+ { mode: 0o600 },
+ );
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ XDG_CONFIG_HOME: configHome,
+ XDG_CACHE_HOME: cacheHome,
+ XDG_DATA_HOME: dataHome,
+ OPENCODEX_HOME: opencodexHome,
+ CODEX_HOME: codexHome,
+ NO_PROXY: "127.0.0.1,localhost",
+ no_proxy: "127.0.0.1,localhost",
+ WEBKIT_DISABLE_COMPOSITING_MODE: "1",
+ };
+ // Hold the listener while preparing the isolated home so no unrelated process can claim the
+ // selected port. Release it only at the spawn boundary; the packaged runtime can then bind it.
+ await reservedPort.release();
+ reservedPort = undefined;
+ child = spawn(executable, [], {
+ cwd: dirname(executable),
+ env,
+ detached: true,
+ stdio: ["ignore", stdout, stderr],
+ });
+ if (!child.pid) throw new Error("desktop app did not report a pid");
+ const appPid = child.pid;
+ let appExit: AppExit | undefined;
+ child.once("exit", (code, signal) => {
+ appExit = { code, signal };
+ });
+ const windowId = await waitFor(xdotoolWindow, READY_DEADLINE_MS);
+ const recordPath = join(opencodexHome, "runtime-port.json");
+ const record = assertRuntimeRecordPort(
+ await waitFor(() => readRuntimeRecord(recordPath), READY_DEADLINE_MS),
+ configuredPort,
+ );
+ runtimePid = record.pid;
+ let lastHealth: HealthObservation | undefined;
+ let ready: Record;
+ try {
+ ready = await waitFor(async () => {
+ const observed = await health(record);
+ if (!observed) return undefined;
+ lastHealth = observed;
+ const body = observed.body;
+ return observed.status >= 200 && observed.status < 300
+ && body.service === "opencodex"
+ && body.pid === record.pid
+ && body.port === record.port
+ && body.version === version
+ ? body
+ : undefined;
+ }, READY_DEADLINE_MS);
+ } catch {
+ const observed = lastHealth
+ ? `status ${lastHealth.status}, body ${JSON.stringify(lastHealth.body)}`
+ : "no readable /healthz response";
+ throw new Error(`packaged runtime health identity did not become ready (${observed})`);
+ }
+ const readyMs = Date.now() - started;
+ const rssKiB = processTreeRssKiB(appPid);
+
+ command("wmctrl", windowManagerCloseArgs(windowId));
+ await waitFor(
+ () => appExit && !processAlive(runtimePid) ? true : undefined,
+ EXIT_DEADLINE_MS,
+ );
+ const exit = assertCleanExit(appExit);
+ return {
+ format,
+ artifact: basename(artifact),
+ ok: true,
+ durationMs: Date.now() - started,
+ windowId,
+ appPid,
+ appExitCode: exit.code,
+ appExitSignal: exit.signal,
+ runtimePid,
+ runtimeVersion: typeof ready.version === "string" ? ready.version : undefined,
+ configuredPort,
+ readyMs,
+ processTreeRssKiB: rssKiB,
+ stdoutTail: tail(stdoutPath),
+ stderrTail: tail(stderrPath),
+ };
+ } catch (error) {
+ return {
+ format,
+ artifact: basename(artifact),
+ ok: false,
+ durationMs: Date.now() - started,
+ ...(child?.pid ? { appPid: child.pid } : {}),
+ ...(runtimePid ? { runtimePid } : {}),
+ ...(configuredPort ? { configuredPort } : {}),
+ error: error instanceof Error ? error.message : String(error),
+ stdoutTail: tail(stdoutPath),
+ stderrTail: tail(stderrPath),
+ };
+ } finally {
+ await reservedPort?.release();
+ if (child) await stopGroup(child);
+ closeSync(stdout);
+ closeSync(stderr);
+ }
+}
+
+export async function runAcceptance(options: LinuxE2eOptions): Promise {
+ if (process.platform !== "linux") throw new Error("Linux packaged E2E runs only on Linux");
+ for (const dependency of ["dpkg-deb", "ps", "wmctrl", "xdotool"]) {
+ const probe = spawnSync("sh", ["-c", `command -v ${dependency}`]);
+ if (probe.status !== 0) throw new Error(`missing required command: ${dependency}`);
+ }
+ if (!process.env.DISPLAY) throw new Error("DISPLAY is required; run under Xvfb");
+
+ const artifacts = locateArtifacts(options.bundleRoot);
+ const root = mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-"));
+ const startedAt = new Date().toISOString();
+ let formats: FormatReport[] = [];
+ try {
+ formats = [
+ await runFormat("appimage", artifacts.appimage, options.version, root),
+ await runFormat("deb", artifacts.deb, options.version, root),
+ ];
+ } finally {
+ const report: AcceptanceReport = {
+ schema: "opencodex-linux-packaged-e2e/1",
+ version: options.version,
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ ok: formats.length === 2 && formats.every(format => format.ok),
+ formats,
+ };
+ mkdirSync(dirname(options.reportPath), { recursive: true });
+ writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
+ rmSync(root, { recursive: true, force: true });
+ }
+ return JSON.parse(readFileSync(options.reportPath, "utf8")) as AcceptanceReport;
+}
+
+async function main(): Promise {
+ const options = parseArguments(process.argv.slice(2));
+ const report = await runAcceptance(options);
+ for (const format of report.formats) {
+ console.log(`${format.ok ? "PASS" : "FAIL"} ${format.format}: ${format.error ?? `${format.readyMs}ms ready, ${format.processTreeRssKiB} KiB RSS`}`);
+ }
+ process.exitCode = report.ok ? 0 : 1;
+}
+
+if (import.meta.main) {
+ main().catch(error => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts
index 502127870dc..2403e130933 100644
--- a/desktop/scripts/prepare-sidecar.ts
+++ b/desktop/scripts/prepare-sidecar.ts
@@ -1,5 +1,6 @@
import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
+import { adHocSignSidecar, shouldAdHocSignSidecar } from "./sidecar-signing";
const targetByTriple: Record = {
"aarch64-apple-darwin": "bun-darwin-arm64",
@@ -55,5 +56,9 @@ mkdirSync(binaries, { recursive: true });
mkdirSync(resources, { recursive: true });
const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`);
copyFileSync(executable, destination);
+if (shouldAdHocSignSidecar(process.platform, target)) {
+ const signed = adHocSignSidecar(destination);
+ if (signed !== 0) process.exit(signed);
+}
cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true });
console.log(`Prepared ${destination}`);
diff --git a/desktop/scripts/sidecar-signing.ts b/desktop/scripts/sidecar-signing.ts
new file mode 100644
index 00000000000..a41642b3062
--- /dev/null
+++ b/desktop/scripts/sidecar-signing.ts
@@ -0,0 +1,31 @@
+// Ad-hoc signing of the prepared desktop sidecar on macOS.
+//
+// Bun's linker-signed standalone output is killed by macOS page validation
+// (CODESIGNING "Invalid Page"), so the copied sidecar is resealed with an
+// ad-hoc signature before Tauri bundles it. Only a macOS host preparing a
+// bun-darwin-* target signs: a Mac cross-preparing a Linux or Windows sidecar
+// must never run codesign on that file. Release builds re-sign the bundled
+// binary with Developer ID afterwards; this step only has to leave a runnable
+// input.
+
+export const CODESIGN_PATH = "/usr/bin/codesign";
+
+export function shouldAdHocSignSidecar(hostPlatform: string, bunTarget: string): boolean {
+ return hostPlatform === "darwin" && bunTarget.startsWith("bun-darwin-");
+}
+
+export function adHocSignArgv(destination: string): string[] {
+ return [CODESIGN_PATH, "-s", "-", "-f", destination];
+}
+
+export type SidecarSignSpawn = (argv: string[]) => { exitCode: number | null };
+
+const inheritSpawn: SidecarSignSpawn = (argv) =>
+ Bun.spawnSync(argv, { stdout: "inherit", stderr: "inherit" });
+
+/** Returns 0 on success, otherwise the nonzero exit code the caller should exit with. */
+export function adHocSignSidecar(destination: string, spawn: SidecarSignSpawn = inheritSpawn): number {
+ const result = spawn(adHocSignArgv(destination));
+ if (result.exitCode === 0) return 0;
+ return result.exitCode ?? 1;
+}
diff --git a/desktop/scripts/verify-linux-sidecar.sh b/desktop/scripts/verify-linux-sidecar.sh
index 88c5df2ba34..7695767ebe6 100644
--- a/desktop/scripts/verify-linux-sidecar.sh
+++ b/desktop/scripts/verify-linux-sidecar.sh
@@ -1,8 +1,11 @@
#!/usr/bin/env bash
# Run only on a Linux packaging runner, against the completed AppImage.
+# Usage: verify-linux-sidecar.sh [appimage-bundle-dir]
+# The release workflow builds each Linux format in its own Cargo target and stages the AppImage
+# into an isolated read-only directory, which it passes here; a local build keeps the default.
set -euo pipefail
root="$(cd "$(dirname "$0")/../.." && pwd)"
-bundle="$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage"
+bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}"
original="$root/desktop/src-tauri/binaries/ocx-x86_64-unknown-linux-gnu"
shopt -s nullglob
images=("$bundle"/*.AppImage)
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 2a3f97a9619..a74fd7686bc 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -2541,7 +2541,7 @@ dependencies = [
[[package]]
name = "opencodex-desktop"
-version = "2.64.0-preview.20260923"
+version = "2.65.0-preview.20260925"
dependencies = [
"dbus",
"reqwest 0.12.24",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 24d29642b75..67774337c65 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opencodex-desktop"
-version = "2.64.0-preview.20260923"
+version = "2.65.0-preview.20260925"
description = "OpenCodex desktop shell"
authors = ["OpenCodex contributors"]
license = "MIT"
diff --git a/desktop/src-tauri/capabilities/dashboard-zoom.json b/desktop/src-tauri/capabilities/dashboard-zoom.json
new file mode 100644
index 00000000000..dc2b0734244
--- /dev/null
+++ b/desktop/src-tauri/capabilities/dashboard-zoom.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "dashboard-zoom",
+ "description": "Page zoom hotkeys for the main window, including the loopback dashboard",
+ "windows": ["main"],
+ "remote": {
+ "urls": ["http://127.0.0.1:*"]
+ },
+ "permissions": ["core:webview:allow-set-webview-zoom"]
+}
diff --git a/desktop/src-tauri/icons/tray/icon-update.png b/desktop/src-tauri/icons/tray/icon-update.png
new file mode 100644
index 00000000000..9cd0fc910ed
Binary files /dev/null and b/desktop/src-tauri/icons/tray/icon-update.png differ
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index e9467013ebb..90955564f19 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -25,6 +25,12 @@ mod popup;
#[cfg(target_os = "macos")]
#[path = "native_tray.rs"]
mod popup;
+// The macOS build selects native_tray.rs as the popup module; compile the portable popup
+// module's tests on macOS too so its navigation rules run on the maintainers' platform.
+#[cfg(all(test, target_os = "macos"))]
+#[allow(dead_code)]
+#[path = "popup.rs"]
+mod popup_portable_test;
mod proxy;
mod resolve;
mod runtime_stop;
@@ -135,9 +141,7 @@ impl Default for AppState {
#[tauri::command]
fn show_dashboard(app: tauri::AppHandle) {
popup::hide(&app);
- if let Some(window) = app.get_webview_window("main") {
- window::show(&window);
- }
+ startup::open_dashboard(&app);
}
#[tauri::command]
@@ -188,13 +192,49 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) {
}
}
+#[tauri::command]
+async fn update_status(
+ window: tauri::WebviewWindow,
+ app: tauri::AppHandle,
+) -> Result {
+ window::require_update_page(&window)?;
+ Ok(updater::page_status(&app))
+}
+
+#[tauri::command]
+async fn update_check(
+ window: tauri::WebviewWindow,
+ app: tauri::AppHandle,
+) -> Result {
+ window::require_update_page(&window)?;
+ let check_result = updater::check_and_show(&app).await;
+ check_result.map_err(|_| "the update check failed; try again".to_owned())?;
+ Ok(updater::page_status(&app))
+}
+
+#[tauri::command]
+async fn update_install(
+ window: tauri::WebviewWindow,
+ app: tauri::AppHandle,
+) -> Result {
+ window::require_update_page(&window)?;
+ updater::install_pending(&app).await.map_err(|error| {
+ logging::log_once("updater install failed", &error);
+ "the update could not be installed; try again".to_owned()
+ })
+}
+
+#[tauri::command]
+fn return_to_dashboard(window: tauri::WebviewWindow, app: tauri::AppHandle) -> Result<(), String> {
+ window::require_update_page(&window)?;
+ startup::return_to_dashboard(&app)
+}
+
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
- if let Some(window) = app.get_webview_window("main") {
- popup::hide(app);
- window::show(&window);
- }
+ popup::hide(app);
+ startup::open_dashboard(app);
}))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
@@ -222,11 +262,21 @@ pub fn run() {
startup_snapshot,
startup_phases,
retry_startup,
- decide_takeover
+ decide_takeover,
+ update_status,
+ update_check,
+ update_install,
+ return_to_dashboard
])
.setup(|app| {
app.manage(AppState::new());
app.manage(updater::PendingUpdate(Mutex::new(None)));
+ app.manage(updater::DesktopUpdateState::new(
+ app.package_info().version.to_string(),
+ ));
+ app.manage(updater::CheckGeneration::default());
+ updater::start_ui_projection_worker(app.handle().clone());
+ updater::start_snapshot_publisher(app.handle().clone());
app.manage(tray::TrayState::default());
app.manage(exit::ExitCoordinator::new());
app.manage(startup::Startup::new());
@@ -241,7 +291,24 @@ pub fn run() {
.inner_size(1100.0, 720.0)
.visible(false)
.user_agent(&window::webview_user_agent())
+ // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on
+ // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted
+ // to the loopback dashboard by `capabilities/dashboard-zoom.json`.
+ .zoom_hotkeys_enabled(true)
.on_navigation(window::navigation_allowed(app.handle().clone()))
+ // A hidden window still loads pages: wry builds this one with WebView2
+ // IsVisible=false, and the bootstrap page navigates to the dashboard URL
+ // afterwards, so the eval that a later show or hide would rely on has nowhere
+ // to land during a reload. Re-sending the current state here is what keeps the
+ // GUI's answer correct across navigation.
+ .on_page_load(|window, payload| {
+ if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
+ window::report_visibility(
+ &window,
+ window.is_visible().unwrap_or(false),
+ );
+ }
+ })
.build()?;
window::configure(&window);
if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User {
@@ -260,6 +327,11 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building OpenCodex desktop shell")
.run(|app, event| {
+ // Dock/Finder reopening an existing macOS app does not launch a second instance.
+ #[cfg(target_os = "macos")]
+ if let tauri::RunEvent::Reopen { .. } = event {
+ show_dashboard(app.clone());
+ }
// Window close and the platform quit gesture arrive here as an exit request, and until
// this handler existed they went straight through to a SIGKILL of the runtime. D2 makes
// them hide; only the tray's Quit, and an update's coordinated restart, get past.
diff --git a/desktop/src-tauri/src/native_tray.rs b/desktop/src-tauri/src/native_tray.rs
index 1098f1d1d09..c1e17fdbdff 100644
--- a/desktop/src-tauri/src/native_tray.rs
+++ b/desktop/src-tauri/src/native_tray.rs
@@ -19,6 +19,7 @@ extern "C" {
fn ocx_native_tray_hide();
fn ocx_native_tray_visible() -> i32;
fn ocx_native_tray_update(bytes: *const u8, count: isize);
+ fn ocx_native_tray_update_dot(item: *mut c_void, show: i32);
}
static HOST: OnceLock = OnceLock::new();
@@ -75,6 +76,27 @@ fn present(app: &AppHandle, toggle: bool) -> tauri::Result<()> {
})
}
+pub fn set_update_dot(app: &AppHandle, _show: bool) {
+ let app = app.clone();
+ let target = app.clone();
+ let _ = target.run_on_main_thread(move || {
+ let Some(tray) = app.tray_by_id("main") else {
+ return;
+ };
+ let pending = app
+ .try_state::()
+ .is_some_and(|state| state.update_pending.load(Ordering::Acquire));
+ let _ = tray.with_inner_tray_icon(move |inner| {
+ if let Some(item) = inner.ns_status_item() {
+ let pointer = (&*item as *const _ as *mut c_void).cast();
+ unsafe {
+ ocx_native_tray_update_dot(pointer, i32::from(pending));
+ }
+ }
+ });
+ });
+}
+
pub fn hide(app: &AppHandle) {
stop_refresh(app);
let _ = app.run_on_main_thread(|| unsafe { ocx_native_tray_hide() });
@@ -93,12 +115,16 @@ extern "C" fn native_event(event: i32) {
return;
};
if let Some(main) = app.get_webview_window("main") {
+ let session = app
+ .state::()
+ .session_id()
+ .to_string();
let path = if event == 4 {
- "/?desktop=open#/usage/companion"
+ format!("/?desktop=open&desktop_session={session}#/usage/companion")
} else {
- "/?desktop=open#/usage"
+ format!("/?desktop=open&desktop_session={session}#/usage")
};
- if let Ok(url) = proxy.endpoint().url(path).parse() {
+ if let Ok(url) = proxy.endpoint().url(&path).parse() {
let _ = main.navigate(url);
window::show(&main);
}
diff --git a/desktop/src-tauri/src/popup.rs b/desktop/src-tauri/src/popup.rs
index 46bf0891bea..3f5d2ff087d 100644
--- a/desktop/src-tauri/src/popup.rs
+++ b/desktop/src-tauri/src/popup.rs
@@ -257,7 +257,11 @@ fn popup_navigation_allowed(
hide(&app);
if let Some(main) = app.get_webview_window("main") {
window::show(&main);
- let _ = main.navigate(url.clone());
+ let session = app
+ .state::()
+ .session_id()
+ .to_string();
+ let _ = main.navigate(dashboard_destination(url, &session));
}
return false;
}
@@ -319,6 +323,14 @@ fn is_dashboard_url(url: &Url, endpoint: ProxyEndpoint) -> bool {
&& matches!(url.fragment(), Some("/usage") | Some("/usage/companion"))
}
+fn dashboard_destination(url: &Url, session: &str) -> Url {
+ let mut destination = url.clone();
+ destination
+ .query_pairs_mut()
+ .append_pair("desktop_session", session);
+ destination
+}
+
fn set_visibility(popup: &WebviewWindow, visible: bool) {
let script = format!(
"window.__OPENCODEX_TRAY_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:tray-visibility', {{detail: {visible}}}));"
@@ -381,6 +393,24 @@ mod tests {
));
}
+ #[test]
+ fn dashboard_navigation_keeps_the_validated_fragment() {
+ for fragment in ["/usage", "/usage/companion"] {
+ let source: Url = ENDPOINT
+ .url(&format!("/?desktop=open#{fragment}"))
+ .parse()
+ .unwrap();
+ assert!(is_dashboard_url(&source, ENDPOINT));
+ let destination = dashboard_destination(&source, "session-123");
+ assert_eq!(
+ destination.as_str(),
+ ENDPOINT.url(&format!(
+ "/?desktop=open&desktop_session=session-123#{fragment}"
+ ))
+ );
+ }
+ }
+
#[test]
fn initialization_script_matches_native_surface() {
let expected_value = if VIBRANT_SURFACE { "on" } else { "off" };
diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs
index 8e797918b1e..4c679fd701e 100644
--- a/desktop/src-tauri/src/proxy.rs
+++ b/desktop/src-tauri/src/proxy.rs
@@ -178,6 +178,26 @@ impl ProxyClient {
self.request(Method::GET, path).await
}
+ pub async fn post_desktop_snapshot(&self, body: &Value) -> Result<(), ProxyError> {
+ let token = self.authorised_token().await?;
+ let response = self
+ .client
+ .post(self.endpoint.url("/api/update/desktop-snapshot"))
+ .header("X-OpenCodex-API-Key", token)
+ .json(body)
+ .send()
+ .await
+ .map_err(|error| {
+ if error.is_connect() {
+ ProxyError::Unreachable
+ } else {
+ ProxyError::Decode(error)
+ }
+ })?;
+ let _ = decode(response).await?;
+ Ok(())
+ }
+
async fn request(&self, method: Method, path: &str) -> Result {
let response = self.send(&method, path, None).await?;
if response.status() == StatusCode::UNAUTHORIZED {
diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs
index 3a693e37cda..afdb4453125 100644
--- a/desktop/src-tauri/src/startup.rs
+++ b/desktop/src-tauri/src/startup.rs
@@ -365,6 +365,18 @@ pub struct Startup {
/// before `live`, and never held across an await.
reporting: Mutex<()>,
running: AtomicBool,
+ /// Whether this window has already left the bundled bootstrap surface.
+ ///
+ /// Explicit open actions can arrive repeatedly from the tray, the single-instance hook, and
+ /// the shell command. Navigating on every action would recreate the React application and
+ /// discard renderer state, so the transition is owned here and consumed exactly once per run.
+ dashboard_loaded: AtomicBool,
+ /// Whether a person asked for the dashboard during this run.
+ ///
+ /// An explicit open that arrives while startup is still running only shows the bootstrap page;
+ /// `finish` reads this after it has recorded Ready, and `open_dashboard` sets it before it
+ /// reads progress, so whichever of the two runs second sees the other and navigates.
+ dashboard_requested: AtomicBool,
/// Which run the state belongs to.
///
/// A run's deadline guard outlives the run it was started for, and a retry that begins before
@@ -385,6 +397,8 @@ impl Startup {
}),
reporting: Mutex::new(()),
running: AtomicBool::new(false),
+ dashboard_loaded: AtomicBool::new(false),
+ dashboard_requested: AtomicBool::new(false),
generation: AtomicU64::new(0),
registered: Mutex::new(None),
}
@@ -460,6 +474,33 @@ impl Startup {
live.consent = ConsentState::Idle;
live.reported.clear();
live.latest = Progress::new(Phase::NotStarted, 0);
+ self.dashboard_loaded.store(false, Ordering::SeqCst);
+ self.dashboard_requested.store(false, Ordering::SeqCst);
+ }
+
+ fn should_navigate_dashboard(&self) -> bool {
+ !self.dashboard_loaded.swap(true, Ordering::SeqCst)
+ }
+
+ /// Give the one navigation back when the WebView refused the script, so the next open retries.
+ fn navigation_failed(&self) {
+ self.dashboard_loaded.store(false, Ordering::SeqCst);
+ }
+
+ fn request_dashboard(&self) {
+ self.dashboard_requested.store(true, Ordering::SeqCst);
+ }
+
+ fn dashboard_requested(&self) -> bool {
+ self.dashboard_requested.load(Ordering::SeqCst)
+ }
+
+ /// The dashboard URL once this run is Ready, otherwise nothing.
+ fn ready_dashboard(&self) -> Option {
+ let progress = self.latest();
+ (progress.phase == Phase::Ready.id())
+ .then_some(progress.dashboard)
+ .flatten()
}
/// Whether the run has already said how it ended.
@@ -1370,7 +1411,12 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) {
app.try_state::()
.is_some_and(|state| state.owns_runtime()),
);
- let dashboard = endpoint.url("/#/usage");
+ let path = format!(
+ "/?desktop_session={}#/usage",
+ app.state::()
+ .session_id()
+ );
+ let dashboard = endpoint.url(&path);
let mut progress = Progress::new(Phase::Ready, elapsed(started));
progress.dashboard = Some(dashboard.clone());
if !emit(app, progress, None) {
@@ -1378,11 +1424,96 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) {
// terminal state stays and the window must not navigate away from it.
return;
}
+ app.state::().wake();
if let Some(window) = app.get_webview_window("main") {
- // justified: replacing the bootstrap page with the dashboard is how this window has always
- // navigated, and the string is a URL this process resolved, not anything a page supplied.
- let _ = window.eval(format!("window.location.replace({dashboard:?})"));
+ let visible = window.is_visible().unwrap_or(true);
+ let startup = app.try_state::();
+ let requested = startup
+ .as_ref()
+ .is_some_and(|startup| startup.dashboard_requested());
+ if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) {
+ match startup {
+ Some(startup) => {
+ navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url));
+ }
+ None => {
+ navigate_dashboard(&window, &dashboard);
+ }
+ }
+ }
+ }
+}
+
+/// Open the full dashboard only when a person asks for it.
+///
+/// A hidden login launch deliberately leaves its WebView on the tiny bundled startup surface after
+/// the runtime becomes ready. The tray, a second ordinary application launch, or the bootstrap
+/// command reaches this function and pays the dashboard cost at that point. If startup is still in
+/// progress the bootstrap is merely shown; `finish` observes the now-visible window and performs
+/// the navigation once the endpoint is ready.
+pub fn open_dashboard(app: &AppHandle) {
+ let startup = app.try_state::();
+ let Some(window) = app.get_webview_window("main") else {
+ return;
+ };
+ if let Some(startup) = startup {
+ // The request is recorded before progress is read; see `dashboard_requested`.
+ startup.request_dashboard();
+ if let Some(dashboard) = startup.ready_dashboard() {
+ navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url));
+ }
+ }
+ crate::window::show(&window);
+}
+
+pub fn return_to_dashboard(app: &AppHandle) -> Result<(), String> {
+ let startup = app.try_state::().ok_or("dashboard is not ready")?;
+ let dashboard = startup.ready_dashboard();
+ let window = app
+ .get_webview_window("main")
+ .ok_or("dashboard window is unavailable")?;
+ return_ready_dashboard(dashboard.as_deref(), |url| navigate_dashboard(&window, url))?;
+ crate::window::show(&window);
+ Ok(())
+}
+
+fn return_ready_dashboard(
+ dashboard: Option<&str>,
+ navigate: impl FnOnce(&str) -> bool,
+) -> Result<(), String> {
+ let dashboard = dashboard.ok_or("dashboard is not ready")?;
+ if !navigate(dashboard) {
+ return Err("dashboard could not be opened".into());
+ }
+ Ok(())
+}
+
+fn loads_dashboard_on_ready(origin: LaunchOrigin, window_visible: bool, requested: bool) -> bool {
+ origin == LaunchOrigin::User || window_visible || requested
+}
+
+/// Perform this run's single dashboard navigation through `navigate`.
+///
+/// `navigate` reports whether the WebView accepted the script. Acceptance is not proof that the
+/// page finished loading, but a refusal certainly left the bootstrap page in place, so the claim is
+/// returned and the next explicit open tries again instead of being suppressed for the whole run.
+fn navigate_once(startup: &Startup, dashboard: &str, navigate: impl FnOnce(&str) -> bool) -> bool {
+ if !startup.should_navigate_dashboard() {
+ return false;
}
+ if navigate(dashboard) {
+ return true;
+ }
+ startup.navigation_failed();
+ false
+}
+
+fn navigate_dashboard(window: &tauri::WebviewWindow, dashboard: &str) -> bool {
+ // justified: replacing the bootstrap page with the dashboard is how this window has always
+ // navigated, and the string is a URL this process resolved, not anything a page supplied.
+ window
+ .eval(format!("window.location.replace({dashboard:?})"))
+ .is_ok()
}
#[allow(clippy::too_many_arguments)]
@@ -1489,9 +1620,10 @@ fn elapsed(started: Instant) -> u64 {
#[cfg(test)]
mod tests {
use super::{
- approval_still_current, attach_plan, claim_after_silence, shows_window,
- stop_after_approval, unavailable, AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase,
- Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL,
+ approval_still_current, attach_plan, claim_after_silence, loads_dashboard_on_ready,
+ navigate_once, return_ready_dashboard, shows_window, stop_after_approval, unavailable,
+ AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG,
+ DEADLINE, PHASES, POLL,
};
use crate::claim::ClaimResult;
use crate::ownership::{Claim, Consent, Owner, Recorded};
@@ -1773,6 +1905,123 @@ mod tests {
));
}
+ #[test]
+ fn only_a_hidden_login_launch_defers_the_full_dashboard() {
+ assert!(loads_dashboard_on_ready(LaunchOrigin::User, false, false));
+ assert!(loads_dashboard_on_ready(LaunchOrigin::User, true, false));
+ assert!(loads_dashboard_on_ready(
+ LaunchOrigin::Autostart,
+ true,
+ false
+ ));
+ assert!(!loads_dashboard_on_ready(
+ LaunchOrigin::Autostart,
+ false,
+ false
+ ));
+ // An open that arrived during startup counts even if the queued show has not landed yet.
+ assert!(loads_dashboard_on_ready(
+ LaunchOrigin::Autostart,
+ false,
+ true
+ ));
+ }
+
+ #[test]
+ fn explicit_dashboard_navigation_is_consumed_once_per_run() {
+ let startup = Startup::new();
+ let mut navigations = Vec::new();
+ assert!(navigate_once(
+ &startup,
+ "http://127.0.0.1:10100/#/usage",
+ |url| {
+ navigations.push(url.to_string());
+ true
+ }
+ ));
+ assert!(!navigate_once(
+ &startup,
+ "http://127.0.0.1:10100/#/usage",
+ |url| {
+ navigations.push(url.to_string());
+ true
+ }
+ ));
+ assert_eq!(
+ navigations,
+ vec!["http://127.0.0.1:10100/#/usage".to_string()]
+ );
+
+ startup.restart();
+ assert!(navigate_once(
+ &startup,
+ "http://127.0.0.1:10101/#/usage",
+ |_| true
+ ));
+ assert!(!navigate_once(
+ &startup,
+ "http://127.0.0.1:10101/#/usage",
+ |_| true
+ ));
+ }
+
+ #[test]
+ fn a_refused_dashboard_navigation_is_retried_on_the_next_open() {
+ let startup = Startup::new();
+ assert!(!navigate_once(
+ &startup,
+ "http://127.0.0.1:10100/#/usage",
+ |_| false
+ ));
+ let mut attempts = 0;
+ assert!(navigate_once(
+ &startup,
+ "http://127.0.0.1:10100/#/usage",
+ |_| {
+ attempts += 1;
+ true
+ }
+ ));
+ assert_eq!(attempts, 1);
+ assert!(!navigate_once(
+ &startup,
+ "http://127.0.0.1:10100/#/usage",
+ |_| true
+ ));
+ }
+
+ #[test]
+ fn an_open_during_startup_is_remembered_until_the_run_restarts() {
+ let startup = Startup::new();
+ assert!(!startup.dashboard_requested());
+ assert_eq!(startup.ready_dashboard(), None);
+ startup.request_dashboard();
+ assert!(startup.dashboard_requested());
+ startup.restart();
+ assert!(!startup.dashboard_requested());
+ }
+
+ #[test]
+ fn update_page_return_requires_a_ready_dashboard_and_retries_refused_navigation() {
+ assert_eq!(
+ return_ready_dashboard(None, |_| true).unwrap_err(),
+ "dashboard is not ready"
+ );
+ assert_eq!(
+ return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |_| false).unwrap_err(),
+ "dashboard could not be opened"
+ );
+ let mut visited = None;
+ assert!(
+ return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |url| {
+ visited = Some(url.to_owned());
+ true
+ })
+ .is_ok()
+ );
+ assert_eq!(visited.as_deref(), Some("http://127.0.0.1:10100/#/usage"));
+ }
+
#[test]
fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() {
assert!(!shows_window(
diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs
index ef50233140c..479a5a90906 100644
--- a/desktop/src-tauri/src/tray.rs
+++ b/desktop/src-tauri/src/tray.rs
@@ -20,6 +20,7 @@ use tauri_plugin_opener::OpenerExt;
pub struct TrayState {
pub menu: Mutex |