Files
Zeyi (Rice) Fan 2c2afac192 feat(electron): clarify desktop update prompts (#5160)
## Related issue

N/A

## Summary

- Show the installed and available Omnigent Desktop versions in the shell-owned update prompt, with session-safety copy on its own line and unclipped card chrome.
- Carry the effective desktop version through updater status events and keep the native up-to-date dialog consistent.
- Add a development-only semantic-version override that checks the production update feed, making the real update flow reproducible without changing packaged behavior.

ELI5: development can pretend the installed desktop app is older, compare it with the production feed, and pass that same version into the update card.

```text
OMNIGENT_DESKTOP_VERSION_OVERRIDE
                |
                v
      electron-updater baseline ---> production update feed
                |
                v
      update status + current version ---> shell overlay
```

## Test Plan

- `node --test web/electron/test/desktop_updater.test.js`
- `node --test web/electron/test/update-main.test.js`
- `pnpm --filter web exec vitest run src/components/UpdateBanner.test.tsx`
- `pnpm --filter web type-check`
- Targeted `oxlint` over the changed Electron and web sources/tests.
- `pnpm --filter web build:overlay`
- Development flow: `OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start`, then **Server → Check for Updates…** against the production feed.

## Demo

N/A — the UI is rendered in an Electron-owned transparent child window; the focused component assertions and overlay production build cover the final copy and layout contract.

## Type of change

- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The updater unit harness covers version propagation, development override isolation, and production-feed configuration. The UpdateBanner test covers available, downloading, and downloaded copy.

## Changelog

Desktop update prompts now show the installed and available versions with clearer copy and polished overlay spacing.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-21 05:57:33 +00:00
..

Omnigent Desktop (Electron)

A thin Electron desktop shell around the existing Omnigent web UI. It shows the same UI you get in a browser, but adds native niceties:

  • OS-native desktop notifications (via the main-process Notification API) when an agent finishes a turn (runningidle/failed), raises a new elicitation (asks for input), or a runner disconnects (onlineoffline). A notification fires for any such event except the one conversation you're actively viewing (window focused and that chat open). Sessions already settled at launch don't fire; only fresh transitions this client observes do. On a turn-end the notification body shows the first few lines of the agent's final message when they can be fetched (one best-effort GET /items call), falling back to a generic "Agent finished and is ready for your input." On macOS each notification can also play a sound — a system sound you pick in the Notifications menu (see below). It's off by default (opt-in): a fresh install stays silent until you turn it on, so the sound never surprises you.

  • A foreground attention cue. macOS (and Windows) suppress the notification banner for the frontmost app — and macOS suppresses its sound too — so the notification still lands in Notification Center, but no toast pops (and on macOS no sound plays), which reads as "notifications only work when the app is in the background." Because the web layer already only notifies for sessions you are not actively viewing, the shell adds OS-level cues the frontmost app can produce: it bounces the macOS dock icon (or flashes the taskbar frame on Windows/Linux), and on macOS it plays the chosen sound itself (via afplay) instead of the suppressed notification sound. Because the shell plays it, the alert is audible whether Omnigent is backgrounded or in front — and the toast's own sound is muted so the cue never doubles.

  • Multiple windows (Server → New Window, Cmd/Ctrl+N). Each window is an independent view, opening on the current window's URL so you can then navigate it to a different conversation and watch two side by side. A window can also be opened against a different server (see "Multiple servers" below). Notifications and the dock badge are app-wide (one badge for all windows); a notification click focuses the window that fired it.

  • A dock / taskbar badge showing the number of unread sessions at all times (macOS dock badge, Linux Unity launcher count, via app.setBadgeCount). A session becomes "unread" when it finishes a turn or asks for input while you're not actively viewing it, and is cleared the moment you view it. Runner disconnects notify but do not count toward the badge.

  • The standard native menu (App / Edit / View / Window / Help) built from Electron's menu roles, so the usual text-editing shortcuts — Cmd/Ctrl-A, C, V, X, Z — work inside the webview's text fields. Our custom actions — New Window, New Window on Different Server…, and Change Server… — live in a dedicated Server submenu. On macOS a Notifications submenu turns the notification sound on/off (Play Notification Sound, off by default — the user opts in) and picks which macOS system sound to play (Sound ▸ — Glass, Ping, Hero, …); choosing one previews it, and the choice persists in settings.json and applies to the next notification.

  • Browser-style file drag-and-drop works out of the box: Electron does not intercept file drops the way Tauri does by default, so dropping an image onto a text field reaches the web app's HTML5 drop handler with no extra configuration.

  • Microphone permission for voice dictation. The composer's dictation button uses the Web Speech API plus a getUserMedia audio stream (the mic level meter). Both go through Chromium's permission layer, which in Electron asks the embedder (us) rather than showing Chrome's prompt — with no handler wired, Chromium denies by default, so recognition.start() fails instantly with not-allowed and the button appears dead. The main process now wires setPermissionRequestHandler / setPermissionCheckHandler to grant the audio permissions, and on macOS calls systemPreferences.askForMediaAccess("microphone") lazily — on the first actual mic request (the user clicking dictate), not at app startup — so the OS-level mic gate is open too (packaged builds ship NSMicrophoneUsageDescription).

    Caveat — Web Speech does not transcribe in Electron. Granting the mic clears the permission gate, but SpeechRecognition also depends on Google's cloud speech backend keyed to official Google Chrome builds, which Electron's bundled Chromium does not ship. Electron therefore uses the server-side dictation fallback instead: when the connected server has the dictation extra and models installed (GET /v1/info reports dictation_available), a take that fails with Web Speech's network error falls back to streaming audio to WS /v1/dictation/stream and transcribing on the server — no cloud, no Chrome dependency. See designs/server-dictation.md. Without the server extra, the button still renders (the constructor exists) but shows "Dictation unavailable" when clicked, as before.

How it works (zero UI duplication)

The desktop app does not ship a copy of the web UI. It bundles only a tiny "connect to server" page (setup/index.html). On launch:

  1. If no server URL is saved yet, it shows the setup page (one input + Connect). You enter your Omnigent server URL (default http://localhost:8000).
  2. It persists that URL to the per-user app data dir (settings.json under Electron's userData path) and loads the server's own origin, where the server serves the real SPA (the production web build, the same bytes a browser would load).
  3. On subsequent launches it skips the setup page and loads the saved server directly.

If the saved server fails to load (server down, DNS failure, TLS error), the window falls back to the setup page with the error shown and the failed URL pre-filled — the saved URL is kept, so Connect simply retries it.

Entering a plain-http:// URL for a non-local host shows a warning first (anyone on the network path can act as that server); a second Connect click proceeds. http://localhost:8000 connects with no friction.

Change the server later via the Server → Change Server… menu item, which clears the saved URL and returns the focused window to the setup page.

Open another view with Server → New Window (Cmd/Ctrl+N). It clones the focused window's current URL onto a new window against the same server, so two conversations can be watched at once.

Debugging a packaged macOS build

Developer Tools are disabled by default in the production app. To opt in, quit Omnigent, set its macOS user default, and reopen it:

defaults write ai.omnigent.desktop DeveloperMode -bool true

The Debug → Developer Tools menu is then available in the packaged app. To turn production debugging off again, quit Omnigent and remove the override:

defaults delete ai.omnigent.desktop DeveloperMode

Development (pnpm --dir web/electron dev) builds keep Developer Tools enabled without this preference. The override is intentionally macOS-only and does not relax production update security checks.

The native enhancements live on the web side in ../src/lib/nativeBridge.ts. It detects the Electron shell at runtime (the preload exposes window.omnigentDesktop with kind: "electron") and routes notifications/badge through the IPC bridge; in a plain browser it falls back to the Web Notifications path. So the one web bundle works both in a browser and under Electron.

Architecture

electron/
  package.json             # Electron + electron-builder deps and build config
  src/main.js              # main process: window, settings, menu, IPC, badge, notify
  src/preload.js           # contextBridge: window.omnigentDesktop + omnigentSetup
  src/find_preload.js      # contextBridge for the find bar: window.omnigentFind
  src/browserViewRegistry.js  # per-conversation WebContentsView registry (browser pane)
  src/browserViewBounds.js    # CSS-px → window-DIP bounds conversion (browser pane)
  src/browserIpc.js           # omnigent:browser-* IPC handlers (extracted from main.js)
  setup/index.html         # the bundled "connect to server" setup page
  find/index.html          # the bundled find-in-page bar (Cmd/Ctrl+F)
  icons/                   # app icons

Native niceties beyond notifications/badge: a right-click context menu (cut/copy/paste, spelling suggestions + Add to Dictionary, Copy Link Address), window size/position persistence across launches, and find-in-page (Edit → Find…, Cmd/Ctrl+F) — a small bar anchored to the window's top-right corner; Enter / Shift+Enter step through matches, Esc dismisses.

  • Main process (src/main.js) owns settings persistence, window creation, the application menu, permission handling (microphone), and IPC handlers for the badge and notifications (normalize_url, change_server, navigate-to-server, New Window).

  • Preload (src/preload.js) is the only bridge between the remote (untrusted) SPA and the main process. It runs with contextIsolation and exposes a tiny, serialization-safe API via contextBridge — never raw ipcRenderer or Node.

  • Security posture: nodeIntegration: false, contextIsolation: true. window.open / target=_blank links are opened in the user's real browser, not chromeless Electron windows — with one narrow exception, OAuth sign-in popups (next bullet). Non-web schemes (vscode://, ssh://, …) launch an OS protocol handler with page-controlled arguments, so they prompt for consent first — showing the requesting origin and the full URL — with an optional persisted "always allow this scheme from this server". Beyond that, each window is pinned to the one server origin the user explicitly connected it to, and that pin — not navigation — is the trust boundary:

    • Navigation is deliberately not restricted: servers may sit behind auth that redirects through external identity providers, so a window can legitimately visit foreign origins mid-login.
    • Instead, every privileged IPC handler verifies its sender frame. notify / setBadgeCount only work when both the calling frame and the window's top-level page are on the pinned origin (so a pinned-origin iframe embedded in a hostile page gets nothing); the setup bridge (omnigentSetup) only works for the bundled setup page itself, so a server page can never read or silently re-point the saved server URL. Foreign pages get an inert bridge.
    • The microphone permission grant is likewise scoped: only the audio set, only for pages on an origin some window is pinned to, and only when the requesting page is the top-level page — everything else is denied.
  • OAuth sign-in popups: the workspace UI's OAuth flows (connect an MCP service, Catalog Explorer connections) hand the authorization code back via window.opener.postMessage plus a nonce in the opener's localStorage — both exist only in a real, same-profile child window, so sending these popups to the external browser strands the code and the sign-in fails. A window.open is therefore allowed as a real child window only when all of these hold (src/popupPolicy.js): it is popup-shaped (explicit width/height features), the opener window is pinned and currently on its pinned origin, and the target is https on the pinned origin itself, a well-known OAuth authorization host (github.com, accounts.google.com, slack.com, mcp.atlassian.com, auth.atlassian.com, login.microsoftonline.com, salesforce.com), or hand-listed in settings.json under popup_allowed_origins. The child is hardened (hardenOauthPopup): it never gets the shell preload (a no-op popup_preload.js instead), runs sandboxed, shows the current host in its title on every navigation (the page can't control the prefix), and cannot open popups of its own. It is never entered in the shell's window registry, so it gains none of that registry's privileges — its only grant is the auth-surface localhost trust described below (sign-in chains run IdP device-trust checks, e.g. Okta FastPass, inside the popup). The shell also strips Cross-Origin-Opener-Policy from main-frame responses inside these popups (and only there): a COOP: same-origin hop — slack.com's sign-in pages serve one — would sever window.opener mid-flow, which both kills the code hand-off and makes the opener misread the popup as closed, so first-time sign-ins fail while retries succeed. Custom providers on other domains fall back to the external browser; add their authorization origin to popup_allowed_origins to sign in without leaving the app:

    { "popup_allowed_origins": ["https://sso.my-git-host.example.com"] }
    

Embedded browser pane

The desktop shell hosts an embedded browser pane: a real Chromium page the user can drive (URL bar + toolbar) and point-and-prompt in design mode. This PR covers that user-facing pane plus the Electron/renderer plumbing; the agent-facing builtin browser_* tools (navigate / snapshot / click / type / screenshot) that can also drive the pane land in a separate PR. A webview/iframe can't provide screenshots, arbitrary in-page JS, or cross-origin navigation, so each browser is a native Electron WebContentsView positioned over a placeholder <div> the SPA measures — not an in-page element.

sequenceDiagram
    participant A as Agent (runner — any host)
    participant S as Omnigent server
    participant R as Renderer / BrowserPane (this PR)
    participant V as WebContentsView (local Chromium)

    Note over A,S: browser_* tools ship in a separate PR
    A->>S: browser_navigate / click / snapshot …
    S->>R: browser.action_request (SSE, on the session stream)
    R->>R: claim the action (single-winner token)
    R->>V: drive via IPC (navigate / capture / …)
    V-->>R: result (URL, snapshot, screenshot)
    R-->>S: POST action result + claim token
    S-->>A: result JSON (or clean timeout)

The browser runs on the user's machine (a native WebContentsView); the agent — which may run on a different host — drives it purely by messages: an action request fans out over the session stream, the renderer claims and executes it against its local Chromium, and the result is posted back.

Pieces:

  • src/browserViewRegistry.js — a per-conversation Map of WebContentsViews (cap 10). setActive attaches one view to the host window and detaches (does not destroy) the previous one, so a background conversation's page keeps running when the user switches away; views are destroyed only on explicit close or window teardown. Each child view keeps nodeIntegration:false, contextIsolation:true, sandbox:true.
  • src/browserViewBounds.js — converts the placeholder's renderer CSS pixels to window device-independent pixels (they diverge after Cmd+/Cmd- zoom).
  • src/main.js — instantiates one registry per shell window and injects it (plus the isPinnedOriginSender trust gate) into registerBrowserIpc(...).
  • src/browserIpc.js — the whole ipcMain.handle('omnigent:browser-*') surface, extracted out of main.js so that file stays bounded: open-or-navigate, set-active, resize, screenshot (capturePage().toPNG() → base64), execute, has-view, close, plus the toolbar handlers go-back, go-forward, reload, and open-devtools (toggle, docked bottom), plus the design-mode handlers enable-design-mode / disable-design-mode / signal-design-result (inject / tear down the in-page element picker and paint result feedback). Every handler is gated on isPinnedOriginSender (only the connected server's own page may drive the views) and resolves the sender window's own registry, so one window can never manipulate another's panes. On view creation it also wires did-navigate / did-navigate-in-page listeners that push browser-url-changed + browser-nav-state to the renderer so the toolbar's URL bar live-tracks the real URL (redirects, in-page link clicks, agent navigation) instead of going stale.
  • src/preload.js — adds browserOpenOrNavigate/SetActive/Resize/Screenshot/ Execute/Close + browserHasView, the toolbar methods browserGoBack/GoForward/Reload + openBrowserDevTools, the design-mode methods browserEnableDesignMode/DisableDesignMode/SignalDesignResult, and the subscriptions onBrowserViewCreated / onBrowserHostActiveChanged / onBrowserViewClosed / onBrowserUrlChanged / onBrowserNavState + onBrowserElementSelected / onBrowserElementPromptSubmit / onBrowserElementPromptDismiss to window.omnigentDesktop, each a thin ipcRenderer.invoke / ipcRenderer.on.
  • Renderer side (in web/src): hooks/useBrowserAgentRelay.ts receives the browser.action_request SSE event (emitted by the separate agent-tools PR), claims the action on the server (atomic check-and-set so two windows on one server can't double-execute), runs it via the preload bridge, and POSTs the result back with its claim token; components/BrowserPane/BrowserPane.tsx measures the placeholder and keeps the native view positioned over it. Both self-gate on isElectronShell(), so a plain browser tab is inert (the action times out on the server with a clean "is the desktop app open?" error).

First-navigate activation. The first browser_navigate on a conversation creates the view detached (nothing is active yet), so no browser-host-active-changed fires. The registry therefore also emits a browser-view-created event on create; BrowserPane listens for it (and probes browserHasView on remount), mounts its measuring placeholder, and calls browserSetActive(conversationId) — which attaches the view and starts bounds sync. Without this signal the pane would gate itself off forever and the embedded browser would stay invisible. (browserViewRegistry.test.js locks the create-signal → setActive → attached transition.)

Toolbar. When a view is attached, BrowserPane renders a user-facing toolbar above the page: back / forward / reload, a DevTools toggle, and an editable URL bar (Enter navigates; the typed value is normalized to add a scheme — a dotless host like localhost gets http://, everything else https://). The bar reflects the real URL via onBrowserUrlChanged, but never overwrites what the user is actively typing. The pane is a flex column: the toolbar is a fixed-height row above the measured container, because the native WebContentsView paints over that container's rect — a toolbar inside it would be hidden by the overlay. The URL bar reuses the existing browserOpenOrNavigate(..., {force:true}) path (the same one the relay uses), so no separate navigation IPC exists for manual entry.

Design mode (point-and-prompt). A toolbar toggle (next to DevTools) injects an in-page element picker into the WebContentsView via executeJavaScript: hovering highlights the element under the cursor (overlay + <Component>/tag label); clicking opens a popup anchored to that element with an input + Send. On Send the popup emits a console.log marker (the injected script can't require('electron')), which the per-view console-message listener in browserIpc.js forwards to the SPA as browser-element-prompt-submit (carrying the element info; a cropped element screenshot arrives on the earlier browser-element-selected event). There is no backend design-edit routeAppShell (where the relay is hoisted, so it's listening even when the Browser tab isn't mounted) builds a [Design Mode — …] prompt, attaches the screenshot as a File, and sends it through the normal chat path (chatStore.send, targeting the conversation's own bound agent). It then calls browserSignalDesignResult so the popup paints green/red feedback. The picker markers are __omni_element_select__ / __omni_element_prompt_submit__ / __omni_element_dismiss__, and the per-view console listener is stored on the registry entry (designModeListener / designModeWebContents) so browserViewRegistry's close() detaches it on teardown. Electron-only (needs executeJavaScript + the native view); no server flag.

JS trust boundary (important): omnigent:browser-execute runs arbitrary JS in the child view via executeJavaScript(js, true). It is exposed to the SPA only for the relay's own fixed templates (the DOM-snapshot walk, and the click / type element resolvers) — there is deliberately no agent-facing generic evaluate. This keeps the agent boundary: the agent picks elements by ref/selector and supplies text, but never ships a raw JS string that main will run. (It does not, and is not intended to, defend against XSS within the visited page — that page runs its own scripts in its own sandboxed view regardless.) Preserve this when extending the bridge: add typed, argument-shaped actions, not a passthrough JS channel.

Availability. The pane is always on in this build — this shell machinery activates the moment a browser.action_request arrives (the agent-side browser_* tools that emit it ship in the separate tools PR). No flag to enable it. Outside the Electron shell (a plain browser tab) the renderer half is inert, so the tools fail cleanly with a "is the desktop app open?" error rather than hanging.

Prerequisites

  • Node 22.x + pnpm (already used by web).
  • Electron ships its own Chromium/Node, so no system webview libs are needed on Linux for running the built app, though packaging tools may pull a few build deps.

Run it (development)

From the web/electron/ directory:

pnpm install     # installs electron + electron-builder
pnpm start        # launches the Electron shell

The shell opens on the bundled setup page. Point it at a running Omnigent server (see below), Connect, and you're in.

Note: this loads the UI from whatever server URL you give it — it does not run the Vite dev server. To develop the web UI itself with hot reload, run pnpm run dev (plain Vite in a browser) from web/ as usual.

Test desktop updates

To override the current version used by development update checks, launch the unpackaged app with a valid semantic version:

OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start

The override controls both the Current version shown in update prompts and the baseline electron-updater uses to decide whether a production release is newer. It does not change Electron's real app/package version. Packaged builds ignore it. pnpm start rebuilds the shell-owned update overlay before launching it. Unpackaged runs read dev-app-update.yml, which intentionally checks the same production HTTPS update server as packaged builds.

Build a distributable

From web/electron/:

pnpm run build             # current platform
pnpm run build:mac         # .dmg + .zip (signed if an identity is available, not notarized)
pnpm run build:mac:release # .dmg + .zip; app and DMG signed + notarized (see below)
pnpm run build:linux       # AppImage + .deb
pnpm run build:win         # NSIS installer

Output lands in electron/dist/ (the DMG is named Omnigent-<version>-<arch>.dmg).

macOS code signing & notarization

The mac build is configured for Apple's hardened runtime with the entitlements Electron needs (build/entitlements.mac.plist: V8 JIT plus microphone for dictation). Signing is driven entirely by what credentials are present — there are no code changes between a dev build and a release build:

Credentials present Result
none ad-hocsigned app; runs locally, other Macs see a Gatekeeper warning
Developer ID cert signed app; downloads still warn until notarized
Developer ID cert + Apple notarization creds (build:mac:release) app and DMG signed + notarized; installs cleanly everywhere

1. Get a signing certificate

You need a Developer ID Application certificate from an Apple Developer Program account (the kind used for distribution outside the App Store). Create it at https://developer.apple.com/account/resources/certificates (or via Xcode → Settings → Accounts → Manage Certificates), then either:

  • Keychain (local builds): install the cert + private key into your login keychain. electron-builder auto-discovers it — pnpm run build:mac just works. Verify with security find-identity -v -p codesigning (you should see Developer ID Application: <Your Name> (<TEAMID>)).

  • Env vars (CI): export the cert + key as a password-protected .p12 and set:

    export CSC_LINK=/path/to/developer-id.p12   # or a base64 string / https URL
    export CSC_KEY_PASSWORD='the p12 password'
    

To force an unsigned build even when a cert is present (faster dev iteration): CSC_IDENTITY_AUTO_DISCOVERY=false pnpm run build:mac.

2. Notarize (release builds)

Notarization uploads the signed app to Apple for malware scanning; without it, macOS warns on first launch of a downloaded app. It needs network access and Apple credentials — either an App Store Connect API key (preferred for CI):

export APPLE_API_KEY=/path/to/AuthKey_XXXXXXXXXX.p8
export APPLE_API_KEY_ID=XXXXXXXXXX
export APPLE_API_ISSUER=<issuer-uuid>

or your Apple ID with an app-specific password:

export APPLE_ID=you@example.com
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
export APPLE_TEAM_ID=<TEAMID>

then:

pnpm run build:mac:release

This release build signs and notarizes the app first so both the DMG and ZIP contain a trusted app. It then signs each finished DMG, submits it to Apple, and staples and validates the resulting ticket. Expect the notarization steps to add a few minutes per architecture (Apple-side processing). Verify the result with:

spctl -a -vv dist/mac-arm64/Omnigent.app   # → "accepted, source=Notarized Developer ID"
codesign --verify --verbose=2 dist/Omnigent-*-arm64.dmg
xcrun stapler validate -v dist/Omnigent-*-arm64.dmg

build:mac:release fails loudly if signing or notarization credentials are missing, if Apple rejects a DMG, or if stapling fails. That's intentional, so a release artifact can't silently ship unsigned or unnotarized.

Getting a server to point at

Any reachable Omnigent server works. For a quick local target, run the server from this repo:

# from the repo root, with the project venv:
.venv/bin/python -m omnigent.server   # serves on http://localhost:8000

Then enter http://localhost:8000 in the setup page.

Managing servers and hosting

Beyond pointing at an already-running server, the shell can drive the local omnigent CLI to start a server and register this machine as a host (a machine that runs the agent work a server dispatches). Two concepts stay deliberately separate:

  • Server — the backend the webview talks to (local or remote).
  • Hostthis machine executing agent work for a server. Because hosting runs agent code, it is opt-in and explicit: the shell never connects this machine as a runner on its own — not on connect, not on launch. You connect it from the host selection menu inside the app (when starting a chat, pick this machine), which drives controlHost over the bridge. That request alone isn't trusted to authorize hosting: the SPA is served by the server, so start/restart additionally require a native, main-process confirmation the page can't forge or auto-dismiss (persisted per server origin, so a trusted server is asked only once).

Detecting the CLI and customizing its path

The CLI ships under two names that resolve to the same entry point — omnigent (canonical) and omni (short alias) — and the shell probes both: settings.omnigent_path first, then PATH (omnigent then omni), then the well-known install locations (~/.local/bin, ~/.cargo/bin, Homebrew, /usr/local/bin, each tried under both names). A GUI-launched app inherits a minimal PATH, which is why the install locations are probed directly. The path is resolved once at startup and cached in-memory for the session.

You can see and change which binary is used in two places:

  • Setup page — hidden by default behind a gear icon (top-right) that opens a small modal. The resolved/auto-detected path shows as the field's placeholder (the value stays empty until you type an override); set it via free-text or a native file picker. When nothing is found the gear gets an accent dot and the modal shows the install one-liner
    curl -fsSL https://raw.githubusercontent.com/omnigent-ai/omnigent/main/scripts/install_oss.sh | sh
    
  • In-appSettings → Local CLI (desktop only): shows the resolved path and version, a Change… button (native file picker) and Reset to auto-detected. For safety the in-app surface exposes no free-text setter — a connected server must not be able to silently repoint the CLI at an arbitrary binary, so changing it requires a user-driven OS dialog.

A configured path is saved to settings.json (omnigent_path) only once it validates as a runnable CLI; clearing it reverts to auto-detection. Connecting to a remote server never needs the CLI — only "Start locally" and hosting do.

Start locally

"Start a server on this machine" runs omnigent server --background (idempotent — reuses a healthy one) and then connects this window to its http://127.0.0.1:<port> URL through the normal connect flow. It does not connect this machine as a runner — that stays an explicit step in the app.

Connecting this machine as a runner

There is no connect-time toggle and no sidebar status row: the shell never connects a runner automatically. Inside the connected app, the host selection menu (when starting a chat) tags this machine and offers to connect it. Choosing it calls controlHost("start") over the bridge. Because that call originates in server-served code, the main process does not treat it as the user's consent: on the first start/restart for a server origin it shows a native confirmation dialog ("Allow host to manage Omnigent on this machine?") with Don't Allow (default) / Allow Once / Always Allow. Only after approval does it — once the CLI is authenticated for the server (remote only; local needs none) — either adopt a daemon already serving that server (one you started by hand) or spawn omnigent host --server <url>. Allow Once connects this time and re-prompts next time; Always Allow records the origin in settings.json (allowed_hosting_origins) so later connects skip the prompt. stop is fail-safe and needs no confirmation. The same bridge exposes stop / restart.

Status is read live (host connected = a live daemon process and an online host tunnel; the shell never caches it). The host surface goes through the JS bridge — window.omnigentDesktopgetHostStatus / getHostIdentity / onHostStatusChanged (read + live) and controlHost (start/stop/restart), typed in ../src/lib/nativeBridge.ts and gated to the window's pinned origin like the badge/notification bridge.

Lifecycle

The desktop owns the host processes it starts: quitting the app SIGTERMs them (and stops a local server it started), so closing the app disconnects this machine. A daemon the shell merely adopted (you started it in a terminal) is left running on quit. Hosting is not restored on the next launch — you reconnect this machine explicitly from the host menu when you want it.

Passkeys (WebAuthn)

External security keys (e.g. a YubiKey) work out of the box: Chromium's content layer speaks CTAP to the key directly. That's also why the flow is invisible — the passkey sheet you see in Chrome/Safari is browser chrome, which Electron doesn't ship. Touching the key completes the ceremony with no UI.

The shell intentionally does not enable Electron's Touch ID platform authenticator (app.configureWebAuthn). Doing so routes the entire WebAuthn ceremony through Apple's AuthenticationServices provider, which cannot complete a roaming USB security-key request (e.g. YubiKey) against a third-party SSO relying party — the ceremony dies with an opaque NotAllowedError ("The operation either timed out or was not allowed"). Leaving it off keeps security keys on Chromium's built-in CTAP path, which handles both roaming keys and Touch-ID-as-security-key. The native Touch ID platform passkey served no supported sign-in path: Databricks Touch ID sign-in goes through Okta FastPass (Okta Verify over the localhost loopback), not WebAuthn, and browser-registered passkeys are invisible to the app's keychain-access group anyway.

Because no restricted entitlements are used, a Developer ID certificate alone is sufficient for signing — no embedded provisioning profile is needed.

Localhost access (auth flows)

Trusted pages may call services on the user's own machine (http://localhost:<port>, 127.0.0.1, [::1]) even when those services don't send CORS headers — authentication flows use this to reach local auth helpers/token brokers. The shell injects the CORS (and preflight) response headers itself, scoped to requests from a trusted page origin to a loopback host; see src/localhost_cors.js. Trusted means:

  • a window's pinned server origin, or
  • the current top-level page of a pinned window — auth flows redirect the main frame through SSO/IdP origins that can't be known in advance (server → SSO domain → localhost helper probe), and those pages get localhost access while the user is actually on them. In-window navigation only starts from the pinned server (links open in the external browser), which keeps this from extending to arbitrary sites; iframes never match (main-frame origin only). The current top-level page of a live OAuth sign-in popup gets the same trust for the same reason — the IdP device-trust checks (Okta FastPass) run inside the popup and fail closed without it — bounded the same way: popups only ever start on allowlisted sign-in hosts, and a closed popup confers nothing. Popups gain no other shell-window privileges.

Anything else stays blocked by normal CORS, and a localhost service that sends its own Access-Control-Allow-Origin keeps enforcing its own policy untouched.

If a page needs localhost while not being the visible top-level page, hand-add its origin to settings.json:

{ "localhost_allowed_origins": ["https://login.example.com"] }

(settings.json lives in Electron's per-user userData dir — on macOS, ~/Library/Application Support/Omnigent/settings.json.)

Multiple servers

One server URL is saved as the default, but extra windows can be opened against different servers via Server → New Window on Different Server…. It opens a setup page in per-window mode: the URL you connect applies to that window only and is never saved, so the default server is untouched and the extra connection ends when the window closes. These windows get the same per-window origin pinning as regular ones. With windows on more than one server, the dock badge shows the sum of each server's unread count and notification titles are prefixed with the firing server's hostname.

An omnigent://<hostname>/c/<session_id> URL opens that session on that server in the desktop app — the way a browser deep link opens a page:

omnigent://localhost:8000/c/conv_abc              → http://localhost:8000/c/conv_abc
omnigent://my-workspace.cloud.databricks.com/c/x → https://…/omnigent/c/x

The link names a server by host (with port if non-default) and carries no http/https — the shell infers the scheme with the same rule the setup page uses (http for loopback, https for a remote host), so a deep link and a pasted URL can never disagree. The Databricks workspace mount (/omnigent) is not in the link; it is server-determined and discovered the same way a pasted workspace URL is. v1 accepts only /c/<session_id>; other paths are ignored.

Window handling (the careful part):

  • A window already open on that server and currently on its page is reused in place — the shell tells the SPA's router to navigate to the conversation without a reload, so the in-flight stream isn't dropped. (Same basename-less /c/<id> path a notification click routes.)
  • A window pinned to that server but mid-SSO-redirect (off the server origin) is reused with a reload to the conversation, since the SPA's listener isn't reliably mounted on a foreign IdP page.
  • A server you've previously connected to (in the recent-servers list or the saved default) but have no live window for opens in a new window — no prompt, the way a second tab for a known site would.
  • A server you have never connected to prompts with a native confirmation dialog (Cancel is the default). Pinning a new origin is a privilege grant (notifications, badge, mic), so a clicked link never silently pins an attacker-chosen origin; once you allow it, the server is remembered so the next link is frictionless.

Cold start vs warm start. On macOS, open-url fires for the link and can arrive before the app is ready, so pre-ready links are queued and drained once the windows exist. On Windows/Linux, a second launch carrying the URL is funneled to the running instance by the single-instance lock. Links are handled one at a time, so two arriving together can't race two consent dialogs. At cold start the deep link replaces the default launch window; if you cancel an unknown-server prompt, a normal launch window opens instead.

Registration. The scheme is registered two ways: the build manifest (build.protocols in package.json, which writes CFBundleURLSchemes on macOS, a .desktop MimeType on Linux, and registry entries on Windows) for packaged installs, plus a runtime app.setAsDefaultProtocolClient("omnigent") call so electron . dev clicks route to the running dev instance.

The decision logic (parse + window selection) is pure and unit-tested in src/deepLink.js; the orchestration lives in src/main.js.

Implementation notes

  • Runtime: bundled Chromium (so the build is ~100+ MB, but the renderer matches Chrome's behavior exactly — no OS-webview quirks).
  • Native bridge detection: window.omnigentDesktop (kind: "electron"), exposed by the preload. The web-side nativeBridge.ts routes the badge to app.setBadgeCount and notifications to the main-process Notification API via IPC; in a plain browser it falls back to the Web Notifications path.
  • File drag-drop works by default (Electron doesn't intercept HTML5 file drops).
  • Toolchain: Node only — no Rust or platform webview libraries.

Historical note: an earlier Tauri-based shell lived in web/src-tauri. It was removed in favor of shipping Electron only; nativeBridge.ts no longer carries a Tauri code path.