Skip to content
Open
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
91 changes: 91 additions & 0 deletions apps/desktop/src/main/app/App.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
}
39 changes: 39 additions & 0 deletions apps/desktop/src/main/app/AppIcon.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
120 changes: 120 additions & 0 deletions apps/desktop/src/main/commands/Command.ts
Original file line number Diff line number Diff line change
@@ -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<CommandId, Command>();

/**
* 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<void> {
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<void>;
};
54 changes: 54 additions & 0 deletions apps/desktop/src/main/commands/Commands.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 7 additions & 7 deletions apps/desktop/src/main/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
9 changes: 9 additions & 0 deletions apps/desktop/src/main/document/WorkingDocument.ts
Original file line number Diff line number Diff line change
@@ -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() {}
}
14 changes: 0 additions & 14 deletions apps/desktop/src/main/logger.ts

This file was deleted.

Loading
Loading