diff --git a/apps/desktop/src/main/app-icon.ts b/apps/desktop/src/main/app-icon.ts index bc39cb54..40aef2b0 100644 --- a/apps/desktop/src/main/app-icon.ts +++ b/apps/desktop/src/main/app-icon.ts @@ -55,7 +55,9 @@ export function createAppIcon({ file, dir }: { file: string; dir: string }) { get: (): AppIconId => current, /** After `ready`, before the first window: applies the saved icon and keeps new windows on it. */ start() { - if (current !== DEFAULT_APP_ICON) { + // Linux windows have no icon of their own, so an unpackaged run (and X11 taskbars that read + // the window rather than its .desktop entry) would show Electron's; set the default there too. + if (current !== DEFAULT_APP_ICON || process.platform === "linux") { image = load(current); apply(); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 56cde6ff..42a7a860 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,7 +4,12 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { createInterface } from "node:readline"; -import type { DesktopBridge, MenuShortcuts, UpdateProgress } from "@gram/contracts"; +import { + APP_ICONS, + type DesktopBridge, + type MenuShortcuts, + type UpdateProgress, +} from "@gram/contracts"; import { createLogger, defaultDataDir, logDirFor } from "@gram/server/logging"; import { readUpdatePrefs } from "@gram/server/updates"; import { @@ -14,6 +19,7 @@ import { ipcMain, Menu, MessageChannelMain, + nativeImage, net, Notification, powerMonitor, @@ -71,7 +77,7 @@ import { APP_ID } from "./app-id.ts"; import { devAllowedOrigins, devBootUrl, logLevelFromMessage } from "./boot.ts"; import { appIconDir, createAppIcon } from "./app-icon.ts"; import { registerIpc } from "./ipc.ts"; -import { createLoginItem, startsHidden } from "./login-item.ts"; +import { createLoginItem, fileAutostart, startsHidden } from "./login-item.ts"; import { installSmokeIpc } from "./smoke-ipc.ts"; import { claimSingleInstance, @@ -171,8 +177,15 @@ const vaultStore = fileVaultStore(join(app.getPath("userData"), "gateway-keys.js const vaultCrypto = makeMacVaultCrypto( { available: () => safeStorage.isEncryptionAvailable(), + // Electron 45 no longer falls back to `basic_text` without a keyring daemon: it still names the + // desktop's keyring and only reports encryption unavailable, which the vault and Settings read + // as `unavailable` rather than as a keyring. backend: () => - process.platform === "linux" ? safeStorage.getSelectedStorageBackend() : undefined, + process.platform !== "linux" + ? undefined + : safeStorage.isEncryptionAvailable() + ? safeStorage.getSelectedStorageBackend() + : "unavailable", encrypt: (plainText: string) => safeStorage.encryptString(plainText), decrypt: (encrypted: Buffer) => safeStorage.decryptString(encrypted), }, @@ -376,6 +389,16 @@ const trayMode = createTrayMode({ path: join(app.getPath("userData"), "tray.json"), show: () => showWindow(), quit: () => app.quit(), + // Linux executables carry no icon for the tray to borrow; the bundled PNG, at a size every + // StatusNotifierItem host scales cleanly. + ...(process.platform === "linux" + ? { + icon: () => { + const image = nativeImage.createFromPath(join(appIconDir(), `${APP_ICONS[0]}.png`)); + return image.isEmpty() ? undefined : image.resize({ width: 64, height: 64 }); + }, + } + : {}), }); /** The quit, from the first ⌘Q to `app.exit`; see `quit.ts`. */ @@ -557,6 +580,15 @@ async function runApp() { packaged: app.isPackaged, argv: process.argv, openExternal: (url) => shell.openExternal(url), + // An AppImage runs from a mount that changes every launch; `$APPIMAGE` is the file itself. + ...(process.platform === "linux" + ? { + autostart: fileAutostart( + join(app.getPath("appData"), "autostart", "gram.desktop"), + process.env["APPIMAGE"] || process.execPath, + ), + } + : {}), }); if (loginItem.launchedAtLogin) log.info({ source: "login" }, "launched at login"); const notificationStatus = createNotificationStatus({ diff --git a/apps/desktop/src/main/login-item.test.ts b/apps/desktop/src/main/login-item.test.ts index 2c2f4e15..6825c454 100644 --- a/apps/desktop/src/main/login-item.test.ts +++ b/apps/desktop/src/main/login-item.test.ts @@ -13,10 +13,14 @@ vi.mock("electron", () => ({ vi.mock("@gram/shell/electron", () => ({})); import { registerIpc, type IpcOptions } from "./ipc.ts"; import { + autostartEnabled, + autostartEntry, createLoginItem, + desktopExecArg, LOGIN_ARG, LOGIN_ITEMS_SETTINGS_URL, startsHidden, + type Autostart, type LoginItemApi, } from "./login-item.ts"; @@ -58,6 +62,30 @@ const mac = (api: LoginItemApi, more: { packaged?: boolean; argv?: string[] } = openExternal: async () => {}, }); +const linux = (autostart: Autostart | undefined, packaged = true, argv: string[] = []) => + createLoginItem({ + api: fakeApi({}).api, + platform: "linux", + packaged, + argv, + openExternal: async () => {}, + ...(autostart === undefined ? {} : { autostart }), + }); +const memoryAutostart = (text?: string) => { + const box = { text }; + const autostart: Autostart = { + exec: "/opt/Gram/gram", + read: () => box.text, + write: (next) => { + box.text = next; + }, + remove: () => { + box.text = undefined; + }, + }; + return { box, autostart }; +}; + describe("createLoginItem", () => { it("is off by default and touches nothing until the user turns it on", () => { const { api } = fakeApi({}); @@ -128,16 +156,41 @@ describe("createLoginItem", () => { expect(create(["Gram.exe", LOGIN_ARG]).launchedAtLogin).toBe(true); }); - it("isn't offered on Linux or in a development run, which would register bare Electron", () => { + it("keeps Linux's item in an XDG autostart entry that marks a login launch", () => { + const { box, autostart } = memoryAutostart(); + const item = linux(autostart); + expect(item.state()).toEqual({ enabled: false, needsApproval: false }); + expect(item.set(true)).toEqual({ enabled: true, needsApproval: false }); + expect(box.text).toContain(`Exec=/opt/Gram/gram ${LOGIN_ARG}\n`); + expect(item.set(false)).toEqual({ enabled: false, needsApproval: false }); + expect(box.text).toBeUndefined(); + expect(linux(autostart, true, ["gram", LOGIN_ARG]).launchedAtLogin).toBe(true); + expect(linux(autostart, true, ["gram"]).launchedAtLogin).toBe(false); + }); + + it("follows an entry the desktop's own settings switched off", () => { + const on = autostartEntry("/opt/Gram/gram"); + expect(autostartEnabled(on)).toBe(true); + expect(autostartEnabled(undefined)).toBe(false); + expect(autostartEnabled(`${on}Hidden=true\n`)).toBe(false); + expect(autostartEnabled(on.replace("enabled=true", "enabled=false"))).toBe(false); + }); + + it("quotes the executable as a desktop entry's Exec key reads it back", () => { + expect(desktopExecArg("/opt/Gram/gram")).toBe("/opt/Gram/gram"); + expect(desktopExecArg("/home/me/My Apps/Gram.AppImage")).toBe( + '"/home/me/My Apps/Gram.AppImage"', + ); + expect(desktopExecArg("/home/me/100%/Gram")).toBe("/home/me/100%%/Gram"); + expect(desktopExecArg('/a b/$c"d`e')).toBe('"/a b/\\\\$c\\\\"d\\\\`e"'); + expect(() => desktopExecArg("/a\nHidden=true")).toThrow(); + }); + + it("isn't offered in a development run, which would register bare Electron", () => { for (const item of [ mac(fakeApi({ wasOpenedAtLogin: true }).api, { packaged: false }), - createLoginItem({ - api: fakeApi({}).api, - platform: "linux", - packaged: true, - argv: [LOGIN_ARG], - openExternal: async () => {}, - }), + linux(memoryAutostart().autostart, false, [LOGIN_ARG]), + linux(undefined, true, [LOGIN_ARG]), ]) { expect(item.state()).toBeNull(); expect(item.set(true)).toBeNull(); @@ -176,6 +229,8 @@ describe("startsHidden", () => { // No tray icon, and a hidden window has no taskbar button: it would be unreachable. expect(startsHidden("win32", true, false)).toBe(false); expect(startsHidden("darwin", false, true)).toBe(false); + expect(startsHidden("linux", true, true)).toBe(true); + expect(startsHidden("linux", true, false)).toBe(false); }); }); diff --git a/apps/desktop/src/main/login-item.ts b/apps/desktop/src/main/login-item.ts index 32375a4b..719896aa 100644 --- a/apps/desktop/src/main/login-item.ts +++ b/apps/desktop/src/main/login-item.ts @@ -4,10 +4,16 @@ // bundle identifier and team rather than one build. On Windows it is the run key, given an argument // so a login launch can tell itself apart. Nothing here registers or removes the item at startup, // so an update never undoes the user's choice, and one removed in System Settings stays removed. +// Linux has no API for it: there it is an XDG autostart entry (`~/.config/autostart/gram.desktop`) +// that Gram writes and removes itself, which GNOME, KDE and the other XDG desktops run at login and +// list in their own autostart settings. + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import type { LaunchAtLogin } from "@gram/contracts"; -/** The argument a Windows login launch carries; macOS reports a login launch itself. */ +/** The argument a Windows or Linux login launch carries; macOS reports a login launch itself. */ export const LOGIN_ARG = "--launched-at-login"; /** macOS System Settings → General → Login Items. */ @@ -24,6 +30,70 @@ export interface LoginItemApi { setLoginItemSettings(settings: { readonly openAtLogin: boolean; readonly args?: string[] }): void; } +/** Linux's login item: an XDG autostart entry, and what it launches. */ +export interface Autostart { + /** The executable to start: the AppImage itself (`$APPIMAGE`), else the installed binary. */ + readonly exec: string; + /** The entry's text; undefined when there is none. */ + read(): string | undefined; + write(text: string): void; + remove(): void; +} + +/** + * One argument of a desktop entry's `Exec` key, as the Desktop Entry spec reads it back. `%` is + * doubled everywhere, since field codes start with it. Anything beyond plain path characters is + * double-quoted, with `"`, `` ` ``, `$` and `\` backslash-escaped inside; the key is also a string + * value, whose own escapes are undone first, so every backslash is then doubled once more. + */ +export const desktopExecArg = (arg: string): string => { + // A line break would end the key, and the line after it could be any key at all. + // oxlint-disable-next-line no-control-regex -- control characters are what this refuses + if (/[\u0000-\u001f\u007f]/.test(arg)) + throw new Error("the app's path can't be started at login"); + const percent = arg.replaceAll("%", "%%"); + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(percent)) return percent; + return `"${percent.replace(/(["`$\\])/g, "\\$1")}"`.replaceAll("\\", "\\\\"); +}; + +/** The autostart entry that starts `exec` at login, marked as a login launch. */ +export const autostartEntry = (exec: string): string => + [ + "[Desktop Entry]", + "Type=Application", + "Name=Gram", + "Comment=Starts Gram when you log in", + `Exec=${desktopExecArg(exec)} ${LOGIN_ARG}`, + "Icon=gram", + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "", + ].join("\n"); + +/** Whether an autostart entry runs at login: there, and not hidden or switched off by the desktop. */ +export const autostartEnabled = (text: string | undefined): boolean => + text !== undefined && + /^\[Desktop Entry\]/m.test(text) && + !/^Hidden\s*=\s*true\s*$/im.test(text) && + !/^X-GNOME-Autostart-enabled\s*=\s*false\s*$/im.test(text); + +/** The autostart entry at `path`, read and written synchronously in main. */ +export const fileAutostart = (path: string, exec: string): Autostart => ({ + exec, + read: () => { + try { + return readFileSync(path, "utf8"); + } catch { + return undefined; + } + }, + write: (text) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + }, + remove: () => rmSync(path, { force: true }), +}); + export interface LoginItem { /** The setting; null where it isn't offered. */ state(): LaunchAtLogin | null; @@ -35,9 +105,9 @@ export interface LoginItem { } /** - * The login item for an installed app on macOS or Windows. A development run would register the - * bare Electron binary, and Linux has no API for it, so neither offers the setting; a login launch - * there is never assumed. + * The login item for an installed app on macOS or Windows, or the autostart entry on Linux. A + * development run would register the bare Electron binary, so it doesn't offer the setting, and + * neither does Linux without an `autostart`; a login launch there is never assumed. */ export function createLoginItem(deps: { readonly api: LoginItemApi; @@ -45,13 +115,20 @@ export function createLoginItem(deps: { readonly packaged: boolean; readonly argv: readonly string[]; readonly openExternal: (url: string) => Promise; + /** Linux's autostart entry; absent elsewhere. */ + readonly autostart?: Autostart; }): LoginItem { const { api, platform } = deps; - const supported = deps.packaged && (platform === "darwin" || platform === "win32"); + const autostart = platform === "linux" ? deps.autostart : undefined; + const supported = + deps.packaged && (platform === "darwin" || platform === "win32" || autostart !== undefined); const args = platform === "win32" ? { args: [LOGIN_ARG] } : {}; const state = (): LaunchAtLogin | null => { if (!supported) return null; + if (autostart !== undefined) { + return { enabled: autostartEnabled(autostart.read()), needsApproval: false }; + } const settings = api.getLoginItemSettings(args); const needsApproval = platform === "darwin" && settings.status === "requires-approval"; // Registered but waiting for approval doesn't launch anything yet; the row says so. @@ -70,7 +147,12 @@ export function createLoginItem(deps: { state, set(enabled) { if (!supported) return null; - api.setLoginItemSettings({ openAtLogin: enabled, ...args }); + if (autostart !== undefined) { + if (enabled) autostart.write(autostartEntry(autostart.exec)); + else autostart.remove(); + } else { + api.setLoginItemSettings({ openAtLogin: enabled, ...args }); + } return state(); }, launchedAtLogin, @@ -88,8 +170,8 @@ export function createLoginItem(deps: { /** * Whether a launch starts with its windows hidden: a login launch with a way back. On macOS the - * Dock icon (and the menu bar item, when shown) is one; on Windows a hidden window leaves the - * taskbar, so only the tray icon is, and without it the windows show as usual. + * Dock icon (and the menu bar item, when shown) is one; on Windows and Linux a hidden window leaves + * the taskbar, so only the tray icon is, and without it the windows show as usual. */ export const startsHidden = ( platform: NodeJS.Platform, diff --git a/apps/web/src/composer/model-picker.tsx b/apps/web/src/composer/model-picker.tsx index 0da9db91..809ff078 100644 --- a/apps/web/src/composer/model-picker.tsx +++ b/apps/web/src/composer/model-picker.tsx @@ -2,6 +2,7 @@ import { Combobox } from "@base-ui/react/combobox"; import { CheckIcon, ChevronDownIcon, Search, Star } from "lucide-react"; import { useRef, type ReactNode } from "react"; +import { keyLabel } from "@/keyboard/registry"; import { cn } from "@/lib/utils"; import { matchesModel, modelGroups, type ModelGroup } from "./models"; @@ -172,7 +173,9 @@ export function ModelCombobox({ : `Add ${option.label} to favourites` } aria-pressed={starred} - title={starred ? "Remove from favourites (⇧↵)" : "Favourite (⇧↵)"} + title={keyLabel( + starred ? "Remove from favourites (⇧↵)" : "Favourite (⇧↵)", + )} className={cn( "grid size-5 shrink-0 place-items-center rounded-sm text-muted-foreground hover:text-foreground", !starred && diff --git a/apps/web/src/desktop/use-desktop-bridge.ts b/apps/web/src/desktop/use-desktop-bridge.ts index ce3ac409..3ac878be 100644 --- a/apps/web/src/desktop/use-desktop-bridge.ts +++ b/apps/web/src/desktop/use-desktop-bridge.ts @@ -184,7 +184,8 @@ export function useDesktopBridge() { bridge.setVibrancy(isTranslucentLook(state.look)); const vibrancy = vibrancyAttribute( state.look, - true, + // Linux has no vibrancy to show through: the look paints its own backdrop there. + bridge.platform !== "linux", window.matchMedia?.("(prefers-reduced-transparency: reduce)").matches ?? false, ); if (vibrancy) document.documentElement.dataset["vibrancy"] = vibrancy; diff --git a/apps/web/src/keyboard/catalog.ts b/apps/web/src/keyboard/catalog.ts index 4624144b..7807ff40 100644 --- a/apps/web/src/keyboard/catalog.ts +++ b/apps/web/src/keyboard/catalog.ts @@ -12,6 +12,15 @@ export interface ShortcutDefinition { experimental?: keyof ExperimentalSettings; } +/** + * The chord that moves pane focus towards `key`. GNOME keeps Ctrl+Alt+arrows for switching + * workspaces, so the window never sees them; the Linux app uses Alt+Shift+arrows. + */ +export const paneMoveKeys = ( + key: string, + platform: string | undefined = typeof window === "undefined" ? undefined : window.gram?.platform, +): string => (platform === "linux" ? `alt+shift+${key}` : `mod+alt+${key}`); + const paneMove = ( [ ["left", "arrowleft"], @@ -21,7 +30,7 @@ const paneMove = ( ] as const ).map(([direction, key]): ShortcutDefinition => ({ id: `pane.move-${direction}`, - keys: `mod+alt+${key}`, + keys: paneMoveKeys(key), label: `Move focus ${direction}`, group: "Navigation", })); diff --git a/apps/web/src/keyboard/registry-capture.test.ts b/apps/web/src/keyboard/registry-capture.test.ts index dc575b70..0e2cb135 100644 --- a/apps/web/src/keyboard/registry-capture.test.ts +++ b/apps/web/src/keyboard/registry-capture.test.ts @@ -1,7 +1,35 @@ // @vitest-environment happy-dom import { describe, expect, it } from "vitest"; -import { formatKeys, keysFromEvent, matches } from "./registry"; +import { paneMoveKeys } from "./catalog"; +import { formatKeys, keyLabel, keysFromEvent, matches } from "./registry"; + +describe("keyLabel", () => { + it("leaves macOS's glyphs as they are", () => { + expect(keyLabel("Close (⌘\\)", true)).toBe("Close (⌘\\)"); + expect(keyLabel("⌘⇧T brings it back", true)).toBe("⌘⇧T brings it back"); + }); + + it("spells keys out elsewhere, in the order formatKeys uses", () => { + expect(keyLabel("⌘⇧T brings it back", false)).toBe("Ctrl+Shift+T brings it back"); + expect(keyLabel("⌥⌘N", false)).toBe(formatKeys("mod+alt+n", false)); + expect(keyLabel("Close (⌘\\)", false)).toBe("Close (Ctrl+\\)"); + expect(keyLabel("⌘⏎", false)).toBe("Ctrl+Enter"); + expect(keyLabel("Favourite (⇧↵)", false)).toBe("Favourite (Shift+Enter)"); + expect(keyLabel("⌥-click", false)).toBe("Alt-click"); + expect(keyLabel("⌘-click", false)).toBe("Ctrl-click"); + expect(keyLabel("⏎ open", false)).toBe("Enter open"); + }); +}); + +describe("paneMoveKeys", () => { + it("avoids GNOME's Ctrl+Alt+arrows on Linux only", () => { + expect(paneMoveKeys("arrowleft", "linux")).toBe("alt+shift+arrowleft"); + expect(paneMoveKeys("arrowleft", "darwin")).toBe("mod+alt+arrowleft"); + expect(paneMoveKeys("arrowleft", "win32")).toBe("mod+alt+arrowleft"); + expect(paneMoveKeys("arrowleft", undefined)).toBe("mod+alt+arrowleft"); + }); +}); const press = (init: KeyboardEventInit & { key: string; code: string }) => new KeyboardEvent("keydown", init); diff --git a/apps/web/src/keyboard/registry.ts b/apps/web/src/keyboard/registry.ts index 5f0cea0d..9f6d8c7c 100644 --- a/apps/web/src/keyboard/registry.ts +++ b/apps/web/src/keyboard/registry.ts @@ -162,6 +162,27 @@ const GLYPHS: Record = { tab: "⇥", }; +/** + * A key written the macOS way in a sentence, for this platform: unchanged on macOS, and elsewhere + * spelled out as `formatKeys` does, so `⌘⇧T` reads `Ctrl+Shift+T` and `⌥-click` reads `Alt-click`. + * For hints with a fixed key; a rebindable shortcut's hint comes from `useShortcutHint`. + */ +export function keyLabel(text: string, mac = isMac()): string { + if (mac) return text; + return text + .replace(/([⌃⌘⌥⇧]+)([A-Za-z0-9\\/⏎↵]?)/g, (_match, mods: string, key: string) => + [ + /[⌘⌃]/.test(mods) && "Ctrl", + mods.includes("⌥") && "Alt", + mods.includes("⇧") && "Shift", + key === "" ? false : /[⏎↵]/.test(key) ? "Enter" : key.toUpperCase(), + ] + .filter(Boolean) + .join("+"), + ) + .replace(/[⏎↵]/g, "Enter"); +} + /** Off macOS, keys are spelled out as Windows labels them. */ const WORDS: Record = { enter: "Enter", diff --git a/apps/web/src/orchestration/beside-dock.tsx b/apps/web/src/orchestration/beside-dock.tsx index 6ccf7b6c..0e866adc 100644 --- a/apps/web/src/orchestration/beside-dock.tsx +++ b/apps/web/src/orchestration/beside-dock.tsx @@ -5,6 +5,7 @@ import * as m from "motion/react-m"; import { Maximize2, X } from "lucide-react"; import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { keyLabel } from "@/keyboard/registry"; import { Hint } from "@/components/hint"; import { cn } from "@/lib/utils"; import { SegmentSlab, Swap, useMotionLevel, usePresetTransition } from "@/motion"; @@ -128,7 +129,7 @@ function DockBody({ worker }: { worker: ThreadId }) { + keyLabel( + "Discard this dream? What it wrote and its report are deleted, and memory stays as it is. Its thread is closed too, with the folder it ran in, so ⌘⇧T brings back only its conversation to read.", + ); const dateRange = (oldest: number | undefined, newest: number | undefined): string => oldest === undefined || newest === undefined @@ -1065,7 +1068,7 @@ function Review({ }); }} > - {DISCARD_WARNING} + {discardWarning()} ); @@ -1204,7 +1207,7 @@ function DreamRow({ onCancel={() => setDiscarding(false)} onConfirm={() => run(() => api.discardDream(dream.id))} > - {DISCARD_WARNING} + {discardWarning()} {error !== undefined && (

diff --git a/apps/web/src/orchestration/overview-sidebar.tsx b/apps/web/src/orchestration/overview-sidebar.tsx index 47ae728f..8ee113f5 100644 --- a/apps/web/src/orchestration/overview-sidebar.tsx +++ b/apps/web/src/orchestration/overview-sidebar.tsx @@ -24,6 +24,7 @@ import { useShallow } from "zustand/react/shallow"; import { AgentIcon, agentLabel } from "@/agents/agent-display"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { keyLabel } from "@/keyboard/registry"; import { useShortcutHint, withHint } from "@/keyboard/use-shortcut-hint"; import { formatClockTime, formatRelative } from "@/lib/time"; import { useNow } from "@/lib/use-now"; @@ -876,8 +877,8 @@ function WorkerCard({ )}

Click · open - ⌥-click · peek - ⌘-click · beside + {keyLabel("⌥-click")} · peek + {keyLabel("⌘-click")} · beside

); diff --git a/apps/web/src/orchestration/worker-header.tsx b/apps/web/src/orchestration/worker-header.tsx index 90d2fa52..7657d6dd 100644 --- a/apps/web/src/orchestration/worker-header.tsx +++ b/apps/web/src/orchestration/worker-header.tsx @@ -5,7 +5,7 @@ import { ChevronRight, Hand, PanelRight } from "lucide-react"; import { Hint } from "@/components/hint"; import { focusOtherWindow } from "@/desktop/open-thread"; -import { useShortcut } from "@/keyboard/registry"; +import { keyLabel, useShortcut } from "@/keyboard/registry"; import { useNow } from "@/lib/use-now"; import { cn } from "@/lib/utils"; import { GlideText, Swap } from "@/motion"; @@ -290,7 +290,7 @@ export function WorkerActions({ <> {actions.keepBeside !== undefined && ( - ⏎ open + {keyLabel("⏎")} open - ⌘⏎ keep beside + {keyLabel("⌘⏎")} keep beside diff --git a/apps/web/src/panels/diff-review.tsx b/apps/web/src/panels/diff-review.tsx index e2cc4b8a..108c21be 100644 --- a/apps/web/src/panels/diff-review.tsx +++ b/apps/web/src/panels/diff-review.tsx @@ -2,6 +2,7 @@ import type { ThreadId } from "@gram/contracts"; import { MessageSquare, MessageSquarePlus, X } from "lucide-react"; import { createContext, useContext, useMemo, useState, type KeyboardEvent } from "react"; +import { keyLabel } from "@/keyboard/registry"; import { Button } from "@/components/ui/button"; import { commentLabel, @@ -179,7 +180,7 @@ export function CommentEditorRow({ diff --git a/apps/web/src/panels/terminal/keys.ts b/apps/web/src/panels/terminal/keys.ts index 733b7c3c..e3b2877e 100644 --- a/apps/web/src/panels/terminal/keys.ts +++ b/apps/web/src/panels/terminal/keys.ts @@ -26,6 +26,8 @@ export function terminalClipboardKey( */ const answeredByApp = (shortcut: Shortcut, keys: string, mac: boolean): boolean => { const chord = parseKeys(keys); + // Linux's pane moves have no Ctrl (`paneMoveKeys`), and still have to get out of a terminal. + if (chord.alt && chord.shift && shortcut.id.startsWith("pane.move-")) return true; if (mac) return chord.mod || (chord.ctrl && (chord.shift || chord.alt || chord.key === "tab")); if (!chord.mod && !chord.ctrl) return false; if (chord.shift || chord.alt) return true; diff --git a/apps/web/src/settings/appearance-section.tsx b/apps/web/src/settings/appearance-section.tsx index 76c929aa..5a40878a 100644 --- a/apps/web/src/settings/appearance-section.tsx +++ b/apps/web/src/settings/appearance-section.tsx @@ -205,8 +205,10 @@ const APP_ICON_NAMES: Record = { glass: "Runes on glass", }; -const APP_ICON_HINT = - "Shows in the Dock or taskbar while Gram runs. Finder and the Start menu keep the Gift rune."; +const appIconHint = (platform: string | undefined) => + platform === "linux" + ? "Shows in the taskbar while Gram runs. The app launcher keeps the Gift rune." + : "Shows in the Dock or taskbar while Gram runs. Finder and the Start menu keep the Gift rune."; /** The desktop app's Dock or taskbar icon; absent in a browser or a shell older than it. */ export function AppIconControl() { @@ -223,7 +225,11 @@ export function AppIconControl() { }); if (bridge === undefined) return null; return ( - +
{APP_ICONS.map((icon) => ( @@ -554,16 +580,19 @@ function ImportKey({ profile, vault }: { profile: GatewayProfile; vault: VaultIn
) : (
- + + + {importKey.data?.imported === false && ( {OPENCODE} has no key for this gateway. diff --git a/apps/web/src/shell/global-shortcuts.ts b/apps/web/src/shell/global-shortcuts.ts index 738bf963..a097fe72 100644 --- a/apps/web/src/shell/global-shortcuts.ts +++ b/apps/web/src/shell/global-shortcuts.ts @@ -2,7 +2,8 @@ import type { ThreadId } from "@gram/contracts"; import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { useEffect } from "react"; -import { registerShortcut, useShortcut } from "@/keyboard/registry"; +import { paneMoveKeys } from "@/keyboard/catalog"; +import { keyLabel, registerShortcut, useShortcut } from "@/keyboard/registry"; import { besideToggle, closeBeside, keepBeside } from "@/orchestration/beside-store"; import { boardWorkspaceFor } from "@/orchestration/board-model"; import { newEntryRoute } from "@/orchestration/new-entry"; @@ -272,8 +273,8 @@ export function useGlobalShortcuts() { const moveFocus = FOCUS_KEYS.map(([direction, key]) => registerShortcut({ id: `pane.move-${direction}`, - keys: `mod+alt+${key}`, - label: "Move focus between panes (⌥ arrows)", + keys: paneMoveKeys(key), + label: `Move focus between panes (${window.gram?.platform === "linux" ? "Alt+Shift" : keyLabel("⌥")} arrows)`, group: "Navigation", hidden: direction !== "left", when: gridShown, diff --git a/apps/web/src/whats-new/release-notes.test.tsx b/apps/web/src/whats-new/release-notes.test.tsx index aa0a4106..4fa8200b 100644 --- a/apps/web/src/whats-new/release-notes.test.tsx +++ b/apps/web/src/whats-new/release-notes.test.tsx @@ -9,7 +9,7 @@ afterEach(cleanup); describe("HighlightBody", () => { it("renders spans as key caps and keeps any other markup as text", () => { const { container } = render( - , + , ); expect([...container.querySelectorAll("kbd")].map((kbd) => kbd.textContent)).toEqual([ "⌘T", @@ -18,4 +18,11 @@ describe("HighlightBody", () => { expect(container.querySelector("b")).toBeNull(); expect(container.textContent).toBe("Press ⌘T or Ctrl+Tab, not bold."); }); + + it("spells the keys out off macOS", () => { + const { container } = render( + , + ); + expect(container.textContent).toBe("Ctrl+Alt+N and Ctrl-click"); + }); }); diff --git a/apps/web/src/whats-new/release-notes.tsx b/apps/web/src/whats-new/release-notes.tsx index 204246db..f9068491 100644 --- a/apps/web/src/whats-new/release-notes.tsx +++ b/apps/web/src/whats-new/release-notes.tsx @@ -1,9 +1,11 @@ +import { keyLabel } from "@/keyboard/registry"; + import { highlightsFor, type WhatsNewAudience, type WhatsNewRelease } from "./index"; const KBD = /^([^<]*)<\/kbd>$/; /** A highlight body: `…` spans become key caps; everything else stays text, never HTML. */ -export function HighlightBody({ text }: { text: string }) { +export function HighlightBody({ text, mac }: { text: string; mac?: boolean }) { return ( <> {text.split(/([^<]*<\/kbd>)/).map((part, i) => { @@ -13,7 +15,7 @@ export function HighlightBody({ text }: { text: string }) { key={i} className="rounded-sm border border-border bg-muted px-1 font-mono text-[0.85em] text-foreground" > - {key[1]} + {keyLabel(key[1] ?? "", mac)} ) : ( part diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3fcf07ab..39c8f97c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,6 +30,13 @@ headers. Live port discovery also needs `lsof` and `ps` on PATH. The exact root are trusted for dependency lifecycle scripts. Correct a failed hook before claiming native loading; a no-op install need not rerun it. No blanket script trust. +On a Linux desktop the app keeps the desktop's window frame, and the menu bar shows on Alt. +Gateway keys need a system keyring: GNOME Keyring or another Secret Service, or KWallet. Without +one Gram refuses to store them and Settings says why; it never falls back to `basic_text`. File +watching uses inotify. If `fs.inotify.max_user_watches` is reached, git state for the affected +checkouts stops updating live and the server log says which limit to raise. Voice input isn't +available on Linux yet. + Run from the checkout's physical directory (`pwd -P`). In particular, macOS `/tmp` resolves to `/private/tmp`; passing another checkout's absolute tsconfig to `tsc -b` can lose project-reference redirects. Use the root scripts in the diff --git a/packages/agents/src/claude/environment.ts b/packages/agents/src/claude/environment.ts index e780912b..45c2ed22 100644 --- a/packages/agents/src/claude/environment.ts +++ b/packages/agents/src/claude/environment.ts @@ -104,7 +104,10 @@ export const ClaudeEnvironmentLive = ( const probe = Effect.gen(function* () { if (process.platform === "win32") return { env: inherited, warnings: [] }; - const shell = config.shell ?? process.env["SHELL"] ?? "/bin/zsh"; + const shell = + config.shell ?? + process.env["SHELL"] ?? + (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); return yield* runCommand(shell, ["-ilc", PROBE_SCRIPT], { timeoutMs: probeTimeoutMs, isolateSession: true, diff --git a/packages/agents/src/codex/servers.ts b/packages/agents/src/codex/servers.ts index 2a23ad11..20ac89c0 100644 --- a/packages/agents/src/codex/servers.ts +++ b/packages/agents/src/codex/servers.ts @@ -348,7 +348,9 @@ export const CodexServersLive = ( process.platform === "win32" ? { env: inherited } : yield* runCommand( - config.shell ?? process.env["SHELL"] ?? "/bin/zsh", + config.shell ?? + process.env["SHELL"] ?? + (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"), ["-ilc", probeScript("codex")], { timeoutMs: 5_000, isolateSession: true }, ).pipe( diff --git a/packages/contracts/src/credential-paths.test.ts b/packages/contracts/src/credential-paths.test.ts index b59d0681..98205e17 100644 --- a/packages/contracts/src/credential-paths.test.ts +++ b/packages/contracts/src/credential-paths.test.ts @@ -53,6 +53,11 @@ describe("isCredentialPath", () => { "/Users/me/.config/gh/hosts.yml", "/Users/me/.config/gcloud/application_default_credentials.json", "C:\\Users\\me\\.azure\\msal_token_cache.json", + "/home/me/.local/share/keyrings/login.keyring", + "/home/me/.local/share/kwalletd/kdewallet.kwl", + "/home/me/.pki/nssdb/cert9.db", + "/home/me/.claude/.credentials.json", + "/home/me/.codex/auth.json", ]) { expect(isCredentialPath(path), path).toBe(true); } diff --git a/packages/contracts/src/credential-paths.ts b/packages/contracts/src/credential-paths.ts index 9fcd4552..74201e31 100644 --- a/packages/contracts/src/credential-paths.ts +++ b/packages/contracts/src/credential-paths.ts @@ -56,6 +56,11 @@ const CREDENTIAL_FOLDERS = [ [".config", "gcloud"], [".config", "gh"], ["Library", "Keychains"], + // Linux's keychains: GNOME Keyring's files, KWallet's, and the NSS database Chromium-based apps + // keep certificates and client keys in. + [".local", "share", "keyrings"], + [".local", "share", "kwalletd"], + [".pki", "nssdb"], ] as const; /** Files that hold an agent's login only in their own folder: Codex's and OpenCode's. */ diff --git a/packages/contracts/src/gateway-keys.ts b/packages/contracts/src/gateway-keys.ts index dc45f5fc..a2968d22 100644 --- a/packages/contracts/src/gateway-keys.ts +++ b/packages/contracts/src/gateway-keys.ts @@ -1,13 +1,40 @@ import * as Schema from "effect/Schema"; +/** + * The `safeStorage` backends on Linux that encrypt with a real keyring: the Secret Service (GNOME + * Keyring, KeePassXC and others) and KWallet. `basic_text`, its fallback without one, encrypts with + * a key hard-coded in Chromium, which is plaintext to anyone who can read the file. + */ +export const KEYRING_BACKENDS: ReadonlySet = new Set([ + "gnome_libsecret", + "kwallet", + "kwallet5", + "kwallet6", +]); + +/** + * Whether gateway keys may be stored under `backend`: wherever `safeStorage` names none (macOS, + * Windows), and on Linux only with a real keyring. `unknown`, before `ready` or from a backend + * Electron added since, is refused too: a key is never stored where it isn't known to be encrypted. + */ +export const keyringBackendOk = (backend: string | undefined): boolean => + backend === undefined || KEYRING_BACKENDS.has(backend); + +/** Why a key can't be saved on Linux without a keyring, for the vault's error and for Settings. */ +export const NO_KEYRING_MESSAGE = + "No system keyring is available, so Gram won't store API keys. Install or unlock GNOME Keyring " + + "or KWallet (on other desktops, start Gram with --password-store=gnome-libsecret), then restart " + + "Gram."; + /** Where gateway keys are kept, so Settings can say it plainly. */ export const VaultInfo = Schema.Struct({ /** * What protects keys under Electron `safeStorage`: the macOS Keychain, Windows DPAPI for the - * user's account, or a Linux keyring. `unencrypted` when Linux has no keyring and `safeStorage` - * falls back to `basic_text`; `memory` in browser dev, where nothing is saved. + * user's account, or a Linux keyring. `no-keyring` when Linux has none, where keys aren't stored + * at all (never in `basic_text`, which doesn't encrypt); `memory` in browser dev, where nothing + * is saved. */ - kind: Schema.Literals(["keychain", "dpapi", "libsecret", "unencrypted", "memory"]), + kind: Schema.Literals(["keychain", "dpapi", "libsecret", "no-keyring", "memory"]), /** False when keys are lost once the server stops (browser dev without the desktop app). */ persisted: Schema.Boolean, /** The store in words, e.g. "the macOS Keychain". */ diff --git a/packages/git/src/proc.ts b/packages/git/src/proc.ts index c7f4c0b7..5ce4638b 100644 --- a/packages/git/src/proc.ts +++ b/packages/git/src/proc.ts @@ -291,7 +291,7 @@ class CommandRunner extends Context.Service< const probePath = (run: CommandRunner["Service"]["run"]): Effect.Effect => { const fallback = process.env["PATH"] ?? process.env["Path"] ?? ""; if (process.platform === "win32") return Effect.succeed(fallback); - const shell = process.env["SHELL"] ?? "/bin/zsh"; + const shell = process.env["SHELL"] ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); return run( shell, shell, diff --git a/packages/git/src/watch-backend.test.ts b/packages/git/src/watch-backend.test.ts index 8125ca67..fd9586f1 100644 --- a/packages/git/src/watch-backend.test.ts +++ b/packages/git/src/watch-backend.test.ts @@ -8,6 +8,7 @@ import { FSEVENTS_RESTART_SETTLE_MS, defaultBackend, isIgnored, + isWatchLimitError, parcelIgnore, subscribeRoot, type RootIgnore, @@ -178,3 +179,25 @@ describe("watch backends", () => { }, ); }); + +describe("isWatchLimitError", () => { + it("knows the OS's watch limit, in Node's words, parcel's or a wrapped cause", () => { + expect(isWatchLimitError(Object.assign(new Error("watch"), { code: "ENOSPC" }))).toBe(true); + expect(isWatchLimitError(Object.assign(new Error("watch"), { code: "EMFILE" }))).toBe(true); + expect( + isWatchLimitError(new Error("inotify_add_watch on '/r' failed: No space left on device")), + ).toBe(true); + expect( + isWatchLimitError( + new Error("wrapped", { cause: new Error("inotify_init1: Too many open files") }), + ), + ).toBe(true); + }); + + it("leaves other failures to the quiet path", () => { + expect( + isWatchLimitError(new Error("inotify_add_watch on '/r' failed: Permission denied")), + ).toBe(false); + expect(isWatchLimitError(undefined)).toBe(false); + }); +}); diff --git a/packages/git/src/watch.ts b/packages/git/src/watch.ts index e9ae4b30..5fa919de 100644 --- a/packages/git/src/watch.ts +++ b/packages/git/src/watch.ts @@ -93,6 +93,29 @@ export const isIgnored = (relative: string, ignore: RootIgnore): boolean => { return components.some((c) => ignore.anyDepth.includes(c)); }; +/** + * Whether a watch failed on the OS's watch limit rather than the path: Linux's inotify watches + * (`ENOSPC`, "No space left on device") or instances (`EMFILE`), as Node or @parcel/watcher word + * it, here or in the error's cause. + */ +export const isWatchLimitError = (error: unknown): boolean => { + for (let e: unknown = error, depth = 0; e !== undefined && e !== null && depth < 4; depth++) { + const code: unknown = typeof e === "object" ? Reflect.get(e, "code") : undefined; + if (code === "ENOSPC" || code === "EMFILE") return true; + const message = e instanceof Error ? e.message : typeof e === "string" ? e : ""; + if (/ENOSPC|EMFILE|No space left on device|Too many open files/i.test(message)) return true; + e = typeof e === "object" ? Reflect.get(e, "cause") : undefined; + } + return false; +}; + +/** What the log says, once per process, when a root couldn't be watched for the limit. */ +export const WATCH_LIMIT_MESSAGE = + "file watching hit the system's inotify limit, so some checkouts' git state won't update live; " + + "raise fs.inotify.max_user_watches (and max_user_instances) with sysctl, then restart Gram"; + +let watchLimitLogged = false; + /** Receives absolute event paths, or `undefined` when events were lost. */ export type RootListener = (paths: ReadonlyArray | undefined) => void; @@ -393,11 +416,18 @@ export const watchGitState = ( roots.set(checkout.top, { topLevel: [".git"], anyDepth: PRUNE_DIRS }); } for (const [dir, ignore] of roots) { - // A root that can't be watched (OS limit, permissions) degrades to no live updates for it. + // A root that can't be watched (OS limit, permissions) degrades to no live updates for it; + // the OS limit, which the user can raise, is logged once. yield* Effect.option( Effect.acquireRelease( Effect.tryPromise(() => subscribeRoot(backend, dir, ignore, onEvents)), (release) => Effect.promise(release).pipe(Effect.ignore), + ).pipe( + Effect.tapError((error) => { + if (watchLimitLogged || !isWatchLimitError(error)) return Effect.void; + watchLimitLogged = true; + return Effect.logWarning(WATCH_LIMIT_MESSAGE); + }), ), ); } diff --git a/packages/server/src/keybindings/keybindings.test.ts b/packages/server/src/keybindings/keybindings.test.ts index f4e92619..d2e9dd43 100644 --- a/packages/server/src/keybindings/keybindings.test.ts +++ b/packages/server/src/keybindings/keybindings.test.ts @@ -50,6 +50,19 @@ describe("parseKeybindings", () => { }); describe("keybindings file", () => { + it("polls the file when its folder can't be watched, and says so", async () => { + const dir = await tempDir(); + const failed = vi.fn(); + const stop = watchKeybindings(join(dir, "gone"), () => {}, { + platform: "linux", + pollMs: 10, + onWatchFailed: failed, + }); + expect(failed).toHaveBeenCalledOnce(); + stop(); + stop(); + }); + it("reads a missing file as empty, writes the schema beside it and round-trips", async () => { const dir = await tempDir(); expect(await readKeybindings(dir)).toEqual({ diff --git a/packages/server/src/keybindings/keybindings.ts b/packages/server/src/keybindings/keybindings.ts index c1d59f84..84589e23 100644 --- a/packages/server/src/keybindings/keybindings.ts +++ b/packages/server/src/keybindings/keybindings.ts @@ -120,6 +120,8 @@ export interface WatchKeybindingsOptions { /** The stat interval when polling. */ readonly pollMs?: number; readonly platform?: NodeJS.Platform; + /** Told when the folder can't be watched and the file is polled instead. */ + readonly onWatchFailed?: (error: unknown) => void; } /** @@ -149,13 +151,24 @@ export function watchKeybindings( watchFile(path, { interval: options.pollMs ?? 1000, persistent: false }, listener); close = () => unwatchFile(path, listener); } else { - const watcher = watch(dataDir, (_event, filename) => { - if (filename !== null && filename.toString() !== KEYBINDINGS_FILE) return; - changed(); - }); - // A watch that dies (e.g. the folder is removed) just stops hot-reloading. - watcher.on("error", () => watcher.close()); - close = () => watcher.close(); + try { + const watcher = watch(dataDir, (_event, filename) => { + if (filename !== null && filename.toString() !== KEYBINDINGS_FILE) return; + changed(); + }); + // A watch that dies (e.g. the folder is removed) just stops hot-reloading. + watcher.on("error", () => watcher.close()); + close = () => watcher.close(); + } catch (error) { + // Linux's inotify limit (ENOSPC/EMFILE) throws here; polling the file needs no watch. + options.onWatchFailed?.(error); + const path = join(dataDir, KEYBINDINGS_FILE); + const listener = (current: Stats, previous: Stats) => { + if (current.mtimeMs !== previous.mtimeMs || current.size !== previous.size) changed(); + }; + watchFile(path, { interval: options.pollMs ?? 1000, persistent: false }, listener); + close = () => unwatchFile(path, listener); + } } return () => { diff --git a/packages/server/src/rpc/handlers/gateway-keys.test.ts b/packages/server/src/rpc/handlers/gateway-keys.test.ts index 75dfdb53..f359f5ba 100644 --- a/packages/server/src/rpc/handlers/gateway-keys.test.ts +++ b/packages/server/src/rpc/handlers/gateway-keys.test.ts @@ -198,10 +198,17 @@ describe("vaultInfo", () => { label: "the system keyring", }); expect(vaultInfo("safeStorage", "linux", "basic_text")).toEqual({ - kind: "unencrypted", - persisted: true, - label: "an unencrypted file, as no system keyring is available", + kind: "no-keyring", + persisted: false, + label: "nowhere: no system keyring is available", }); + // Neither a backend Electron added since nor an unanswered read counts as a keyring. + for (const backend of ["unknown", "unavailable", undefined]) { + expect(vaultInfo("safeStorage", "linux", backend).kind).toBe("no-keyring"); + } + for (const backend of ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"]) { + expect(vaultInfo("safeStorage", "linux", backend).kind).toBe("libsecret"); + } expect(vaultInfo("memory", "darwin")).toEqual({ kind: "memory", persisted: false, diff --git a/packages/server/src/rpc/handlers/gateway.test.ts b/packages/server/src/rpc/handlers/gateway.test.ts index 6987f1c2..0542cba0 100644 --- a/packages/server/src/rpc/handlers/gateway.test.ts +++ b/packages/server/src/rpc/handlers/gateway.test.ts @@ -7,6 +7,7 @@ import type { AgentHost } from "@gram/agents"; import { AgentUnavailable, GatewayError, + NO_KEYRING_MESSAGE, NotFound, type GatewayProfile, type ProfileId, @@ -218,6 +219,31 @@ describe("gateway handlers", () => { }), ); + it.effect("setKey says why when Linux has no keyring, and stores nothing", () => + Effect.gen(function* () { + const { handlers, store } = yield* setup(); + yield* handlers["profiles.upsert"]({ profile: profile("a") }); + const refusing = makeGateway({ + store, + hosts: AgentHosts.fromHosts([codexHost]), + vault: { + ...makeMemoryVault(), + set: () => Effect.fail(new VaultError({ message: NO_KEYRING_MESSAGE })), + }, + fetcher: async () => ({ ok: true, status: 200, json: async () => ({}) }), + home, + }); + const error = yield* Effect.flip( + refusing.handlers["profiles.setKey"]({ + profileId: "a" as ProfileId, + key: Redacted.make(SECRET), + }), + ); + expect(error.message).toBe(NO_KEYRING_MESSAGE); + expect((yield* store.listProfiles)[0]?.keyRef).toBe(""); + }), + ); + it.effect("remove deletes the profile and its key", () => Effect.gen(function* () { const { handlers, store, vault } = yield* setup(); diff --git a/packages/server/src/rpc/handlers/gateway.ts b/packages/server/src/rpc/handlers/gateway.ts index 53e899eb..4c126325 100644 --- a/packages/server/src/rpc/handlers/gateway.ts +++ b/packages/server/src/rpc/handlers/gateway.ts @@ -19,6 +19,8 @@ import { type SlashCommand, type ThreadId, type VaultInfo, + keyringBackendOk, + NO_KEYRING_MESSAGE, } from "@gram/contracts"; import type { AgentHost } from "@gram/agents"; import * as Effect from "effect/Effect"; @@ -94,14 +96,15 @@ export const vaultInfo = ( label: "a file encrypted with Windows data protection for your account", }; default: - // With no keyring, `safeStorage` falls back to `basic_text`, which doesn't encrypt. - return backend === "basic_text" - ? { - kind: "unencrypted", - persisted: true, - label: "an unencrypted file, as no system keyring is available", - } - : { kind: "libsecret", persisted: true, label: "the system keyring" }; + // With no keyring `safeStorage` falls back to `basic_text`, which doesn't encrypt, so the + // desktop refuses to store keys there (`keyringBackendOk`) and Settings says why. + return backend !== undefined && keyringBackendOk(backend) + ? { kind: "libsecret", persisted: true, label: "the system keyring" } + : { + kind: "no-keyring", + persisted: false, + label: "nowhere: no system keyring is available", + }; } }; @@ -217,13 +220,16 @@ export const makeGateway = ({ yield* store.upsertProfile({ ...profile, keyRef: ref }); } }).pipe( - // Not the vault's own reason: it may quote what it was handed. - Effect.catchTag("VaultError", () => + // Not the vault's own reason, which may quote what it was handed; only the fixed no-keyring + // text, which says what to do, comes through. + Effect.catchTag("VaultError", (error) => Effect.fail( new GatewayError({ profileId: profile.id, message: - "Couldn't update the stored key: the key store refused it. Try again, or restart Gram.", + error.message === NO_KEYRING_MESSAGE + ? NO_KEYRING_MESSAGE + : "Couldn't update the stored key: the key store refused it. Try again, or restart Gram.", }), ), ), diff --git a/packages/server/src/rpc/handlers/keybindings.ts b/packages/server/src/rpc/handlers/keybindings.ts index 9eec3d0d..3c4c8251 100644 --- a/packages/server/src/rpc/handlers/keybindings.ts +++ b/packages/server/src/rpc/handlers/keybindings.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { InvalidState } from "@gram/contracts"; +import { isWatchLimitError } from "@gram/git"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FiberSet from "effect/FiberSet"; @@ -22,20 +23,33 @@ export const keybindingsHandlers = Effect.gen(function* () { const run = yield* FiberSet.makeRuntime(); yield* Effect.acquireRelease( Effect.sync(() => - watchKeybindings(dataDir, () => { - run( - read.pipe( - Effect.flatMap((keybindings) => - hub.publish({ type: "keybindings.changed", keybindings }), + watchKeybindings( + dataDir, + () => { + run( + read.pipe( + Effect.flatMap((keybindings) => + hub.publish({ type: "keybindings.changed", keybindings }), + ), + Effect.onError((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("keybindings watcher failed", cause), + ), ), - Effect.onError((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.void - : Effect.logError("keybindings watcher failed", cause), + ); + }, + { + onWatchFailed: (error) => + run( + Effect.logWarning( + isWatchLimitError(error) + ? "keybindings.json is polled: the system's inotify limit is reached" + : "keybindings.json is polled: its folder couldn't be watched", + ), ), - ), - ); - }), + }, + ), ), (stop) => Effect.sync(stop), ); diff --git a/packages/shell/src/electron/windows.ts b/packages/shell/src/electron/windows.ts index c602bc4e..59b9c7f8 100644 --- a/packages/shell/src/electron/windows.ts +++ b/packages/shell/src/electron/windows.ts @@ -1,6 +1,7 @@ /** - * The app's BrowserWindows: macOS hidden-inset and Windows overlay title bars, theme and - * vibrancy sync, the dock badge and Windows overlay/flash, notifications with click-to-focus, and + * The app's BrowserWindows: macOS hidden-inset and Windows overlay title bars (Linux keeps the + * desktop's frame), theme and vibrancy sync, the dock badge and Windows overlay/flash, notifications + * with click-to-focus, and * every link that leaves the app's origin sent through the shell's link trust. What the app * provides comes in as a `WindowContext`: the preload, the title, and how links open. */ @@ -24,6 +25,7 @@ import { windowArg } from "../window-arg.ts"; const isMac = process.platform === "darwin"; const isWindows = process.platform === "win32"; +const isLinux = process.platform === "linux"; /** What every window needs from the shell that makes it. */ export interface WindowContext { @@ -43,7 +45,9 @@ let vibrancyWanted = false; let overlayDot: NativeImage | undefined; const liveNotifications = new Set(); -const vibrancyActive = () => vibrancyWanted && !nativeTheme.prefersReducedTransparency; +// Linux has no vibrancy or Mica: a translucent look paints its own backdrop there, on an opaque window. +const vibrancyActive = () => + (isMac || isWindows) && vibrancyWanted && !nativeTheme.prefersReducedTransparency; function applyAppearance(win: BrowserWindow) { const glass = vibrancyActive(); @@ -89,6 +93,9 @@ export function createWindow({ titleBarOverlay: { color: theme.background, symbolColor: theme.foreground, height: 40 }, } : {}), + // Linux keeps the desktop's own frame and window buttons, which follow its theme and button + // layout. The menu bar would be a second row under it; Alt shows it, and its accelerators work. + ...(isLinux ? { autoHideMenuBar: true } : {}), webPreferences: { preload: context.preloadPath, additionalArguments: info === undefined ? [] : [windowArg(info)], @@ -189,7 +196,13 @@ export function setVibrancy(enabled: boolean) { export function setBadge(count: number) { if (!isWindows) { + // Linux shows a count only where the launcher reads Unity's API (KDE's task manager, Dash to + // Dock); the window's urgency hint, which every taskbar shows, is what's left elsewhere. app.setBadgeCount(count); + if (isLinux) { + for (const win of BrowserWindow.getAllWindows()) + win.flashFrame(count > 0 && !win.isFocused()); + } return; } overlayDot ??= nativeImage.createFromBitmap(overlayDotBitmap(16), { width: 16, height: 16 }); diff --git a/packages/shell/src/modules/key-vault/vault.test.ts b/packages/shell/src/modules/key-vault/vault.test.ts index d43e1934..7d05837d 100644 --- a/packages/shell/src/modules/key-vault/vault.test.ts +++ b/packages/shell/src/modules/key-vault/vault.test.ts @@ -3,6 +3,7 @@ import type * as FsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { NO_KEYRING_MESSAGE } from "@gram/contracts"; import type { ProcShape } from "@gram/git/proc"; import * as Effect from "effect/Effect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -158,6 +159,59 @@ describe("macOS keychain compatibility", () => { }); describe("handleVaultRequest", () => { + it("refuses to store or read a key on a Linux backend that doesn't encrypt", async () => { + for (const backend of ["basic_text", "unknown", "unavailable"]) { + const store = memoryStore(); + const write = vi.spyOn(store, "write"); + const crypto = { ...fakeCrypto(), backend: () => backend }; + const encrypt = vi.spyOn(crypto, "encrypt"); + const set = await handleVaultRequest( + { type: "vault.request", id: "s", op: "set", ref: "r", value: "sk-secret" }, + crypto, + store, + ); + expect(set).toEqual({ + type: "vault.response", + id: "s", + ok: false, + message: NO_KEYRING_MESSAGE, + }); + expect(encrypt).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + const get = await handleVaultRequest( + { type: "vault.request", id: "g", op: "get", ref: "r" }, + crypto, + store, + ); + expect(get).toMatchObject({ ok: false, message: NO_KEYRING_MESSAGE }); + } + }); + + it("still removes a key without a keyring", async () => { + const store = memoryStore(); + await store.write({ r: "c2VjcmV0" }); + const crypto = { ...fakeCrypto(), available: () => false, backend: () => "unavailable" }; + await handleVaultRequest( + { type: "vault.request", id: "d", op: "delete", ref: "r" }, + crypto, + store, + ); + expect(await store.read()).toEqual({}); + }); + + it("stores a key with a real keyring backend", async () => { + for (const backend of ["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"]) { + const store = memoryStore(); + const crypto = { ...fakeCrypto(), backend: () => backend }; + const set = await handleVaultRequest( + { type: "vault.request", id: "s", op: "set", ref: "r", value: "sk-secret" }, + crypto, + store, + ); + expect(set).toEqual({ type: "vault.response", id: "s", ok: true }); + } + }); + it("reports the Linux storage backend, and nothing where there is none to report", async () => { const store = memoryStore(); const linux = { ...fakeCrypto(), backend: () => "basic_text" }; diff --git a/packages/shell/src/modules/key-vault/vault.ts b/packages/shell/src/modules/key-vault/vault.ts index ef1353d7..6db0f85e 100644 --- a/packages/shell/src/modules/key-vault/vault.ts +++ b/packages/shell/src/modules/key-vault/vault.ts @@ -3,9 +3,12 @@ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import { keyringBackendOk, NO_KEYRING_MESSAGE } from "@gram/contracts"; + /** * Encryption as Electron's `safeStorage` offers it: the macOS Keychain, Windows DPAPI for the user's - * account, or a Linux keyring, which falls back to the unencrypted `basic_text` backend without one. + * account, or a Linux keyring, which falls back to the unencrypted `basic_text` backend without one; + * `handleVaultRequest` refuses to store or read keys there (`keyringBackendOk`). */ export interface VaultCrypto { available(): boolean | Promise; @@ -181,12 +184,24 @@ export async function handleVaultRequest( }; } const run = async (): Promise => { + let backend: string | undefined; try { - if (!(await crypto.available())) - return fail("OS encryption isn't available, so keys can't be stored"); + backend = crypto.backend?.(); } catch { - // Backend selection can fail before a read; report its stage without the OS exception. - return fail("couldn't access OS encryption for the key store"); + return fail("couldn't read the key store's backend"); + } + // Before availability, so a Linux desktop without a keyring daemon gets the reason. Removing a + // key needs no encryption, so there it goes ahead without one. + const keyring = keyringBackendOk(backend); + if (!keyring && message.op !== "delete") return fail(NO_KEYRING_MESSAGE); + if (keyring) { + try { + if (!(await crypto.available())) + return fail("OS encryption isn't available, so keys can't be stored"); + } catch { + // Backend selection can fail before a read; report its stage without the OS exception. + return fail("couldn't access OS encryption for the key store"); + } } switch (message.op) { case "set": { diff --git a/packages/shell/src/modules/tray/main.ts b/packages/shell/src/modules/tray/main.ts index 0c1ab08e..714d096a 100644 --- a/packages/shell/src/modules/tray/main.ts +++ b/packages/shell/src/modules/tray/main.ts @@ -51,6 +51,12 @@ export function createTrayMode(options: { /** Brings a window forward, or opens one. */ readonly show: () => void; readonly quit: () => void; + /** + * The tray icon off macOS, when the app has one to give; else the executable's own. Linux needs + * it: there the executable carries no icon, and the StatusNotifierItem host (KDE, or GNOME's + * AppIndicator extension) shows the image it's given. + */ + readonly icon?: () => NativeImage | undefined; }) { let prefs = DEFAULT_PREFS; let tray: Tray | undefined; @@ -133,13 +139,14 @@ export function createTrayMode(options: { const showTray = async () => { if (tray !== undefined || !prefs.keepInTray) return; - // On macOS a template glyph for the menu bar; elsewhere the executable's own icon, which - // carries `icon.ico` on Windows. + // On macOS a template glyph for the menu bar; elsewhere the app's icon, or the executable's + // own, which carries `icon.ico` on Windows. icon ??= isMac ? menuBarIcon() - : await app + : (options.icon?.() ?? + (await app .getFileIcon(process.execPath, { size: "small" }) - .catch(() => nativeImage.createEmpty()); + .catch(() => nativeImage.createEmpty()))); if (tray !== undefined || !prefs.keepInTray) return; tray = new Tray(icon); // macOS opens a menu bar item's menu on click; elsewhere a click brings the window back.