Repository navigation
feat(desktop): run on Linux - #82
Merged
Merged
Conversation
Linux keeps the desktop's window frame with an Alt menu bar, a PNG tray icon, an XDG autostart entry for Open at login, and a window icon. Gateway keys are stored only with a real keyring, never basic_text. inotify limits degrade with a log line. Key hints read Ctrl/Alt off macOS through one keyLabel helper, and pane focus moves with Alt+Shift+arrows on Linux, where GNOME owns Ctrl+Alt+arrows.
ANT1OFF
added a commit
that referenced
this pull request
Nov 2, 2026
Follow-up to #82 (merged): the four non-blocking findings from its independent review, fixed forward, on `main`. **Security-sensitive:** this changes the key vault (`packages/shell/src/modules/key-vault/vault.ts`) and the Linux autostart entry (`apps/desktop/src/main/login-item.ts`). It needs a re-review of the current head before it merges. ## What changes 1. **A stale autostart `Exec`.** A versioned AppImage that has since been replaced or moved left an entry that started nothing at login, while Settings still said on. At launch, a packaged build now swaps the `Exec` line, and only that line, of Gram's own entry for the current executable. It does so only when the entry is on and its `Exec` has exactly the shape Gram writes (one argument, read back to the same text by `desktopExecArg`, then `--launched-at-login`), and only when the executable it names no longer exists. Every other line, and any `Exec` a person wrote (an `env` prefix, more arguments), stays as it is. An entry that's off (`Hidden=true`, `X-GNOME-Autostart-enabled=false`), someone else's, or seen from an unpackaged run is left alone. A failed rewrite never stops the launch. - **Which executable:** `autostartExec` uses `$APPIMAGE` only when `process.execPath` is inside `$APPDIR`, the AppImage's own mount. Both paths are compared resolved (`realpath`, so a trailing `/`, a doubled `//` or a linked `/home` don't matter), and the `$APPIMAGE` file must exist. - A .deb install started from another AppImage's terminal (Cursor, for example) inherits that app's `$APPIMAGE`, and now uses its own binary instead. - When Gram runs from a mount but has no usable `$APPIMAGE`, Linux doesn't offer the setting, rather than write a mount path that's gone by the next login. - A relative `$APPDIR`, or `/`, is no mount. - **Absolute targets only:** an `Exec` is stale only when it names an absolute path, which is all Gram writes. A bare command such as `Exec=gram --launched-at-login`, which a person might use for a wrapper on their PATH, is left alone. 2. **Vault ciphertext on Linux.** A key is written only as Chromium's keyring ciphertext (prefix `v11`). If the keyring says yes and still produces anything else, `v10` (Chromium's hard-coded fallback key) included, nothing is stored and the request fails with the no-keyring message. On read, an entry without `v11` isn't decrypted: it and every other such entry are purged, and the key reads as missing, so Settings asks for it again. A `set` drops any weak entries too. A value that isn't a base64 string at all is treated as weak and purged, so one malformed entry can't make every Linux write fail. macOS's `v10` ciphertext from the Keychain is untouched. 3. **The Linux gate takes the platform.** `handleVaultRequest(message, crypto, store, platform)` now gets it explicitly, from `process.platform` in main. Linux's rules (a real keyring backend and available encryption) apply because the platform is Linux, not because `backend()` returned a value. `keyringBackendOk(undefined)` is now `false`, and the server's `vaultInfo` and the vault both rely on that. 4. **Only Gram's own `gram.desktop`.** The entry Gram writes now carries `X-Gram-Autostart=true`. Entries from #82 without the mark are still recognised by `Name=Gram` and `--launched-at-login`. A `gram.desktop` Gram didn't write, or one it can't read (any error but `ENOENT`), is never rewritten or removed. Turning the setting on or off then fails with a message saying so, which Settings shows on the row. Writes go to a temp file beside the entry (not ending in `.desktop`, created `wx`, mode 0644) and are renamed over it, so a crash leaves the old entry or the new one, never half of one. An entry that is a symlink (into a dotfiles repository, say) is written through the link onto its target, so the link stays. ## Verification On macOS: - `bunx vitest run` on `packages/shell/src/modules/key-vault`, `apps/desktop/src/main`, `packages/server/src/rpc/handlers/gateway.test.ts`, `gateway-keys.test.ts` and `apps/web/src/settings/gateways-section.test.tsx`: 368 passed, 5 skipped (with `packages/contracts/src/credential-paths.test.ts`). - New vault tests: Linux refuses a backend that is unnamed, `basic_text`, `unknown` or `unavailable`, whatever the crypto object names. macOS keeps its rules even if a backend is named. Each real keyring backend stores and reads back `v11`. `v10` from a keyring is refused before any write. `v10` and unprefixed entries are purged unread, and a `v11` set drops the rest. macOS `v10` is left alone. - New autostart tests: a stale entry is repointed. One that is off, foreign or unpackaged is left alone. A foreign `gram.desktop` is neither written nor removed. Gram's mark is recognised, and so is its pre-mark form. `fileAutostart` writes atomically with no temp file left behind, mode 0644 under the umask. - Added after the re-review: - An inherited `$APPIMAGE` alongside an unrelated `execPath` (the .deb-under-Cursor case) is ignored, and the deb's entry is left as it was. - Gram's own AppImage is used only from its mount and when the file exists. - A stale `Exec` is swapped while an added `X-GNOME-Autostart-Delay` line stays. - An `env`-prefixed `Exec`, or one whose target still exists, is left alone. - `gramExecTarget` round-trips quoted, `%` and plain paths. - An unreadable entry is foreign: never written or removed, and read as off. - A linked entry is written through its link, which stays a link. - A malformed vault value is purged on `set` and on `get`. - Added after the second re-review: - `$APPDIR` spelled `/tmp/.mount_X/` or `/tmp//.mount_X`, or reached through a linked `/home`, still finds Gram's own `$APPIMAGE`. - A mount without a usable `$APPIMAGE` names nothing. - A relative or root `$APPDIR` is ignored. - A bare `Exec=gram` is kept. - `bun run lint`, `bun run typecheck`, `bun run fmt:check`, `bun run check:scaffold`: pass. On Linux (CachyOS, from #82's testing): Electron 45 with no keyring daemon refuses to encrypt at all, even under `--password-store=basic`, so neither prefix can be produced there without a real keyring. ## Not tested - **A real Linux keyring:** never exercised. The `v11` check rests on Chromium's documented prefix for keyring-derived keys, not on ciphertext seen from Electron 45 against KWallet or the Secret Service. If a keyring writes another prefix, keys fail closed with the no-keyring message rather than being stored. A portal-backed keyring (Chromium's `v12`, as in Flatpak, which Gram doesn't ship) would be refused the same way. - **The packaged AppImage:** the launch-time `Exec` rewrite, a login launch from the entry, and `$APPIMAGE` and `$APPDIR` from a real AppImage (and whether its runtime resolves symlinks in `$APPIMAGE`) are untested. They're covered only by the injected autostart in unit tests. - **Electron 45's real prefix:** the re-review flagged this too. No test fixes Electron's own output, since none can be produced here. - **How the AppImage runtime builds `$APPDIR`:** from `$TMPDIR` as given, per the reviewer's knowledge of the runtime, not checked against its source or a real AppImage. - **Settings in the GUI:** the row's error for a foreign entry wasn't checked; it's the same error path every failed set already uses.
ANT1OFF
added a commit
that referenced
this pull request
Nov 2, 2026
Desktop release metadata proposal only. Merging this PR does not publish or tag Gram; follow docs/RELEASING.md to release. --- ## [1.4.0](v1.3.1...v1.4.0) (2026-11-02) ### Features * **agents:** identify Gram's Claude processes as the gram entrypoint ([#91](#91)) ([f7dd703](f7dd703)) * **desktop:** run on Linux ([#82](#82)) ([1d37deb](1d37deb)) * **desktop:** turn voice input off on Linux with one check ([#79](#79)) ([470ff8b](470ff8b)) * **orchestration:** a person's PR comment wakes the worker that owns it ([#86](#86)) ([19bdc1c](19bdc1c)) * **orchestration:** a wrap-up turn when a worker stops without reporting ([#88](#88)) ([36a07fb](36a07fb)) * **orchestration:** workers say what they ruled out and check before repeating ([#90](#90)) ([8bdce01](8bdce01)) * **release:** publish and update Linux AppImage and .deb ([#95](#95)) ([c33c464](c33c464)) * **voice:** an accurate mode with a Parakeet pass on each pause ([#83](#83)) ([2b04a52](2b04a52)) * **voice:** hold one key to talk, with a tone at each end ([#87](#87)) ([1a9b3ef](1a9b3ef)) * **voice:** start capture at the press, and an opt-in ready microphone ([#81](#81)) ([505d240](505d240)) * **web:** a Done section that keeps the last 7 days ([#65](#65)) ([8c7bd49](8c7bd49)) * **web:** Done lists every finished chat ([#67](#67)) ([c91d9ac](c91d9ac)) * **web:** one attention count, quieter rows, a sidebar and board that agree ([#92](#92)) ([f193da4](f193da4)) * **web:** orchestrator → workers in the sidebar, a board that matches ([#80](#80)) ([686e737](686e737)) ### Bug Fixes * **desktop:** harden the Linux key vault and autostart entry ([#85](#85)) ([4b35eac](4b35eac)) * **e2e:** hold the t-flaky turn while the smoke test reads its opening line ([#93](#93)) ([2edc4ac](2edc4ac)) * **e2e:** release the dictation tap as soon as a word is heard ([#94](#94)) ([7781194](7781194)) * prepare only approved local-check sources ([#32](#32)) ([af3fab7](af3fab7)) * repair main after [#32](#32) ([#68](#68)) ([ce352e6](ce352e6)) * **voice:** keep the last word after release ([#78](#78)) ([cc40a47](cc40a47)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: gram-release-please[bot] <351321620+gram-release-please[bot]@users.noreply.github.com> Co-authored-by: Johan <32440065+ANT1OFF@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The app-code side of Gram's first Linux release (AppImage and .deb, x64, voice off). #77 did the packaging and #79 the voice gating. This PR makes the app behave correctly on a Linux desktop and leaves macOS's paths as they were.
Security-sensitive: the key vault's Linux backend (
packages/shell/src/modules/key-vault/vault.ts, thebackendpassed in fromapps/desktop/src/main/index.ts,packages/contracts/src/gateway-keys.ts). It needs an independent review of the current head before merging.Audit: macOS-only behaviour and what Linux does
hiddenInsettraffic lights, renderer keeps an 80 px insetunder-windowvibrancy behind translucent looksdata-vibrancy=nativeset, with nothing behindGrammenu with About, Services, Hideapp.getFileIcon(execPath), which on Linux is a theme's generic icon or nothingsetBadgeCountonly (shows only where a launcher reads Unity's API)SMAppServicelogin item~/.config/autostart/gram.desktop($APPIMAGEor the binary,--launched-at-login), written and removed by the toggle; one switched off by the desktop reads off; packaged builds onlysafeStoragebasic_text, i.e. unencryptedgnome_libsecret/kwallet/kwallet5/kwallet6and encryption available; otherwise refused with a message, never written. Delete still worksLibrary/Keychains.local/share/keyrings,.local/share/kwalletd,.pki/nssdb; Claude Code's.credentials.jsonand Codex'sauth.jsonalready matched~/Library/Application Support/gram-state$XDG_DATA_HOMEor~/.local/share/gram-state; Electron's own in~/.config/Gramclaude,codex,gh,git$SHELL -ilc/bin/zsh/bin/bashwhen$SHELLis unset; fish already joins"$PATH"with colons (checked)fs.watch(FSEvents)fs.watchthrew onENOSPCfs.inotify.max_user_watches; keybindings falls back to pollingopen -R,openxdg-open(folder),xdg-open(URL)shell.showItemInFolder(FileManager1)claude mcp loginin a terminalscriptscripttakes other arguments; out of scope)system_profilerfc-listformatKeys; hard-coded ⌘ text in hints, tooltips, the Dream discard warning and What's New showed ⌘keyLabel()inkeyboard/registry.tsturns a macOS spelling into Ctrl/Alt/Shift off macOS, and is used for every fixed hint; rebindable ones keepuseShortcutHintgh-based check,canSelfInstallfalseShortcuts
CmdOrCtrl(menu.ts), and the terminal already keeps plain Ctrl chords for the shell off macOS.<kbd>keys, so the renderer applieskeyLabelto them:⌥⌘NreadsCtrl+Alt+Non Linux and Windows.Screenshots
These were taken on a real KDE Plasma 6 (Wayland) machine (CachyOS), with a virtual
kwin_waylandsession on a private D-Bus. Gram ran unpackaged (bun run dev:desktop, fake agent, temporary HOME and data folder) with no keyring daemon. Before ismainat the time, after is this branch, and the same driver ran both.KWin frame and Settings → Gateways, adding a gateway with a key and no keyring. Before: a second menu row, the generic Wayland icon, and the key refused with a generic error. After: one frame, the Gift rune icon, no key field, and the reason.
Appearance's icon hint, before and after:
Settings → Keyboard on Linux (after):
Verification
On the Linux machine above (CachyOS, KDE Plasma 6, Wayland):
platform: linux, nospeechbridge, no vibrancy, a 0 px inset and a 1440×900 viewport (871 high before, with the menu row).gateway-keys.json, and the synthetic key appears in no file under the run's HOME or data folder, before or after.claude,git,ghandcodexthrough the login-shell PATH.XDG_CURRENT_DESKTOPKDE, GNOME and XFCE:getSelectedStorageBackend()reportskwalletorgnome_libsecretwhileisEncryptionAvailable()isfalse. Electron 45 no longer falls back tobasic_textthere, which is why the vault now treats unavailable encryption as no keyring.On macOS:
bunx vitest runon the changed areas: login item, key vault, gateway handlers and Settings, credential paths, keyboard, What's New, terminal keys, git watch, keybindings and settings search all pass.bun run lint,bun run typecheck,bun run fmt:check,bun run check:docs,bun run check:scaffoldall pass.Not tested, and left for later
--launched-at-loginstarting hidden, and the AppImage'sIcon=gram(an AppImage installs no theme icon, so the autostart entry may show none).gh-based check withcanSelfInstallfalse, so it can say an update exists but can't install one. Whether a release has Linux assets isn't checked.codexTarget()has no Linux triple, so Gram's managed Codex install and update don't apply on Linux. An installedcodexon PATH works.