-
Notifications
You must be signed in to change notification settings - Fork 77
feat(macos+web): branded About window — React route loaded by Electron (LUM-1971) #32622
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1525c9d
feat(macos): branded About window (LUM-1971)
claude 4c75b39
fix(macos): lock About-window navigation + scope activate handler to …
claude fa313dc
refactor(macos+web): About window is a React route, not inline HTML
claude 0201751
fix(macos): strip trailing slash from VELLUM_DEV_URL before composing…
claude f6a5d68
fix(web): preserve href navigation on the About page off Electron
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; | ||
|
|
||
| type StubWebContents = { | ||
| on: (event: string, listener: (...args: unknown[]) => void) => StubWebContents; | ||
| setWindowOpenHandler: ( | ||
| handler: (details: { url: string }) => { action: "deny" | "allow" }, | ||
| ) => void; | ||
| events: Map<string, Array<(...args: unknown[]) => void>>; | ||
| }; | ||
|
|
||
| type StubWindow = { | ||
| show: () => void; | ||
| focus: () => void; | ||
| isDestroyed: () => boolean; | ||
| on: (event: string, listener: () => void) => StubWindow; | ||
| once: (event: string, listener: () => void) => StubWindow; | ||
| loadURL: (url: string) => Promise<void>; | ||
| webContents: StubWebContents; | ||
| 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<string, Array<() => void>>(); | ||
| const webContentsListeners = new Map< | ||
| string, | ||
| Array<(...args: unknown[]) => void> | ||
| >(); | ||
| let destroyed = false; | ||
| const webContents: StubWebContents = { | ||
| on: (event, listener) => { | ||
| const arr = webContentsListeners.get(event) ?? []; | ||
| arr.push(listener); | ||
| webContentsListeners.set(event, arr); | ||
| return webContents; | ||
| }, | ||
| setWindowOpenHandler: () => undefined, | ||
| events: webContentsListeners, | ||
| }; | ||
| 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, | ||
| webContents, | ||
| 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 loading the /about route", () => { | ||
| openAboutWindow(); | ||
| expect(constructed).toHaveLength(1); | ||
| expect(loadURLMock).toHaveBeenCalledTimes(1); | ||
| // 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", () => { | ||
| 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); | ||
| }); | ||
|
|
||
| test("blocks top-level navigation and popups so the preload bridge can't be carried elsewhere", () => { | ||
| openAboutWindow(); | ||
| const win = constructed[0]; | ||
| expect(win).toBeDefined(); | ||
|
|
||
| // `will-navigate` handler is registered and calls preventDefault. | ||
| const willNavigateHandlers = win?.webContents.events.get("will-navigate"); | ||
| expect(willNavigateHandlers?.length).toBe(1); | ||
| const preventDefault = mock(() => undefined); | ||
| willNavigateHandlers?.[0]?.({ preventDefault }); | ||
| expect(preventDefault).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { BrowserWindow, app, ipcMain, shell } from "electron"; | ||
| import path from "node:path"; | ||
|
|
||
| /** | ||
| * 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 UI itself is a React route in `apps/web` — `/assistant/about` — | ||
| * loaded into a BrowserWindow by this module. Putting the UI in | ||
| * `apps/web` keeps it inside the design system, lets it iterate | ||
| * alongside the rest of the app, and stays in the dev contributor's | ||
| * mental model that "UI lives in apps/web." Future auxiliary windows | ||
| * (thread pop-outs, command palette, etc.) follow this same pattern; | ||
| * this module is the working example. | ||
| * | ||
| * Version + commit SHA + copyright still flow from the host: the | ||
| * renderer asks for them via `window.vellum.app.versionInfo()`, and | ||
| * the IPC handler installed here returns them. `app.setAboutPanelOptions` | ||
| * is also seeded so the native panel — which AppleScript and other | ||
| * tooling can still invoke independently of our menu — carries the | ||
| * right metadata. | ||
| */ | ||
|
|
||
| const APP_NAME = "Vellum"; | ||
| const WEBSITE = "https://vellum.ai"; | ||
|
|
||
| // Injected by `electron.vite.config.ts` at build time. Resolves to a | ||
| // short SHA in CI / dev checkouts, or "unknown" if neither | ||
| // `GITHUB_SHA` nor a git tree is available. | ||
| declare const __VELLUM_BUILD_SHA__: string; | ||
|
|
||
| const COMMIT_SHA: string = | ||
| typeof __VELLUM_BUILD_SHA__ === "string" ? __VELLUM_BUILD_SHA__ : "unknown"; | ||
|
|
||
| const COPYRIGHT = (): string => `© ${new Date().getFullYear()} ${APP_NAME}`; | ||
|
|
||
| export interface AppVersionInfo { | ||
| appName: string; | ||
| version: string; | ||
| commitSha: string; | ||
| copyright: string; | ||
| website: string; | ||
| } | ||
|
|
||
| export const getVersionInfo = (): AppVersionInfo => ({ | ||
| appName: APP_NAME, | ||
| version: app.getVersion(), | ||
| commitSha: COMMIT_SHA, | ||
| copyright: COPYRIGHT(), | ||
| website: WEBSITE, | ||
| }); | ||
|
|
||
| // The renderer route the About window loads. Mirrors `routes.about` in | ||
| // `apps/web/src/utils/routes.ts`; the literal is duplicated rather than | ||
| // imported because `apps/macos` and `apps/web` are separate TS projects. | ||
| // Drift surfaces as the About window loading the app's catch-all | ||
| // NotFound page. | ||
| const ABOUT_PATH = "/about"; | ||
|
|
||
| const aboutWindowUrl = (): string => { | ||
| if (app.isPackaged) return `app://vellum.ai/assistant${ABOUT_PATH}`; | ||
| // Dev: build atop the same env-var the main window honors. Strip any | ||
| // trailing slash so a `VELLUM_DEV_URL=http://localhost:5173/assistant/` | ||
| // override doesn't produce `/assistant//about`. | ||
| const devBase = ( | ||
| process.env.VELLUM_DEV_URL ?? "http://localhost:5173/assistant" | ||
| ).replace(/\/+$/, ""); | ||
| return `${devBase}${ABOUT_PATH}`; | ||
| }; | ||
|
|
||
| // Module-scope handle so reopening the menu item focuses the existing | ||
| // window instead of stacking duplicates. Reset on `closed` so the next | ||
| // invocation rebuilds. | ||
| let aboutWindow: BrowserWindow | null = null; | ||
|
|
||
| export const openAboutWindow = (): void => { | ||
| if (aboutWindow && !aboutWindow.isDestroyed()) { | ||
| aboutWindow.show(); | ||
| aboutWindow.focus(); | ||
| return; | ||
| } | ||
|
|
||
| aboutWindow = new BrowserWindow({ | ||
| width: 360, | ||
| height: 360, | ||
| resizable: false, | ||
| minimizable: false, | ||
| maximizable: false, | ||
| fullscreenable: false, | ||
| // `hiddenInset` keeps the macOS traffic-light buttons but removes | ||
| // the title bar's chrome — same effect as Swift's | ||
| // `titlebarAppearsTransparent = true`. | ||
| titleBarStyle: "hiddenInset", | ||
| title: `About ${APP_NAME}`, | ||
| show: false, | ||
| webPreferences: { | ||
| preload: path.join(__dirname, "../preload/index.js"), | ||
| contextIsolation: true, | ||
| nodeIntegration: false, | ||
| sandbox: true, | ||
| }, | ||
|
ashleeradka marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| aboutWindow.once("ready-to-show", () => { | ||
| aboutWindow?.show(); | ||
| }); | ||
|
|
||
| aboutWindow.on("closed", () => { | ||
| aboutWindow = null; | ||
| }); | ||
|
|
||
| // The About window has no legitimate top-level navigations — its | ||
| // only outbound link routes through `window.vellum.app.openWebsite()` | ||
| // → `shell.openExternal` in main. Block every other path so the | ||
| // preload-exposed `window.vellum` surface can't be carried into a | ||
| // destination we don't control: bare-`<a>` href fallbacks, dropped | ||
| // URLs or files onto the window, `window.location` writes from | ||
| // future renderer code, etc. | ||
| aboutWindow.webContents.on("will-navigate", (event) => { | ||
| event.preventDefault(); | ||
| }); | ||
| aboutWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" })); | ||
|
|
||
| void aboutWindow.loadURL(aboutWindowUrl()); | ||
| }; | ||
|
|
||
| let installed = false; | ||
| export const installAbout = (): void => { | ||
| if (installed) return; | ||
| installed = true; | ||
|
|
||
| app.setAboutPanelOptions({ | ||
| applicationName: APP_NAME, | ||
| applicationVersion: app.getVersion(), | ||
| // The native panel renders `version` after an em-dash. Using the | ||
| // commit SHA here matches what Sparkle / Swift Vellum's About | ||
| // window shows. | ||
| version: COMMIT_SHA, | ||
| copyright: COPYRIGHT(), | ||
| website: WEBSITE, | ||
| }); | ||
|
|
||
| ipcMain.handle("vellum:app:versionInfo", () => getVersionInfo()); | ||
|
|
||
| // The renderer is sandboxed — `shell.openExternal` only works from | ||
| // main. The About page's website-link click handler routes through | ||
| // this IPC so the URL opens in the user's default browser instead | ||
| // of navigating the About window away from its route. | ||
| ipcMain.handle("vellum:app:openWebsite", () => shell.openExternal(WEBSITE)); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.