diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts new file mode 100644 index 00000000..549f934d --- /dev/null +++ b/apps/desktop/src/main/app/App.ts @@ -0,0 +1,91 @@ +import { app, ipcMain } from "electron"; +import started from "electron-squirrel-startup"; +import path from "node:path"; +import { Window } from "../windows/Window"; +import { getRendererSource } from "../utils"; +import * as ipc from "../../shared/ipc/main"; +import { AppIcon } from "./AppIcon"; +import { CommandRegistry, type CommandContext } from "../commands/Command"; +import { registerCommands } from "../commands/Commands"; +import { ApplicationMenu } from "../menu/ApplicationMenu"; + +const applicationName = "Shift"; + +/** + * Owns Electron app startup and the first main-process service graph. + * + * @remarks + * `App` wires the shell-level pieces together: command registration, IPC + * registration, working-window creation, and renderer loading. Domain behavior + * should live behind the services it creates rather than accumulating here. + */ +export class App { + #workingWindow: Window | null = null; + #commands = new CommandRegistry(); + #appIcon = new AppIcon(); + #applicationMenu = new ApplicationMenu(this.#appIcon.path()); + + constructor() {} + + /** + * Starts Electron after installer-startup handling has completed. + * + * @remarks + * Commands and IPC handlers are registered before the window exists so + * renderer calls can arrive as soon as preload exposes `window.shiftHost`. + * Command handlers resolve the active window from a fresh context at run time. + */ + start(): void { + if (started) { + app.quit(); + return; + } + + this.#registerCommands(); + this.#registerIpcHandlers(); + app.setName(applicationName); + + void app.whenReady().then(() => { + this.#appIcon.install(); + this.#applicationMenu.install(); + + this.#workingWindow = new Window({ + preloadPath: path.join(__dirname, "preload.js"), + }); + + this.#loadRenderer(); + }); + } + + #loadRenderer() { + if (!this.#workingWindow) return; + + const source = getRendererSource(); + if (source.type === "url") { + // in dev load the renderer from vite at MAIN_WINDOW_VITE_DEV_SERVER_URL + this.#workingWindow.window.loadURL(source.source); + return; + } + + // otherwise this is the build, load the built file directly + this.#workingWindow.window.loadFile(source.source); + } + + #registerCommands(): void { + registerCommands(this.#commands); + } + + #registerIpcHandlers(): void { + ipc.handle(ipcMain, "commands.run", (_event, id) => { + return this.#commands.run(id, this.#commandContext()); + }); + } + + #commandContext(): CommandContext { + return { + windows: { + active: () => this.#workingWindow, + }, + }; + } +} diff --git a/apps/desktop/src/main/app/AppIcon.ts b/apps/desktop/src/main/app/AppIcon.ts new file mode 100644 index 00000000..6c6177b1 --- /dev/null +++ b/apps/desktop/src/main/app/AppIcon.ts @@ -0,0 +1,39 @@ +import { app } from "electron"; +import path from "node:path"; + +const iconFileName = "icon.png"; + +/** + * Resolves and applies the app icon used by runtime shell features. + * + * @remarks + * Packaged app icons are still owned by Forge configuration. This class covers + * runtime APIs such as the macOS Dock icon during development and About panel + * fallback icons on platforms that support `iconPath`. + */ +export class AppIcon { + /** + * Applies the runtime icon to macOS Dock when available. + * + * Electron's Dock API is macOS-only; other platforms get their taskbar/window + * icon from packaging and BrowserWindow configuration instead. + */ + install(): void { + if (process.platform !== "darwin") return; + + app.dock.setIcon(this.path()); + } + + /** + * Returns the PNG icon path available to runtime Electron APIs. + * + * @returns the packaged resource path in production, or the repo icon during development. + */ + path(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, iconFileName); + } + + return path.resolve(process.cwd(), "../../icons", iconFileName); + } +} diff --git a/apps/desktop/src/main/commands/Command.ts b/apps/desktop/src/main/commands/Command.ts new file mode 100644 index 00000000..988c55f7 --- /dev/null +++ b/apps/desktop/src/main/commands/Command.ts @@ -0,0 +1,120 @@ +import type { CommandId } from "../../shared/commands"; +import type { Window } from "../windows/Window"; + +/** + * Stores app commands and runs them against the current main-process context. + * + * @remarks + * The registry owns command lookup and enabled checks. It does not own app + * state; callers provide a fresh {@link CommandContext} each time a command + * runs so commands resolve the current window at execution time. + */ +export class CommandRegistry { + #commands = new Map(); + + /** + * Adds one command to the registry. + * + * @param command - Command definition whose ID must be unique within the registry. + * @throws {Error} when another command already uses the same ID. + */ + register(command: Command): void { + if (this.#commands.has(command.id)) { + throw new Error(`Command already registered: ${command.id}`); + } + + this.#commands.set(command.id, command); + } + + /** + * Looks up a command by ID. + * + * @param id - Command identity to resolve. + * @returns the registered command, or `null` when the ID is not registered. + */ + get(id: CommandId): Command | null { + return this.#commands.get(id) ?? null; + } + + /** + * Returns a snapshot of registered commands. + * + * @returns a fresh array; mutating it does not change the registry. + */ + list(): Command[] { + return [...this.#commands.values()]; + } + + /** + * Checks whether a command can run in the supplied context. + * + * @param id - Command identity to check. + * @param ctx - Current app state available to command implementations. + * @returns `false` when the command is missing or its enabled predicate rejects the context. + */ + isEnabled(id: CommandId, ctx: CommandContext): boolean { + const command = this.get(id); + if (!command) return false; + return command.enabled?.(ctx) ?? true; + } + + /** + * Runs a command if it is registered and enabled. + * + * @param id - Command identity to run. + * @param ctx - Current app state available to command implementations. + * @throws {Error} when the command ID is not registered. + */ + async run(id: CommandId, ctx: CommandContext): Promise { + const command = this.get(id); + if (!command) { + throw new Error(`Unknown command: ${id}`); + } + + if (!this.isEnabled(id, ctx)) { + return; + } + + await command.run(ctx); + } +} + +/** + * Main-process state exposed to command implementations. + * + * @remarks + * Accessors should resolve live state at call time. Commands should not retain + * objects from this context after they finish running. + */ +export type CommandContext = { + windows: { + /** + * Returns the current working window. + * + * @returns `null` when the app has not created a window or the window is gone. + */ + active: () => Window | null; + }; +}; + +/** + * Declarative app command owned by the main process. + * + * @remarks + * Command metadata can be reused by menus, command palettes, shortcuts, and + * renderer chrome while the `run` callback remains the single behavior source. + */ +export type Command = { + /** Stable command identity shared across process boundaries. */ + id: CommandId; + /** Human-readable label suitable for menus or command palettes. */ + label: string; + /** Optional longer explanation for command palettes or accessibility hints. */ + description?: string; + /** Optional Electron accelerator string for native menu bindings. */ + accelerator?: string; + /** Returns whether the command can run in the current app context. */ + enabled?: (ctx: CommandContext) => boolean; + /** Performs the command's main-process side effects. */ + run: (ctx: CommandContext) => void | Promise; +}; diff --git a/apps/desktop/src/main/commands/Commands.ts b/apps/desktop/src/main/commands/Commands.ts new file mode 100644 index 00000000..1520f18a --- /dev/null +++ b/apps/desktop/src/main/commands/Commands.ts @@ -0,0 +1,54 @@ +import type { Command } from "./Command"; +import type { CommandRegistry } from "./Command"; + +const windowCommands: Command[] = [ + { + id: "window.close", + label: "Close Window", + accelerator: "CmdOrCtrl+W", + enabled: (ctx) => ctx.windows.active() !== null, + run: (ctx) => { + ctx.windows.active()?.close(); + }, + }, + { + id: "window.minimise", + label: "Minimise Window", + accelerator: "CmdOrCtrl+M", + enabled: (ctx) => ctx.windows.active() !== null, + run: (ctx) => { + ctx.windows.active()?.minimize(); + }, + }, + { + id: "window.maximise", + label: "Maximise Window", + enabled: (ctx) => ctx.windows.active() !== null, + run: (ctx) => { + ctx.windows.active()?.toggleMaximize(); + }, + }, +]; + +const fileCommands: Command[] = []; +const editCommands: Command[] = []; + +/** + * Snapshot of commands available to the app shell. + * + * Group commands by domain above, then compose them here so registration, + * menus, and future command-palette code read from the same source. + */ +export const commands: Command[] = [...windowCommands, ...fileCommands, ...editCommands]; + +/** + * Registers every app command into the supplied registry. + * + * @param registry - Registry that receives the command definitions for this app instance. + * @throws {Error} when two commands use the same ID. + */ +export function registerCommands(registry: CommandRegistry): void { + for (const command of commands) { + registry.register(command); + } +} diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 9949887f..cfd94530 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -6,7 +6,7 @@ Electron main process: application lifecycle, window management, menus, document - **Architecture Invariant:** All managers receive dependencies via constructor injection in `main.ts`. `DocumentState` is the root dependency; `WindowManager` depends on it; `MenuManager` depends on both; `AppLifecycle` depends on all three. - **Architecture Invariant:** `DocumentState` is the single source of truth for dirty state and file path. All save/close dialogs flow through `DocumentState.confirmClose`. No manager may show its own save dialog. -- **Architecture Invariant:** IPC channels are type-safe. All `ipcMain.handle` calls use the typed `ipc.handle` wrapper from `shared/ipc/main`, and all `webContents.send` calls use the typed `ipc.send` wrapper. Channel names and payload types are defined in `IpcCommands` (renderer-to-main) and `IpcEvents` (main-to-renderer). +- **Architecture Invariant:** IPC channels are type-safe. All `ipcMain.handle` calls use the typed `ipc.handle` wrapper from `shared/ipc/main`, and all `webContents.send` calls use the typed `ipc.send` wrapper. Channel names and payload types are defined in `RendererToMain` and `MainToRenderer`. - **Architecture Invariant: CRITICAL:** `main.ts` enforces a single-instance lock via `app.requestSingleInstanceLock()`. The second instance forwards its argv to the first instance via the `second-instance` event and then quits. Removing this breaks file-association double-click on all platforms. - **Architecture Invariant: CRITICAL:** The `before-quit` handler in `AppLifecycle` must call `event.preventDefault()` before the async `confirmClose` check. If the guard is removed, the app quits before the save dialog can appear. - **Architecture Invariant:** `.designspace` is the default writable format, with `.ufo` still accepted for direct UFO saves (`DocumentState.isWritableFormat`). Saving other imported formats triggers Save As. Autosave skips non-writable files silently. @@ -31,8 +31,8 @@ src/main/ - `ThemeName` -- `"light" | "dark" | "system"`, stored in `MenuManager.currentTheme` - `Debug` -- aggregates `reactScanEnabled`, `debugPanelOpen`, and `DebugOverlays`; only used in dev builds - `DebugOverlays` -- per-overlay booleans (`tightBounds`, `hitRadii`, `segmentBounds`, `glyphBbox`) -- `IpcCommands` -- renderer-to-main request/response channels (invoke/handle) -- `IpcEvents` -- main-to-renderer broadcast channels (send/on) +- `RendererToMain` -- renderer-to-main request/response channels (invoke/handle) +- `MainToRenderer` -- main-to-renderer broadcast channels (send/on) - `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`, `.designspace`) ## How it works @@ -69,20 +69,20 @@ IPC handlers are split across managers. `WindowManager` registers window-control ### Add a new IPC command (renderer calls main) -1. Add the channel signature to `IpcCommands` in `shared/ipc/channels.ts`. +1. Add the channel signature to `RendererToMain` in `shared/ipc/contract.ts`. 2. Add the handler using `ipc.handle(ipcMain, "your:channel", ...)` in whichever manager owns the domain. 3. Expose it in the preload layer (see preload DOCS.md). ### Add a new main-to-renderer event -1. Add the channel signature to `IpcEvents` in `shared/ipc/channels.ts`. +1. Add the channel signature to `MainToRenderer` in `shared/ipc/contract.ts`. 2. Send via `ipc.send(webContents, "your:channel", ...)` from a manager. 3. Listen in the renderer via the preload bridge. ### Add a menu item 1. Add a new entry in `MenuManager.create`'s template array under the appropriate submenu. -2. If it triggers a renderer action, add a channel to `IpcEvents` and call `this.sendToRenderer(...)`. +2. If it triggers a renderer action, add a channel to `MainToRenderer` and send it through the typed IPC wrapper. 3. Call `this.create()` if the menu item state needs to update after clicking (checkboxes, radios). ### Support a new writable format @@ -108,7 +108,7 @@ IPC handlers are split across managers. `WindowManager` registers window-control ## Related -- `IpcCommands`, `IpcEvents` -- type-safe IPC channel definitions in `shared/ipc/channels.ts` +- `RendererToMain`, `MainToRenderer` -- type-safe IPC channel definitions in `shared/ipc/contract.ts` - `ipc.send`, `ipc.handle` -- typed wrappers in `shared/ipc/main.ts` - `ThemeName`, `Debug`, `DebugOverlays` -- shared types in `shared/ipc/types.ts` - Preload bridge -- exposes IPC to renderer (see preload DOCS.md) diff --git a/apps/desktop/src/main/document/WorkingDocument.ts b/apps/desktop/src/main/document/WorkingDocument.ts new file mode 100644 index 00000000..02fe57b6 --- /dev/null +++ b/apps/desktop/src/main/document/WorkingDocument.ts @@ -0,0 +1,9 @@ +/** + * Main-process shell record for the document currently associated with a window. + * + * It tracks the OS/app-shell facts Electron main needs: + * working-store location, user-facing save path, display name, and dirty state. + */ +export class WorkingDocument { + constructor() {} +} diff --git a/apps/desktop/src/main/logger.ts b/apps/desktop/src/main/logger.ts deleted file mode 100644 index daae2ebd..00000000 --- a/apps/desktop/src/main/logger.ts +++ /dev/null @@ -1,14 +0,0 @@ -function formatDetails(details: unknown[]): string { - if (details.length === 0) return ""; - - try { - return ` ${JSON.stringify(details)}`; - } catch { - return ` ${details.map(String).join(" ")}`; - } -} - -export function mainLog(scope: string, message: string, ...details: unknown[]): void { - const timestamp = new Date().toISOString(); - process.stdout.write(`[shift:${scope}] ${timestamp} ${message}${formatDetails(details)}\n`); -} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c5f365ad..a557e095 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,24 +1,4 @@ -import { app } from "electron"; -import started from "electron-squirrel-startup"; -import { AppLifecycle, DocumentState, MenuManager, WindowManager } from "./managers"; +import { App } from "./app/App"; -if (started) { - app.quit(); -} else { - const hasSingleInstanceLock = app.requestSingleInstanceLock(); - if (!hasSingleInstanceLock) { - app.quit(); - } else { - const documentState = new DocumentState(); - const windowManager = new WindowManager(documentState); - const menuManager = new MenuManager(documentState, windowManager); - const appLifecycle = new AppLifecycle(documentState, windowManager, menuManager); - - app.on("second-instance", (_event, argv) => { - appLifecycle.handleSecondInstance(argv); - }); - - appLifecycle.handleLaunchArgs(process.argv); - appLifecycle.initialize(); - } -} +const ShiftApp = new App(); +ShiftApp.start(); diff --git a/apps/desktop/src/main/managers/AppLifecycle.ts b/apps/desktop/src/main/managers/AppLifecycle.ts deleted file mode 100644 index e7452ad7..00000000 --- a/apps/desktop/src/main/managers/AppLifecycle.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { app, BrowserWindow, globalShortcut, ipcMain, dialog } from "electron"; -import fs from "node:fs"; -import path from "node:path"; -import type { DocumentState } from "./DocumentState"; -import type { WindowManager } from "./WindowManager"; -import type { MenuManager } from "./MenuManager"; -import { extractFirstFontPath, normalizeFontPath } from "./openFontPath"; -import * as ipc from "../../shared/ipc/main"; - -export class AppLifecycle { - private documentState: DocumentState; - private windowManager: WindowManager; - private menuManager: MenuManager; - private isQuitting = false; - private pendingExternalOpenPaths: string[] = []; - private processingExternalOpenPath = false; - - constructor( - documentState: DocumentState, - windowManager: WindowManager, - menuManager: MenuManager, - ) { - this.documentState = documentState; - this.windowManager = windowManager; - this.menuManager = menuManager; - } - - initialize() { - this.registerAppEvents(); - this.registerIpcHandlers(); - } - - public handleLaunchArgs(argv: readonly string[]): void { - const filePath = extractFirstFontPath(argv); - if (!filePath) return; - this.enqueueExternalOpenPath(filePath); - } - - public handleSecondInstance(argv: readonly string[]): void { - this.focusWindow(); - this.handleLaunchArgs(argv); - } - - private registerAppEvents() { - app.on("ready", () => { - this.windowManager.create(); - this.menuManager.create(); - this.setupDockIcon(); - this.registerDevShortcuts(); - this.registerDevToolsListeners(); - this.processPendingExternalOpenPaths(); - }); - - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - this.windowManager.create(); - this.registerDevShortcuts(); - this.registerDevToolsListeners(); - } - this.processPendingExternalOpenPaths(); - }); - - app.on("open-file", (event, filePath) => { - event.preventDefault(); - this.enqueueExternalOpenPath(filePath); - }); - - app.on("before-quit", async (event) => { - if (this.isQuitting) { - return; - } - - event.preventDefault(); - - const shouldQuit = await this.documentState.confirmClose(); - if (shouldQuit) { - this.isQuitting = true; - this.windowManager.setQuitting(true); - this.documentState.stopAutosave(); - app.quit(); - } - }); - - app.on("will-quit", () => { - globalShortcut.unregisterAll(); - }); - } - - private setupDockIcon() { - if (process.platform === "darwin") { - const iconPath = app.isPackaged - ? path.join(process.resourcesPath, "icon.png") - : path.join(app.getAppPath(), "../../icons/icon.png"); - app.dock?.setIcon(iconPath); - } - } - - private registerDevShortcuts() { - const window = this.windowManager.getWindow(); - if (!window) return; - - window.webContents.once("did-finish-load", () => { - globalShortcut.register( - process.platform === "darwin" ? "Command+Shift+R" : "Control+Shift+R", - () => { - const win = this.windowManager.getWindow(); - if (win?.isFocused()) { - win.reload(); - } - }, - ); - }); - } - - private registerDevToolsListeners() { - const window = this.windowManager.getWindow(); - if (!window) return; - - window.webContents.on("devtools-opened", () => { - ipc.send(window.webContents, "devtools-toggled"); - }); - - window.webContents.on("devtools-closed", () => { - ipc.send(window.webContents, "devtools-toggled"); - }); - } - - private focusWindow(): void { - const window = this.windowManager.getWindow(); - if (!window) return; - if (window.isMinimized()) { - window.restore(); - } - window.focus(); - } - - private enqueueExternalOpenPath(filePath: string): void { - const normalizedPath = normalizeFontPath(filePath); - if (!normalizedPath) return; - this.pendingExternalOpenPaths.push(normalizedPath); - this.processPendingExternalOpenPaths(); - } - - private processPendingExternalOpenPaths(): void { - if (this.processingExternalOpenPath) return; - if (this.pendingExternalOpenPaths.length === 0) return; - if (!app.isReady()) return; - - let window = this.windowManager.getWindow(); - if (!window) { - window = this.windowManager.create(); - this.registerDevShortcuts(); - this.registerDevToolsListeners(); - } - if (!window) return; - - if (window.webContents.isLoadingMainFrame()) { - window.webContents.once("did-finish-load", () => this.processPendingExternalOpenPaths()); - return; - } - - const filePath = this.pendingExternalOpenPaths.shift(); - if (!filePath) return; - - this.processingExternalOpenPath = true; - void this.openExternalFont(filePath) - .catch((error) => { - console.error("Failed to open external font:", error); - }) - .finally(() => { - this.processingExternalOpenPath = false; - this.processPendingExternalOpenPaths(); - }); - } - - private async openExternalFont(filePath: string): Promise { - const window = this.windowManager.getWindow(); - if (!window) { - this.pendingExternalOpenPaths.unshift(filePath); - return; - } - - this.focusWindow(); - - if (this.documentState.isDirty()) { - const shouldOpen = await this.documentState.confirmClose(); - if (!shouldOpen) return; - } - - ipc.send(window.webContents, "external:open-font", filePath); - } - - private registerIpcHandlers() { - ipc.handle(ipcMain, "theme:get", () => this.menuManager.getTheme()); - - ipc.handle(ipcMain, "theme:set", (_event, theme) => { - this.menuManager.setTheme(theme); - }); - - ipc.handle(ipcMain, "debug:getState", () => this.menuManager.getDebug()); - - ipc.handle(ipcMain, "dialog:openFont", async () => { - const result = await dialog.showOpenDialog({ - properties: ["openFile", "openDirectory"], - filters: [ - { - name: "Fonts", - extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"], - }, - ], - }); - if (!result.canceled && result.filePaths[0]) { - return result.filePaths[0]; - } - return null; - }); - - ipc.handle(ipcMain, "fs:pathsExist", async (_event, paths: string[]) => { - const results = await Promise.all( - paths.map(async (filePath) => { - try { - await fs.promises.access(filePath, fs.constants.F_OK); - return true; - } catch { - return false; - } - }), - ); - return results; - }); - } -} diff --git a/apps/desktop/src/main/managers/DocumentState.ts b/apps/desktop/src/main/managers/DocumentState.ts deleted file mode 100644 index c061c04d..00000000 --- a/apps/desktop/src/main/managers/DocumentState.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { dialog, BrowserWindow } from "electron"; -import path from "node:path"; -import * as ipc from "../../shared/ipc/main"; - -const AUTOSAVE_INTERVAL_MS = 30_000; - -export class DocumentState { - private dirty = false; - private filePath: string | null = null; - private autosaveIntervalId: ReturnType | null = null; - private window: BrowserWindow | null = null; - private onTitleUpdate: (() => void) | null = null; - - setWindow(window: BrowserWindow | null) { - this.window = window; - } - - setOnTitleUpdate(callback: () => void) { - this.onTitleUpdate = callback; - } - - isDirty(): boolean { - return this.dirty; - } - - getFilePath(): string | null { - return this.filePath; - } - - getFileName(): string { - return this.filePath ? path.basename(this.filePath) : "Untitled"; - } - - setDirty(dirty: boolean) { - this.dirty = dirty; - if (this.onTitleUpdate) this.onTitleUpdate(); - } - - setFilePath(filePath: string | null) { - this.filePath = filePath; - if (!this.onTitleUpdate) return; - - this.onTitleUpdate(); - } - - private isWritableFormat(filePath: string | null): boolean { - if (!filePath) return false; - return filePath.endsWith(".designspace") || filePath.endsWith(".ufo"); - } - - async save(saveAs = false): Promise { - if (this.filePath && !this.isWritableFormat(this.filePath) && !saveAs) { - return false; - } - - let savePath = this.filePath; - - if (!savePath || saveAs || !this.isWritableFormat(savePath)) { - let defaultPath = "Untitled.designspace"; - if (this.filePath) { - const baseName = path.basename(this.filePath, path.extname(this.filePath)); - defaultPath = `${baseName}.designspace`; - } - - const result = await dialog.showSaveDialog({ - defaultPath, - filters: [ - { name: "Designspace Files", extensions: ["designspace"] }, - { name: "UFO Files", extensions: ["ufo"] }, - ], - }); - - if (result.canceled || !result.filePath) { - return false; - } - - savePath = result.filePath; - if (!savePath.endsWith(".designspace") && !savePath.endsWith(".ufo")) { - savePath += ".designspace"; - } - } - - if (this.window) { - ipc.send(this.window.webContents, "menu:save-font", savePath); - } - return true; - } - - async confirmClose(): Promise { - if (!this.dirty) { - return true; - } - - if (!this.window) { - return true; - } - - const fileName = this.getFileName(); - - const { response } = await dialog.showMessageBox(this.window, { - type: "question", - buttons: ["Don't Save", "Cancel", "Save"], - defaultId: 2, - cancelId: 1, - message: `Do you want to save changes to "${fileName}"?`, - detail: "Your changes will be lost if you don't save.", - }); - - if (response === 1) { - return false; - } - - if (response === 2) { - const saved = await this.save(false); - if (!saved) { - return false; - } - } - - return true; - } - - onSaveCompleted(filePath: string) { - this.setFilePath(filePath); - this.setDirty(false); - } - - startAutosave() { - if (this.autosaveIntervalId) return; - - this.autosaveIntervalId = setInterval(() => { - if (this.dirty && this.filePath && this.isWritableFormat(this.filePath)) { - this.save(false); - } - }, AUTOSAVE_INTERVAL_MS); - } - - stopAutosave() { - if (this.autosaveIntervalId) { - clearInterval(this.autosaveIntervalId); - this.autosaveIntervalId = null; - } - } -} diff --git a/apps/desktop/src/main/managers/MenuManager.ts b/apps/desktop/src/main/managers/MenuManager.ts deleted file mode 100644 index ea2a3d85..00000000 --- a/apps/desktop/src/main/managers/MenuManager.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { Menu, dialog, nativeTheme, app } from "electron"; -import type { DocumentState } from "./DocumentState"; -import type { WindowManager } from "./WindowManager"; -import type { ThemeName, DebugOverlays, Debug } from "../../shared/ipc/types"; -import type { IpcEvents } from "../../shared/ipc/channels"; -import * as ipc from "../../shared/ipc/main"; -import { mainLog } from "../logger"; - -export class MenuManager { - private documentState: DocumentState; - private windowManager: WindowManager; - private currentTheme: ThemeName = "light"; - private debugState: Debug = { - reactScanEnabled: false, - debugPanelOpen: false, - overlays: { - tightBounds: false, - hitRadii: false, - segmentBounds: false, - glyphBbox: false, - }, - }; - - constructor(documentState: DocumentState, windowManager: WindowManager) { - this.documentState = documentState; - this.windowManager = windowManager; - } - - getTheme(): ThemeName { - return this.currentTheme; - } - - getDebug(): Debug { - return { ...this.debugState }; - } - - private sendToRenderer( - channel: K, - ...args: Parameters - ): void { - const webContents = this.windowManager.getWindow()?.webContents; - if (!webContents) { - mainLog("menu", `drop ${String(channel)}: no renderer window`); - return; - } - - mainLog("menu", `send ${String(channel)}`, ...args); - ipc.send(webContents, channel, ...args); - } - - private setDebug(key: K, value: Debug[K]) { - this.debugState[key] = value; - switch (key) { - case "reactScanEnabled": - this.sendToRenderer("debug:react-scan", value as boolean); - break; - case "debugPanelOpen": - this.sendToRenderer("debug:panel", value as boolean); - break; - case "overlays": - this.sendToRenderer("debug:overlays", value as DebugOverlays); - break; - } - this.create(); - } - - private toggleOverlay(key: keyof DebugOverlays): void { - this.debugState.overlays[key] = !this.debugState.overlays[key]; - this.sendToRenderer("debug:overlays", { ...this.debugState.overlays }); - this.create(); - } - - private static ZOOM_LEVELS = [ - 25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500, - ]; - - private zoomLevelToPercent(zoomLevel: number): number { - return Math.round(Math.pow(1.2, zoomLevel) * 100); - } - - private percentToZoomLevel(percent: number): number { - return Math.log(percent / 100) / Math.log(1.2); - } - - private getNextZoomPercent(currentPercent: number): number { - const levels = MenuManager.ZOOM_LEVELS; - for (const level of levels) { - if (level > currentPercent + 1) return level; - } - return levels[levels.length - 1]; - } - - private getPrevZoomPercent(currentPercent: number): number { - const levels = MenuManager.ZOOM_LEVELS; - for (let i = levels.length - 1; i >= 0; i--) { - if (levels[i] < currentPercent - 1) return levels[i]; - } - return levels[0]; - } - - setTheme(theme: ThemeName) { - this.currentTheme = theme; - this.sendToRenderer("theme:set", theme); - - if (theme === "system") { - nativeTheme.themeSource = "system"; - } else { - nativeTheme.themeSource = theme; - } - - this.create(); - } - - create() { - const isMac = process.platform === "darwin"; - - const template: Electron.MenuItemConstructorOptions[] = [ - ...(isMac ? [{ role: "appMenu" as const }] : []), - { - label: "File", - submenu: [ - { - label: "New Font", - accelerator: "CmdOrCtrl+N", - click: async () => { - if (!(await this.documentState.confirmClose())) return; - this.sendToRenderer("document:new"); - }, - }, - { - label: "Open Font...", - accelerator: "CmdOrCtrl+O", - click: async () => { - if (!(await this.documentState.confirmClose())) return; - const result = await dialog.showOpenDialog({ - properties: ["openFile", "openDirectory"], - filters: [ - { - name: "Fonts", - extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"], - }, - ], - }); - if (!result.canceled && result.filePaths[0]) { - this.sendToRenderer("menu:open-font", result.filePaths[0]); - } - }, - }, - { type: "separator" }, - { - label: "Save", - accelerator: "CmdOrCtrl+S", - click: () => this.documentState.save(false), - }, - { - label: "Save As...", - accelerator: "CmdOrCtrl+Shift+S", - click: () => this.documentState.save(true), - }, - { - label: "Export TTF...", - accelerator: "CmdOrCtrl+E", - click: async () => { - const baseName = this.documentState - .getFileName() - .replace(/\.[^.]+$/, "") - .replace(/\.designspace$/, ""); - const result = await dialog.showSaveDialog({ - defaultPath: `${baseName || "Untitled"}.ttf`, - filters: [{ name: "TrueType Font", extensions: ["ttf"] }], - }); - if (!result.canceled && result.filePath) { - const exportPath = result.filePath.endsWith(".ttf") - ? result.filePath - : `${result.filePath}.ttf`; - this.sendToRenderer("menu:export-font", exportPath); - } - }, - }, - { type: "separator" }, - isMac ? { role: "close" } : { role: "quit" }, - ], - }, - { - label: "Edit", - submenu: [ - { - label: "Undo", - accelerator: "CmdOrCtrl+Z", - click: () => { - this.sendToRenderer("menu:undo"); - }, - }, - { - label: "Redo", - accelerator: "CmdOrCtrl+Shift+Z", - click: () => { - this.sendToRenderer("menu:redo"); - }, - }, - { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { - label: "Delete", - accelerator: "Backspace", - click: () => { - this.sendToRenderer("menu:delete"); - }, - }, - { type: "separator" }, - { - label: "Select All", - click: () => { - this.sendToRenderer("menu:select-all"); - }, - }, - ], - }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { - label: "Zoom In", - accelerator: "CmdOrCtrl+Plus", - click: () => { - const win = this.windowManager.getWindow(); - if (win) { - const currentPercent = this.zoomLevelToPercent(win.webContents.getZoomLevel()); - const newPercent = this.getNextZoomPercent(currentPercent); - win.webContents.setZoomLevel(this.percentToZoomLevel(newPercent)); - ipc.send(win.webContents, "ui:zoom-changed", newPercent); - } - }, - }, - { - label: "Zoom Out", - accelerator: "CmdOrCtrl+Shift+-", - click: () => { - const win = this.windowManager.getWindow(); - if (win) { - const currentPercent = this.zoomLevelToPercent(win.webContents.getZoomLevel()); - const newPercent = this.getPrevZoomPercent(currentPercent); - win.webContents.setZoomLevel(this.percentToZoomLevel(newPercent)); - ipc.send(win.webContents, "ui:zoom-changed", newPercent); - } - }, - }, - { - label: "Reset Zoom", - accelerator: "CmdOrCtrl+0", - click: () => { - const win = this.windowManager.getWindow(); - if (win) { - win.webContents.setZoomLevel(0); - ipc.send(win.webContents, "ui:zoom-changed", 100); - } - }, - }, - { type: "separator" }, - { - label: "Theme", - submenu: [ - { - label: "Light", - type: "radio", - checked: this.currentTheme === "light", - click: () => this.setTheme("light"), - }, - { - label: "Dark", - type: "radio", - checked: this.currentTheme === "dark", - click: () => this.setTheme("dark"), - }, - { - label: "System", - type: "radio", - checked: this.currentTheme === "system", - click: () => this.setTheme("system"), - }, - ], - }, - ], - }, - ...(!app.isPackaged - ? [ - { - label: "Debug", - submenu: [ - { - label: "React Scan", - type: "checkbox" as const, - checked: this.debugState.reactScanEnabled, - click: () => this.setDebug("reactScanEnabled", !this.debugState.reactScanEnabled), - }, - { - label: "Debug Panel", - type: "checkbox" as const, - checked: this.debugState.debugPanelOpen, - click: () => this.setDebug("debugPanelOpen", !this.debugState.debugPanelOpen), - }, - { type: "separator" as const }, - { - label: "Dump Glyph Snapshot", - accelerator: "CmdOrCtrl+Shift+D", - click: () => { - this.sendToRenderer("debug:dump-snapshot"); - }, - }, - { - label: "Dump Selection Patterns", - accelerator: "CmdOrCtrl+Alt+D", - click: () => { - this.sendToRenderer("debug:dump-selection-patterns"); - }, - }, - { type: "separator" as const }, - { - label: "Debug Overlays", - submenu: [ - { - label: "Tight Bounds on Hover", - type: "checkbox" as const, - checked: this.debugState.overlays.tightBounds, - click: () => this.toggleOverlay("tightBounds"), - }, - { - label: "Hit Test Radii", - type: "checkbox" as const, - checked: this.debugState.overlays.hitRadii, - click: () => this.toggleOverlay("hitRadii"), - }, - { - label: "Segment Bounding Boxes", - type: "checkbox" as const, - checked: this.debugState.overlays.segmentBounds, - click: () => this.toggleOverlay("segmentBounds"), - }, - { - label: "Glyph Bounding Box", - type: "checkbox" as const, - checked: this.debugState.overlays.glyphBbox, - click: () => this.toggleOverlay("glyphBbox"), - }, - ], - }, - ], - }, - ] - : []), - ]; - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); - } -} diff --git a/apps/desktop/src/main/managers/WindowManager.ts b/apps/desktop/src/main/managers/WindowManager.ts deleted file mode 100644 index acee5721..00000000 --- a/apps/desktop/src/main/managers/WindowManager.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BrowserWindow, ipcMain } from "electron"; -import path from "node:path"; -import type { DocumentState } from "./DocumentState"; -import * as ipc from "../../shared/ipc/main"; - -declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string; - -export class WindowManager { - private window: BrowserWindow | null = null; - private documentState: DocumentState; - private isQuitting = false; - - constructor(documentState: DocumentState) { - this.documentState = documentState; - this.registerIpcHandlers(); - } - - setQuitting(quitting: boolean) { - this.isQuitting = quitting; - } - - getWindow(): BrowserWindow | null { - return this.window; - } - - create() { - this.window = new BrowserWindow({ - width: 800, - height: 600, - minWidth: 1200, - title: "Shift", - titleBarStyle: "hidden", - trafficLightPosition: { x: -100, y: -100 }, - webPreferences: { - preload: path.join(__dirname, "preload.js"), - sandbox: false, - }, - }); - - this.window.maximize(); - this.updateTitle(); - this.documentState.setWindow(this.window); - this.documentState.setOnTitleUpdate(() => this.updateTitle()); - this.documentState.startAutosave(); - - if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { - this.window.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); - } else { - this.window.loadFile(path.join(__dirname, `../renderer/index.html`)); - } - - this.window.on("close", async (event) => { - if (this.isQuitting) { - this.documentState.stopAutosave(); - return; - } - - if (!this.documentState.isDirty()) { - this.documentState.stopAutosave(); - return; - } - - event.preventDefault(); - - const shouldClose = await this.documentState.confirmClose(); - if (shouldClose) { - this.documentState.stopAutosave(); - this.window?.destroy(); - } - }); - - this.window.on("closed", () => { - this.window = null; - this.documentState.setWindow(null); - }); - - return this.window; - } - - updateTitle() { - if (!this.window) return; - - const fileName = this.documentState.getFileName(); - const dirty = this.documentState.isDirty(); - const title = this.documentState.getFilePath() - ? `${fileName}${dirty ? " — Edited" : ""}` - : `Untitled Font${dirty ? " — Edited" : ""}`; - - this.window.setTitle(title); - this.window.setDocumentEdited(dirty); - } - - private registerIpcHandlers() { - ipc.handle(ipcMain, "window:close", () => { - this.window?.close(); - }); - - ipc.handle(ipcMain, "window:minimize", () => { - this.window?.minimize(); - }); - - ipc.handle(ipcMain, "window:maximize", () => { - if (this.window?.isMaximized()) { - this.window.unmaximize(); - } else { - this.window?.maximize(); - } - }); - - ipc.handle(ipcMain, "window:isMaximized", () => { - return this.window?.isMaximized() ?? false; - }); - - ipc.handle(ipcMain, "document:setDirty", (_event, dirty) => { - this.documentState.setDirty(dirty); - }); - - ipc.handle(ipcMain, "document:setFilePath", (_event, filePath) => { - this.documentState.setFilePath(filePath); - }); - - ipc.handle(ipcMain, "document:saveCompleted", (_event, filePath) => { - this.documentState.onSaveCompleted(filePath); - }); - } -} diff --git a/apps/desktop/src/main/managers/index.ts b/apps/desktop/src/main/managers/index.ts deleted file mode 100644 index c47fae43..00000000 --- a/apps/desktop/src/main/managers/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AppLifecycle } from "./AppLifecycle"; -export { DocumentState } from "./DocumentState"; -export { MenuManager } from "./MenuManager"; -export { WindowManager } from "./WindowManager"; diff --git a/apps/desktop/src/main/managers/openFontPath.test.ts b/apps/desktop/src/main/managers/openFontPath.test.ts deleted file mode 100644 index 97bb68b6..00000000 --- a/apps/desktop/src/main/managers/openFontPath.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import path from "node:path"; -import { extractFirstFontPath, isSupportedFontPath, normalizeFontPath } from "./openFontPath"; - -describe("openFontPath", () => { - describe("isSupportedFontPath", () => { - it("accepts supported font extensions", () => { - expect(isSupportedFontPath("/tmp/font.ufo")).toBe(true); - expect(isSupportedFontPath("/tmp/font.ttf")).toBe(true); - expect(isSupportedFontPath("/tmp/font.otf")).toBe(true); - expect(isSupportedFontPath("/tmp/font.OTF")).toBe(true); - expect(isSupportedFontPath("/tmp/font.glyphs")).toBe(true); - expect(isSupportedFontPath("/tmp/font.glyphspackage")).toBe(true); - expect(isSupportedFontPath("/tmp/font.GLYPHSPACKAGE")).toBe(true); - expect(isSupportedFontPath("/tmp/font.designspace")).toBe(true); - expect(isSupportedFontPath("/tmp/font.DESIGNSPACE")).toBe(true); - }); - - it("rejects unsupported extensions", () => { - expect(isSupportedFontPath("/tmp/font.txt")).toBe(false); - expect(isSupportedFontPath("/tmp/font")).toBe(false); - }); - }); - - describe("normalizeFontPath", () => { - it("returns normalized path for supported files", () => { - const source = ` ${path.join("tmp", "a", "..", "font.ufo")} `; - expect(normalizeFontPath(source)).toBe(path.normalize(path.join("tmp", "font.ufo"))); - }); - - it("returns null for unsupported files", () => { - expect(normalizeFontPath("font.txt")).toBeNull(); - expect(normalizeFontPath("")).toBeNull(); - }); - }); - - describe("extractFirstFontPath", () => { - it("returns the first valid font path from argv", () => { - const first = path.join("tmp", "A.otf"); - const second = path.join("tmp", "B.ttf"); - const argv = ["electron", ".", "--inspect", first, second]; - expect(extractFirstFontPath(argv)).toBe(path.normalize(first)); - }); - - it("returns null when argv contains no supported font paths", () => { - const argv = ["electron", ".", "--inspect", "--foo=bar", "/tmp/readme.md"]; - expect(extractFirstFontPath(argv)).toBeNull(); - }); - }); -}); diff --git a/apps/desktop/src/main/managers/openFontPath.ts b/apps/desktop/src/main/managers/openFontPath.ts deleted file mode 100644 index ca318c03..00000000 --- a/apps/desktop/src/main/managers/openFontPath.ts +++ /dev/null @@ -1,32 +0,0 @@ -import path from "node:path"; - -const SUPPORTED_FONT_EXTENSIONS = new Set([ - ".ufo", - ".ttf", - ".otf", - ".glyphs", - ".glyphspackage", - ".designspace", -]); - -export function isSupportedFontPath(filePath: string): boolean { - const ext = path.extname(filePath).toLowerCase(); - return SUPPORTED_FONT_EXTENSIONS.has(ext); -} - -export function normalizeFontPath(filePath: string): string | null { - const candidate = filePath.trim(); - if (!candidate || !isSupportedFontPath(candidate)) { - return null; - } - return path.normalize(candidate); -} - -export function extractFirstFontPath(argv: readonly string[]): string | null { - for (const arg of argv) { - if (!arg || arg.startsWith("-")) continue; - const normalized = normalizeFontPath(arg); - if (normalized) return normalized; - } - return null; -} diff --git a/apps/desktop/src/main/menu/ApplicationMenu.ts b/apps/desktop/src/main/menu/ApplicationMenu.ts new file mode 100644 index 00000000..fe475935 --- /dev/null +++ b/apps/desktop/src/main/menu/ApplicationMenu.ts @@ -0,0 +1,71 @@ +import { app, Menu, type MenuItemConstructorOptions } from "electron"; + +const isMac = process.platform === "darwin"; + +/** + * Builds and installs the native application menu. + * + * @remarks + * Native OS roles belong here directly. Shift-specific behavior should route + * through the command registry so menus, shortcuts, and renderer chrome share + * the same command implementation. + */ +export class ApplicationMenu { + readonly #aboutIconPath: string; + + constructor(aboutIconPath: string) { + this.#aboutIconPath = aboutIconPath; + } + + /** Installs the current menu template as Electron's application menu. */ + install(): void { + this.configureAboutPanel(); + Menu.setApplicationMenu(this.build()); + } + + /** Configures the native About panel opened by Electron's `about` role. */ + configureAboutPanel(): void { + app.setAboutPanelOptions({ + applicationName: app.name, + applicationVersion: app.getVersion(), + version: app.getVersion(), + copyright: "Copyright © 2026 Shift", + credits: "A font editor for drawing, spacing, and shaping type.", + iconPath: this.#aboutIconPath, + }); + } + + /** + * Builds a fresh Electron menu from the current app state. + * + * @returns a new menu instance ready to install. + */ + build(): Menu { + return Menu.buildFromTemplate(this.template()); + } + + /** Builds the platform-appropriate top-level menu template. */ + template(): MenuItemConstructorOptions[] { + return isMac ? this.buildMacMenu() : this.buildWindowsMenu(); + } + + /** Builds the macOS app menu. */ + buildMacMenu(): MenuItemConstructorOptions[] { + return [ + { + label: app.name, + submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }], + }, + ]; + } + + /** Builds the Windows/Linux app menu. */ + buildWindowsMenu(): MenuItemConstructorOptions[] { + return [ + { + label: "Help", + submenu: [{ role: "about" }], + }, + ]; + } +} diff --git a/apps/desktop/src/main/utils.ts b/apps/desktop/src/main/utils.ts new file mode 100644 index 00000000..3f79295e --- /dev/null +++ b/apps/desktop/src/main/utils.ts @@ -0,0 +1,21 @@ +import path from "path"; + +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; + +export interface RenderSource { + type: "url" | "file"; + source: string; +} +export function getRendererSource(): RenderSource { + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + return { + type: "url", + source: MAIN_WINDOW_VITE_DEV_SERVER_URL, + }; + } + + return { + type: "file", + source: path.join(__dirname, "../renderer/index.html"), + }; +} diff --git a/apps/desktop/src/main/windows/Window.ts b/apps/desktop/src/main/windows/Window.ts new file mode 100644 index 00000000..67f88634 --- /dev/null +++ b/apps/desktop/src/main/windows/Window.ts @@ -0,0 +1,83 @@ +import { BrowserWindow, type BrowserWindowConstructorOptions } from "electron"; + +export interface WindowOptions { + title?: string; + width?: number; + height?: number; + minWidth?: number; + maximised?: boolean; + preloadPath: string; + browserWindowOptions?: BrowserWindowConstructorOptions; +} + +const WINDOW_DEFAULT_OPTIONS: Omit = { + width: 800, + height: 600, + title: "Shift", + minWidth: 1200, + maximised: false, +}; + +const BROWSER_WINDOW_DEFAULT_OPTIONS: BrowserWindowConstructorOptions = { + titleBarStyle: "hidden", + trafficLightPosition: { x: -100, y: -100 }, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, +}; + +export class Window { + #window: BrowserWindow; + + constructor(options: WindowOptions) { + const windowOptions = { ...WINDOW_DEFAULT_OPTIONS, ...options }; + const browserWindowOptions = { + ...BROWSER_WINDOW_DEFAULT_OPTIONS, + ...windowOptions.browserWindowOptions, + }; + + this.#window = new BrowserWindow({ + ...browserWindowOptions, + width: windowOptions.width, + height: windowOptions.height, + title: windowOptions.title, + minWidth: windowOptions.minWidth, + show: windowOptions.maximised, + webPreferences: { + ...BROWSER_WINDOW_DEFAULT_OPTIONS.webPreferences, + ...windowOptions.browserWindowOptions?.webPreferences, + preload: windowOptions.preloadPath, + }, + }); + + if (windowOptions.maximised) { + this.#window.maximize(); + } + + this.#window.once("ready-to-show", () => { + this.#window.show(); + }); + } + + get window(): BrowserWindow { + return this.#window; + } + + close(): void { + this.#window.close(); + } + + minimize(): void { + this.#window.minimize(); + } + + toggleMaximize(): void { + if (this.#window.isMaximized()) { + this.#window.unmaximize(); + } else { + this.#window.maximize(); + } + } +} diff --git a/apps/desktop/src/preload/docs/DOCS.md b/apps/desktop/src/preload/docs/DOCS.md index 34fd3a58..7781d97c 100644 --- a/apps/desktop/src/preload/docs/DOCS.md +++ b/apps/desktop/src/preload/docs/DOCS.md @@ -1,27 +1,26 @@ # Preload -Electron preload script that exposes the native Rust bridge and typed IPC channels to the renderer through `contextBridge`. +Electron preload script that exposes the native Rust bridge and Shift host API to the renderer through `contextBridge`. ## Architecture Invariants - **Architecture Invariant:** The native bridge is created through `@shift/bridge`, not by importing the raw `shift-bridge` NAPI package here. **WHY:** native loading and native-module typing stay in one package boundary. - **Architecture Invariant:** `buildContextBridgeApi` flattens prototype methods into a plain object before exposing them. **WHY:** `contextBridge` does not preserve class prototype semantics across the isolated context boundary. -- **Architecture Invariant:** Two separate globals are exposed: `window.shiftBridge` for Rust bridge calls and `window.electronAPI` for IPC/system access. **WHY:** native bridge calls and Electron IPC have different lifecycles and failure modes. -- **Architecture Invariant:** The `electronAPI` object must satisfy the `ElectronAPI` interface exactly. **WHY:** adding IPC channels should fail at typecheck time unless preload wiring is updated. +- **Architecture Invariant:** Two separate globals are exposed: `window.shiftBridge` for Rust bridge calls and `window.shiftHost` for app shell calls. **WHY:** native bridge calls and Electron app-shell IPC have different lifecycles and failure modes. ## Codemap ``` preload/ - preload.ts -- creates BridgeApi, flattens it for contextBridge, wires IPC globals + preload.ts -- creates BridgeApi, flattens it for contextBridge, exposes shiftHost ``` ## Key Types - `BridgeApi` -- native bridge API generated from Rust declarations and exposed by `@shift/bridge`. -- `ElectronAPI` -- typed interface for IPC commands, event listeners, system utilities, and clipboard access. -- `IpcEvents` -- main-to-renderer broadcast channel map. -- `IpcCommands` -- renderer-to-main request/response channel map. +- `ShiftHost` -- renderer-facing app shell API. +- `RendererToMain` -- renderer-to-main request/response channel map. +- `MainToRenderer` -- main-to-renderer broadcast channel map. ## How It Works @@ -30,7 +29,7 @@ The preload runs once before the renderer loads: 1. Calls `createBridge()` from `@shift/bridge`. 2. Converts the bridge class instance into a plain method object with `buildContextBridgeApi`. 3. Exposes that object as `window.shiftBridge`. -4. Builds typed IPC helpers and exposes them as `window.electronAPI`. +4. Builds the Shift host API and exposes it as `window.shiftHost`. ## Gotchas @@ -49,5 +48,5 @@ pnpm --filter @shift/desktop lint - `@shift/bridge` -- runtime native bridge loader and bridge type exports. - `@shift/types` -- generated bridge DTO/API facade plus shared primitive DTO types. -- `ElectronAPI` -- IPC/system API surface exposed as `window.electronAPI`. -- `WindowManager` -- loads this preload script through `webPreferences.preload`. +- `ShiftHost` -- app shell API surface exposed as `window.shiftHost`. +- `Window` -- loads this preload script through `webPreferences.preload`. diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0ae178ad..1b20f42a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1,15 +1,19 @@ // See the Electron documentation for details on how to use preload scripts: // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts -const { contextBridge, ipcRenderer, clipboard } = require("electron"); -const os = require("os"); +const { contextBridge, ipcRenderer } = require("electron"); import { createBridge, type BridgeApi } from "@shift/bridge"; -import type { IpcEvents, IpcCommands } from "../shared/ipc/channels"; -import type { ElectronAPI } from "../shared/ipc/electronAPI"; -import { listener, command } from "../shared/ipc/preload"; +import type { ShiftHost } from "../shared/host/ShiftHost"; +import { invoke } from "../shared/ipc/renderer"; const bridge = createBridge(); +/** + * Converts a bridge class instance into a contextBridge-safe plain object. + * + * @param instance - Bridge instance whose prototype methods should be exposed. + * @returns a plain method object suitable for `contextBridge.exposeInMainWorld`. + */ function buildContextBridgeApi(instance: T): T { const api: Record = {}; const target = instance as Record; @@ -23,52 +27,11 @@ function buildContextBridgeApi(instance: T): T { const bridgeApi = buildContextBridgeApi(bridge); -// Expose to renderer via contextBridge -contextBridge.exposeInMainWorld("shiftBridge", bridgeApi); - -const on = (ch: K) => listener(ipcRenderer, ch); -const invoke = (ch: K) => command(ipcRenderer, ch); - -const electronAPI: ElectronAPI = { - // Commands - openFontDialog: invoke("dialog:openFont"), - getTheme: invoke("theme:get"), - setTheme: invoke("theme:set"), - closeWindow: invoke("window:close"), - minimizeWindow: invoke("window:minimize"), - maximizeWindow: invoke("window:maximize"), - isWindowMaximized: invoke("window:isMaximized"), - setDocumentDirty: invoke("document:setDirty"), - setDocumentFilePath: invoke("document:setFilePath"), - saveCompleted: invoke("document:saveCompleted"), - getDebug: invoke("debug:getState"), - pathsExist: invoke("fs:pathsExist"), - - // Events - onDocumentNew: on("document:new"), - onMenuOpenFont: on("menu:open-font"), - onExternalOpenFont: on("external:open-font"), - onMenuSaveFont: on("menu:save-font"), - onMenuExportFont: on("menu:export-font"), - onMenuUndo: on("menu:undo"), - onMenuRedo: on("menu:redo"), - onMenuDelete: on("menu:delete"), - onMenuSelectAll: on("menu:select-all"), - onSetTheme: on("theme:set"), - onUiZoomChanged: on("ui:zoom-changed"), - onDevToolsToggled: on("devtools-toggled"), - onDebugReactScan: on("debug:react-scan"), - onDebugPanel: on("debug:panel"), - onDebugDumpSnapshot: on("debug:dump-snapshot"), - onDebugDumpSelectionPatterns: on("debug:dump-selection-patterns"), - onDebugOverlays: on("debug:overlays"), - - // System - homePath: os.homedir() as string, - - // Clipboard (direct, no IPC) - clipboardReadText: (): string => clipboard.readText(), - clipboardWriteText: (text: string): void => clipboard.writeText(text), +const shiftHost: ShiftHost = { + commands: { + run: invoke(ipcRenderer, "commands.run"), + }, }; -contextBridge.exposeInMainWorld("electronAPI", electronAPI); +contextBridge.exposeInMainWorld("shiftBridge", bridgeApi); +contextBridge.exposeInMainWorld("shiftHost", shiftHost); diff --git a/apps/desktop/src/renderer/src/components/chrome/Titlebar.tsx b/apps/desktop/src/renderer/src/components/chrome/Titlebar.tsx index b9b065f7..e38b388b 100644 --- a/apps/desktop/src/renderer/src/components/chrome/Titlebar.tsx +++ b/apps/desktop/src/renderer/src/components/chrome/Titlebar.tsx @@ -1,3 +1,4 @@ +import { shiftHost } from "@/host/shiftHost"; import { useState } from "react"; interface TrafficLightButtonProps { @@ -68,15 +69,15 @@ export const Titlebar = () => { const [isHovered, setIsHovered] = useState(false); const handleClose = () => { - window.electronAPI?.closeWindow(); + shiftHost.commands.run("window.close"); }; const handleMinimize = () => { - window.electronAPI?.minimizeWindow(); + shiftHost.commands.run("window.minimise"); }; const handleMaximize = () => { - window.electronAPI?.maximizeWindow(); + shiftHost.commands.run("window.maximise"); }; return ( diff --git a/apps/desktop/src/renderer/src/host/shiftHost.ts b/apps/desktop/src/renderer/src/host/shiftHost.ts new file mode 100644 index 00000000..b599f749 --- /dev/null +++ b/apps/desktop/src/renderer/src/host/shiftHost.ts @@ -0,0 +1,27 @@ +import type { ShiftHost } from "@shared/host/ShiftHost"; + +/** + * Returns the Shift host exposed by Electron preload. + * + * @returns the renderer-facing app shell API. + * @throws {Error} when the renderer is running without the expected preload bridge. + */ +export function getShiftHost(): ShiftHost { + const host = window.shiftHost; + + if (!host) { + throw new Error("window.shiftHost is not available. Is the Electron preload loaded?"); + } + + return host; +} + +/** + * Shared renderer access point for app-shell calls. + * + * @remarks + * Import this instead of reading `window.shiftHost` directly so missing preload + * wiring fails once at the boundary instead of forcing optional checks at every + * call site. + */ +export const shiftHost = getShiftHost(); diff --git a/apps/desktop/src/renderer/src/types/electron.d.ts b/apps/desktop/src/renderer/src/types/electron.d.ts index 4d60ed64..f96888c1 100644 --- a/apps/desktop/src/renderer/src/types/electron.d.ts +++ b/apps/desktop/src/renderer/src/types/electron.d.ts @@ -1,11 +1,10 @@ -import type { ElectronAPI } from "@shared/ipc/electronAPI"; +import type { ShiftHost } from "@shared/host/ShiftHost"; -export type { ThemeName, DebugOverlays, Debug } from "@shared/ipc/types"; -export type { ElectronAPI } from "@shared/ipc/electronAPI"; +export type { ShiftHost } from "@shared/host/ShiftHost"; declare global { interface Window { - electronAPI?: ElectronAPI; + shiftHost?: ShiftHost; } } diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index bca66c91..9aaa47c1 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -3,6 +3,7 @@ import { getDocument } from "@/store/store"; import logo from "@/assets/logo@1024.png"; import { Button, Separator } from "@shift/ui"; import { RecentFiles } from "./RecentFiles"; +import { Titlebar } from "@/components/chrome/Titlebar"; export const Landing = () => { const navigate = useNavigate(); @@ -26,27 +27,40 @@ export const Landing = () => { }; return ( -
-
-
- Shift -

- Shift . -

+
+ +
+
+
+ Shift +

+ Shift . +

+
-
-
- - -
-
- - -
-
+
+ + +
+
+ + +
+ + ); }; diff --git a/apps/desktop/src/shared/commands.ts b/apps/desktop/src/shared/commands.ts new file mode 100644 index 00000000..f9d0416d --- /dev/null +++ b/apps/desktop/src/shared/commands.ts @@ -0,0 +1,8 @@ +/** + * Identifies an app command that can be requested through the Shift host API. + * + * Command IDs are shared between renderer-facing UI, native menus, and the main + * process command registry. The ID is only an identity token; main owns the + * behavior for each command. + */ +export type CommandId = "window.close" | "window.minimise" | "window.maximise"; diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts new file mode 100644 index 00000000..306280fa --- /dev/null +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -0,0 +1,22 @@ +import type { CommandId } from "../commands"; + +/** + * Renderer-facing API for Electron app-shell behavior. + * + * @remarks + * This is the product API exposed by preload as `window.shiftHost`. Renderer + * code should depend on this shape instead of Electron's `ipcRenderer` or raw + * IPC channel names. + */ +export interface ShiftHost { + /** Runs app commands owned by the main process. */ + commands: { + /** + * Requests that main run a registered command. + * + * @param id - Command identity from the shared command list. + * @throws {Error} when the preload bridge is unavailable or main rejects the command. + */ + run: (id: CommandId) => Promise; + }; +} diff --git a/apps/desktop/src/shared/ipc/channels.ts b/apps/desktop/src/shared/ipc/channels.ts deleted file mode 100644 index 30d70656..00000000 --- a/apps/desktop/src/shared/ipc/channels.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ThemeName, Debug, DebugOverlays } from "./types"; - -/** Main -> Renderer broadcasts (webContents.send / ipcRenderer.on) */ -export type IpcEvents = { - "document:new": () => void; - "menu:open-font": (path: string) => void; - "external:open-font": (path: string) => void; - "menu:save-font": (path: string) => void; - "menu:export-font": (path: string) => void; - "menu:undo": () => void; - "menu:redo": () => void; - "menu:delete": () => void; - "menu:select-all": () => void; - "theme:set": (theme: ThemeName) => void; - "ui:zoom-changed": (zoomPercent: number) => void; - "devtools-toggled": () => void; - "debug:react-scan": (enabled: boolean) => void; - "debug:panel": (open: boolean) => void; - "debug:dump-snapshot": () => void; - "debug:dump-selection-patterns": () => void; - "debug:overlays": (overlays: DebugOverlays) => void; -}; - -/** Renderer -> Main request/response (ipcRenderer.invoke / ipcMain.handle) */ -export type IpcCommands = { - "dialog:openFont": () => string | null; - "theme:get": () => ThemeName; - "theme:set": (theme: ThemeName) => void; - "debug:getState": () => Debug; - "window:close": () => void; - "window:minimize": () => void; - "window:maximize": () => void; - "window:isMaximized": () => boolean; - "document:setDirty": (dirty: boolean) => void; - "document:setFilePath": (filePath: string | null) => void; - "document:saveCompleted": (filePath: string) => void; - "fs:pathsExist": (paths: string[]) => boolean[]; -}; diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts new file mode 100644 index 00000000..50f4bd1d --- /dev/null +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -0,0 +1,20 @@ +import type { CommandId } from "../commands"; + +/** + * Defines request/response channels that the renderer may invoke on main. + * + * @remarks + * This is the private transport contract underneath the Shift host API. Add + * channels here only when preload needs a new main-process capability. + */ +export type RendererToMain = { + "commands.run": (id: CommandId) => void; +}; + +/** + * Defines broadcast channels that main may send to renderer windows. + * + * @remarks + * Keep this empty until main needs to push state or events into the renderer. + */ +export type MainToRenderer = {}; diff --git a/apps/desktop/src/shared/ipc/electronAPI.ts b/apps/desktop/src/shared/ipc/electronAPI.ts deleted file mode 100644 index 820bc83c..00000000 --- a/apps/desktop/src/shared/ipc/electronAPI.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IpcEvents, IpcCommands } from "./channels"; - -/** A listener created by the preload `on()` helper */ -type EventListener = ( - callback: (...args: Parameters) => void, -) => () => void; - -/** A command created by the preload `invoke()` helper */ -type CommandInvoker = ( - ...args: Parameters -) => Promise>; - -/** - * Typed API exposed to the renderer via contextBridge. - * Derived from the IpcEvents/IpcCommands channel maps — this is the single source of truth. - */ -export interface ElectronAPI { - // Commands - openFontDialog: CommandInvoker<"dialog:openFont">; - getTheme: CommandInvoker<"theme:get">; - setTheme: CommandInvoker<"theme:set">; - closeWindow: CommandInvoker<"window:close">; - minimizeWindow: CommandInvoker<"window:minimize">; - maximizeWindow: CommandInvoker<"window:maximize">; - isWindowMaximized: CommandInvoker<"window:isMaximized">; - setDocumentDirty: CommandInvoker<"document:setDirty">; - setDocumentFilePath: CommandInvoker<"document:setFilePath">; - saveCompleted: CommandInvoker<"document:saveCompleted">; - getDebug: CommandInvoker<"debug:getState">; - pathsExist: CommandInvoker<"fs:pathsExist">; - - // Events - onDocumentNew: EventListener<"document:new">; - onMenuOpenFont: EventListener<"menu:open-font">; - onExternalOpenFont: EventListener<"external:open-font">; - onMenuSaveFont: EventListener<"menu:save-font">; - onMenuExportFont: EventListener<"menu:export-font">; - onMenuUndo: EventListener<"menu:undo">; - onMenuRedo: EventListener<"menu:redo">; - onMenuDelete: EventListener<"menu:delete">; - onMenuSelectAll: EventListener<"menu:select-all">; - onSetTheme: EventListener<"theme:set">; - onUiZoomChanged: EventListener<"ui:zoom-changed">; - onDevToolsToggled: EventListener<"devtools-toggled">; - onDebugReactScan: EventListener<"debug:react-scan">; - onDebugPanel: EventListener<"debug:panel">; - onDebugDumpSnapshot: EventListener<"debug:dump-snapshot">; - onDebugDumpSelectionPatterns: EventListener<"debug:dump-selection-patterns">; - onDebugOverlays: EventListener<"debug:overlays">; - - // System - homePath: string; - - // Clipboard (direct, no IPC) - clipboardReadText: () => string; - clipboardWriteText: (text: string) => void; -} diff --git a/apps/desktop/src/shared/ipc/main.ts b/apps/desktop/src/shared/ipc/main.ts index 3bbac204..a4b6facb 100644 --- a/apps/desktop/src/shared/ipc/main.ts +++ b/apps/desktop/src/shared/ipc/main.ts @@ -1,23 +1,35 @@ import type { WebContents, IpcMain, IpcMainInvokeEvent } from "electron"; -import type { IpcEvents, IpcCommands } from "./channels"; +import type { MainToRenderer, RendererToMain } from "./contract"; -/** Send a typed event from main to renderer */ -export function send( +/** + * Sends a typed main-to-renderer event to one renderer target. + * + * @param webContents - Renderer target that should receive the event. + * @param channel - Channel declared in {@link MainToRenderer}. + * @param args - Payload required by the selected channel. + */ +export function send( webContents: WebContents, channel: K, - ...args: Parameters + ...args: Parameters ): void { webContents.send(channel, ...args); } -/** Register a typed handler for a renderer command */ -export function handle( +/** + * Registers a typed renderer-to-main request handler. + * + * @param ipcMain - Electron's process-wide IPC main object. + * @param channel - Channel declared in {@link RendererToMain}. + * @param handler - Function that receives the Electron event plus the channel payload. + */ +export function handle( ipcMain: IpcMain, channel: K, handler: ( event: IpcMainInvokeEvent, - ...args: Parameters - ) => ReturnType | Promise>, + ...args: Parameters + ) => ReturnType | Promise>, ): void { ipcMain.handle(channel, handler as any); } diff --git a/apps/desktop/src/shared/ipc/preload.ts b/apps/desktop/src/shared/ipc/preload.ts deleted file mode 100644 index 8aa8fddc..00000000 --- a/apps/desktop/src/shared/ipc/preload.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { IpcEvents, IpcCommands } from "./channels"; - -type IpcRenderer = { - on(channel: string, listener: (...args: any[]) => void): void; - removeListener(channel: string, listener: (...args: any[]) => void): void; - invoke(channel: string, ...args: any[]): Promise; -}; - -/** Create a subscribe function for a main->renderer event */ -export function listener( - ipcRenderer: IpcRenderer, - channel: K, -): (callback: (...args: Parameters) => void) => () => void { - return (callback) => { - const handler = (_event: any, ...args: any[]) => (callback as Function)(...args); - ipcRenderer.on(channel, handler); - return () => ipcRenderer.removeListener(channel, handler); - }; -} - -/** Create a typed invoke function for a renderer->main command */ -export function command( - ipcRenderer: IpcRenderer, - channel: K, -): (...args: Parameters) => Promise> { - return (...args) => ipcRenderer.invoke(channel, ...args); -} diff --git a/apps/desktop/src/shared/ipc/renderer.ts b/apps/desktop/src/shared/ipc/renderer.ts new file mode 100644 index 00000000..b3eaf64a --- /dev/null +++ b/apps/desktop/src/shared/ipc/renderer.ts @@ -0,0 +1,39 @@ +import type { MainToRenderer, RendererToMain } from "./contract"; + +type IpcRenderer = { + on(channel: string, listener: (...args: any[]) => void): void; + removeListener(channel: string, listener: (...args: any[]) => void): void; + invoke(channel: string, ...args: any[]): Promise; +}; + +/** + * Creates a typed subscription helper for one main-to-renderer event channel. + * + * @param ipcRenderer - Electron renderer IPC object supplied by preload. + * @param channel - Channel declared in {@link MainToRenderer}. + * @returns a subscribe function that returns an unsubscribe callback. + */ +export function on( + ipcRenderer: IpcRenderer, + channel: K, +): (callback: (...args: Parameters) => void) => () => void { + return (callback) => { + const handler = (_event: any, ...args: any[]) => (callback as Function)(...args); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); + }; +} + +/** + * Creates a typed invoke helper for one renderer-to-main request channel. + * + * @param ipcRenderer - Electron renderer IPC object supplied by preload. + * @param channel - Channel declared in {@link RendererToMain}. + * @returns a function whose arguments and result are inferred from the channel contract. + */ +export function invoke( + ipcRenderer: IpcRenderer, + channel: K, +): (...args: Parameters) => Promise> { + return (...args) => ipcRenderer.invoke(channel, ...args); +} diff --git a/apps/desktop/src/shared/ipc/types.ts b/apps/desktop/src/shared/ipc/types.ts deleted file mode 100644 index 64b23400..00000000 --- a/apps/desktop/src/shared/ipc/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ThemeName = "light" | "dark" | "system"; - -export interface DebugOverlays { - tightBounds: boolean; - hitRadii: boolean; - segmentBounds: boolean; - glyphBbox: boolean; -} - -export interface Debug { - reactScanEnabled: boolean; - debugPanelOpen: boolean; - overlays: DebugOverlays; -} diff --git a/apps/desktop/vite.renderer.config.ts b/apps/desktop/vite.renderer.config.ts index 26ecb5cd..a56f61ae 100644 --- a/apps/desktop/vite.renderer.config.ts +++ b/apps/desktop/vite.renderer.config.ts @@ -3,7 +3,6 @@ import svgr from "vite-plugin-svgr"; import path from "path"; const packagesDir = path.resolve(__dirname, "../../packages"); -const rulesSrcRoot = `${path.resolve(packagesDir, "rules", "src")}${path.sep}`; // https://vitejs.dev/config export default defineConfig(async () => { @@ -30,17 +29,6 @@ export default defineConfig(async () => { }, }), tsconfigPaths(), - { - name: "shift-rules-force-reload", - handleHotUpdate(ctx) { - const normalized = path.resolve(ctx.file); - if (normalized.startsWith(rulesSrcRoot)) { - ctx.server.ws.send({ type: "full-reload", path: "*" }); - return []; - } - return undefined; - }, - }, ], resolve: { alias: {