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
7 changes: 6 additions & 1 deletion apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ Applies to all code under `apps/`. Subordinate to root [`AGENTS.md`](../AGENTS.m
- No workspaces, no Turborepo. Per-package `bun install`. Exact version
pinning is enforced repo-wide; see root `AGENTS.md` for the dependency,
license, and tool-version rules.
- TypeScript imports use `.js` extensions (NodeNext module resolution).
- TypeScript imports use `.js` extensions. Default module resolution is
NodeNext; apps that ship with a bundler that handles ESM/CJS interop
(currently `apps/macos/` via electron-vite) may use `moduleResolution:
"Bundler"` with `module: "ESNext"` so the bundler's resolution rules
match TypeScript's view of the import graph. The `.js` extension
convention applies regardless.

## Adding a new app

Expand Down
34 changes: 31 additions & 3 deletions apps/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,46 @@ apps/macos/
├── electron.vite.config.ts # main + preload Vite entries (no renderer)
├── src/
│ ├── main/index.ts # window creation, app://, assistant supervisor
│ ├── main/settings.ts # electron-store schema + IPC-backed accessors
│ └── preload/index.ts # contextBridge: window.vellum.*
└── tsconfig.json
```

## Renderer bridge

The preload script exposes a typed `window.vellum` API to the renderer. Today
it only reports `platform: "electron"`; auth, settings, and helper methods
are typed stubs to be wired up in follow-up tickets.
The preload script exposes a typed `window.vellum` API to the renderer:

- `platform: "electron"` — host discriminator.
- `settings.get<T>(key)` / `settings.set<T>(key, value)` — persisted preferences,
backed by `electron-store` in the main process. Writes are validated against
a JSON schema (`hotkeys`, `theme`, `featureFlags`); a schema violation
surfaces as a rejected `Promise`.
- `auth.*` and `helper.*` — typed stubs that reject with "not implemented yet"
until the corresponding feature tickets land.

Verify the bridge from the renderer:

```js
console.log(window.vellum.platform); // "electron"
await window.vellum.settings.set("theme", "dark");
console.log(await window.vellum.settings.get("theme")); // "dark"
```

### When to extend the bridge with new methods

The generic `settings.{get,set}` surface is appropriate for user preferences
where the renderer is the source of truth and the value is non-sensitive
(theme, layout, feature-flag overrides, etc.). For higher-sensitivity
capabilities — auth tokens, biometric keys, file paths, anything where the
renderer should not be free to read or write arbitrary keys — add a
dedicated bridge method (`window.vellum.<capability>.<verb>()`) with its
own IPC channel. This follows Electron's "one method per IPC message"
guidance from the [security tutorial](https://www.electronjs.org/docs/latest/tutorial/security#17-validate-the-sender-of-all-ipc-messages),
which keeps the renderer-exposed surface narrow and auditable.

Renderer-side consumers in `apps/web/` should wrap bridge access in a
per-capability module (see `apps/web/src/runtime/native-biometric.ts` for
the established shape) rather than reaching into `window.vellum.*`
directly from feature code. That keeps the platform-branching logic in
one place and makes the cross-platform contract (web / iOS / Electron)
live in TypeScript types.
53 changes: 50 additions & 3 deletions apps/macos/bun.lock

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion apps/macos/electron.vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { defineConfig } from "electron-vite";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";

// Reference: https://electron-vite.org/config/
//
// No renderer config: the renderer is the apps/web/ Vite project, served in
// dev via http://localhost:5173 and in prod via a custom `app://` protocol.
//
// `electron-store` (and its `conf` parent) are ESM-only. electron-vite's
// default externalize plugin would emit `require("electron-store")` in the
// CJS main bundle, which returns the module namespace rather than the
// default export and breaks `new Store(...)`. Excluding them from
// externalization tells Rollup to bundle their ESM source inline, where the
// CJS interop is handled correctly at bundle time.
const ESM_ONLY_DEPS_TO_INLINE = ["electron-store", "conf"];

export default defineConfig({
main: {
plugins: [externalizeDepsPlugin({ exclude: ESM_ONLY_DEPS_TO_INLINE })],
build: {
outDir: "out/main",
lib: {
Expand All @@ -17,6 +27,7 @@ export default defineConfig({
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: "out/preload",
lib: {
Expand Down
3 changes: 3 additions & 0 deletions apps/macos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@
"electron-vite": "5.0.0",
"typescript": "5.9.3",
"vite": "7.3.3"
},
"dependencies": {
"electron-store": "11.0.2"
}
}
15 changes: 14 additions & 1 deletion apps/macos/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { app, BrowserWindow, net, protocol, session, shell } from "electron";
import { app, BrowserWindow, ipcMain, net, protocol, session, shell } from "electron";
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node:path";

import { readSetting, writeSetting } from "./settings.js";

const DEV_SERVER_URL = "http://localhost:5173";
const DEV_SERVER_ORIGIN = new URL(DEV_SERVER_URL).origin;
const APP_PROTOCOL = "app";
Expand Down Expand Up @@ -152,6 +154,16 @@ const installPermissionHandler = (): void => {
);
};

// IPC bridge for the `window.vellum.settings.*` API exposed by preload.
// Errors from electron-store's schema validator (thrown as SyntaxError from
// `set`) propagate as rejected Promises to the renderer.
const installSettingsIpc = (): void => {
ipcMain.handle("vellum:settings:get", (_event, key: string) => readSetting(key));
ipcMain.handle("vellum:settings:set", (_event, key: string, value: unknown) => {
writeSetting(key, value);
});
};

// ---------------------------------------------------------------------------
// Daemon supervisor
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -258,6 +270,7 @@ app
registerAppProtocol();
}
installPermissionHandler();
installSettingsIpc();
spawnDaemon();
createWindow();

Expand Down
71 changes: 71 additions & 0 deletions apps/macos/src/main/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Store, { type Schema } from "electron-store";

/**
* Persisted user preferences shape. The schema below validates writes; reads
* are returned as `null` when a key has never been written and no default
* applies. Top-level keys are the renderer-facing categories from LUM-1846 —
* additional categories get added here as future tickets need them, with a
* matching schema entry to keep validation honest.
*
* Note: window geometry (position, size) is intentionally NOT here. It's a
* main-process-managed concern in Electron (system-managed on iOS,
* browser-managed on web), and the renderer never reads or writes it. If
* window-state restore is wired in a future ticket, it lives in its own
* keyspace or via a dedicated library (e.g. `electron-window-state`).
*/
export interface AppSettings {
hotkeys: Record<string, string>;
theme: "light" | "dark" | "system";
featureFlags: Record<string, boolean>;
}

const schema: Schema<AppSettings> = {
hotkeys: {
type: "object",
additionalProperties: { type: "string" },
default: {},
},
theme: {
type: "string",
enum: ["light", "dark", "system"],
default: "system",
},
featureFlags: {
type: "object",
additionalProperties: { type: "boolean" },
default: {},
},
};

let instance: Store<AppSettings> | null = null;

const store = (): Store<AppSettings> => {
if (!instance) {
instance = new Store<AppSettings>({
schema,
// Close the root so a renderer typo (e.g. `set("them", "dark")`) is
// rejected at validation time instead of silently persisted as an
// unknown top-level key. Per-key shapes are still validated by `schema`.
rootSchema: { additionalProperties: false },
});
}
return instance;
};

/**
* Read a setting. Returns `null` (not `undefined`) when the key is absent so
* the IPC channel marshals cleanly across the contextBridge.
*/
export const readSetting = (key: string): unknown => {
const value = store().get(key as keyof AppSettings);
return value === undefined ? null : value;
};

/**
* Write a setting. electron-store validates the value against the schema and
* throws `SyntaxError` (with the ajv error message) when invalid; that
* surfaces to the renderer as a rejected Promise from `window.vellum.settings.set`.
*/
export const writeSetting = (key: string, value: unknown): void => {
store().set(key as keyof AppSettings, value as never);
};
18 changes: 12 additions & 6 deletions apps/macos/src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { contextBridge } from "electron";
import { contextBridge, ipcRenderer } from "electron";

// Surface exposed to the renderer as `window.vellum`. Implementations land in
// follow-up tickets; for now these are typed stubs so the renderer can
// feature-detect the Electron host.
// Surface exposed to the renderer as `window.vellum`. `platform` and the
// `settings` accessors are wired through IPC; `auth` and `helper` are typed
// stubs that reject with "not implemented yet" until their feature tickets
// land. When adding new bridge methods, see the "When to extend the bridge
// with new methods" section in `apps/macos/README.md` for the convention
// (generic KV for non-sensitive prefs; dedicated `<capability>.<verb>()`
// methods for sensitive capabilities).
export interface VellumBridge {
platform: "electron";
auth: {
Expand Down Expand Up @@ -30,8 +34,10 @@ const bridge: VellumBridge = {
getToken: notImplemented("auth.getToken"),
},
settings: {
get: notImplemented("settings.get"),
set: notImplemented("settings.set"),
get: <T>(key: string): Promise<T | null> =>
ipcRenderer.invoke("vellum:settings:get", key) as Promise<T | null>,
set: <T>(key: string, value: T): Promise<void> =>
ipcRenderer.invoke("vellum:settings:set", key, value) as Promise<void>,
},
helper: {
ping: notImplemented("helper.ping"),
Expand Down
4 changes: 2 additions & 2 deletions apps/macos/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023"],
"outDir": "out",
"strict": true,
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/runtime/is-electron.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
/**
* Minimal ambient declaration of the `window.vellum` bridge exposed by the
* Electron preload script (see `apps/macos/src/preload/index.ts`). Only the
* `platform` discriminator is declared here — additional bridge surfaces
* (`auth`, `settings`, `helper`, etc.) are added in the follow-up tickets
* that wire each feature so the renderer's view of the bridge stays honest
* about what's actually implemented at any given commit.
* Electron preload script (see `apps/macos/src/preload/index.ts`). Surface is
* expanded here as each follow-up ticket wires a real implementation, keeping
* the renderer's view of the bridge honest about what's actually available
* at any given commit.
*
* Feature code in `apps/web/` should NOT call `window.vellum.*` directly.
* Instead, wrap each persisted capability in a per-feature module under
* `apps/web/src/runtime/` with named functions (see `native-biometric.ts`
* for the established shape: `isBiometricEnabled()` / `setBiometricEnabled()`).
* The module owns the cross-platform branch — `isElectron()` calls into
* `window.vellum`, `isNativePlatform()` calls Capacitor, and the web branch
* uses `localStorage` — so consumers stay platform-agnostic.
*/
declare global {
interface Window {
vellum?: {
platform: "electron";
settings: {
get<T = unknown>(key: string): Promise<T | null>;
set<T = unknown>(key: string, value: T): Promise<void>;
};
};
}
}
Expand Down