Add Linux support (AppImage + deb)#161
Conversation
Brings up Linux support across packaging and platform-specific code paths.
Started in Codex; checkpointing to save progress.
Additive/refactor changes (behavior-preserving on macOS):
- Platform helpers: src/shared/platform.ts, src/renderer/utils/platform.ts
- Agent fs sandbox extracted + platform-gated: claude-agent-sandbox.ts
- Cross-platform zip (replaces macOS ditto/unzip): src/main/utils/zip.ts
- Titlebar (hiddenInset/traffic-lights) gated to darwin in window.ts
- Cmd/Ctrl modifier display across renderer components
- Linux release job in .github/workflows/release.yml + linux build targets
- Packaged smoke test: Linux --no-sandbox, strip ELECTRON_RUN_AS_NODE
KNOWN BREAKAGE (to fix next): src/renderer/env.d.ts had its
`declare global { interface Window { api: ElectronAPI } }` block deleted
with no replacement, so window.api resolves to `unknown` and typecheck
fails with ~40 errors. Restoring that declaration is the next step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex deleted the `declare global { interface Window { api: ElectronAPI } }`
block, which made window.api resolve to `unknown` and broke typecheck with
~40 errors (TS18046 + TS2717 conflicts between the per-file partial api
declarations). Restoring it (faithful to upstream) fixes the cascade.
typecheck (node+web), lint, and all 1370 unit tests pass.
Note: this reproduces a pre-existing upstream quirk — the import path
`../main/preload` does not resolve, so ElectronAPI is effectively `any`,
which is what absorbed the partial-declaration conflicts. Properly typing
window.api (point at ../preload, drop the 7 partial declare-global blocks)
is a separate, non-additive cleanup left for later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tests/e2e/launch-helpers.ts + tests/integration.spec.ts: pass --no-sandbox on Linux and strip ELECTRON_RUN_AS_NODE, mirroring the packaged smoke test. Replace the macOS-only `text=Exo` brand wait with a platform-agnostic Inbox-button anchor (the brand <h1> is gated to darwin). - claude-agent-sandbox.ts: add browser-profile dirs (~/.mozilla, Chrome/ Chromium) to the Linux denyRead for parity with the macOS ~/Library deny, and make the unknown-platform fallback default-deny instead of unsandboxed. - .github/workflows/release.yml: install python3-setuptools so node-gyp can rebuild better-sqlite3 for Electron on Python 3.12 runners. - Remove leftover scratch file src/renderer/__check_api_tmp.ts. typecheck (node+web), lint, and unit tests (1371) pass. Unblocks the e2e suite on Linux: 321/337 e2e now pass (was fully blocked by the sandbox abort). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…brand Several e2e specs waited for / asserted the "Exo" titlebar brand (text=Exo, heading "Exo", or h1 hasText "Exo"), which is now rendered only on macOS. On Linux those selectors never match, so the tests timed out. Replace them with cross-platform anchors: - load/alive waits → the always-visible Settings button (button[aria-label= "Settings"]) or the per-row [data-thread-id], depending on what the test needs - inbox-tabs: its own inline electron.launch now passes --no-sandbox on Linux and strips ELECTRON_RUN_AS_NODE (matching launch-helpers) Verified via full `npm test`: these 5 specs now pass; e2e 326 passed, unit 1371. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds Linux support (AppImage + deb) to Exo by filling two gaps: packaging/distribution and a handful of macOS-specific code paths. The guiding principle — gate darwin behaviour rather than replace it — is well-applied throughout.
Confidence Score: 4/5Safe to merge; the changes are additive and the macOS experience is unchanged. The Linux port is well-structured and follows the stated gate-not-replace principle consistently. The four findings are maintenance and hardening concerns that don’t affect the macOS release path or core email features. src/preload/index.ts (inline platform logic), src/renderer/utils/platform.ts (deprecated fallback), src/main/utils/zip.ts (missing archive abort), src/main/agents/providers/claude-agent-sandbox.ts (silent no-op when HOME unset).
|
| Filename | Overview |
|---|---|
| src/shared/platform.ts | New shared platform detection module with getPlatformInfo, formatModifierShortcut, and formatSymbolShortcut; clean and well-typed. |
| src/renderer/utils/platform.ts | Renderer-side helper that reads platform from window.api.platform and falls back to deprecated navigator.platform for non-Electron contexts. |
| src/preload/index.ts | Exposes new api.platform object with inline platform logic that duplicates getPlatformInfo() from the shared module instead of importing it. |
| src/main/agents/providers/claude-agent-sandbox.ts | New module providing platform-aware filesystem deny lists and sandbox guidance text; silently returns empty deny list when HOME is unset. |
| src/main/utils/zip.ts | Cross-platform zip helpers using archiver and extract-zip; archive is not aborted when the output stream errors mid-write. |
| src/main/window.ts | macOS-specific window chrome correctly gated behind process.platform === darwin; find-bar shortcut also platform-aware. |
| .github/workflows/release.yml | Adds build-linux CI job; release job correctly gates on both macOS and Linux build success. |
| tests/unit/linux-platform.spec.ts | New unit tests covering platform labels, cross-platform zip round-trip, and agent sandbox deny lists for darwin/linux/unknown platforms. |
| tests/e2e/launch-helpers.ts | Adds --no-sandbox flag on Linux; replaces macOS-only text=Exo anchor with platform-agnostic getByRole wait. |
| src/main/agents/providers/claude-agent-provider.ts | Replaces inline sandbox/guidance logic with imports from the new claude-agent-sandbox module; no functional change. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[process.platform] -->|darwin| B[macOS window chrome]
A -->|linux| C[Standard window frame]
A --> D[getPlatformInfo - shared/platform.ts]
D -->|isMac=true| E[Cmd modifier labels]
D -->|isMac=false| F[Ctrl modifier labels]
G[api.platform - preload] --> H[getRendererPlatform - renderer]
H -->|window.api present| G
H -->|fallback| I[navigator.platform deprecated]
J[buildFilesystemSandbox] -->|darwin| K[Deny Library, Allow app data]
J -->|linux or unknown| L[Deny .ssh .gnupg .aws .mozilla]
J -->|HOME unset| M[denyRead empty - no sandboxing]
N[zipDirectory] --> O[archiver v5]
P[extractZipArchive] --> Q[extract-zip v2 zip-slip protected]
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 4
src/preload/index.ts:3-13
**Platform detection logic duplicated between preload and shared module**
`preload/index.ts` manually constructs the platform object using `platform === "darwin"` comparisons, while `src/shared/platform.ts`'s `getPlatformInfo()` adds a broader `/^mac/i` regex check (intended for the `navigator.platform` fallback). These two code paths are currently equivalent in practice — `process.platform` always returns exactly `"darwin"` on macOS — but they will drift if `getPlatformInfo`'s logic is updated later. Importing and calling `getPlatformInfo(process.platform)` here would keep a single source of truth and also give compile-time assurance that the exposed object matches the `PlatformInfo` type.
### Issue 2 of 4
src/renderer/utils/platform.ts:10
**Fallback uses deprecated `navigator.platform` API**
When `window.api` is absent (non-Electron contexts such as unit tests), `getRendererPlatform()` falls back to `getPlatformInfo(navigator.platform)`. `navigator.platform` is deprecated in the Living Standard and returns host-OS-specific strings like `"Linux x86_64"` or `"MacIntel"`. In a CI environment running tests on macOS, the renderer-side `isMac` would resolve to `true` for tests that don't boot Electron, potentially masking platform-specific bugs. A safer fallback would be an explicit `"unknown"` sentinel or a mock injected via the test environment, rather than relying on the deprecated browser API.
### Issue 3 of 4
src/main/utils/zip.ts:5-16
**Archive is not aborted when the output stream errors**
`output.on("error", reject)` correctly rejects the promise, but it does not call `archive.abort()` first. If the write stream fails mid-archive (e.g., a full disk or a permissions error), the archiver continues processing entries in the background after the promise has already rejected. This wastes CPU and can cause `archive.on("error", ...)` to fire on a settled promise. Adding `archive.abort()` before `reject(err)` in the output error handler ensures clean teardown and mirrors the pattern recommended by the `archiver` library.
### Issue 4 of 4
src/main/agents/providers/claude-agent-sandbox.ts:9-10
**Empty deny list returned when `$HOME` is unset, leaving the agent unsandboxed**
`buildFilesystemSandbox()` is called from `claude-agent-provider.ts` with no arguments, so `homeDir` defaults to `process.env.HOME`. If `HOME` is unset — possible in certain Docker containers or stripped CI environments — the early return at line 10 yields `{ denyRead: [] }`, which gives the Claude agent unrestricted filesystem read access. The per-platform deny lists for `~/.ssh`, `~/.aws`, `~/.gnupg`, and browser profiles all become inaccessible because path construction depends on knowing the home directory. A comment explaining this intentional trade-off, or a log-level warning when `HOME` is absent, would make the behaviour explicit for operators deploying in non-standard environments.
Reviews (1): Last reviewed commit: "test(linux): use platform-agnostic ancho..." | Re-trigger Greptile
|
|
||
| const platform = process.platform; | ||
|
|
||
| // Expose a limited API to the renderer | ||
| const api = { | ||
| platform: { | ||
| platform, | ||
| isMac: platform === "darwin", | ||
| modifierKey: platform === "darwin" ? "Cmd" : "Ctrl", | ||
| modifierSymbol: platform === "darwin" ? "\u2318" : "Ctrl+", | ||
| }, |
There was a problem hiding this comment.
Platform detection logic duplicated between preload and shared module
preload/index.ts manually constructs the platform object using platform === "darwin" comparisons, while src/shared/platform.ts's getPlatformInfo() adds a broader /^mac/i regex check (intended for the navigator.platform fallback). These two code paths are currently equivalent in practice — process.platform always returns exactly "darwin" on macOS — but they will drift if getPlatformInfo's logic is updated later. Importing and calling getPlatformInfo(process.platform) here would keep a single source of truth and also give compile-time assurance that the exposed object matches the PlatformInfo type.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/preload/index.ts
Line: 3-13
Comment:
**Platform detection logic duplicated between preload and shared module**
`preload/index.ts` manually constructs the platform object using `platform === "darwin"` comparisons, while `src/shared/platform.ts`'s `getPlatformInfo()` adds a broader `/^mac/i` regex check (intended for the `navigator.platform` fallback). These two code paths are currently equivalent in practice — `process.platform` always returns exactly `"darwin"` on macOS — but they will drift if `getPlatformInfo`'s logic is updated later. Importing and calling `getPlatformInfo(process.platform)` here would keep a single source of truth and also give compile-time assurance that the exposed object matches the `PlatformInfo` type.
How can I resolve this? If you propose a fix, please make it concise.|
|
||
| export function getRendererPlatform(): PlatformInfo { | ||
| const api = (window as { api?: { platform?: PlatformInfo } }).api; | ||
| return api?.platform ?? getPlatformInfo(navigator.platform); |
There was a problem hiding this comment.
Fallback uses deprecated
navigator.platform API
When window.api is absent (non-Electron contexts such as unit tests), getRendererPlatform() falls back to getPlatformInfo(navigator.platform). navigator.platform is deprecated in the Living Standard and returns host-OS-specific strings like "Linux x86_64" or "MacIntel". In a CI environment running tests on macOS, the renderer-side isMac would resolve to true for tests that don't boot Electron, potentially masking platform-specific bugs. A safer fallback would be an explicit "unknown" sentinel or a mock injected via the test environment, rather than relying on the deprecated browser API.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/utils/platform.ts
Line: 10
Comment:
**Fallback uses deprecated `navigator.platform` API**
When `window.api` is absent (non-Electron contexts such as unit tests), `getRendererPlatform()` falls back to `getPlatformInfo(navigator.platform)`. `navigator.platform` is deprecated in the Living Standard and returns host-OS-specific strings like `"Linux x86_64"` or `"MacIntel"`. In a CI environment running tests on macOS, the renderer-side `isMac` would resolve to `true` for tests that don't boot Electron, potentially masking platform-specific bugs. A safer fallback would be an explicit `"unknown"` sentinel or a mock injected via the test environment, rather than relying on the deprecated browser API.
How can I resolve this? If you propose a fix, please make it concise.| await new Promise<void>((resolve, reject) => { | ||
| const output = createWriteStream(destinationPath); | ||
| const archive = archiver("zip", { zlib: { level: 9 } }); | ||
|
|
||
| output.on("close", resolve); | ||
| output.on("error", reject); | ||
| archive.on("error", reject); | ||
|
|
||
| archive.pipe(output); | ||
| archive.directory(sourceDir, false); | ||
| archive.finalize().catch(reject); | ||
| }); |
There was a problem hiding this comment.
Archive is not aborted when the output stream errors
output.on("error", reject) correctly rejects the promise, but it does not call archive.abort() first. If the write stream fails mid-archive (e.g., a full disk or a permissions error), the archiver continues processing entries in the background after the promise has already rejected. This wastes CPU and can cause archive.on("error", ...) to fire on a settled promise. Adding archive.abort() before reject(err) in the output error handler ensures clean teardown and mirrors the pattern recommended by the archiver library.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/utils/zip.ts
Line: 5-16
Comment:
**Archive is not aborted when the output stream errors**
`output.on("error", reject)` correctly rejects the promise, but it does not call `archive.abort()` first. If the write stream fails mid-archive (e.g., a full disk or a permissions error), the archiver continues processing entries in the background after the promise has already rejected. This wastes CPU and can cause `archive.on("error", ...)` to fire on a settled promise. Adding `archive.abort()` before `reject(err)` in the output error handler ensures clean teardown and mirrors the pattern recommended by the `archiver` library.
How can I resolve this? If you propose a fix, please make it concise.| } { | ||
| if (!homeDir) return { denyRead: [] }; |
There was a problem hiding this comment.
Empty deny list returned when
$HOME is unset, leaving the agent unsandboxed
buildFilesystemSandbox() is called from claude-agent-provider.ts with no arguments, so homeDir defaults to process.env.HOME. If HOME is unset — possible in certain Docker containers or stripped CI environments — the early return at line 10 yields { denyRead: [] }, which gives the Claude agent unrestricted filesystem read access. The per-platform deny lists for ~/.ssh, ~/.aws, ~/.gnupg, and browser profiles all become inaccessible because path construction depends on knowing the home directory. A comment explaining this intentional trade-off, or a log-level warning when HOME is absent, would make the behaviour explicit for operators deploying in non-standard environments.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/agents/providers/claude-agent-sandbox.ts
Line: 9-10
Comment:
**Empty deny list returned when `$HOME` is unset, leaving the agent unsandboxed**
`buildFilesystemSandbox()` is called from `claude-agent-provider.ts` with no arguments, so `homeDir` defaults to `process.env.HOME`. If `HOME` is unset — possible in certain Docker containers or stripped CI environments — the early return at line 10 yields `{ denyRead: [] }`, which gives the Claude agent unrestricted filesystem read access. The per-platform deny lists for `~/.ssh`, `~/.aws`, `~/.gnupg`, and browser profiles all become inaccessible because path construction depends on knowing the home directory. A comment explaining this intentional trade-off, or a log-level warning when `HOME` is absent, would make the behaviour explicit for operators deploying in non-standard environments.
How can I resolve this? If you propose a fix, please make it concise.- preload: use getPlatformInfo(process.platform) as the single source of truth
for the exposed platform object instead of duplicating the darwin checks
(keeps it in sync with PlatformInfo at compile time).
- renderer platform fallback: when window.api is absent (non-Electron/test
contexts), return getPlatformInfo("unknown") instead of the deprecated
navigator.platform, which reported the host OS and could mask platform bugs.
- zip: abort the archiver in the output "error" handler so it stops processing
entries in the background after the promise rejects (disk full / permissions).
- agent sandbox: document the no-HOME case (empty deny list = effectively
unsandboxed reads; only happens when HOME is unset).
typecheck, lint, and build all pass; preload bundle confirms the import inlines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
❌ Pre-PR verification — FAIL
Agentic verification — summaryAgentic verification — verify-diff
SummaryCould not connect to the Exo app via chrome-devtools MCP. The configured debug port 127.0.0.1:9222 has no listener; the only active Electron debug port (9223) exposes an empty target list (json/list returns [], CDP Target.getTargets returns no targetInfos). MCP list_pages returned an empty result with no selectable pages, making it impossible to take snapshots, click through UI flows, or verify the primary regression criteria (cross-platform keyboard shortcut display on Linux). Code review shows the platform-detection logic in src/shared/platform.ts and src/renderer/utils/platform.ts looks correct, but no live UI verification was possible. Anomalies
Agentic verification — literal traceFailuresagentic-verify — exit 3This comment is upserted by |
CI on Linux surfaced 5 failures, all from tests asserting on incidental,
macOS-specific DOM rather than stable identifiers of the feature under test.
They passed on macOS by coincidence (the brand element was always present):
- integration "app launches and shows main window": asserted text=Exo (the
titlebar brand, now gated to macOS) -> assert the always-visible Settings
control instead.
- compose "toolbar has formatting buttons": matched the Bold button by the
exact title "Bold (Cmd+B)", which is "Bold (Ctrl+B)" on Linux -> match the
modifier-agnostic title prefix "Bold (".
- sender-profile (x2) and draft-refinement: used page.locator("h1").first(),
which always matched the macOS titlebar <h1>Exo</h1> instead of the email
subject, so they never actually verified the detail rendered. Add a stable
data-testid="email-subject" hook to the subject <h1> (EmailDetail.tsx) and
target it directly — now correct on both platforms.
typecheck + lint pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
thanks! will try to get this in today. |
Simpler than the previous attempt: these tests used page.locator("h1").first()
as a loose "something rendered" check that only matched the macOS-only titlebar
<h1>Exo</h1>. Keep each test's exact flow and just swap that one brittle anchor
for an always-present element — no app change, no interaction rewrite:
- sender-profile (both tests): assert the always-visible Settings button.
- draft-refinement: read textContent from a [data-thread-id] list row.
Reverts the data-testid hook added to EmailDetail.tsx in the prior commit — not
needed with this approach. Verified locally (xvfb 1920x1080x24, --workers=1):
both spec files pass. typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
I wanted to actually run Exo on Linux, so I took a shot at porting it.
Summary
At the code level Exo already runs on Linux (CI runs the suite on Ubuntu); the gaps were really (a) packaging/distribution and (b) a handful of macOS-specific code paths. The guiding principle throughout was to stay additive — gate macOS behavior behind a platform check rather than change it — so nothing about the macOS experience changes.
Key design decisions
src/shared/platform.ts(getPlatformInfo→isMac/modifierKey/modifierSymbol), exposed it to the renderer viaapi.platformin the preload, and a renderer helper that falls back tonavigator.platformwhenwindow.apiis absent (so tests / non-Electron contexts don't crash).titleBarStyle: hiddenInset, traffic-light positioning, the "Exo" titlebar brand) is untouched on darwin; Linux simply gets a standard window frame. Same for the Cmd→Ctrl shortcut labels — only the display changed; the key handling already acceptedmetaKey || ctrlKey.MAIN_VITE_GOOGLE_CLIENT_ID/SECRET, which is already platform-agnostic — so the Linux release job just wires up the same secrets. No new auth surface.~/Librarydeny, and unknown platforms get a default-deny (never unsandboxed).ditto/unzip→archiver/extract-zipfor log export and extension install.Changes by area
src/shared/platform.ts,src/renderer/utils/platform.ts,api.platforminsrc/preload/index.ts.src/main/window.ts+App.tsx/SetupWizard/SettingsPanel/OfflineBanner/EmailPreviewSidebar— macOS titlebar/brand/traffic-light bits gated to darwin.src/main/agents/providers/claude-agent-sandbox.ts, consumed byclaude-agent-provider.ts.src/main/utils/zip.ts;settings.ipc.ts(log export) andextension-host.ts(extension install) updated.package.jsonadds LinuxAppImage/debtargets, a.desktopentry registeringx-scheme-handler/mailto, andarchiver/extract-zipdeps..github/workflows/release.ymlgains abuild-linuxjob (Ubuntu) using the same build secrets; installspython3-setuptoolsso node-gyp can rebuildbetter-sqlite3for Electron on Python 3.12 runners.--no-sandboxin the launchers and replace the macOS-onlytext=Exobrand anchors with platform-agnostic ones (Settings button /data-thread-id). Added alinux-platformunit spec.Testing
run-tests.shon Ubuntu withELECTRON_DISABLE_SANDBOX, so I'm treating CI here as the real arbiter and will chase anything it flags.Minor note (not changed here)
The README/
.env.examplesay to register redirect URIhttp://localhost, but the app actually useshttp://localhost:3847/oauth2callback— worth fixing for self-host setup.