The desktop/ tree owns the Tauri v2 OpenCodex desktop shell. Its Rust crate
discovers the loopback proxy, lazily retries management authentication, starts
the bundled ocx sidecar only when the configured endpoint is unreachable,
and owns the tray, autostart, single-instance, and window lifecycle behavior.
The desktop Cargo package requires Rust 1.88 or newer. Its committed lockfile already
contains dependencies with that minimum; the package declaration must not advertise 1.77.
The lockfile selects patched serde_with and time releases, with compatible exact
serde and serde_json pins in desktop/src-tauri/Cargo.toml. Build and test with the
committed lockfile (--locked); the dependency update does not change app configuration,
the bundled model proxy, or the minimum supported operating-system versions.
desktop/ui/ is the startup surface. Once the runtime reports healthy, a visible or manually
launched shell navigates the webview to the proxy's loopback dashboard (/#/usage) rather than
bundling or serving gui/dist itself. A hidden login launch retains the small bundled ready surface
until a person explicitly opens the dashboard. The page renders what the shell tells it and probes
nothing on its own; it asks
startup_phases for the state list rather than restating it, takes the current state from
startup_snapshot on load because the first states finish in milliseconds, and then follows the
startup-phase event. startup_snapshot always answers with a state; it used to be able to
answer with nothing, and the page returns early on a falsy progress, so the one case it could not
render — a shell with no startup state — arrived as silence rather than as a diagnostic. A shell
that cannot find its own startup state now reports that as a failure the user can read and copy.
It uses no alert, confirm or prompt: the embedded webview implements
none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without
drawing anything.
withGlobalTauri is on so that page can invoke without a bundler. The bootstrap commands are
granted to the local app origin only: capabilities/default.json declares no remote entry, and
Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. The main
window keeps Tauri's zoom hotkeys on (Cmd or Ctrl with + / - / 0): WebView2 handles them natively, and on
macOS and Linux Tauri injects a keydown polyfill that calls set_webview_zoom from whatever page is loaded.
A dashboard that handles zoom itself (gui/src/lib/desktop-zoom.ts) keeps the level in localStorage,
re-applies it at start and calls the same command, and its handlers run in the capture phase and stop the
event before the polyfill's bubble-phase listeners see it. Nothing is negotiated, because the shell cannot
know which dashboard it shows: the runtime serves the dashboard and the installed app is the shell, so a
declined takeover or a service that has not restarted puts them on different versions. An older dashboard
has no handler, so the polyfill keeps working; an older shell's polyfill is pre-empted by a newer
dashboard. capabilities/dashboard-zoom.json grants that single command to the main window for
http://127.0.0.1:*, and a test in window.rs pins its shape.
The main window carries an integrated title bar on macOS: the builder sets
TitleBarStyle::Overlay with hidden_title, so the webview draws to the top of the window and
the traffic lights land inside it at a fixed traffic_light_position. The layout that receives
them is the GUI's: the dashboard keeps a top strip across the sidebar and the main area, reserves
the lights' inset on macOS only, and moves or zooms the window through plugin:window commands.
capabilities/dashboard-titlebar.json grants start_dragging, toggle_maximize, and a read-only
scale_factor query to main for the loopback origin. The dashboard uses the native window scale
and page device-pixel ratio to keep the traffic-light row and inset clear at reduced WebKit zoom;
the macOS window has a 360-point minimum width. The same test pins the capability shape, and
capabilities/default.json grants the drag/zoom pair
on the app origin because the bundled bootstrap and update pages draw their own matching strip —
a page with an overlay title bar and no strip cannot be dragged or zoomed at all. Windows and
Linux keep the native title bar: the shell ships no min/max/close widgets of its own, and the
sidebar-top layout applies unchanged beneath it.
The window is created and shown before anything is registered, resolved, probed or started, and
desktop/src-tauri/src/startup.rs runs the whole sequence inside it as named states —
registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one
30-second machinery deadline. Waiting for takeover consent suspends that budget; consuming the
answer extends the shared deadline before clearing the prompt. An approved Windows takeover adds
60 seconds once to the machinery budget (90 seconds total), covering guarded stop, ownership claim,
and replacement startup. Declined consent and other platforms retain 30 seconds. Calls and the
deadline guard share the same remaining budget; no phase renews it.
The failure state carries a retry, the
child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home
and the runtime's last output; desktop/src-tauri/src/sidecar.rs consumes the spawn event stream
into that record instead of discarding it, which is what makes an immediate sidecar exit
distinguishable from a slow start. The page asks for the state list and the run's progress rather
than reconstructing either, because the early states finish faster than a listener can attach.
The deadline is a promise that the screen stops changing, so something keeps it when the run does not. The sequence publishes its first state before any lookup that can fail, and a guard bound to that run reports a terminal state for it if the run returns without one or outlives the ceiling. The guard checks consent and publishes expiry under the same lock. Terminal reports also reject late progress and dashboard navigation, so a resumed probe cannot reopen a prompt after failure. State publication and its synchronous event dispatch share a reporting gate, acquired before the state lock and released before any await. An already accepted progress event cannot overtake failure. The guard is idempotent and generation-scoped: it will not overwrite a result the run reported, and one left over from an earlier run will not fail the retry that replaced it. It waits a short grace past the ceiling so the run's own failure, which names the endpoint, the home and how the child ended, is the diagnostic on screen rather than the guard's thinner one.
"Has not started" is a state of its own rather than the first phase. The sequence's state used to
be seeded with registering, so a shell that never began rendered exactly like one that had just
begun — on the surface whose whole job is to tell those apart. not-started is deliberately
absent from the phase list the page draws its checklist from: it is the absence of a run, so a row
for it would be a step that never completes.
The shell resolves nothing itself. Resolving runs the bundled ocx resolve --json and reads one
ocx-resolve/1 document: the configuration home, the effective port, and a liveness verdict with
three answers rather than two. live enters the ownership and takeover-consent decision below;
absent-proven means every
recorded and configured endpoint was definitively dead, and only that authorises starting a
runtime. Everything else is unknown — a non-zero exit, a timeout, output that will not parse, a
schema this shell does not know, a missing binary — and unknown fails the state with a diagnostic
and a retry. It is never read as absence, because that reading is what put a second proxy next to
the one already running. This replaces a file that read runtime-port.json, fell back to 10100 and
started there, so a user with a configured config.port was started on a port they had not
chosen; the probe budgets that decision needs live in the CLI, where they were tuned.
Registering runs first, before the runtime is touched. A login launch starts hidden, so a tray
installed only after a successful start would leave a failed start with no window and no icon. The
login item is registered in that state too, before the tray, so its Start at Login checkbox reads
the state first run leaves behind. A launch carrying the --autostart argument that the login
item passes back is the only one that starts hidden, and only where there is a tray to hide in: a
manual launch shows its window before the sequence begins, a login launch after the tray verdict.
Registering happens once per process, so a retry re-runs only the runtime half and cannot build a
second tray icon with its own refresh loop.
A hidden login launch does not preload the full dashboard after Ready. finish keeps the bundled
startup surface while the main window remains hidden; Open Dashboard, a second ordinary app launch,
and the shell's explicit open command all pass through startup::open_dashboard, which performs the
one lazy navigation before showing the window. A no-tray login launch is already visible and keeps
the eager behavior, as does every manual launch. If a person opens during startup, the bootstrap is
shown immediately and the open is recorded before progress is read; finish reads that request
after it records Ready, so whichever side runs second navigates, and the one-shot claim keeps it to
one navigation. A WebView that refuses the navigation script gives the claim back, so the next open
retries instead of being suppressed for the run. Both the claim and the request reset with each run.
Decision record: ADR-5494
desktop/src-tauri/src/exit.rs owns what ends the process. Where there is a usable tray, closing
the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an
installed update asks for a coordinated restart. Where there is no usable tray, closing the window
is the quit. macOS needs one thing beyond the event loop: Tauri's default menu carries a predefined
Quit wired to Cocoa's terminate: and the pinned tao raises no cancellable event for it, so
desktop/src-tauri/src/menu.rs rebuilds that menu with an ordinary item on the same accelerator.
On macOS, the event loop in desktop/src-tauri/src/lib.rs handles RunEvent::Reopen through the
existing dashboard entry point. Opening the running app from Dock or Finder restores its main
window, closes the usage popup if it is open, and loads the dashboard if a hidden launch deferred
it. This is separate from the single-instance callback, which handles a second process notifying
the existing one.
The host window also answers whether the dashboard is visible at all. Windows WebView2 is reported
to keep document.visibilityState === "visible" while the Tauri window sits hidden in the tray
(tauri issues #10592 and #6864; macOS WKWebView does flip it, measured), so a hidden dashboard went
on polling for nobody. desktop/src-tauri/src/window.rs therefore publishes the shell's own
answer — the page global window.__OPENCODEX_HOST_VISIBLE__ and an opencodex:host-visibility
CustomEvent — from show and hide, with a label guard so only main reports while
exit::hide_windows hides every window through the same hide; the main window's builder in
lib.rs re-sends the current state on every PageLoadEvent::Finished, which covers a reload or
the bootstrap page's later navigation to the dashboard URL. The GUI folds both the standard event
and this one into a single predicate in gui/src/host-visibility.ts, which
gui/src/visibility-poll.ts and gui/src/client-resource.ts read in place of
document.visibilityState. The tray popup keeps its own equivalent bridge.
Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over one child rather than several racing. Ownership is re-established at the start of each drain rather than read off a flag — the pid the endpoint reports has to be the child this app started — because between the spawn and now the child can have exited and a service can have taken the port back, and an owner's stop sent to that listener is a stop sent to somebody else's runtime. A listener that cannot be identified is left alone.
A runtime counts as gone only when the child reports its own exit or the endpoint refuses a
connection; a timeout or an unauthorized reply is not proof. The stop itself is the bundled
ocx stop --json, not a management call from inside this process: the CLI's stop owns the
receipt-backed teardown, the drain, the Windows respawn verification and the client-configuration
restore, and an in-process endpoint cannot own its own teardown because launchd and systemd can
terminate the request handler during self-unload. The shell reads that run's ocx-stop/1 summary
rather than inferring it, and treats a stop as done only when the CLI reported exit 0 and that
no proxy of this home is left running. A service that failed while the proxy happened to stop
satisfies the second and not the first, and it is exactly the case that may respawn the runtime a
moment later. Nothing kills the child.
A drain that does not complete within DRAIN_DEADLINE is not recorded as a drain. It becomes
DrainFailed, and an unidentifiable runtime becomes OwnershipUnknown. A user's quit still
proceeds from either — refusing to close when the user asked is the worse answer, and a standing
runtime is recoverable with ocx stop. A coordinated restart does not: coming back onto a runtime
that was never stopped puts the user on the old version while they believe they upgraded. A runtime
this app did not start is never stopped. A quit that arrives while the sequence is starting one is
held: the coordinator reserves the spawn rather than holding its lock across process creation, and
the quit is deferred until the child is owned and then drains it.
An in-app update downloads and signature-checks the package, confirms who owns the running runtime,
drains it and confirms the child is gone, and only then installs. The order is not cosmetic: the
pinned updater's Windows installer hands off to the installer process and ends this one, so a
restart asked for after install is never reached, and the package would be replaced under a
runtime still serving out of those files. A drain that did not complete refuses the install and
leaves the update pending. Neither that refusal nor an install that fails after the drain strands
the app: ExitCoordinator::abort_restart takes a coordinated restart's settled drain phase back to
idle with no claimed reason, so a close hides again and Quit works. When the drain had stopped the
runtime and it was wanted before the update or requested again while draining, the startup
sequence brings one back in recovery mode. A runtime already stopped from the tray stays stopped after a failed update unless the person
explicitly requests startup while that update drain is in flight; that newer request wins over the
captured stopped intent. A quit's drain is never aborted.
Decision record: ADR-6033
The Tauri updater also publishes a bounded desktop snapshot over its identity-bound ProxyClient. A random process-session id travels in the embedded dashboard URL, and the dashboard requests GET /api/update/badge?surface=desktop&session=. A normal browser keeps the package badge. The shell posts each updater-state change and a 60-second heartbeat; if the proxy loses the snapshot or the shell stops, the desktop badge becomes unknown after 180 seconds. This display path never installs an update or replaces the signed Tauri result. The tray shows the same pending state: macOS draws a blue child NSView dot over the template status-item image; Windows/Linux swap a generated dotted PNG when a tray host exists. The Windows base glyph is unchanged.
The embedded dashboard sends both update entries to the bundled desktop/ui/update.html
on the app origin. Its page is the only WebView route accepted by the four native update
commands. Tray and page installation share one atomic claim before taking PendingUpdate;
a failed download or drain restores that pending signed update and reenables retry. The
page returns through the startup sequence's resolved dashboard URL, independently of the
one-time initial navigation claim. The loopback dashboard has no updater IPC permission.
The window may navigate to the tauri:// scheme, to the loopback endpoint the sequence resolved,
and on Windows to tauri.localhost, which is where the pinned Tauri serves the app itself because
wry needs an http origin there. That is the one host and no port — not localhost generally, and not
a widening of what the loopback dashboard may reach.
desktop/src-tauri/src/proxy.rs is the local management client and has its own network policy,
separate from the updater's download client. It refuses redirects and system proxies and never sends the reusable management token.
Allowlisted GETs use the existing single-use read-v1 capability; the snapshot POST uses a separate body-bound capability for exactly /api/update/desktop-snapshot without a query.
Both grants bind a fresh nonce, PID, port and ten-second expiry to the recorded runtime secret. The snapshot additionally signs the SHA-256 digest of the exact serialized JSON bytes.
The server consumes the grant once and verifies the bounded body before parsing or storing it; the snapshot grant authorizes no other read or write. Existing admin-token publishers remain compatible, but GUI sessions and browser-origin writes are refused.
The unauthenticated health body is only a discovery hint. Minting re-confirms the recorded runtime against the current identity and binding generation; an earlier binding does not authorize a request after the shell rebinds.
The native panel's account switch uses a third body-bound grant (put_account_switch) for exactly one of PUT /api/codex-auth/active, /api/oauth/accounts/active or /api/providers/keys/active, contract in GUI and management API. The panel passes only a provider id and the provider's own account id through ocx_native_tray_set_switch_handler; desktop/src-tauri/src/native_tray.rs bounds and copies those strings on the main thread, picks the route and body from its own provider sources (native_tray_accounts::switch_request), sends the request, and refreshes the panel or lists the failure.
desktop/src-tauri/src/tray_availability.rs asks the session bus whether
org.kde.StatusNotifierWatcher reports a host registered; macOS and Windows answer yes without a
probe. Neither construction success nor the watcher's mere existence is the question — the pinned
Linux backend creates an AppIndicator and reports success with no host attached, and a watcher with
no host accepts registrations and draws nothing. Until the probe answers, Linux assumes no tray, so
a window closed in the first moments quits rather than vanishing, and the verdict is published only
once an icon actually exists — a tray that fails to build is a session with no tray, not a claimed
one. Where the answer is no, no tray icon is claimed, the window is shown on launch whatever the
launch origin, and closing it quits through the same drain. The update controls live in the tray
menu, so a session without one checks for updates in the background and has no place to install
them from.
Every tray menu setter dispatches to the main thread and waits for it, and the tray is built on the main thread while holding the menu mutex, so the handles are copied out from under that mutex before any setter is called. Holding it across a setter is a cycle, and the symptom would be an app that stops answering Quit.
desktop/src-tauri/src/supervisor.rs brings back a runtime that went away without the app asking.
The startup sequence used to run only at launch and from the failure page's retry, so a runtime that
exited later — a crash, a terminal ocx stop, or a restart the runtime carried out by handing the
port to a detached grandchild the app could not see — left the port refusing connections until the
app was quit and reopened.
The sidecar is spawned with OCX_DESKTOP_SUPERVISED=1. Under it the runtime's own restarts — a join
into a Child, a memory or package restart, the recycle after a disconnect — exit 75 instead of
spawning a replacement; the drain-and-restart marks recycling first, so exit cleanup keeps Codex
routing (restart handoff).
The runtime honors the marker only while the app that set it is still its parent. A link-mode client
runtime gives up on a busy port within 25 seconds under it, inside the 30-second startup deadline, and
publishes an attestation secret in runtime-port.json like a standalone start, so the app can
authenticate the runtime it started.
sidecar.rs reports each child's exit to the supervisor once the exit is recorded. The pure decide
brings a runtime back only when the exit belongs to the tracked child, the exit coordinator is idle,
the app still wants a runtime and no ending is claimed; while a startup run is in flight, that run's
outcome decides instead. Exit 75 goes after half a second when no recovery has run since the last
120 healthy seconds; otherwise it takes the next backoff step like any other exit. Any other exit
waits 3, 6, 12, 24 and then 30 seconds as recoveries repeat, and the count starts over after 120
healthy seconds; the first step leaves a replacement or a service wrapper that owns the port time to
bind first. A recovery drops the dead child's handle without signalling anything and runs the startup
sequence in Mode::Recover: resolve is still the only authority, only a proven absence starts a
runtime, and a runtime that answers is attached as a guest. A recovery never shows the window or the
takeover prompt, and one that finishes while the window shows the update page leaves that page up. A
failed recovery, or a failed run that swallowed an exit of this app's child, schedules the next
attempt; any other failed launch still waits for the person's retry. The exception is a run that
found the port held by a listener this app cannot use (one bound off loopback): another attempt would
find the same listener, so the supervisor parks, and the watchdog below only asks whether the
endpoint changed — a different process answering, or a holder that had answered going silent for
about a minute.
A Child's client runtime (role: client in the resolve answer) is attached to the same way, at
launch and in a recovery, and never offered a takeover. It serves Codex and the Child's dashboard,
not the management plane, so the tray's usage reads have nothing to show on it. When it is the child
this app started, bind confirms ownership, so the tray's Stop and Quit reach it. A run also never
spawns beside the child it already tracks: while that child has reported no exit and was spawned
under 90 seconds ago (a 60-second port reclaim plus its retry fits), the run waits on it. Past that
it is wedged, or its exit event is held up by a grandchild that kept its output pipes (the shell
plugin reports an exit only once both close), and a start goes ahead.
A watchdog asks /healthz every five seconds while a run is Ready and supervision is allowed. A
different pid answering starts a recovery at once. Refused connections start one after three in a row
for a runtime this app started, and after twelve (about a minute) for one it is only a guest on, so a
service or an update restarting its own runtime gets there first. Timeouts and unauthorized or
unreadable answers never count. It covers guest runtimes and an exit event that never arrived.
The exit coordinator's wanted intent keeps this from fighting the person. It is true from launch;
the tray's Stop (when it takes the phase), a quit's drain and an update's drain clear it before the
runtime's exit can arrive, finishing a stop does not restore it, and the failure page's retry sets it
again. A coordinated update remembers the intent it temporarily clears: an aborted update restores a
previously wanted runtime, but never turns a completed tray Stop back on. A terminal
ocx stop of the runtime this app started clears nothing, so the app starts it again after the
backoff; the tray's Stop and Quit keep it stopped. The dashboard's own Stop, in the app's window or
a browser, is refused with desktop_supervised while the app supervises the runtime
(src/server/stop-teardown.ts): it would be undone within seconds, after a full native-Codex
teardown. Only a dashboard session is refused; ocx stop authenticates with the admin token. Every
decision is appended to
runtime-supervisor.log in the app's log directory, emptied at 256 KiB, never through a symlink.
desktop/src-tauri/src/identity.rs holds this installation's own install id: an opaque value minted
once into the app's config directory and never rewritten, exclusively so two launches racing each
other answer to the same one. It exists because the recorded claim names the owning installation,
so the app needs a value of its own to compare against; an id kept only in the shared record would
be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another
installation's. The cost is that a reinstall which keeps the directory keeps its consent and one
that loses it asks again.
desktop/src-tauri/src/ownership.rs mirrors the claim, the three answers a read can give and the
comparison, all of which are defined by
background-service runtime ownership and not
here. The shell does not read the record: resolving a claim means reading every state path and
failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second
weaker implementation of a question core already answers is the mistake this tree has made before.
The bundled CLI answers ownership and takeover compatibility through ocx resolve --json.
It also answers how the live runtime's version compares to the bundled CLI's
(versionSkew.relation; future relation strings read as unknown without discarding the live answer), and the shell acts on the direction instead of reparsing the
warning: proxy-newer makes a supported takeover a downgrade, so the run attaches as a
guest with the versions, downgrade risk and verbatim CLI warning rather than asking consent to it. Every other guest path — held
consent, an unreadable owner, a blocked takeover, a declined prompt, a recovery — appends
the CLI's warning to its phase detail, and the consent panel shows it beside the subject.
Unknown ownership never means "nobody owns it". A supported offer shows the endpoint, home
and owner. After consent, the shell resolves again and refuses a changed answer without
invoking stop. It passes the approved token, endpoint and PID to the CLI's opt-in guarded stop.
That command checks the evidence and manager-to-PID binding under its ownership mutation lease
before action; it stops the manager or approved PID, waits within a bounded deadline for PID
exit and endpoint silence, and only then requires definitive manager inactivity. A manager
that remains active or becomes unreadable produces terminal manager-still-active, not a stop
receipt. The shell also treats approval-changed, unreadable output and child timeout as
terminal before its own silence wait or claim. Only parsed stopped or validated exit-79
history-incomplete proceeds to the refused-probe receipt and ocx service claim, which
rechecks the approved subject and compatibility. Declining attaches as a guest; a failed claim
does not pretend a stopped runtime was restored.
The consent surface labels the exact ownership subject it is about to record. A relaunch of the
same desktop installation reuses consent when the recorded owner and app-local installId
still match; the generation is deliberately not part of that comparison, because the recorded
claim this app holds is its own consent, not a freshness token.
Package update and service repair also preserve that grant and its generation ceiling through
preservedConsent; they do not perform a new subject comparison. The write path is stricter than the relaunch
path: a different owner, different installId, moved consentGeneration or unreadable ownership record is not reuse
there — a pending approval is revalidated against the full
subject, so a grant, a release or a re-grant that moved the generation between the prompt and
the write cannot be claimed by the stale approval. On relaunch the same list narrows to the
comparison itself: a different owner or installId makes the app ask again, and an
unreadable record refuses closed rather than reading as unowned.
Uninstall or an explicit handback releases only the live claim and keeps the generation
ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to an
old package-owned registration is only attachable as a guest until an ownership-aware CLI
records protocol support; the shell must not treat that attachment as durable takeover consent.
desktop/src-tauri/src/first_run.rs turns Start at Login on once per installation,
before the tray is built so its checkbox reads the resulting state. A menu bar app
that is not running has no menu bar item, so leaving autostart off by default left an
installed app absent after a reboot. The marker in the app config directory is written
before the login item is touched and is never removed, so a user who turns the setting
off keeps it off; writing it afterwards would let a failed enable retry on every launch.
The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux
registrations and reads or disables the current-user Windows Run entry. Shell enables
on Windows write a quoted executable path followed by --autostart, preserving the
plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific
launch-origin migration marker revisits older registrations once, only when already
enabled; entries disabled in the tray or Task Manager stay disabled.
The WidgetKit extension in app/ needs three things that Xcode's app-extension target
would supply on its own, and SwiftPM has no such target: @main on
OpenCodexWidgetBundle, the -e _NSExtensionMain linker entry, and
-application-extension — the compiler spelling of APPLICATION_EXTENSION_API_ONLY — all
in app/Package.swift. Any one missing yields a widget that never appears: without
@main the linker drops the bundle and the extension registers with nothing to offer, and
without the entry override ExtensionFoundation traps during bootstrap. Nothing observable
distinguishes these from a working widget, because the bundle still builds, signs and
registers. com.apple.security.app-sandbox is also mandatory — pkd refuses to register
an unsandboxed plug-in at all — which is why the shell writes its snapshot into the
extension's own container rather than a shared App Group, which ad-hoc signing cannot use.
desktop/scripts/prepare-sidecar.ts maps Rust target triples to the standalone
Bun targets and prepares the external binary plus dashboard resources used by
Tauri. Generated files under desktop/src-tauri/binaries/ and
desktop/src-tauri/resources/ remain ignored.
The compiled ocx sidecar cannot resolve or execute a N-API addon from Bun's virtual
$bunfs. scripts/build-standalone.ts therefore stages the exact target's pinned
@napi-rs/keyring-* binary under keyring/, and desktop/scripts/prepare-sidecar.ts
copies that directory into Tauri resources. A universal macOS bundle carries both Darwin
architectures. src/lib/keyring-native.ts selects only the platform/architecture filename
under Contents/Resources/keyring (or an adjacent standalone keyring/ directory); it never
searches the launch working directory. Source and npm installs retain ordinary package
resolution and never probe beside the shared Bun or Node executable. Compiled installs derive
their asset root from the executable's canonical real path, so a symlinked launcher still finds
the addon shipped with the real binary.
The macOS bundle verifier launches the signed sidecar from a disposable unrelated directory and
requires its bounded, load-only keyring probe to expose both native constructors. It does not read
or write an OS credential, which would make an ad-hoc CI identity depend on a consent dialog.
Release verification separately requires both Darwin architecture files inside the universal app.
Merely finding a .node file in the source checkout is not sufficient evidence.
Linux desktop bundles place resources under usr/lib/OpenCodex while the sidecar lives under
usr/bin. The compiled loader recognizes only that exact bundle shape after the adjacent
standalone directory, and the extracted-AppImage verifier executes the same bounded load-only
probe in ordinary PR CI and release CI. This keeps source/npm runtimes and non-usr/bin
standalone layouts out of the Tauri resource fallback.
Decision record: ADR-6139
The management API companion presence check in
src/server/management/companion-routes.ts accepts both
OpenCodexMenuBar/ (legacy Swift companion) and OpenCodexDesktop/ user agents.
This is presence telemetry only; management
authentication remains in the shared API boundary.
The desktop webview uses a Mozilla-compatible OpenCodexDesktop/ user-agent
marker, which the GUI detects to identify the shell without using IPC.
The ordinary hosted Linux lane builds both AppImage and deb bundles with updater artifacts disabled,
extracts each payload into a disposable directory, and boots its real application executable under a
private Xvfb, Openbox, and D-Bus session. Openbox supplies only the window-manager close protocol;
it does not supply a tray host. desktop/scripts/linux-packaged-e2e.ts gives each format fresh
HOME, XDG_*, CODEX_HOME, and OPENCODEX_HOME roots plus a loopback port held until the app
spawn boundary, then requires a visible OpenCodex window, the bundled sidecar's matching /healthz
identity, port and version. It then asks the window manager to close the only window (wmctrl -i -c,
the path a close button takes) and requires the app to exit on its own with code 0 and no signal and
the runtime to be gone; destroying the X window or a crash does not count as a drain. Its
report records readiness time and whole app-process-tree RSS as evidence; those observations are not
pass/fail budgets until a reviewed cross-platform baseline exists.
The lane takes about 15 minutes, so a pull request selects it only through the changes job's
desktop filter: desktop/**, the standalone build and its runtime locator
(scripts/build-standalone.ts, scripts/standalone-targets.ts, src/lib/standalone.ts,
src/lib/bun-runtime.ts), native keyring staging (scripts/standalone-keyring.ts,
src/lib/keyring-native.ts), package.json, bun.lock and ci.yml itself. Ordinary src/** and
gui/** edits do not run it on a pull request; promotion pushes to main and preview and
workflow_dispatch always do, so a packaging regression from such an edit surfaces at promotion.
Extraction is intentional. A GitHub-hosted runner is disposable but its package database is still a
shared job resource, and a normal pull request does not need passwordless package installation or GUI
elevation to prove that the packaged executable and resources boot together. The separate
desktop-installed-gate.yml remains the authority for real installation, package-manager ownership,
takeover consent, elevation cancellation/acceptance, and in-place updater behavior on explicitly
approved disposable GUI runners. Passing the hosted lane must never be described as passing those
privileged installation flows.
AppImage and deb are built with independent CARGO_TARGET_DIR roots in hosted acceptance and release
jobs, then copied into a read-only staging layout for verification and collection. Tauri patches a
per-format updater marker into the release binary while bundling; sharing one Cargo target lets one
format observe a binary mutated for the other. The isolated roots make the marker and every other
bundler mutation format-local.
Decision record: ADR-5493
Linux AppImage packaging uses desktop/scripts/appimage-patchelf.py to preserve
the compiled Bun CLI when linuxdeploy sets the executable RPATH. Only the exact
AppDir sidecar under the active CARGO_TARGET_DIR, still byte-identical to the
prepared target-matching CLI, is exempt; other ELF
operations use the system patchelf. desktop/scripts/verify-linux-sidecar.sh
extracts the completed AppImage (the release passes the staged isolated AppImage directory; a local
build keeps the default Cargo target path), compares its CLI bytes and runs its version command
on the hosted runner before any release asset is collected.
The macOS release combines both prepared CLI architectures with lipo into the
universal external binary Tauri expects, and checks that both slices are present.
The release workflow packages the desktop shell as OpenCodex-<version>-macos.dmg,
OpenCodex-<version>-windows-x64.msi, OpenCodex-<version>-linux-x86_64.AppImage, and
OpenCodex-<version>-linux-amd64.deb. Each artifact is collected with a .sha256 file;
signed updater artifacts also carry .sig files. A pre-publication verification job
combines the standalone and desktop assets, derives the expected file set from the
packaging matrices, verifies every checksum and every updater signature, and writes
latest.json only when the updater key secret is configured, requiring all four
platforms to have updater signatures. Publication waits for that verification, and the
attachment job uploads the verified bundle only after the verification receipt names
the same version and commit.
Updater signature verification decodes Tauri’s outer-base64 minisign box, checks the
ED signature over the BLAKE2b-512 digest against the pinned key, and verifies the
trusted-comment signature. Missing or malformed fields fail before publication.
On macOS, in-app updates download OpenCodex-<version>-macos.app.tar.gz; the DMG is for
the first installation.
The Tauri updater public key and endpoint are checked in to
desktop/src-tauri/tauri.conf.json. Private updater and Apple signing credentials are
provided only as release secrets. Windows certificate signing is not wired yet, so MSI
users may see a SmartScreen warning.
The app's own version comes from desktop/src-tauri/tauri.conf.json and Cargo.toml (mirrored
in Cargo.lock), not from package.json, and the release workflow injects none. Those files move
together with package.json through scripts/release-version-sources.ts, and the release refuses
to build when they disagree with the requested version; see ops/docs-and-release.md.
The macOS desktop shell writes the WidgetKit snapshot to
~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json.
The schema version is 1; the Rust writer refreshes it every five minutes after an
immediate first write. The WidgetKit appex reads this privacy-safe file and performs no
network access.
app/Sources/NativeTray/ defines the macOS SwiftUI display model and AppKit panel library.
It accepts a versioned display-only snapshot and emits UI actions; it owns no network client,
runtime process or application loop. NativeTrayTests exercises its decoding and formatting.
The library is built separately from the WidgetKit extension.
A left click on the tray icon opens a small always-on-top window anchored to the icon, not the dashboard. Reading the current numbers is the reason to look at a tray icon at all, and the dashboard is still one menu item away. On Windows/Linux the web popup reuses the dashboard session and management endpoints, with no additional IPC capability or admin token. The macOS native collector uses the shell's existing authenticated client, described below.
Two platform facts shape it. A Linux tray host may deliver no usable click to the application, so the same surface is reachable from a menu item there. And before the startup sequence has resolved a runtime there is nothing to report, so a click with no proxy falls back to showing the main window rather than opening an empty popup.
On macOS, desktop/src-tauri/src/native_tray.rs links the Swift library into the existing
Tauri process and borrows the existing status item's button on the main thread. A key-capable
nonactivating AppKit panel hosts SwiftUI; Apple Liquid Glass (NSGlassEffectView) owns its single
rounded surface on macOS 26+, with native popover material on older systems. A bounded native
scroll view keeps the header and footer reachable. This restores the keyboard-capable panel
mechanism used by the former native companion without restoring a second application or runtime owner.
The native collector uses the existing identity-bound ProxyClient for GET-only reads and
projects a versioned display DTO. Credentials and raw configuration never reach Swift. Closing
aborts the owned task and its bounded request group; generation and runtime-binding checks reject
late results. Swift callbacks only refresh, close, or navigate the existing dashboard window.
Native/web/widget filtering, title parity and corrupt-settings preservation follow the companion usage contract.
Windows keeps the Acrylic web popup; Linux remains opaque. The VIBRANT_SURFACE constant in
desktop/src-tauri/src/popup.rs connects that native webview builder to its
data-tray-vibrancy="on" hook. The macOS panel does not load that web route or its CSS.
The web popup constrains its document/root to the viewport and scrolls .tray-page inside it,
so the vibrant body's rounded clipping cannot trap the footer below a long account list.
Transparent Tauri windows on macOS require the macos-private-api Cargo feature and
app.macOSPrivateApi in desktop/src-tauri/tauri.conf.json. Enabling that API forecloses Mac App
Store submission; this shell ships as a Developer ID DMG, so its release channel accepts that
tradeoff.
The tray title keeps its existing period. The popup answers the detailed question, so the title does not change meaning as a side effect of adding it.