diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index d9ec802f..b757252c 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -93,6 +93,10 @@ export class App { this.#lifecycle.start(); app.setName(this.applicationName); + app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); + }); + if (!app.isPackaged) { app.setPath("userData", path.join(app.getPath("appData"), this.applicationName)); } @@ -106,6 +110,9 @@ export class App { this.#applicationMenu.install(); this.#openLauncher(); + app.on("activate", () => { + if (this.#windows.allWindows().length === 0) this.#openLauncher(); + }); this.#log.info("finished when ready callback"); }); diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 4fc9679b..c66ca995 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -9,6 +9,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Dirty state and save targets come from the utility-owned workspace state. Main shows native dialogs, but state reads and saves go through the renderer document lane so pending edits flush first. - **Architecture Invariant:** A `.shift` package session is reused by `(packageId, canonicalPath)`, not by the path string the user selected and not by the current document id. - **Architecture Invariant:** Closing the last window for a workspace runs `DocumentSession.confirmClose`. Clean documents and explicitly discarded dirty documents are closed through the utility process so package bindings and SQLite documents are pruned. +- **Architecture Invariant:** Closing every window keeps the application alive on macOS. Activating the windowless app opens a fresh launcher; Windows and Linux quit after the last window closes. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. ## Codemap @@ -54,6 +55,8 @@ src/main/ `main.ts` constructs `App` and calls `start()`. `App` registers commands and IPC handlers, starts `AppLifecycle`, sets the user-data-backed `working-documents` root, creates the launcher window, and installs the application menu. +On macOS, closing the last window leaves Shift running. A later Dock activation opens a new launcher window. Windows and Linux keep the conventional quit-on-last-window behavior. + ### Workspace Creation And Open File -> New asks `WorkspaceManager.createUntitled()` for a session. File -> Open shows `showOpenFontDialog()` and then asks `WorkspaceManager.openPath(path)`. @@ -70,6 +73,8 @@ Save and Save As start in `DocumentSession`, but the actual save request goes th Close and quit call `DocumentSession.confirmClose`. If the document is clean, or the user saves successfully, or the user chooses discard, `DocumentSession` calls `workspace.close` in the utility process. The utility drops the Rust workspace handle, removes package bindings, and deletes the clean/discarded SQLite document. Dirty divergent documents created by package-source conflicts are orphaned by the utility process, not by main. +Message lanes reject in-flight calls when their remote port closes. An unexpected utility-process exit also disconnects the renderer document lane, so close and quit guards fail instead of remaining pending indefinitely. + ### IPC Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, document-lane port transfer, and workspace sync-lane port transfer. Font data stays on the workspace sync lane between renderer and utility. diff --git a/apps/desktop/src/main/document/DocumentClient.ts b/apps/desktop/src/main/document/DocumentClient.ts index cff21584..293fd240 100644 --- a/apps/desktop/src/main/document/DocumentClient.ts +++ b/apps/desktop/src/main/document/DocumentClient.ts @@ -27,7 +27,7 @@ export class DocumentClient implements Document { } get connected(): boolean { - return this.#channel !== null; + return this.#channel !== null && !this.#channel.closed; } /** Replaces the active renderer document lane. */ diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index b2abf07b..25b0f0aa 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -24,6 +24,7 @@ export class WorkspaceProcess { #ready: Promise | null = null; #unlistenDocumentChanged: (() => void) | null = null; #documentListeners = new Set<(state: WorkspaceDocumentState | null) => void>(); + #exitListeners = new Set<() => void>(); constructor(log: ShiftLogger = createShiftLogger("workspace.process")) { this.#log = log; @@ -142,6 +143,20 @@ export class WorkspaceProcess { }; } + /** + * Subscribes to unexpected utility-process termination. + * + * @param listener - Disconnects services whose requests depend on this process. + * @returns a function that removes this listener. + */ + onExit(listener: () => void): () => void { + this.#exitListeners.add(listener); + + return () => { + this.#exitListeners.delete(listener); + }; + } + /** * Reads the current utility-owned document lifecycle state. * @@ -189,6 +204,8 @@ export class WorkspaceProcess { this.#channel = null; this.#ready = null; this.#unlistenDocumentChanged = null; + + for (const listener of this.#exitListeners) listener(); } }); diff --git a/apps/desktop/src/main/workspace/WorkspaceSession.ts b/apps/desktop/src/main/workspace/WorkspaceSession.ts index b9888a8a..13ca9724 100644 --- a/apps/desktop/src/main/workspace/WorkspaceSession.ts +++ b/apps/desktop/src/main/workspace/WorkspaceSession.ts @@ -43,6 +43,7 @@ export class WorkspaceSession { readonly windows = new Set(); readonly #unlistenDocumentChanged: () => void; + readonly #unlistenWorkspaceExit: () => void; /** * Creates a session around the services for one loaded font workspace. @@ -65,6 +66,9 @@ export class WorkspaceSession { this.#unlistenDocumentChanged = this.workspaceProcess.onDocumentChanged((state) => { this.document.acceptState(state); }); + this.#unlistenWorkspaceExit = this.workspaceProcess.onExit(() => { + this.documentClient.dispose(); + }); } /** @@ -113,6 +117,7 @@ export class WorkspaceSession { /** Disposes process and renderer-facing document resources for this workspace. */ dispose(): void { this.#unlistenDocumentChanged(); + this.#unlistenWorkspaceExit(); this.documentClient.dispose(); this.workspaceProcess.stop(); this.windows.clear(); diff --git a/apps/desktop/src/shared/workspace/channel.test.ts b/apps/desktop/src/shared/workspace/channel.test.ts index 78d85911..9fd1e025 100644 --- a/apps/desktop/src/shared/workspace/channel.test.ts +++ b/apps/desktop/src/shared/workspace/channel.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, afterEach } from "vitest"; import { MessageChannel } from "node:worker_threads"; -import { Channel, nodePortTransport, serveChannel, type ChannelServer } from "./channel"; +import { + Channel, + domPortTransport, + nodePortTransport, + serveChannel, + type ChannelServer, +} from "./channel"; type TestCalls = { "math.add": { request: { a: number; b: number }; response: number }; @@ -126,6 +132,33 @@ describe("channel dispose", () => { await expect(client.call("math.add", { a: 1, b: 2 })).rejects.toThrow("channel disposed"); }); + + it("rejects a DOM-port call when the server closes the lane", async () => { + const { port1, port2 } = new MessageChannel(); + const client = new Channel, TestEvents>( + domPortTransport(port1 as unknown as MessagePort), + ); + const server = serveChannel, TestEvents>( + nodePortTransport(port2), + { "task.slow": () => new Promise(() => {}) }, + ); + const pending = client.call("task.slow", undefined); + + server.dispose(); + + await expect(pending).rejects.toThrow("channel closed"); + expect(client.closed).toBe(true); + }); + + it("rejects a call when the remote port disconnects", async () => { + const { port1, port2 } = new MessageChannel(); + const client = new Channel, TestEvents>(nodePortTransport(port1)); + const pending = client.call("task.slow", undefined); + + port2.close(); + + await expect(pending).rejects.toThrow("channel closed"); + }); }); describe("channel port transfer", () => { diff --git a/apps/desktop/src/shared/workspace/channel.ts b/apps/desktop/src/shared/workspace/channel.ts index 78c54405..c5c8d588 100644 --- a/apps/desktop/src/shared/workspace/channel.ts +++ b/apps/desktop/src/shared/workspace/channel.ts @@ -15,6 +15,7 @@ export type TransportMessage = { data: unknown; ports: readonly unknown[] }; export type Transport = { post(message: unknown, transfer?: unknown[]): void; onMessage(listener: (message: TransportMessage) => void): void; + onClose(listener: () => void): void; close(): void; }; @@ -54,6 +55,8 @@ type ResponseEnvelope = type EventEnvelope = { kind: "event"; type: string; payload: unknown }; +type CloseEnvelope = { kind: "close" }; + type PendingCall = { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -64,7 +67,8 @@ type PendingCall = { * * @remarks * One `Channel` owns one transport. Disposing the channel rejects every - * in-flight call and closes the transport; the instance is not reusable. + * in-flight call and closes the transport. Remote closure also rejects every + * in-flight call; the instance is not reusable after either transition. */ export class Channel { readonly #transport: Transport; @@ -72,10 +76,17 @@ export class Channel { readonly #listeners = new Map void>>(); #nextRequestId = 0; #disposed = false; + #closedError: Error | null = null; constructor(transport: Transport) { this.#transport = transport; transport.onMessage((message) => this.#handleMessage(message.data)); + transport.onClose(() => this.#disconnect(new Error("channel closed"), false)); + } + + /** Returns whether this lane can no longer accept calls or events. */ + get closed(): boolean { + return this.#disposed; } /** @@ -84,8 +95,8 @@ export class Channel { * @param payload - Pass `undefined` for void requests; there are no overloads. * @param transfer - Ports to transfer alongside the request; they arrive in * the server handler's {@link HandlerContext}. - * @throws {Error} when the remote handler throws or the channel is disposed - * before the response arrives. + * @throws {Error} when the remote handler throws, or when either side closes + * the channel before the response arrives. */ call( type: T, @@ -93,7 +104,7 @@ export class Channel { transfer?: unknown[], ): Promise { if (this.#disposed) { - return Promise.reject(new Error("channel disposed")); + return Promise.reject(this.#closedError ?? new Error("channel disposed")); } this.#nextRequestId += 1; @@ -129,16 +140,21 @@ export class Channel { /** Rejects all pending calls, drops listeners, and closes the transport. */ dispose(): void { - this.#disposed = true; + this.#disconnect(new Error("channel disposed"), true); + } - const error = new Error("channel disposed"); + #disconnect(error: Error, closeTransport: boolean): void { + if (this.#disposed) return; + + this.#disposed = true; + this.#closedError = error; for (const pending of this.#pending.values()) { pending.reject(error); } this.#pending.clear(); this.#listeners.clear(); - this.#transport.close(); + if (closeTransport) this.#transport.close(); } #handleMessage(data: unknown): void { @@ -149,6 +165,11 @@ export class Channel { if (isEventEnvelope(data)) { this.#dispatchEvent(data); + return; + } + + if (isCloseEnvelope(data)) { + this.#disconnect(new Error("channel closed"), true); } } @@ -187,7 +208,10 @@ export function serveChannel( transport: Transport, handlers: ChannelHandlers, ): ChannelServer { - const respond = (response: ResponseEnvelope) => transport.post(response); + let closed = false; + const respond = (response: ResponseEnvelope) => { + if (!closed) transport.post(response); + }; const dispatch = async (request: RequestEnvelope, ports: readonly unknown[]): Promise => { const handler = handlers[request.type as keyof Calls] as @@ -218,14 +242,29 @@ export function serveChannel( }; transport.onMessage((message) => { + if (closed) return; if (!isRequestEnvelope(message.data)) return; void dispatch(message.data, message.ports); }); + transport.onClose(() => { + closed = true; + }); return { - emit: (type, payload) => transport.post({ kind: "event", type, payload }), - dispose: () => transport.close(), + emit: (type, payload) => { + if (!closed) transport.post({ kind: "event", type, payload }); + }, + dispose: () => { + if (closed) return; + + closed = true; + try { + transport.post({ kind: "close" } satisfies CloseEnvelope); + } finally { + transport.close(); + } + }, }; } @@ -263,6 +302,12 @@ function isEventEnvelope(data: unknown): data is EventEnvelope { ); } +function isCloseEnvelope(data: unknown): data is CloseEnvelope { + const candidate = data as CloseEnvelope | null; + + return typeof candidate === "object" && candidate !== null && candidate.kind === "close"; +} + /** Wraps a DOM `MessagePort` (renderer side of the sync lane). */ export function domPortTransport(port: MessagePort): Transport { return { @@ -270,6 +315,9 @@ export function domPortTransport(port: MessagePort): Transport { onMessage: (listener) => { port.onmessage = (event) => listener({ data: event.data, ports: event.ports }); }, + // DOM MessagePort has no remote-close event. Ordered shutdown is delivered + // through the channel close envelope instead. + onClose: () => {}, close: () => port.close(), }; } @@ -282,6 +330,7 @@ export function electronPortTransport(port: MessagePortMain): Transport { port.on("message", (event) => listener({ data: event.data, ports: event.ports })); port.start(); }, + onClose: (listener) => port.on("close", listener), close: () => port.close(), }; } @@ -307,6 +356,7 @@ export function parentPortTransport(): Transport { }, onMessage: (listener) => port.on("message", (event) => listener({ data: event.data, ports: event.ports })), + onClose: () => {}, close: () => {}, }; } @@ -316,6 +366,7 @@ export function utilityProcessTransport(child: UtilityProcess): Transport { return { post: (message, transfer) => child.postMessage(message, (transfer ?? []) as MessagePortMain[]), onMessage: (listener) => child.on("message", (data) => listener({ data, ports: [] })), + onClose: (listener) => child.on("exit", listener), close: () => { child.kill(); }, @@ -331,5 +382,8 @@ export function utilityProcessTransport(child: UtilityProcess): Transport { * of surfacing them in `MessageEvent.ports`. */ export function nodePortTransport(port: NodeMessagePort): Transport { - return domPortTransport(port as unknown as MessagePort); + return { + ...domPortTransport(port as unknown as MessagePort), + onClose: (listener) => port.on("close", listener), + }; }