{/* Titlebar */}
-
-
{/* Space for traffic lights */}
-
Exo Setup
-
+ {getRendererPlatform().isMac && (
+
+
{/* Space for traffic lights */}
+
Exo Setup
+
+ )}
{/* Content */}
diff --git a/src/renderer/components/UndoActionToast.tsx b/src/renderer/components/UndoActionToast.tsx
index 4a2b0493..a57859cb 100644
--- a/src/renderer/components/UndoActionToast.tsx
+++ b/src/renderer/components/UndoActionToast.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback } from "react";
import { useAppStore, type UndoActionItem } from "../store";
+import { modifierShortcut } from "../utils/platform";
// Map of item ID -> cancel function for keyboard shortcut access
const cancelHandlers = new Map void>();
@@ -346,7 +347,7 @@ function UndoActionToastItem({ item }: { item: UndoActionItem }) {
Undo
diff --git a/src/renderer/components/UndoSendToast.tsx b/src/renderer/components/UndoSendToast.tsx
index c9e784be..6fdbaa04 100644
--- a/src/renderer/components/UndoSendToast.tsx
+++ b/src/renderer/components/UndoSendToast.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useAppStore, type UndoSendItem } from "../store";
+import { modifierShortcut } from "../utils/platform";
// Map of item ID → cancel function, so the parent (or keyboard shortcut) can
// trigger a clean undo on any queued item without race conditions.
@@ -156,7 +157,7 @@ function UndoSendToastItem({ item }: { item: UndoSendItem }) {
Undo
diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts
index 7403f5ca..73411211 100644
--- a/src/renderer/hooks/useKeyboardShortcuts.ts
+++ b/src/renderer/hooks/useKeyboardShortcuts.ts
@@ -5,6 +5,7 @@ import { markNavigationActive } from "./useSyncBuffer";
import { mergeAndThreadSearchResults } from "../utils/searchResults";
import { draftMatchesSplit } from "../utils/split-conditions";
import { trackEvent } from "../services/posthog";
+import { modifierShortcut } from "../utils/platform";
declare global {
interface Window {
@@ -1186,7 +1187,7 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") {
: []),
{ key: "x", description: "Select / deselect thread" },
{ key: "Shift+J/K", description: "Extend selection down/up" },
- { key: "Cmd+A", description: "Select all threads" },
+ { key: modifierShortcut("A"), description: "Select all threads" },
],
compose: [
{ key: "c", description: "Compose new email" },
@@ -1196,13 +1197,13 @@ export function getKeyboardShortcuts(bindings: "superhuman" | "gmail") {
],
search: [
{ key: "/", description: "Open search" },
- { key: "Cmd+F", description: "Find in page" },
- { key: "Cmd+K", description: "Command palette" },
- { key: "Cmd+J", description: "Agent action palette" },
+ { key: modifierShortcut("F"), description: "Find in page" },
+ { key: modifierShortcut("K"), description: "Command palette" },
+ { key: modifierShortcut("J"), description: "Agent action palette" },
],
other: [
{ key: "b", description: "Switch sidebar tab" },
- { key: "Cmd+,", description: "Settings" },
+ { key: modifierShortcut(","), description: "Settings" },
{ key: "?", description: "Show shortcuts" },
],
};
diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts
new file mode 100644
index 00000000..cba91daa
--- /dev/null
+++ b/src/renderer/utils/platform.ts
@@ -0,0 +1,24 @@
+import {
+ formatModifierShortcut,
+ formatSymbolShortcut,
+ getPlatformInfo,
+ type PlatformInfo,
+} from "../../shared/platform";
+
+export function getRendererPlatform(): PlatformInfo {
+ const api = (window as { api?: { platform?: PlatformInfo } }).api;
+ if (api?.platform) return api.platform;
+ // The preload injects `api.platform` before the renderer runs, so this branch
+ // only hits non-Electron contexts (e.g. unit tests). Use a neutral platform
+ // rather than the deprecated navigator.platform, which reports the host OS and
+ // could mask platform-specific bugs (e.g. isMac=true when tests run on macOS).
+ return getPlatformInfo("unknown");
+}
+
+export function modifierShortcut(key: string): string {
+ return formatModifierShortcut(key, getRendererPlatform());
+}
+
+export function symbolShortcut(key: string): string {
+ return formatSymbolShortcut(key, getRendererPlatform());
+}
diff --git a/src/shared/platform.ts b/src/shared/platform.ts
new file mode 100644
index 00000000..92a77625
--- /dev/null
+++ b/src/shared/platform.ts
@@ -0,0 +1,26 @@
+export type RuntimePlatform = string;
+
+export interface PlatformInfo {
+ platform: RuntimePlatform;
+ isMac: boolean;
+ modifierKey: "Cmd" | "Ctrl";
+ modifierSymbol: "\u2318" | "Ctrl+";
+}
+
+export function getPlatformInfo(platform: RuntimePlatform): PlatformInfo {
+ const isMac = platform === "darwin" || /^mac/i.test(platform);
+ return {
+ platform,
+ isMac,
+ modifierKey: isMac ? "Cmd" : "Ctrl",
+ modifierSymbol: isMac ? "\u2318" : "Ctrl+",
+ };
+}
+
+export function formatModifierShortcut(key: string, platform: PlatformInfo): string {
+ return `${platform.modifierKey}+${key}`;
+}
+
+export function formatSymbolShortcut(key: string, platform: PlatformInfo): string {
+ return `${platform.modifierSymbol}${key}`;
+}
diff --git a/tests/e2e/compose.spec.ts b/tests/e2e/compose.spec.ts
index 3ade4822..1324ae9f 100644
--- a/tests/e2e/compose.spec.ts
+++ b/tests/e2e/compose.spec.ts
@@ -400,8 +400,9 @@ test.describe("Compose - Rich Text Editor", () => {
await composeButton.click();
await page.waitForTimeout(500);
- // Check for toolbar buttons - Bold button has title "Bold (Cmd+B)"
- const boldButton = page.locator("button[title='Bold (Cmd+B)']").first();
+ // Check for toolbar buttons. The Bold button's title is "Bold (Cmd+B)" on
+ // macOS and "Bold (Ctrl+B)" on Linux, so match the modifier-agnostic prefix.
+ const boldButton = page.locator("button[title^='Bold (']").first();
const hasBold = await boldButton.isVisible().catch(() => false);
// At minimum, some formatting options should exist
diff --git a/tests/e2e/draft-refinement.spec.ts b/tests/e2e/draft-refinement.spec.ts
index 180d88b4..3a2ae5f2 100644
--- a/tests/e2e/draft-refinement.spec.ts
+++ b/tests/e2e/draft-refinement.spec.ts
@@ -204,8 +204,9 @@ test.describe("Draft Generation - Multiple Emails", () => {
await page.keyboard.press("j");
await page.waitForTimeout(500);
- // The subject should change (or the detail view should update)
- const newSubject = await page.locator("h1").first().textContent();
+ // Some content should be rendered. (Previously h1.first() — which matched
+ // the macOS-only titlebar brand; read a list row instead.)
+ const newSubject = await page.locator("[data-thread-id]").first().textContent();
expect(newSubject).toBeTruthy();
});
});
diff --git a/tests/e2e/error-states.spec.ts b/tests/e2e/error-states.spec.ts
index 95326411..20fd8e08 100644
--- a/tests/e2e/error-states.spec.ts
+++ b/tests/e2e/error-states.spec.ts
@@ -72,8 +72,14 @@ test.describe("Error States - App Load", () => {
});
test("app title is visible", async () => {
- // The Exo title should be in the titlebar
- await expect(page.locator("text=Exo").first()).toBeVisible({ timeout: 5000 });
+ // The "Exo" brand renders only on macOS (hidden-inset titlebar). On other
+ // platforms there is no brand text, so assert the always-visible titlebar
+ // Settings control instead.
+ const anchor =
+ process.platform === "darwin"
+ ? page.locator("text=Exo").first()
+ : page.locator('button[aria-label="Settings"]');
+ await expect(anchor).toBeVisible({ timeout: 5000 });
});
});
@@ -343,7 +349,8 @@ test.describe("Error States - UI Resilience", () => {
// switches to full-view mode which hides the sidebar, so a previous
// assertion against `text=Inbox` would flake. The titlebar is always
// visible whatever the view mode.
- await expect(page.locator("h1").filter({ hasText: "Exo" })).toBeVisible({ timeout: 5000 });
+ // Always-visible titlebar control (the "Exo" brand h1 is macOS-only).
+ await expect(page.locator('button[aria-label="Settings"]')).toBeVisible({ timeout: 5000 });
}
});
@@ -363,7 +370,8 @@ test.describe("Error States - UI Resilience", () => {
// App should still be alive after refresh. Verify against the always-
// visible titlebar rather than the sidebar Inbox button, which is
// hidden when the previous keyboard actions land the app in full view.
- await expect(page.locator("h1").filter({ hasText: "Exo" })).toBeVisible({ timeout: 5000 });
+ // Always-visible titlebar control (the "Exo" brand h1 is macOS-only).
+ await expect(page.locator('button[aria-label="Settings"]')).toBeVisible({ timeout: 5000 });
}
});
});
diff --git a/tests/e2e/image-loading.spec.ts b/tests/e2e/image-loading.spec.ts
index 49c70aa2..47ecaa0c 100644
--- a/tests/e2e/image-loading.spec.ts
+++ b/tests/e2e/image-loading.spec.ts
@@ -35,8 +35,8 @@ test.describe("Image Loading in Emails", () => {
});
test("images load in HTML emails", async () => {
- // Wait for app and emails to load
- await page.waitForSelector("text=Exo", { timeout: 15000 });
+ // Wait for app and emails to load. (The "Exo" titlebar brand is macOS-only,
+ // so anchor on a known demo sender row instead.)
await page
.locator("button")
.filter({ hasText: /Garry|HR Team|Product Team/ })
diff --git a/tests/e2e/inbox-tabs.spec.ts b/tests/e2e/inbox-tabs.spec.ts
index 041044e2..6b89f87a 100644
--- a/tests/e2e/inbox-tabs.spec.ts
+++ b/tests/e2e/inbox-tabs.spec.ts
@@ -21,10 +21,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function launchWithoutTabSwitch(
workerIndex = 0,
): Promise<{ app: ElectronApplication; page: Page }> {
+ const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...baseEnv } = process.env;
const app = await electron.launch({
- args: [path.join(__dirname, "../../out/main/index.js")],
+ args: [
+ path.join(__dirname, "../../out/main/index.js"),
+ ...(process.platform === "linux" ? ["--no-sandbox"] : []),
+ ],
env: {
- ...process.env,
+ ...baseEnv,
NODE_ENV: "test",
EXO_DEMO_MODE: "true",
TEST_WORKER_INDEX: String(workerIndex),
@@ -33,7 +37,9 @@ async function launchWithoutTabSwitch(
const window = await app.firstWindow();
await window.waitForLoadState("domcontentloaded");
- await window.waitForSelector("text=Exo", { timeout: 15000 });
+ // The "Exo" titlebar brand is macOS-only; anchor on the always-visible
+ // Settings button so the load wait works on Linux too.
+ await window.locator('button[aria-label="Settings"]').waitFor({ timeout: 15000 });
return { app, page: window };
}
diff --git a/tests/e2e/launch-helpers.ts b/tests/e2e/launch-helpers.ts
index 49600ea5..445e428d 100644
--- a/tests/e2e/launch-helpers.ts
+++ b/tests/e2e/launch-helpers.ts
@@ -23,10 +23,14 @@ export async function launchElectronApp(
): Promise<{ app: ElectronApplication; page: Page }> {
const { workerIndex = 0, extraEnv = {}, waitAfterLoad } = options;
+ const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...baseEnv } = process.env;
const app = await electron.launch({
- args: [path.join(__dirname, "../../out/main/index.js")],
+ args: [
+ path.join(__dirname, "../../out/main/index.js"),
+ ...(process.platform === "linux" ? ["--no-sandbox"] : []),
+ ],
env: {
- ...process.env,
+ ...baseEnv,
NODE_ENV: "test",
EXO_DEMO_MODE: "true",
TEST_WORKER_INDEX: String(workerIndex),
@@ -36,7 +40,12 @@ export async function launchElectronApp(
const window = await app.firstWindow();
await window.waitForLoadState("domcontentloaded");
- await window.waitForSelector("text=Exo", { timeout: 15000 });
+ // Wait for the inbox shell to render. Use a platform-agnostic anchor — the
+ // "Exo" titlebar brand is only rendered on macOS.
+ await window
+ .getByRole("button", { name: /Inbox/ })
+ .first()
+ .waitFor({ state: "visible", timeout: 15000 });
// The app defaults to the Priority tab. Switch to "All" so tests see every
// email in the demo inbox (most tests search for specific emails by name).
diff --git a/tests/e2e/sender-profile.spec.ts b/tests/e2e/sender-profile.spec.ts
index aa4c0072..c52808cb 100644
--- a/tests/e2e/sender-profile.spec.ts
+++ b/tests/e2e/sender-profile.spec.ts
@@ -41,9 +41,11 @@ test.describe("Sender Profile - Display", () => {
await page.keyboard.press("j");
await page.waitForTimeout(500);
- // The email detail should show a subject line
- const h1 = page.locator("h1").first();
- await expect(h1).toBeVisible({ timeout: 5000 });
+ // App shell should be visible. (Previously h1.first() — which matched the
+ // macOS-only titlebar brand; use an always-present control instead.)
+ await expect(page.locator('button[aria-label="Settings"]').first()).toBeVisible({
+ timeout: 5000,
+ });
// Sender name should be visible in the detail view
// Demo emails have known senders
@@ -178,9 +180,12 @@ test.describe("Sender Profile - Switching Emails", () => {
await page.waitForTimeout(500);
- // App should still be responsive
- const h1 = page.locator("h1").first();
- await expect(h1).toBeVisible({ timeout: 5000 });
+ // App should still be responsive after rapid navigation. This flow only
+ // navigates the list (never opens an email), so assert an always-visible
+ // shell control rather than the detail subject.
+ await expect(page.locator('button[aria-label="Settings"]').first()).toBeVisible({
+ timeout: 5000,
+ });
// Navigate back up
for (let i = 0; i < 5; i++) {
@@ -189,7 +194,9 @@ test.describe("Sender Profile - Switching Emails", () => {
}
await page.waitForTimeout(500);
- await expect(h1).toBeVisible({ timeout: 5000 });
+ await expect(page.locator('button[aria-label="Settings"]').first()).toBeVisible({
+ timeout: 5000,
+ });
});
});
diff --git a/tests/e2e/snooze-removal.spec.ts b/tests/e2e/snooze-removal.spec.ts
index 6467ef1e..556599c2 100644
--- a/tests/e2e/snooze-removal.spec.ts
+++ b/tests/e2e/snooze-removal.spec.ts
@@ -37,11 +37,9 @@ test.describe("Snooze — email must leave inbox and cursor must advance", () =>
console.log(`[RENDERER ${msg.type()}]: ${msg.text()}`);
});
- // Wait for the app to fully load with emails
- await page.waitForSelector("text=Exo", { timeout: 15000 });
- // Priority pills were collapsed in issue #143 — wait on the stable
- // per-row data-thread-id attribute instead.
- await page.locator("[data-thread-id]").first().waitFor({ timeout: 10000 });
+ // Wait for the app to fully load with emails. The "Exo" titlebar brand is
+ // macOS-only, so anchor on the stable per-row data-thread-id attribute.
+ await page.locator("[data-thread-id]").first().waitFor({ timeout: 15000 });
});
test.afterAll(async () => {
diff --git a/tests/e2e/undo-send.spec.ts b/tests/e2e/undo-send.spec.ts
index 8d78b76a..1f94efa6 100644
--- a/tests/e2e/undo-send.spec.ts
+++ b/tests/e2e/undo-send.spec.ts
@@ -104,7 +104,8 @@ test.describe("Undo Send - Inline Reply", () => {
});
test("app loads with inbox emails", async () => {
- await expect(page.getByRole("heading", { name: "Exo" })).toBeVisible();
+ // "Exo" heading is macOS-only; assert the always-visible titlebar control.
+ await expect(page.locator('button[aria-label="Settings"]')).toBeVisible();
await expect(page.locator("text=Inbox").first()).toBeVisible();
await expect(page.locator("button").filter({ hasText: "Garry Tan" }).first()).toBeVisible({
timeout: 5000,
diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts
index 86f8a3e6..24e4287f 100644
--- a/tests/integration.spec.ts
+++ b/tests/integration.spec.ts
@@ -16,10 +16,14 @@ test.describe("Exo Integration Tests", () => {
test.beforeAll(async () => {
// Launch the Electron app in test mode
+ const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...baseEnv } = process.env;
electronApp = await electron.launch({
- args: [path.join(__dirname, "../out/main/index.js")],
+ args: [
+ path.join(__dirname, "../out/main/index.js"),
+ ...(process.platform === "linux" ? ["--no-sandbox"] : []),
+ ],
env: {
- ...process.env,
+ ...baseEnv,
NODE_ENV: "test",
EXO_TEST_MODE: "true",
},
@@ -66,9 +70,11 @@ test.describe("Exo Integration Tests", () => {
});
test("app launches and shows main window", async () => {
- // Verify the app title or header is visible
- const title = await page.locator("text=Exo").first();
- await expect(title).toBeVisible({ timeout: 10000 });
+ // The "Exo" brand is macOS-only; assert an always-visible titlebar control
+ // (Settings) to confirm the main window rendered on any platform.
+ await expect(page.locator('button[aria-label="Settings"]').first()).toBeVisible({
+ timeout: 10000,
+ });
});
test("shows setup wizard when not authenticated", async () => {
diff --git a/tests/packaged/smoke.spec.ts b/tests/packaged/smoke.spec.ts
index 94e0baad..59d9011f 100644
--- a/tests/packaged/smoke.spec.ts
+++ b/tests/packaged/smoke.spec.ts
@@ -35,10 +35,12 @@ test.describe("Packaged app smoke", () => {
let page: Page;
test.beforeAll(async () => {
+ const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...baseEnv } = process.env;
app = await electron.launch({
executablePath: BINARY,
+ args: process.platform === "linux" ? ["--no-sandbox"] : [],
env: {
- ...process.env,
+ ...baseEnv,
// Demo mode so the packaged app doesn't need OAuth / Gmail creds
// in CI. The packaging itself is what we're verifying, not
// real-Gmail behavior.
@@ -70,8 +72,10 @@ test.describe("Packaged app smoke", () => {
}
});
- test("app launches within 30s and shows the Exo brand", async () => {
- await expect(page.locator("text=Exo").first()).toBeVisible({ timeout: 30_000 });
+ test("app launches within 30s and shows the inbox shell", async () => {
+ await expect(page.getByRole("button", { name: /Inbox/ }).first()).toBeVisible({
+ timeout: 30_000,
+ });
});
test("no main-process crash in the first 10s", async () => {
diff --git a/tests/unit/linux-platform.spec.ts b/tests/unit/linux-platform.spec.ts
new file mode 100644
index 00000000..8b4b1f55
--- /dev/null
+++ b/tests/unit/linux-platform.spec.ts
@@ -0,0 +1,87 @@
+import { test, expect } from "@playwright/test";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
+import {
+ formatModifierShortcut,
+ formatSymbolShortcut,
+ getPlatformInfo,
+} from "../../src/shared/platform";
+import {
+ buildFilesystemSandbox,
+ buildPlatformSandboxGuidance,
+} from "../../src/main/agents/providers/claude-agent-sandbox";
+import { extractZipArchive, zipDirectory } from "../../src/main/utils/zip";
+
+test.describe("platform labels", () => {
+ test("uses macOS command labels on darwin", () => {
+ const platform = getPlatformInfo("darwin");
+ expect(platform.isMac).toBe(true);
+ expect(formatModifierShortcut("Enter", platform)).toBe("Cmd+Enter");
+ expect(formatSymbolShortcut("K", platform)).toBe("\u2318K");
+ });
+
+ test("uses control labels on linux", () => {
+ const platform = getPlatformInfo("linux");
+ expect(platform.isMac).toBe(false);
+ expect(formatModifierShortcut("Enter", platform)).toBe("Ctrl+Enter");
+ expect(formatSymbolShortcut("K", platform)).toBe("Ctrl+K");
+ });
+});
+
+test.describe("cross-platform zip helpers", () => {
+ test("creates and extracts a zip without platform shell tools", async () => {
+ const root = mkdtempSync(join(tmpdir(), "exo-zip-"));
+ const source = join(root, "source");
+ const extracted = join(root, "extracted");
+ const zipPath = join(root, "logs.zip");
+
+ try {
+ mkdirSync(join(source, "nested"), { recursive: true });
+ writeFileSync(join(source, "exo.log"), "hello logs");
+ writeFileSync(join(source, "nested", "trace.log"), "nested trace");
+
+ await zipDirectory(source, zipPath);
+ await extractZipArchive(zipPath, extracted);
+
+ expect(readFileSync(join(extracted, "exo.log"), "utf8")).toBe("hello logs");
+ expect(readFileSync(join(extracted, "nested", "trace.log"), "utf8")).toBe("nested trace");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
+
+test.describe("Claude agent platform sandbox", () => {
+ test("keeps macOS TCC-sensitive directories blocked with app data re-allowed", () => {
+ const sandbox = buildFilesystemSandbox("/Users/alice", "darwin");
+ expect(sandbox.denyRead).toContain("/Users/alice/Library");
+ expect(sandbox.denyRead).toContain("/Volumes");
+ expect(sandbox.allowRead).toContain("/Users/alice/Library/Application Support/exo");
+ expect(buildPlatformSandboxGuidance("darwin")).toContain("On macOS");
+ });
+
+ test("blocks sensitive Linux home-directory credential stores", () => {
+ const sandbox = buildFilesystemSandbox("/home/alice", "linux");
+ expect(sandbox.denyRead).toEqual(
+ expect.arrayContaining([
+ "/home/alice/.ssh",
+ "/home/alice/.gnupg",
+ "/home/alice/.aws",
+ "/home/alice/.local/share/keyrings",
+ // Browser profiles (parity with the macOS ~/Library deny).
+ "/home/alice/.mozilla",
+ "/home/alice/.config/google-chrome",
+ "/home/alice/.config/chromium",
+ ]),
+ );
+ expect(sandbox.allowRead).toBeUndefined();
+ expect(buildPlatformSandboxGuidance("linux")).toContain("On Linux");
+ });
+
+ test("falls back to default-deny (never unsandboxed) on an unknown platform", () => {
+ const sandbox = buildFilesystemSandbox("/home/alice", "freebsd");
+ expect(sandbox.denyRead).toContain("/home/alice/.ssh");
+ expect(sandbox.denyRead.length).toBeGreaterThan(0);
+ });
+});