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
20 changes: 20 additions & 0 deletions apps/macos/electron.vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { execSync } from "node:child_process";

import { defineConfig, externalizeDepsPlugin } from "electron-vite";

// Reference: https://electron-vite.org/config/
Expand All @@ -13,9 +15,27 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite";
// CJS interop is handled correctly at bundle time.
const ESM_ONLY_DEPS_TO_INLINE = ["electron-store", "conf"];

// Resolved at config-evaluation time and inlined into the main bundle via
// Vite's `define`. Prefer the CI-provided GITHUB_SHA (7-char prefix);
// fall back to `git rev-parse --short HEAD` on a developer checkout; emit
// "unknown" when neither is available (e.g. building from a tarball).
const resolveBuildSha = (): string => {
if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA.slice(0, 7);
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
return "unknown";
}
};

const BUILD_SHA_DEFINE = {
__VELLUM_BUILD_SHA__: JSON.stringify(resolveBuildSha()),
};

export default defineConfig({
main: {
plugins: [externalizeDepsPlugin({ exclude: ESM_ONLY_DEPS_TO_INLINE })],
define: BUILD_SHA_DEFINE,
build: {
outDir: "out/main",
lib: {
Expand Down
200 changes: 200 additions & 0 deletions apps/macos/src/main/about.test.ts
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);
});
});
152 changes: 152 additions & 0 deletions apps/macos/src/main/about.ts
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({
Comment thread
ashleeradka marked this conversation as resolved.
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,
},
Comment thread
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));
};
Loading