diff --git a/apps/macos/electron.vite.config.ts b/apps/macos/electron.vite.config.ts index af5e146c640..2cdf891bd30 100644 --- a/apps/macos/electron.vite.config.ts +++ b/apps/macos/electron.vite.config.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + import { defineConfig, externalizeDepsPlugin } from "electron-vite"; // Reference: https://electron-vite.org/config/ @@ -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: { diff --git a/apps/macos/src/main/about.test.ts b/apps/macos/src/main/about.test.ts new file mode 100644 index 00000000000..e5898666a0f --- /dev/null +++ b/apps/macos/src/main/about.test.ts @@ -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 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; + 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 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); + }); +}); diff --git a/apps/macos/src/main/about.ts b/apps/macos/src/main/about.ts new file mode 100644 index 00000000000..ef2fc68b515 --- /dev/null +++ b/apps/macos/src/main/about.ts @@ -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, + }, + }); + + 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-`` 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)); +}; diff --git a/apps/macos/src/main/index.ts b/apps/macos/src/main/index.ts index bf2798c88a7..35fc306ce1a 100644 --- a/apps/macos/src/main/index.ts +++ b/apps/macos/src/main/index.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import { pathToFileURL } from "node:url"; import path from "node:path"; +import { installAbout } from "./about"; import { resolveAppProtocolPath } from "./app-protocol"; import { installDock } from "./dock"; import { installApplicationMenu } from "./menu"; @@ -329,13 +330,19 @@ app } installPermissionHandler(); installSettingsIpc(); + installAbout(); installApplicationMenu(); installDock(); spawnDaemon(); createWindow(); + // Dock-icon click / Cmd-Tab re-activation. Only the absence of the + // *main* window should trigger a recreate — auxiliary windows + // (About, future thread pop-outs) shouldn't count, otherwise closing + // main while an auxiliary stays open would leave the user stuck + // with no path back to the app. app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { + if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); } }); diff --git a/apps/macos/src/main/menu.ts b/apps/macos/src/main/menu.ts index 00ce2ab96b9..92b29b58e8b 100644 --- a/apps/macos/src/main/menu.ts +++ b/apps/macos/src/main/menu.ts @@ -1,5 +1,6 @@ import { Menu, type MenuItemConstructorOptions, app, shell } from "electron"; +import { openAboutWindow } from "./about"; import { dispatchToFocused, resolveAccelerator, @@ -32,7 +33,17 @@ export const installApplicationMenu = (): void => { // macOS convention: the first submenu is always the app menu. label: app.name, submenu: [ - { role: "about" }, + // Branded About window — replaces `role: "about"` so we render + // version + commit SHA + copyright in our own UI instead of + // Electron's default panel. Native panel metadata is still + // populated by `installAbout()` so AppleScript / accessibility + // queries see the right values. + { + label: `About ${app.name}`, + click: () => { + openAboutWindow(); + }, + }, { type: "separator" }, { role: "services" }, { type: "separator" }, diff --git a/apps/macos/src/preload/index.ts b/apps/macos/src/preload/index.ts index c3afd43295b..6c33d7912b8 100644 --- a/apps/macos/src/preload/index.ts +++ b/apps/macos/src/preload/index.ts @@ -18,8 +18,37 @@ export type VellumCommand = // new methods" section in `apps/macos/README.md` for the convention // (generic KV for non-sensitive prefs; dedicated `.()` // methods for sensitive capabilities). +/** + * Mirror of `AppVersionInfo` in `apps/macos/src/main/about.ts`. Kept inline + * to avoid the cross-project import — the surface is small and rarely + * changes; drift surfaces as a renderer field that's `undefined` at + * runtime rather than a build error. + */ +export interface AppVersionInfo { + appName: string; + version: string; + commitSha: string; + copyright: string; + website: string; +} + export interface VellumBridge { platform: "electron"; + app: { + /** + * Read-only metadata about the running app: name, version, commit + * SHA (injected at build time), copyright, website. Used by the + * branded About window. Safe to call from any window the preload + * is attached to. + */ + versionInfo(): Promise; + /** + * Open the marketing website in the user's default browser. The + * renderer is sandboxed so it can't call `shell.openExternal` + * itself; this routes through main. + */ + openWebsite(): Promise; + }; auth: { signIn(): Promise; signOut(): Promise; @@ -64,6 +93,12 @@ const notImplemented = (name: string) => (): Promise => const bridge: VellumBridge = { platform: "electron", + app: { + versionInfo: (): Promise => + ipcRenderer.invoke("vellum:app:versionInfo") as Promise, + openWebsite: (): Promise => + ipcRenderer.invoke("vellum:app:openWebsite") as Promise, + }, auth: { signIn: notImplemented("auth.signIn"), signOut: notImplemented("auth.signOut"), diff --git a/apps/web/src/components/about-page.tsx b/apps/web/src/components/about-page.tsx new file mode 100644 index 00000000000..610ad617c78 --- /dev/null +++ b/apps/web/src/components/about-page.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; + +import { + getAppVersionInfo, + openAppWebsite, + type AppVersionInfo, +} from "@/runtime/app-info"; +import { isElectron } from "@/runtime/is-electron"; + +/** + * Branded About page rendered inside the Electron About BrowserWindow + * (`apps/macos/src/main/about.ts`). The window is sandboxed, + * non-resizable, and chromeless except for macOS traffic-light buttons; + * the layout assumes that frame and centers content vertically. + * + * Version + commit SHA + copyright come from the Electron host via the + * `window.vellum.app.versionInfo()` bridge. Off-Electron (e.g. someone + * navigates to /assistant/about on the web build), the host wrapper + * returns `null` and the page renders a generic web fallback rather + * than crashing. + */ +export function AboutPage() { + const [info, setInfo] = useState(null); + + useEffect(() => { + void getAppVersionInfo().then(setInfo); + }, []); + + const display = info ?? FALLBACK; + + return ( +
+

