Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src/main/app/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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");
});
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/document/DocumentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/workspace/WorkspaceProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class WorkspaceProcess {
#ready: Promise<void> | 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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -189,6 +204,8 @@ export class WorkspaceProcess {
this.#channel = null;
this.#ready = null;
this.#unlistenDocumentChanged = null;

for (const listener of this.#exitListeners) listener();
}
});

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/workspace/WorkspaceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class WorkspaceSession {
readonly windows = new Set<Window>();

readonly #unlistenDocumentChanged: () => void;
readonly #unlistenWorkspaceExit: () => void;

/**
* Creates a session around the services for one loaded font workspace.
Expand All @@ -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();
});
}

/**
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/src/shared/workspace/channel.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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<Pick<TestCalls, "task.slow">, TestEvents>(
domPortTransport(port1 as unknown as MessagePort),
);
const server = serveChannel<Pick<TestCalls, "task.slow">, TestEvents>(
nodePortTransport(port2),
{ "task.slow": () => new Promise<string>(() => {}) },
);
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<Pick<TestCalls, "task.slow">, TestEvents>(nodePortTransport(port1));
const pending = client.call("task.slow", undefined);

port2.close();

await expect(pending).rejects.toThrow("channel closed");
});
});

describe("channel port transfer", () => {
Expand Down
76 changes: 65 additions & 11 deletions apps/desktop/src/shared/workspace/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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;
Expand All @@ -64,18 +67,26 @@ 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<Calls extends CallMap, Events extends EventMap> {
readonly #transport: Transport;
readonly #pending = new Map<string, PendingCall>();
readonly #listeners = new Map<string, Set<(payload: unknown) => 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;
}

/**
Expand All @@ -84,16 +95,16 @@ export class Channel<Calls extends CallMap, Events extends EventMap> {
* @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<T extends keyof Calls & string>(
type: T,
payload: Calls[T]["request"],
transfer?: unknown[],
): Promise<Calls[T]["response"]> {
if (this.#disposed) {
return Promise.reject(new Error("channel disposed"));
return Promise.reject(this.#closedError ?? new Error("channel disposed"));
}

this.#nextRequestId += 1;
Expand Down Expand Up @@ -129,16 +140,21 @@ export class Channel<Calls extends CallMap, Events extends EventMap> {

/** 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 {
Expand All @@ -149,6 +165,11 @@ export class Channel<Calls extends CallMap, Events extends EventMap> {

if (isEventEnvelope(data)) {
this.#dispatchEvent(data);
return;
}

if (isCloseEnvelope(data)) {
this.#disconnect(new Error("channel closed"), true);
}
}

Expand Down Expand Up @@ -187,7 +208,10 @@ export function serveChannel<Calls extends CallMap, Events extends EventMap>(
transport: Transport,
handlers: ChannelHandlers<Calls>,
): ChannelServer<Events> {
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<void> => {
const handler = handlers[request.type as keyof Calls] as
Expand Down Expand Up @@ -218,14 +242,29 @@ export function serveChannel<Calls extends CallMap, Events extends EventMap>(
};

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();
}
},
};
}

Expand Down Expand Up @@ -263,13 +302,22 @@ 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 {
post: (message, transfer) => port.postMessage(message, (transfer ?? []) as Transferable[]),
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(),
};
}
Expand All @@ -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(),
};
}
Expand All @@ -307,6 +356,7 @@ export function parentPortTransport(): Transport {
},
onMessage: (listener) =>
port.on("message", (event) => listener({ data: event.data, ports: event.ports })),
onClose: () => {},
close: () => {},
};
}
Expand All @@ -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();
},
Expand All @@ -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),
};
}
Loading