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
22 changes: 22 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,28 @@ binaries work with Electron without an ABI-specific rebuild. The packaging
hook opens an in-memory database with Electron before accepting the packaged
SQLite module; older ABI-specific modules still use the prebuild fallback.

macOS notifications require a signed application. Unsigned local and CI artifact
builds cannot display macOS notifications. Published releases use the existing
signing and notarization workflow.

The shipped Linux x64 SQLite prebuild requires glibc 2.34 or newer and
libstdc++ with `GLIBCXX_3.4.29` support (GCC 11 or newer).

The AppImage packaging patch exposes its bundled legacy `libnotify` only as
`libnotify.so`. Electron first tries the system's versioned library names, so
libnotify 0.7.10 or newer can supply notification activation tokens on Wayland.
If no versioned system library loads, Electron can still use the bundled
unversioned fallback for notifications. Older libraries keep their existing
notification behavior but cannot forward activation tokens.

Electron downloads its development runtime lazily; the desktop test command
installs it before starting parallel workers.

The macOS bundle declares macOS 13 as its minimum. The release feed generator
writes the corresponding Darwin kernel minimum, 22.0.0, into both JSON and YAML
update feeds. Run it before publishing release artifacts so older Macs reject
incompatible updates.

## Validation

```bash
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"smoke:browser-cdp": "node scripts/smoke-browser-cdp.mjs",
"start": "pnpm run package && node scripts/run-packaged-app.mjs",
"start:linux": "pnpm run package:linux && node scripts/run-packaged-app.mjs",
"test": "vitest run --config vitest.config.ts",
"test": "install-electron && vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit",
"smoke:browser-broker": "node scripts/smoke-browser-broker.mjs"
},
Expand Down
42 changes: 28 additions & 14 deletions apps/desktop/scripts/generate-version-feed.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { z } from "zod";
import {
bbDesktopVersionFeedSchema,
Expand Down Expand Up @@ -35,19 +35,23 @@ const packageJsonSchema = z.object({
version: z.string().min(1),
});

const updateMetadataFileSchema = z.object({
url: z.string().min(1),
sha512: z.string().min(1),
size: z.number().int().nonnegative(),
});
const updateMetadataFileSchema = z
.object({
url: z.string().min(1),
sha512: z.string().min(1),
size: z.number().int().nonnegative(),
})
.passthrough();

const updateMetadataSchema = z.object({
version: z.string().min(1),
files: z.array(updateMetadataFileSchema).min(1),
path: z.string().min(1),
sha512: z.string().min(1),
releaseDate: z.iso.datetime(),
});
const updateMetadataSchema = z
.object({
version: z.string().min(1),
files: z.array(updateMetadataFileSchema).min(1),
path: z.string().min(1),
sha512: z.string().min(1),
releaseDate: z.iso.datetime(),
})
.passthrough();

function parseJson(text: string): unknown {
return JSON.parse(text);
Expand All @@ -69,7 +73,7 @@ if (updateMetadata.version !== packageJson.version) {
const desktopVersionFeed: BbDesktopVersionFeed = {
channel: releaseChannel,
files: updateMetadata.files,
minimumSystemVersion: null,
minimumSystemVersion: buildPlatform === "macos" ? "22.0.0" : null,
path: updateMetadata.path,
platform: buildPlatform,
releaseDate: updateMetadata.releaseDate,
Expand All @@ -82,6 +86,16 @@ const desktopVersionFeed: BbDesktopVersionFeed = {
};

const validatedFeed = bbDesktopVersionFeedSchema.parse(desktopVersionFeed);
if (validatedFeed.minimumSystemVersion !== null) {
await writeFile(
updateMetadataPath,
stringifyYaml({
...updateMetadata,
minimumSystemVersion: validatedFeed.minimumSystemVersion,
}),
"utf8",
);
}
await writeFile(
desktopVersionFeedPath,
`${JSON.stringify(validatedFeed, null, 2)}\n`,
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/scripts/smoke-linux-appimage-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,18 @@ async function smokeLinuxAppImageLifecycle() {
}

guiMount = resolveMountFromBridgePath(runtime.bridgePath);
const bundledLibraryDirectory = join(guiMount, "usr", "lib");
const bundledLibraries = await readdir(bundledLibraryDirectory);
if (
["libnotify.so.4", "libnotify.so.5", "libnotify.so.1"].some((name) =>
bundledLibraries.includes(name),
)
) {
throw new Error(
"The AppImage overrides the system's versioned libnotify",
);
}
await readFile(join(bundledLibraryDirectory, "libnotify.so"));
runtimeMount = runtimeProcess.environment.APPDIR ?? null;
if (runtimeMount === null || runtimeMount.length === 0) {
throw new Error("The runtime supervisor did not inherit APPDIR");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/desktop-window-command-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const BB_DESKTOP_FOCUS_WINDOW_CHANNEL = "bb-desktop:focus-window";
export const BB_DESKTOP_OPEN_NEW_TAB_CHANNEL = "bb-desktop:open-new-tab";
export const BB_DESKTOP_APP_COMMAND_CHANNEL = "bb-desktop:app-command";
export const BB_DESKTOP_OPEN_SERVER_DAEMON_LOGS_CHANNEL =
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/desktop-window-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BrowserWindow, ipcMain } from "electron";
import { BB_DESKTOP_FOCUS_WINDOW_CHANNEL } from "./desktop-window-command-ipc.js";

export function registerDesktopWindowFocusIpc(
applicationWindowWebContentsIds: ReadonlySet<number>,
): void {
ipcMain.on(BB_DESKTOP_FOCUS_WINDOW_CHANNEL, (event) => {
if (!applicationWindowWebContentsIds.has(event.sender.id)) return;
const browserWindow = BrowserWindow.fromWebContents(event.sender);
if (browserWindow === null || browserWindow.isDestroyed()) return;
if (browserWindow.isMinimized()) browserWindow.restore();
browserWindow.show();
browserWindow.focus();
});
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
type ClientMessage,
} from "@bb/server-contract";
import { z } from "zod";
import { registerDesktopWindowFocusIpc } from "./desktop-window-focus.js";
import {
assertPathExists,
resolveDesktopBridgePath,
Expand Down Expand Up @@ -2242,6 +2243,7 @@ async function finishQuit(): Promise<void> {
}

function registerDesktopUpdateIpc(): void {
registerDesktopWindowFocusIpc(applicationWindowWebContentsIds);
ipcMain.on(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, (event, payload: unknown) => {
const parsed = bbDesktopZoomCommandSchema.safeParse(payload);
if (parsed.success) {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
BB_DESKTOP_SET_SPLIT_NAVIGATION_ENABLED_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL,
BB_DESKTOP_FOCUS_WINDOW_CHANNEL,
BB_DESKTOP_GET_WINDOW_STATE_CHANNEL,
BB_DESKTOP_OPEN_NEW_TAB_CHANNEL,
BB_DESKTOP_OPEN_DATA_DIRECTORY_CHANNEL,
Expand Down Expand Up @@ -408,6 +409,9 @@ const bbDesktopApi: BbDesktopApi = {
getInfo() {
return invokeDesktopInfo(BB_DESKTOP_GET_INFO_CHANNEL);
},
focusWindow() {
ipcRenderer.send(BB_DESKTOP_FOCUS_WINDOW_CHANNEL);
},
getWindowState() {
return invokeDesktopWindowState();
},
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/test/desktop-version-feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { build } from "esbuild";
import { parse, stringify } from "yaml";
import { describe, expect, it } from "vitest";

const execFileAsync = promisify(execFile);

describe("desktop release compatibility metadata", () => {
it.each([
{ platform: "darwin", channel: "latest", suffix: "mac", minimum: "22.0.0" },
{
platform: "darwin",
channel: "nightly",
suffix: "mac",
minimum: "22.0.0",
},
{ platform: "linux", channel: "latest", suffix: "linux", minimum: null },
])(
"publishes the OS requirement for $platform $channel without losing update fields",
async ({ platform, channel, suffix, minimum }) => {
const root = await mkdtemp(join(tmpdir(), "bb-version-feed-test-"));
try {
const release = join(root, "release");
await mkdir(release);
await writeFile(join(root, "package.json"), '{"version":"0.43.1"}');
const metadata = {
version: "0.43.1",
files: [
{ url: "bb.zip", sha512: "checksum", size: 123, blockMapSize: 42 },
],
path: "bb.zip",
sha512: "checksum",
releaseDate: "2026-09-15T00:00:00.000Z",
stagingPercentage: 25,
releaseNotes: "Preserve the upstream release notes",
};
const metadataPath = join(release, `${channel}-${suffix}.yml`);
await writeFile(metadataPath, stringify(metadata));
const script = join(root, "generate.mjs");
await build({
entryPoints: [resolve("scripts/generate-version-feed.mts")],
outfile: script,
bundle: true,
platform: "node",
format: "esm",
banner: {
js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
},
define: { "process.platform": JSON.stringify(platform) },
});
await execFileAsync(process.execPath, [script], {
cwd: root,
env: { ...process.env, BB_DESKTOP_RELEASE_CHANNEL: channel },
});
const updated: unknown = parse(await readFile(metadataPath, "utf8"));
expect(updated).toEqual(
minimum === null
? metadata
: { ...metadata, minimumSystemVersion: minimum },
);
const feedName =
platform === "darwin"
? "desktop-version.json"
: "desktop-version-linux.json";
const feed: unknown = JSON.parse(
await readFile(join(release, feedName), "utf8"),
);
expect(feed).toMatchObject({
channel,
minimumSystemVersion: minimum,
files: [{ url: "bb.zip", sha512: "checksum", size: 123 }],
version: metadata.version,
});
if (platform === "darwin") {
const config: unknown = JSON.parse(
await readFile(resolve("electron-builder.config.json"), "utf8"),
);
expect(config).toMatchObject({
mac: { minimumSystemVersion: "13.0.0" },
});
}
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
});
97 changes: 97 additions & 0 deletions apps/desktop/test/desktop-window-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BB_DESKTOP_FOCUS_WINDOW_CHANNEL } from "../src/desktop-window-command-ipc.js";
import { registerDesktopWindowFocusIpc } from "../src/desktop-window-focus.js";

const mock = vi.hoisted(() => {
const listeners = new Map<
string,
(event: { sender: { id: number } }) => void
>();
const window = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn(),
};
return {
listeners,
window,
fromWebContents: vi.fn((_sender: { id: number }) => window),
on: vi.fn(
(
channel: string,
listener: (event: { sender: { id: number } }) => void,
) => {
listeners.set(channel, listener);
},
),
};
});

vi.mock("electron", () => ({
BrowserWindow: { fromWebContents: mock.fromWebContents },
ipcMain: { on: mock.on },
}));

beforeEach(() => {
vi.clearAllMocks();
mock.window.isDestroyed.mockReturnValue(false);
mock.window.isMinimized.mockReturnValue(false);
mock.fromWebContents.mockReturnValue(mock.window);
registerDesktopWindowFocusIpc(new Set([42, 43]));
});

function request(id: number) {
mock.listeners.get(BB_DESKTOP_FOCUS_WINDOW_CHANNEL)?.({ sender: { id } });
}

describe("desktop notification window activation", () => {
it("restores and focuses the sending application window", () => {
mock.window.isMinimized.mockReturnValue(true);
request(42);
expect(mock.fromWebContents).toHaveBeenCalledWith({ id: 42 });
expect(mock.window.restore).toHaveBeenCalledOnce();
expect(mock.window.show).toHaveBeenCalledOnce();
expect(mock.window.focus).toHaveBeenCalledOnce();
expect(mock.window.restore.mock.invocationCallOrder[0]).toBeLessThan(
mock.window.show.mock.invocationCallOrder[0]!,
);
expect(mock.window.show.mock.invocationCallOrder[0]).toBeLessThan(
mock.window.focus.mock.invocationCallOrder[0]!,
);
});

it("focuses only the second sender when multiple application windows are registered", () => {
const secondWindow = {
isDestroyed: vi.fn(() => false),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show: vi.fn(),
focus: vi.fn(),
};
mock.fromWebContents.mockImplementation((sender) => {
expect(sender).toEqual({ id: 43 });
return secondWindow;
});
request(43);
expect(secondWindow.show).toHaveBeenCalledOnce();
expect(secondWindow.focus).toHaveBeenCalledOnce();
expect(secondWindow.restore).not.toHaveBeenCalled();
expect(mock.window.show).not.toHaveBeenCalled();
expect(mock.window.focus).not.toHaveBeenCalled();
});

it("rejects requests from unregistered web contents", () => {
request(99);
expect(mock.fromWebContents).not.toHaveBeenCalled();
expect(mock.window.focus).not.toHaveBeenCalled();
});

it("ignores a window destroyed before activation", () => {
mock.window.isDestroyed.mockReturnValue(true);
request(42);
expect(mock.window.show).not.toHaveBeenCalled();
expect(mock.window.focus).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions apps/desktop/test/preload-browser-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
BB_DESKTOP_APP_COMMAND_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL,
BB_DESKTOP_FOCUS_WINDOW_CHANNEL,
BB_DESKTOP_GET_WINDOW_STATE_CHANNEL,
BB_DESKTOP_OPEN_NEW_TAB_CHANNEL,
BB_DESKTOP_OPEN_SERVER_DAEMON_LOGS_CHANNEL,
Expand Down Expand Up @@ -251,6 +252,8 @@ describe("desktop preload browser API", () => {
api.browser.setVisibleWithoutFocus?.(visibleRequest);
api.browser.findInPage?.(findRequest);
api.browser.stopFindInPage?.(stopFindRequest);
expect(api.focusWindow).toBeTypeOf("function");
api.focusWindow?.();
api.setTheme("dark");
await api.checkForUpdates();
await expect(api.getWindowState?.()).resolves.toEqual({
Expand Down Expand Up @@ -308,6 +311,7 @@ describe("desktop preload browser API", () => {
channel: BB_DESKTOP_BROWSER_STOP_FIND_IN_PAGE_CHANNEL,
payload: stopFindRequest,
},
{ channel: BB_DESKTOP_FOCUS_WINDOW_CHANNEL, payload: undefined },
{ channel: BB_DESKTOP_SET_THEME_CHANNEL, payload: "dark" },
]);
expect(electronMock.invokeCalls).toContain(BB_DESKTOP_GET_INFO_CHANNEL);
Expand Down
Loading
Loading