@x-cite/zgentic-desktop
v0.37.2
Published
zgentic desktop shell (Electron) — loads the web app from the configured server. Run via `npx`, it carries its OWN Electron runtime: ~120 MB downloaded on first run and ~320 MB on disk, per machine and per package version. There is NO OS integration — no
Keywords
Readme
zgentic-desktop
Electron shell for the zgentic platform (DESIGN-ELECTRON.md §3). The renderer is the existing web app loaded from the configured server URL — the shell adds local bridges (auth, tray, deep links, future bsk/palette) via a context-isolated preload; it never bundles or forks the frontend.
Install / run via npx
npx @x-cite/zgentic-desktop # run the shell
npx @x-cite/zgentic-desktop --help # usage, without starting the app
npx @x-cite/zgentic-desktop zgentic://settings # start on the settings windowKnow the cost before you run it. This is shape 1 of #310: the package declares electron
as a real dependency and its bin launches the compiled main process out of node_modules.
Measured on Electron 43.3.0 (linux-arm64):
| | | |---|---| | the tarball itself | ~43 kB, 24 files | | first-run download | ~120 MB — the Electron runtime | | on disk afterwards | ~320 MB | | how often | once per machine and per package version |
There is also no OS integration whatsoever: no installed app icon, no Applications /
Start-menu entry, no auto-update, and no code signature — macOS Gatekeeper and Windows
SmartScreen see an unidentified binary. So npx here is a developer-convenience channel, not
the product's installer, and it does not replace one. For an installed application, build the
electron-builder artefact (see "Build"), and read "Shipping shape" for what a real
distribution channel still needs.
The server URL is not a command-line flag — set it in the shell's own Settings window (or open
zgentic://settings). Any other arguments are forwarded to the app, which is how deep links
such as zgentic://chat/<id> reach it.
Versioning
This package's version tracks the PLATFORM version (operator rule, established for the TUI
on #308; @x-cite/zgentic follows the same rule). It enters that shared line at 0.37.1 and
moves with the platform from there — the next platform bump takes it to 0.37.1. The old
independent [email protected] line is dead; do not restart it.
Publishing (maintainers)
Why electron is an optionalDependency and not a dependency. It has to be a runtime
dependency for npx to work at all (a devDependency is never installed for a consumer), but
electron-builder hard-fails the installer build when it finds electron in dependencies
(app-builder-lib/out/util/packageMetadata.js: "Package "electron" is only allowed in
"devDependencies"", and the ALLOW_ELECTRON_BUILDER_AS_PRODUCTION_DEPENDENCY escape hatch
covers only electron-builder itself). That check inspects dependencies alone, so
optionalDependencies satisfies both paths: npm/npx installs it, electron-builder builds.
The declaration is also honest — install succeeds without the runtime, and bin/zgentic-desktop.js
then exits with an actionable message instead of a stack trace (the case you get under
--ignore-scripts or a blocked download). But it has a consequence that is easy to ship by
accident: electron-builder treats optional deps as production ones and copies electron's 314 MB
dist/ into the packaged app, shipping the runtime twice (measured: 635 MB unpacked instead of
324 MB). The !node_modules/electron/** exclusions in electron-builder.yml are what prevent
that — do not remove them, and re-check the packaged size after any dependency change.
prepublishOnly runs npm run compile, so out/ is rebuilt from source before every publish.
The files allowlist ships only bin, out, assets and this README — verify with
npm pack --dry-run before publishing, since without an allowlist a publish would ship src/,
test/ and every config file. assets/ is a runtime requirement, not decoration:
src/main/main.ts loads the tray icon from <package>/assets/icon.png, so dropping it from the
allowlist ships a broken tray. Publishing uses NPM_TOKEN from the environment; no token
belongs in any file in this repo.
Layout
src/main/ main process (CJS, tsc → out/)
window-manager.ts main BrowserWindow, window-state persistence, deep-link routing
window-state.ts size/pos persistence + off-screen recovery (pure, tested)
profile.ts ServerProfile: server URL + palette hotkey/hide-on-blur (pure, tested)
+ pruneLegacyProfileKeys(): drops the #316 oidcIssuer/clientId leftovers
deep-link.ts zgentic:// parsing + renderer delivery (pure, tested)
hotkey.ts global "Hey Agent" accelerator registration (DI, tested)
palette-window.ts frameless always-on-top quick-ask palette (DI, tested)
notify-center.ts inbox item → native Notification, click → focus + deep link (DI, tested)
tray-badge.ts unread-count tray title / app badge (pure, tested)
settings-window.ts local settings page + the ONLY sign-in surface (the only file://
window; strict CSP)
tray.ts menubar/tray icon + menu
auth/
cli-transport.ts CliAuthTransport over the injected fetch: POST /auth/cli/{start,token}
token-store.ts safeStorage-encrypted token file (0600 fallback)
auth-bridge.ts sign-in orchestration: the brokered flow → platform session tokens
paste-prompt.ts the paste path's readCode() seam as renderable state (DI, tested)
src/client-auth/ SHARED with the TUI (#316) — see its own README for why it lives here
pkce.ts PKCE verifier/challenge (S256)
loopback.ts LoopbackServer: one-shot 127.0.0.1 listener with a timeout
cli-signin.ts the flow: loopback + paste paths, isLoopbackRedirectUri()
jwt.ts jwtExpMs() — unverified exp read, for local expiry checks only
src/preload/index.ts contextBridge → window.zgDesktop (contextIsolation, no nodeIntegration)
src/renderer/ settings.html + settings.js — server URL, account, paste panel
(plain, CSP: default-src 'none')
test/ vitest (main-process modules AND src/client-auth, which compiles here)Bridge API (window.zgDesktop)
{
version: string,
server: { url(), setUrl(url) → normalized origin, probe() },
auth: { getSession(), signIn(mode?), signOut(), onSessionChanged(cb),
// the paste path (#316): mode "paste" parks the flow on a code
pastePrompt(), onPastePrompt(cb), submitCode(code), cancelSignIn(),
copySignInUrl(), openSignInUrl() }, // act on the PENDING url only
bsk: { status() }, // stub until feat/browser-use
notify: { show({title, body?, deepLink?}) → {shown}, setUnread(count) },
palette:{ open(), // toggle the "Hey Agent" quick-ask window
openInWindow(chatId?), // focus the main window at a chat
getConfig(), setHotkey(accel), setHideOnBlur(b) },
onDeepLink(cb), // chat navigation + notification click-through
}
setOidcIssuer/profile()/oidcIssuer/clientIdare gone (they belonged to the direct-to-IdP flow #316 deleted, and for one release they were live controls that configured nothing).profile()went with them: with the other two fields removed it returned onlyserverUrl, whichurl()already answers.pruneLegacyProfileKeysdeletes the two keys from an existing installation'sconfig.jsonat startup.
"Hey Agent": the global hotkey (default Cmd/Ctrl+Shift+U, configurable via
palette.setHotkey → profile store) toggles a frameless, always-on-top palette window
positioned at the cursor. It loads the same web app at /chat?palette=1 (compact
composer + answer stream; ?palette=1 is a no-op in plain browsers). Esc hides;
focus-loss hides (toggleable via palette.setHideOnBlur). zgentic://chat/<id> deep
links focus the main window and route the running web app; zgentic://settings opens
the shell settings window. While the main window is unfocused, new inbox items raise
native notifications whose clicks focus + deep-link; the unread count rides the
tray/dock badge.
The web app feature-detects window.zgDesktop?.auth and prefers the shell session
(see web/src/lib/desktop-bridge.ts); plain browsers are unaffected.
Auth flow
The platform-brokered native-client flow (#316) — the gcloud shape, shared verbatim
with the TUI (src/client-auth/). The shell no longer speaks OIDC to an identity provider
at all, and that is the point: an IdP-only flow could sign in only identities the IdP held,
so a password account on the platform could not use the desktop app. The brokered flow
lands on the platform's own /login, which already carries password, Google and every other
method the platform supports.
POST {server}/auth/cli/startwith an S256 PKCE challenge and a loopbackredirect_uri—http://127.0.0.1:<random>/auth/callback, bound BEFORE the call so the port in the URL is real. The listener is one-shot,127.0.0.1-only and times out.- The returned
authorize_urlopens in the system browser; the human authorizes there. - The platform redirects to the loopback listener with the code (+ the echoed
state). POST {server}/auth/cli/tokenwith the code and the PKCE verifier returns the platform's ordinary session + refresh tokens — there is no id token and no secondPOST /auth/loginexchange.
Refresh rides POST {server}/auth/refresh (body variant for non-browser clients); sign-out
posts POST {server}/auth/logout. Tokens live in userData/auth/session.json, encrypted via
safeStorage (Keychain/DPAPI) when available, else a plaintext file with mode 0600.
The paste path (--no-launch-browser, in a GUI)
The shared flow's second path is offered too, and it is reachable on purpose rather than only as a fallback: the Settings window's Account section has "Sign in with a code…" beside "Sign in…", for a machine where the browser handoff cannot come back (a blocked loopback listener, no browser, or signing in from a different computer).
It is a state of the Settings window, not a window of its own — that window is already the shell's only sign-in surface, and the code is minted by the very server configured a few controls above it. The panel shows the authorize URL (copy it, or open it here), takes the code, and hands it to the sign-in the main process is still awaiting.
It is not the weaker path. Both paths run the same shared flow with the same S256 challenge,
the same one-time code and the same expiry; the only difference is whether the code arrives over
a socket or from a person. PasteCodePrompt adds no security property — it only turns "a promise
the flow is awaiting" into "state a window can render and an IPC message can settle". Its local
TTL (the server expiry, clamped to 30 s–15 min) is hygiene: it stops an abandoned sign-in parking
a live verifier in memory. The server still refuses an expired code regardless.
Mechanics worth knowing:
auth.signIn("paste")picks it deliberately;signIn()stays loopback, and inside the shared flow only a listener bind failure falls back to paste — a denial, a timeout or a state mismatch is a real outcome the user must see.- The prompt is a main-process singleton with both a push (
onPastePrompt) and a pull (pastePrompt()), because a window opened after the prompt was armed would miss the push. When a prompt is armed, the Settings window is brought up — a sign-in started from the web app has no paste UI of its own. copySignInUrl()/openSignInUrl()act on the URL the main process already holds; no URL is ever passed in from the renderer, so neither is an open-redirect or arbitrary-open primitive.
When the loopback path cannot open a browser at all, the authorize URL is still copied to the clipboard and announced in a native notification, and repeated in the eventual failure message.
Dev
pnpm install
pnpm run dev # compile + launch electron (loads DEFAULT server URL; change in Settings)
pnpm run typecheck # tsc --noEmit gate
pnpm run test # vitest unit tests (no electron runtime needed)Build
pnpm run build # tsc + electron-builder --dir → dist/linux-*-unpacked (unsigned smoke)make desktop-build from the repo root does install + typecheck + this build.
Real installers (mac dmg / win nsis / linux AppImage) are configured in
electron-builder.yml; publish is disabled — drop the --dir flag and add
signing secrets to produce signed installers.
Shipping shape: why npx runs Electron from node_modules (#310)
Issue #310 framed three shapes for "installable via npx". Shape 1 is what ships here: the package
carries its own Electron runtime and bin/zgentic-desktop.js launches it. It was chosen because
it ships now, with no infrastructure to stand up first — and the cost is stated plainly above
and in the package description.
Shape 2 is the intended end state, and this is not it. There, the npm package is a tiny
launcher whose bin downloads the correct signed electron-builder artefact for the host
OS/arch, verifies its checksum, and runs or installs it. That is what most Electron CLIs do and
what would turn npx from a convenience into an actual distribution channel. It is blocked on
things this repo does not have yet:
- artefact hosting — somewhere durable to serve per-OS/per-arch builds (GitHub Releases or a
bucket), plus a version→URL manifest the launcher resolves. Today
electron-builder.ymlsetspublish: nulland CI produces unsigned--dirsmoke artefacts only. - integrity — the launcher must verify what it downloaded (published checksums, ideally signed), because it is fetching an executable from outside the npm registry.
- macOS code signing + notarization — Developer ID certificate plus notarytool credentials. This is the part that usually blocks a first release, and note the direction: an unsigned, un-notarized download is quarantined by Gatekeeper, making shape 2 strictly worse than shape 1 until signing exists, since shape 1's runtime arrives over npm's own path.
- Windows Authenticode — EV/OV certificate or Azure Trusted Signing, else SmartScreen warns.
- an update channel — shape 1 has no auto-update at all; shape 2 must decide whether the installed app updates itself or the launcher re-resolves the newest artefact on each run.
The credential names for the signing work are in "Signing notes" below. Shape 3 from the issue (install nothing, just print the download link) was not implemented.
Signing notes (not wired yet)
- macOS:
CSC_LINK/CSC_KEY_PASSWORD(Developer ID cert) + notarytool creds (APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID). - Windows:
CSC_LINK/CSC_KEY_PASSWORD(EV/OV cert; consider Azure Trusted Signing). - Linux: unsigned AppImage is normal; sign the update channel instead.
- Deep links:
zgentic://is registered at runtime (setAsDefaultProtocolClient); packaged builds should also declare the protocol in the installer (builderprotocols).
