From 1525c9dbddd7715e388f7e0989fafefacc3452b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 21:00:50 +0000 Subject: [PATCH 1/5] feat(macos): branded About window (LUM-1971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Electron's default `aboutPanel` (just shows the bundle name) with the same info surface Swift Vellum exposes today: app name, version, commit SHA, copyright, link to the website. ## Implementation `src/main/about.ts`: - `installAbout()` (idempotent) — registers IPC handlers for `vellum:app:versionInfo` and `vellum:app:openWebsite`, and seeds the native About panel via `app.setAboutPanelOptions` so AppleScript / accessibility queries still see correct metadata. - `openAboutWindow()` — opens (or focuses) a 360x360 frameless BrowserWindow with `titleBarStyle: "hiddenInset"` (Swift's `titlebarAppearsTransparent` equivalent). - `getVersionInfo()` — pure, returns the bundle metadata. Exported for both the IPC handler and tests. The About content lives in `about.html` and is imported via Vite's `?raw` suffix as a string, then loaded into the BrowserWindow as a `data:` URL. That keeps the asset story trivial (no `extraResources` copy step, no file-path resolution that drifts between dev and packaged builds) at the cost of a small `'unsafe-inline'` CSP for the single inline ` + + diff --git a/apps/macos/src/main/about.test.ts b/apps/macos/src/main/about.test.ts new file mode 100644 index 00000000000..6441b881c16 --- /dev/null +++ b/apps/macos/src/main/about.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +type StubWindow = { + show: () => void; + focus: () => void; + isDestroyed: () => boolean; + on: (event: string, listener: () => void) => StubWindow; + once: (event: string, listener: () => void) => StubWindow; + loadURL: (url: string) => Promise; + emit: (event: string) => void; +}; + +let constructed: StubWindow[] = []; +const showMock = mock(() => undefined); +const focusMock = mock(() => undefined); +const loadURLMock = mock((_url: string) => Promise.resolve()); + +const makeWindow = (): StubWindow => { + const listeners = new Map void>>(); + let destroyed = false; + const win: StubWindow = { + show: showMock, + focus: focusMock, + isDestroyed: () => destroyed, + on: (event, listener) => { + const arr = listeners.get(event) ?? []; + arr.push(listener); + listeners.set(event, arr); + return win; + }, + once: (event, listener) => { + const arr = listeners.get(event) ?? []; + arr.push(listener); + listeners.set(event, arr); + return win; + }, + loadURL: loadURLMock, + emit: (event) => { + if (event === "closed") destroyed = true; + for (const l of listeners.get(event) ?? []) l(); + }, + }; + return win; +}; + +const getVersionMock = mock(() => "1.2.3"); +const setAboutPanelOptionsMock = mock((_opts: unknown) => undefined); +const ipcHandleMock = mock((_channel: string, _handler: unknown) => undefined); +const openExternalMock = mock(() => Promise.resolve()); + +mock.module("electron", () => ({ + app: { + getVersion: getVersionMock, + setAboutPanelOptions: setAboutPanelOptionsMock, + }, + BrowserWindow: class { + constructor() { + const win = makeWindow(); + constructed.push(win); + // Mutate `this` so the production code's per-instance method + // bindings (`aboutWindow.show()`, etc.) reach the stub. Caller + // never holds the prototype instance directly. + Object.assign(this, win); + } + }, + ipcMain: { + handle: ipcHandleMock, + }, + shell: { + openExternal: openExternalMock, + }, +})); + +const { getVersionInfo, installAbout, openAboutWindow } = await import( + "./about" +); + +beforeEach(() => { + constructed = []; + showMock.mockClear(); + focusMock.mockClear(); + loadURLMock.mockClear(); + setAboutPanelOptionsMock.mockClear(); + ipcHandleMock.mockClear(); + openExternalMock.mockClear(); +}); + +afterEach(() => { + // Drain any open window between tests so module-scope state resets. + for (const win of constructed) { + if (!win.isDestroyed()) win.emit("closed"); + } +}); + +describe("getVersionInfo", () => { + test("returns name, version, sha, copyright, website", () => { + const info = getVersionInfo(); + expect(info.appName).toBe("Vellum"); + expect(info.version).toBe("1.2.3"); + expect(info.website).toBe("https://vellum.ai"); + expect(info.copyright).toContain("Vellum"); + expect(info.copyright).toContain(String(new Date().getFullYear())); + // SHA isn't defined off the build pipeline; the module falls back + // to "unknown" rather than throwing. + expect(typeof info.commitSha).toBe("string"); + }); +}); + +// Single test that exercises `installAbout()` end-to-end. Bun runs every +// `test()` in the same file inside the same module scope, so the +// `installed` flag inside `about.ts` would prevent re-running the install +// across multiple `test()` blocks. One call, multiple assertions. +describe("installAbout", () => { + test("registers IPC handlers, populates the About panel, and is idempotent on repeated calls", () => { + installAbout(); + installAbout(); + installAbout(); + + const channels = ipcHandleMock.mock.calls.map((c) => c[0]); + expect(channels).toContain("vellum:app:versionInfo"); + expect(channels).toContain("vellum:app:openWebsite"); + expect(ipcHandleMock).toHaveBeenCalledTimes(2); + + expect(setAboutPanelOptionsMock).toHaveBeenCalledTimes(1); + const opts = setAboutPanelOptionsMock.mock.calls[0]?.[0] as { + applicationName: string; + applicationVersion: string; + copyright: string; + website: string; + }; + expect(opts.applicationName).toBe("Vellum"); + expect(opts.applicationVersion).toBe("1.2.3"); + expect(opts.website).toBe("https://vellum.ai"); + }); +}); + +describe("openAboutWindow", () => { + test("constructs a new BrowserWindow on first call", () => { + openAboutWindow(); + expect(constructed).toHaveLength(1); + expect(loadURLMock).toHaveBeenCalledTimes(1); + expect(loadURLMock.mock.calls[0]?.[0]).toMatch( + /^data:text\/html;charset=utf-8,/, + ); + }); + + test("focuses the existing window instead of constructing a second one", () => { + openAboutWindow(); + openAboutWindow(); + openAboutWindow(); + expect(constructed).toHaveLength(1); + expect(showMock).toHaveBeenCalled(); + expect(focusMock).toHaveBeenCalledTimes(2); + }); + + test("reconstructs after the previous window was destroyed", () => { + openAboutWindow(); + constructed[0]?.emit("closed"); + openAboutWindow(); + expect(constructed).toHaveLength(2); + }); +}); diff --git a/apps/macos/src/main/about.ts b/apps/macos/src/main/about.ts new file mode 100644 index 00000000000..10638554a46 --- /dev/null +++ b/apps/macos/src/main/about.ts @@ -0,0 +1,129 @@ +import { BrowserWindow, app, ipcMain, shell } from "electron"; +import path from "node:path"; + +import aboutHtml from "./about.html?raw"; + +/** + * Branded About window — replaces Electron's default `aboutPanel` + * (which only shows the bundle name) with the same information surface + * Swift Vellum exposes today: app name, version, commit SHA, copyright, + * link to the website. + * + * The HTML lives in `about.html` and is imported via Vite's `?raw` + * suffix as a string, then loaded into the BrowserWindow as a `data:` + * URL. That keeps the asset bundling story trivial (no `extraResources` + * copy step, no file-path resolution that drifts between dev and + * packaged builds) at the cost of a small `'unsafe-inline'` CSP for the + * single inline ` - - diff --git a/apps/macos/src/main/about.test.ts b/apps/macos/src/main/about.test.ts index 923c85f5d1b..e5898666a0f 100644 --- a/apps/macos/src/main/about.test.ts +++ b/apps/macos/src/main/about.test.ts @@ -159,13 +159,14 @@ describe("installAbout", () => { }); describe("openAboutWindow", () => { - test("constructs a new BrowserWindow on first call", () => { + test("constructs a new BrowserWindow loading the /about route", () => { openAboutWindow(); expect(constructed).toHaveLength(1); expect(loadURLMock).toHaveBeenCalledTimes(1); - expect(loadURLMock.mock.calls[0]?.[0]).toMatch( - /^data:text\/html;charset=utf-8,/, - ); + // Dev URL pattern — the test env doesn't set `app.isPackaged` true, + // so the dev branch runs. URL must end with the renderer-side + // route path so the React route in apps/web mounts. + expect(loadURLMock.mock.calls[0]?.[0]).toMatch(/\/about$/); }); test("focuses the existing window instead of constructing a second one", () => { diff --git a/apps/macos/src/main/about.ts b/apps/macos/src/main/about.ts index a8eaabdd84f..9413850d565 100644 --- a/apps/macos/src/main/about.ts +++ b/apps/macos/src/main/about.ts @@ -1,36 +1,34 @@ import { BrowserWindow, app, ipcMain, shell } from "electron"; import path from "node:path"; -import aboutHtml from "./about.html?raw"; - /** * Branded About window — replaces Electron's default `aboutPanel` * (which only shows the bundle name) with the same information surface * Swift Vellum exposes today: app name, version, commit SHA, copyright, * link to the website. * - * The HTML lives in `about.html` and is imported via Vite's `?raw` - * suffix as a string, then loaded into the BrowserWindow as a `data:` - * URL. That keeps the asset bundling story trivial (no `extraResources` - * copy step, no file-path resolution that drifts between dev and - * packaged builds) at the cost of a small `'unsafe-inline'` CSP for the - * single inline `