Skip to content

build(desktop): package Linux AppImage and .deb for x64 - #77

Merged
ANT1OFF merged 3 commits into
mainfrom
feat/linux-packaging
Nov 2, 2026
Merged

ANT1OFF merged 3 commits into
mainfrom
feat/linux-packaging

Conversation

@ANT1OFF

@ANT1OFF ANT1OFF commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Builds Gram as a Linux x64 AppImage and .deb in CI. Voice is off on Linux, so no sherpa-onnx ships. No publishing, no updater, no write token.

What changed

  • electron-builder: linux targets AppImage and deb (x64), executableName: gram, category Development, hicolor icons in build/icons, maintainer, .deb Depends/Recommends. Artifacts: gram_<version>_x64.{AppImage,deb}.
  • Window class: desktopName: gram.desktop with syncDesktopName, so Electron's CHROME_DESKTOP-derived WM_CLASS / Wayland app_id and StartupWMClass are all gram.
  • Sandbox: the AppImage entry drops electron-builder's default --no-sandbox. Its AppRun still adds the flag where user namespaces are refused.
  • node-pty: 1.1.0 has no Linux prebuilds (only darwin and win32). New rebuild:node-pty compiles build/Release/pty.node against Electron's headers on Linux only. npmRebuild: false stays, so the macOS path is unchanged.
  • @parcel/watcher: Linux git watching needs it, so it ships in resources/node_modules through Linux-only extraResources. package.json, bun.lock and the macOS and Windows packages are untouched.
  • sherpa-onnx: excluded from Linux files; afterPack and verify-package fail a package that holds any; the notices generator omits it on Linux.
  • verify-package: on Linux also checks both artifacts (metadata, desktop entry, icons, payload hashes), no sherpa/ONNX, the addons' glibc floor, and runs a pty and a file watch in the packaged Electron via native-probe.cjs no-speech.
  • CI: linux package on ubuntu-24.04, on the package lane. Installs the .deb on Ubuntu 24.04 and Debian 12, smokes the installed app and the AppImage under Xvfb, uploads artifacts for 7 days. Added to qualify-ci, .gram/merge-ci.json and the classifier test's job table.

Fuses

Same as macOS. ASAR integrity's fuse is on, but Electron 45 only implements it on macOS and Windows (shell/common/asar/archive.cc), so it is not enforced on Linux. onlyLoadAppFromAsar and the Node option fuses do apply.

Verification

CI on 1433a4b, linux package on ubuntu-24.04 x64 (real, not synthetic):

  • Artifacts: gram_1.3.1_amd64.deb 103.6 MiB, gram_1.3.1_x86_64.AppImage 128.7 MiB, uploaded for 7 days.
  • verify:package: every check passed, on the unpacked app and on the contents of both artifacts.
  • .deb installed on Ubuntu 24.04 and Debian 12 with no missing libraries. On Debian 12, the native probe ran a pty and a real file watch in the packaged Electron 45.0.0 x64 (NATIVE OK).
  • glibc floor: node-pty 2.34, @parcel/watcher 2.14 (Debian 12 has 2.36).
  • --smoke with fake agents and temporary stores under Xvfb: SMOKE OK for the installed app and the AppImage.
  • macos package passed on the same head.

Locally: scaffold, oxlint, check:docs, and Vitest for desktop build, notices, release tools and the CI policy. No local full bun run check (shared Mac).

Fake-agent smoke only: no live provider, real desktop session, or keyring was tested here.

Known limits

  • Depended on feat(desktop): turn voice input off on Linux with one check #79 (voice gating), now merged.
  • The probe loads @parcel/watcher with require; the server uses a lazy import() from inside the asar, which no CI step exercises. Confirm live git updates in a packaged app on a real Linux desktop.
  • An AppImage installs no host theme icon, so an autostart entry's Icon=gram may show a generic icon.
  • ASAR integrity is not enforced on Linux (see Fuses).
  • tools/release-rc/** is lane [], so a PR changing only native-probe.cjs skips linux package; main runs every lane.
  • Not here (updater/release phase): latest-linux.yml, AppImage self-update, checksums, attestations, signing, publishing.

Linux gets AppImage and .deb targets, x64, with no sherpa-onnx: voice is off
there, and its Linux builds link GPL text-to-speech code. node-pty has no Linux
prebuilds, so a Linux-only step compiles it against Electron's headers and
npmRebuild stays off for macOS. verify-package reads both artifacts and loads
node-pty in the packaged Electron; a new CI job on the package lane installs the
.deb on Ubuntu 24.04 and Debian 12 and uploads the artifacts.
@ANT1OFF
ANT1OFF force-pushed the feat/linux-packaging branch from 674d3ec to 1433a4b Compare November 2, 2026 04:43
@ANT1OFF
ANT1OFF merged commit 86eb99d into main Nov 2, 2026
23 checks passed
@ANT1OFF
ANT1OFF deleted the feat/linux-packaging branch November 2, 2026 04:58
ANT1OFF added a commit that referenced this pull request Nov 2, 2026
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](https://github.com/user-attachments/assets/3e70b6a9-280e-4138-b68f-d0f44a609af4)
![Linux
after](https://github.com/user-attachments/assets/d8b99eea-4bff-42c0-82a1-1418aa51f50b)

Appearance's icon hint, before and after:

![Appearance
before](https://github.com/user-attachments/assets/3e54c172-761d-46a6-826d-76c0093c9e63)
![Appearance
after](https://github.com/user-attachments/assets/ccae9014-b476-4fc5-856b-c1934b2dd13b)

Settings → Keyboard on Linux (after):

![Keyboard
after](https://github.com/user-attachments/assets/af6adab6-fa82-442d-bc15-21d01b2487fe)

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