Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/src/main/app-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
38 changes: 35 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +19,7 @@ import {
ipcMain,
Menu,
MessageChannelMain,
nativeImage,
net,
Notification,
powerMonitor,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
},
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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({
Expand Down
71 changes: 63 additions & 8 deletions apps/desktop/src/main/login-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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({});
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});

Expand Down
98 changes: 90 additions & 8 deletions apps/desktop/src/main/login-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand All @@ -35,23 +105,30 @@ 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;
readonly platform: NodeJS.Platform;
readonly packaged: boolean;
readonly argv: readonly string[];
readonly openExternal: (url: string) => Promise<void>;
/** 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.
Expand All @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/composer/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 &&
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/desktop/use-desktop-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/keyboard/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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",
}));
Expand Down
Loading
Loading