Skip to content

Latest commit

 

History

History
201 lines (174 loc) · 14.3 KB

File metadata and controls

201 lines (174 loc) · 14.3 KB

Desktop shell

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.

desktop/ui/ is the startup surface. Once the runtime reports healthy the shell navigates the webview to the proxy's loopback dashboard (/#/usage) rather than bundling or serving gui/dist itself. 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. 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. Only the local app origin carries a capability, so the loopback dashboard reaches no command: capabilities/default.json declares no remote entry, and Tauri checks the ACL for any invoke from a non-local origin.

Startup, quit and the tray

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 deadline. Every call beneath that deadline is bounded by the time left rather than by its own timeout, so the ceiling is the ceiling. 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 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 means attach as a guest; 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.

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.

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.

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 it will not send the management token until it has confirmed, from the unauthenticated health body, that the instance answering is the one the shell bound to: the marker, the pid, and the port it addressed. The binding carries a generation, so a request authorised under an earlier binding is not authorised after the shell rebinds.

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.

Runtime ownership, from the app's side

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 it. Until that contract lands, resolve returns unavailable, which is not the same as "nobody owns it" — the question has not been put — so the shell attempts no takeover and records nothing, and the startup state and the diagnostic say which of the two it is.

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 Linux autostart entry and the current-user Windows Run registration too.

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 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.

Release packaging and updater

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 release attachment job combines the standalone and desktop assets, verifies checksums, and writes latest.json only when the updater key secret is configured; it then requires all four platforms to have updater signatures. 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.

Widget snapshot

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.