{display.appName}

+

+ AI assistant for your Mac +

+
+
Version
+
+ {display.version} +
+
Build
+
+ {display.commitSha} +
+
+
{ + // Off Electron, let the browser navigate via the `href`. In + // Electron the renderer is sandboxed, so the only outbound + // path is the IPC route through `openAppWebsite()` → + // `shell.openExternal` in main; suppressing the default + // there keeps the About BrowserWindow from navigating away + // from its own route. + if (!isElectron()) return; + event.preventDefault(); + void openAppWebsite(); + }} + > + {new URL(display.website).host} + +
+ {display.copyright} +
+
+ ); +} + +const FALLBACK: AppVersionInfo = { + appName: "Vellum", + version: "—", + commitSha: "—", + copyright: `© ${new Date().getFullYear()} Vellum`, + website: "https://vellum.ai", +}; diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 0379eeadb5f..1bb307e7b49 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -79,6 +79,14 @@ export const router = createBrowserRouter( // Logout — standalone page, no app chrome { path: "/logout", ErrorBoundary: RouteErrorBoundary, HydrateFallback: RootHydrateFallback, lazy: { Component: () => import("@/domains/account/pages/logout-page").then((m) => m.LogoutPage) } }, + // About — standalone metadata page rendered inside the Electron + // About BrowserWindow. Declared as a sibling of `/assistant` (not + // a child) so React Router's most-specific matcher picks it for + // `/assistant/about` BEFORE falling into the auth-protected app + // tree below. URL sits under `/assistant/*` so it's served by + // Vite's SPA fallback in dev (which is scoped to the `base`). + { path: "/assistant/about", ErrorBoundary: RouteErrorBoundary, HydrateFallback: RootHydrateFallback, lazy: { Component: () => import("@/components/about-page").then((m) => m.AboutPage) } }, + // Assistant routes — auth-protected app with layout { path: "/assistant", diff --git a/apps/web/src/runtime/app-info.ts b/apps/web/src/runtime/app-info.ts new file mode 100644 index 00000000000..0d934043fcd --- /dev/null +++ b/apps/web/src/runtime/app-info.ts @@ -0,0 +1,31 @@ +import { isElectron } from "@/runtime/is-electron"; + +/** + * Per-capability wrapper for the Electron host's app-metadata bridge — + * version, commit SHA, copyright, website. The renderer never touches + * `window.vellum.*` directly; feature code calls these named functions + * and the cross-platform branch lives here. + * + * Today the only consumer is the About page (`components/about-page.tsx`), + * which only renders inside the Electron About BrowserWindow. The + * wrapper still gates on `isElectron()` and returns a web-shaped + * fallback so a misdirected web load doesn't crash. + */ + +export interface AppVersionInfo { + appName: string; + version: string; + commitSha: string; + copyright: string; + website: string; +} + +export async function getAppVersionInfo(): Promise { + if (!isElectron()) return null; + return (await window.vellum?.app.versionInfo()) ?? null; +} + +export async function openAppWebsite(): Promise { + if (!isElectron()) return; + await window.vellum?.app.openWebsite(); +} diff --git a/apps/web/src/runtime/is-electron.ts b/apps/web/src/runtime/is-electron.ts index dd796a52a7f..0165f7574b7 100644 --- a/apps/web/src/runtime/is-electron.ts +++ b/apps/web/src/runtime/is-electron.ts @@ -29,6 +29,16 @@ declare global { interface Window { vellum?: { platform: "electron"; + app: { + versionInfo(): Promise<{ + appName: string; + version: string; + commitSha: string; + copyright: string; + website: string; + }>; + openWebsite(): Promise; + }; settings: { get(key: string): Promise; set(key: string, value: T): Promise; diff --git a/apps/web/src/utils/routes.ts b/apps/web/src/utils/routes.ts index 7ff194b4e90..f899b82e850 100644 --- a/apps/web/src/utils/routes.ts +++ b/apps/web/src/utils/routes.ts @@ -17,6 +17,18 @@ const LOCAL_ADMIN_ORIGIN = "http://localhost:3000"; export const routes = { assistant: r("/assistant"), + /** + * Standalone About page. Lives under `/assistant/*` so it falls inside + * `apps/web/vite.config.ts`'s `base: "/assistant/"` and Vite's SPA + * fallback serves it in dev. Declared as a sibling of `/assistant` + * in `routes.tsx` rather than a child, so it bypasses the app's auth + * middleware and `RootLayout` — it's metadata, not the app. + * + * Mounted from the Electron host (`apps/macos/src/main/about.ts`) + * into a frameless BrowserWindow; the route is also reachable from + * the web build, where the runtime wrapper degrades to a "—" fallback. + */ + about: r("/assistant/about"), conversation: (key: string) => dyn(r("/assistant/conversations"), key), /** * LLM-context inspector for a single conversation. The conversation id