Skip to content

feat(desktop): run on Linux - #82

Merged
ANT1OFF merged 1 commit into
mainfrom
feat/linux-platform
Nov 2, 2026
Merged

ANT1OFF merged 1 commit into
mainfrom
feat/linux-platform

Conversation

@ANT1OFF

@ANT1OFF ANT1OFF commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

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, the backend passed in from apps/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

Area macOS Linux before Linux now
Title bar and window buttons hiddenInset traffic lights, renderer keeps an 80 px inset Desktop frame plus a native File/Edit/View row under it Desktop frame and its own buttons (follows the theme and button layout); menu bar hidden, Alt shows it, accelerators work. Inset stays 0
Vibrancy under-window vibrancy behind translucent looks Window made transparent and data-vibrancy=native set, with nothing behind Off: the look paints its own backdrop on an opaque window
App menu Gram menu with About, Services, Hide Windows layout (Settings and Quit in File) Unchanged, now behind Alt
App and window icon Dock icon swap No window icon (Electron's) Window icon set to the chosen icon, the default included
Tray Template glyph in the menu bar app.getFileIcon(execPath), which on Linux is a theme's generic icon or nothing The bundled Gift rune PNG at 64 px; registers as a StatusNotifierItem (KDE, GNOME with AppIndicator)
Dock badge Dock badge count setBadgeCount only (shows only where a launcher reads Unity's API) Same, plus the window's urgency hint while unfocused, which taskbars show
Open at login SMAppService login item Setting hidden XDG autostart entry ~/.config/autostart/gram.desktop ($APPIMAGE or the binary, --launched-at-login), written and removed by the toggle; one switched off by the desktop reads off; packaged builds only
Notifications settings link System Settings deep link None None (no standard page); notifications themselves use libnotify through Electron
Folder access recovery Privacy & Security pane n/a n/a (no TCC)
Gateway keys Keychain via safeStorage Keys stored even under basic_text, i.e. unencrypted Stored only with gnome_libsecret/kwallet/kwallet5/kwallet6 and encryption available; otherwise refused with a message, never written. Delete still works
Credential paths (preview deny-list) Library/Keychains Not covered Adds .local/share/keyrings, .local/share/kwalletd, .pki/nssdb; Claude Code's .credentials.json and Codex's auth.json already matched
User data and logs ~/Library/Application Support/gram-state $XDG_DATA_HOME or ~/.local/share/gram-state; Electron's own in ~/.config/Gram Unchanged (already right)
PATH for claude, codex, gh, git Login shell $SHELL -ilc Same probe; fallback shell /bin/zsh Fallback /bin/bash when $SHELL is unset; fish already joins "$PATH" with colons (checked)
File watching fs.watch (FSEvents) @parcel/watcher on inotify; a root at the limit silently got no live updates, and the keybindings fs.watch threw on ENOSPC One warning line naming fs.inotify.max_user_watches; keybindings falls back to polling
Reveal / open open -R, open xdg-open (folder), xdg-open (URL) Unchanged; the desktop's reveal uses shell.showItemInFolder (FileManager1)
claude mcp login in a terminal macOS script Not offered; a running Claude thread signs in Unchanged (util-linux script takes other arguments; out of scope)
Fonts list system_profiler fc-list Unchanged
Voice and dictation sherpa-onnx Would load sherpa-onnx Off (#79): no speech IPC, no bridge, smoke skips the probe, the switch says "Not available on Linux yet."
Key labels ⌘ ⌥ ⇧ Settings, palette and menus already used formatKeys; hard-coded ⌘ text in hints, tooltips, the Dream discard warning and What's New showed ⌘ keyLabel() in keyboard/registry.ts turns a macOS spelling into Ctrl/Alt/Shift off macOS, and is used for every fixed hint; rebindable ones keep useShortcutHint
Updates Squirrel.Mac for signed builds gh-based check, canSelfInstall false Unchanged: no Squirrel on Linux, it can only say an update exists. Left for the release phase

Shortcuts

  • Accelerators are already CmdOrCtrl (menu.ts), and the terminal already keeps plain Ctrl chords for the shell off macOS.
  • Changed on Linux only: pane focus moves from Ctrl+Alt+arrows to Alt+Shift+arrows, because GNOME keeps Ctrl+Alt+arrows for workspace switching and the window never sees them. macOS and Windows keep their keys. A focused terminal passes the new chord to the app.
  • Checked and left alone: Ctrl+Alt+B/R/S/P/N/W/J/1–4 and Ctrl+Alt+Enter don't clash with GNOME, KDE or common terminal defaults. Ctrl+Shift+C/V stay the terminal's copy and paste.
  • What's New bodies are static text with <kbd> keys, so the renderer applies keyLabel to them: ⌥⌘N reads Ctrl+Alt+N on Linux and Windows.

Screenshots

These were taken on a real KDE Plasma 6 (Wayland) machine (CachyOS), with a virtual kwin_wayland session on a private D-Bus. Gram ran unpackaged (bun run dev:desktop, fake agent, temporary HOME and data folder) with no keyring daemon. Before is main at 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.

Linux before
Linux after

Appearance's icon hint, before and after:

Appearance before
Appearance after

Settings → Keyboard on Linux (after):

Keyboard after

Verification

On the Linux machine above (CachyOS, KDE Plasma 6, Wayland):

  • The app boots and the renderer reports platform: linux, no speech bridge, no vibrancy, a 0 px inset and a 1440×900 viewport (871 high before, with the menu row).
  • The tray registers as a StatusNotifierItem on KDE's watcher, before and after.
  • With no keyring daemon, a key typed into Add gateway is never stored: no gateway-keys.json, and the synthetic key appears in no file under the run's HOME or data folder, before or after.
  • Settings shows no Voice input page, and the Diagnostics switch says "Not available on Linux yet."
  • The settings pages visited show no ⌘ ⌥ ⌃ ⇧.
  • Diagnostics found claude, git, gh and codex through the login-shell PATH.
  • A direct Electron probe on the same machine, with a private bus and no keyring daemon, for XDG_CURRENT_DESKTOP KDE, GNOME and XFCE: getSelectedStorageBackend() reports kwallet or gnome_libsecret while isEncryptionAvailable() is false. Electron 45 no longer falls back to basic_text there, which is why the vault now treats unavailable encryption as no keyring.

On macOS:

  • bunx vitest run on 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:scaffold all pass.

Not tested, and left for later

  • A real keyring storing a key on Linux: the positive path is covered by unit tests only. I didn't touch the machine's own KWallet, its session was locked, and a fresh kwalletd6 in the private bus needs a wallet-creation prompt.
  • A packaged build: AppImage/.deb, the autostart entry at a real login, --launched-at-login starting hidden, and the AppImage's Icon=gram (an AppImage installs no theme icon, so the autostart entry may show none).
  • X11, GNOME, and the tray menu: not run on X11 or GNOME, and the tray menu was never opened or clicked; only its registration was checked.
  • Notifications on Linux: not exercised.
  • The inotify limit: covered by unit tests on the error shapes only; not reproduced live.
  • Updater and release phase: Linux runs the gh-based check with canSelfInstall false, so it can say an update exists but can't install one. Whether a release has Linux assets isn't checked.
  • Codex runtime management: codexTarget() has no Linux triple, so Gram's managed Codex install and update don't apply on Linux. An installed codex on PATH works.

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
ANT1OFF merged commit 1d37deb into main Nov 2, 2026
23 checks passed
@ANT1OFF
ANT1OFF deleted the feat/linux-platform branch November 2, 2026 07:34
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant