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
2 changes: 2 additions & 0 deletions apps/desktop/src/main/app/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export class App {
}

#openLauncher(): Window {
this.#workspaces.prepareOpen();
const window = this.#createWindow();
this.#loadLauncher(window);
return window;
Expand Down Expand Up @@ -299,6 +300,7 @@ export class App {
}

async #openWorkspaceFromWindow(opener: Window): Promise<void> {
this.#workspaces.prepareOpen();
const openPath = await showOpenFontDialog(opener);
if (!openPath) return;

Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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, saves, and exports go through the renderer document lane so pending edits flush first.
- **Architecture Invariant:** TTF export snapshots the workspace in the ordered sync lane, then releases that lane before font compilation so subsequent editing is not blocked by fontc.
- **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 the last window for a workspace runs `DocumentSession.confirmClose`. Clean package-backed SQLite documents remain bound for directory-first reopen; untitled/imported documents and explicitly discarded dirty 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`.

Expand Down Expand Up @@ -60,9 +60,9 @@ On macOS, closing the last window leaves Shift running. A later Dock activation

### Workspace Creation And Open

File -> New asks `WorkspaceManager.createUntitled()` for a session. File -> Open shows `showOpenFontDialog()` and then asks `WorkspaceManager.openPath(path)`.
File -> New asks `WorkspaceManager.createUntitled()` for a session. The launcher prepares an idle utility process, so File -> Open overlaps process startup with `showOpenFontDialog()` before asking `WorkspaceManager.openPath(path)`.

For `.shift` paths, `WorkspaceManager` starts a provisional utility process and calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the process opens the package and the resulting state is registered.
For `.shift` paths, `WorkspaceManager` calls `workspace.inspectPackage` before opening. If a live session already owns the same `(packageId, canonicalPath)`, the provisional process is stopped and the existing session is returned. Otherwise the inspected identity is passed into the open request instead of reading and hashing the package a second time. A matching clean or dirty working document resumes directory-first; a divergent clean document is replaced, while a divergent dirty document is orphaned. After main receives the open response, it sends a fire-and-forget complete authored glyph compilation request so layer acquisition and Slug work overlap workspace-window and WebGPU startup; the grid still waits for the complete atlas and never renders placeholder glyphs.

### Window Attachment

Expand All @@ -74,7 +74,7 @@ Save and Save As start in `DocumentSession`, but the actual save request goes th

Export TrueType follows the same document and sync lanes. The utility process captures an immutable native snapshot after prior edits, then awaits direct Shift IR-to-fontc compilation outside the workspace queue. Edits submitted after snapshot capture can proceed and are not included in that export. Export does not change the package binding or dirty state.

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.
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 but retains a clean package-backed binding and SQLite document for the next open. Untitled/imported documents and explicitly discarded dirty package documents are deleted. 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: Save remains blocked because pending edits cannot be settled, while an explicit Discard treats the unavailable workspace as already closed so window and quit guards can finish.

Expand Down
19 changes: 16 additions & 3 deletions apps/desktop/src/main/workspace/WorkspaceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class WorkspaceManager {
readonly #sessionsById = new Map<WorkspaceId, WorkspaceSession>();
readonly #sessionIdByWindowId = new Map<number, WorkspaceId>();
readonly #packageSessions = new PackageSessionIndex();
#preparedProcess: WorkspaceProcess | null = null;

/**
* Creates a manager for live font workspace sessions.
Expand All @@ -42,6 +43,15 @@ export class WorkspaceManager {
this.#applicationName = options.applicationName;
}

/** Starts an idle utility process so source selection can hide its startup cost. */
prepareOpen(): void {
if (this.#preparedProcess) return;

const workspaceProcess = new WorkspaceProcess();
workspaceProcess.start(this.#documentsRoot());
this.#preparedProcess = workspaceProcess;
}

/**
* Creates an untitled workspace session.
*
Expand All @@ -58,7 +68,8 @@ export class WorkspaceManager {
* @returns a live session for the opened source; existing sessions are reused by workspace id.
*/
async openPath(sourcePath: string): Promise<WorkspaceSession> {
const workspaceProcess = new WorkspaceProcess();
const workspaceProcess = this.#preparedProcess ?? new WorkspaceProcess();
this.#preparedProcess = null;
workspaceProcess.start(this.#documentsRoot());

try {
Expand All @@ -73,14 +84,15 @@ export class WorkspaceManager {
return existingBeforeOpen;
}

const state = await workspaceProcess.openWorkspace(sourcePath);
const state = await workspaceProcess.openWorkspace(sourcePath, identity);
const existingAfterOpen = this.#sessionForDocumentState(state);
if (existingAfterOpen) {
workspaceProcess.stop();
existingAfterOpen.document.acceptState(state);
return existingAfterOpen;
}

workspaceProcess.prepareAuthoredGlyphCompilation();
return this.#registerLoadedSession(workspaceProcess, state);
} catch (error) {
workspaceProcess.stop();
Expand Down Expand Up @@ -202,7 +214,8 @@ export class WorkspaceManager {
async #createSession(
load: (workspaceProcess: WorkspaceProcess) => Promise<WorkspaceDocumentState>,
): Promise<WorkspaceSession> {
const workspaceProcess = new WorkspaceProcess();
const workspaceProcess = this.#preparedProcess ?? new WorkspaceProcess();
this.#preparedProcess = null;
workspaceProcess.start(this.#documentsRoot());

try {
Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/main/workspace/WorkspaceProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,28 @@ export class WorkspaceProcess {
* Opens a workspace from a source path through the shell lane.
*
* @param path - User-selected source path to open.
* @param packageIdentity - Previously inspected identity for a `.shift` package.
* @returns utility-owned document state for the opened workspace.
* @throws {Error} when the utility process is not running or rejects the call.
*/
openWorkspace(path: string): Promise<WorkspaceDocumentState> {
return this.#requireChannel().call("workspace.open", { path });
openWorkspace(
path: string,
packageIdentity: WorkspacePackageIdentity | null = null,
): Promise<WorkspaceDocumentState> {
const request = packageIdentity ? { path, packageIdentity } : { path };
return this.#requireChannel().call("workspace.open", request);
}

/** Starts complete authored glyph compilation without delaying workspace-window creation. */
prepareAuthoredGlyphCompilation(): void {
const preparation = this.#requireChannel().call(
"workspace.prepareAuthoredGlyphCompilation",
undefined,
);
// This is a genuine fire-and-forget boundary: renderer startup consumes the result later.
void preparation.catch((error: unknown) => {
this.#log.error("authored glyph compilation failed", error);
});
}

/**
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/shared/workspace/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ export type ShellCallMap = {
response: WorkspacePackageIdentity;
};
"workspace.open": {
request: { path: string };
request: { path: string; packageIdentity?: WorkspacePackageIdentity };
response: WorkspaceDocumentState;
};
"workspace.prepareAuthoredGlyphCompilation": { request: void; response: void };
"workspace.close": { request: { discard: boolean }; response: null };
"workspace.connect": { request: void; response: void };
"document.state": { request: void; response: WorkspaceDocumentState | null };
Expand Down
27 changes: 22 additions & 5 deletions apps/desktop/src/utility/workspace/PackageOpener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,37 @@ describe("PackageOpener preserves package-backed document bindings", () => {
});
});

it("replaces a clean binding with a fresh document", () => {
const { identity } = createPackageSource("Replace.shift");
it("resumes a clean binding when the package fingerprint still matches", () => {
const { identity } = createPackageSource("ResumeClean.shift");
const firstBridge = makeBridge();
const firstOpen = openPackage(firstBridge, identity);
const oldDocumentPath = path.dirname(firstOpen.document.storePath);
firstBridge.closeWorkspace();
const nextBridge = makeBridge();

const reopened = openPackage(nextBridge, identity);

expect(reopened.document.documentId).toBe(firstOpen.document.documentId);
expect(glyphNames(nextBridge)).toEqual(["A"]);
expect(nextBridge.documentState()).toMatchObject({ dirty: false });
expect(storage.packageBinding(reopened.address)?.documentId).toBe(
firstOpen.document.documentId,
);
});

it("replaces a clean binding when the source package diverges", () => {
const { sourcePath, identity } = createPackageSource("ReplaceClean.shift");
const firstBridge = makeBridge();
const firstOpen = openPackage(firstBridge, identity);
const oldDocumentPath = path.dirname(firstOpen.document.storePath);
const divergedIdentity = externallyAddGlyph(sourcePath, "C" as GlyphName, 67 as Unicode);
firstBridge.closeWorkspace();
const nextBridge = makeBridge();

const reopened = openPackage(nextBridge, divergedIdentity);

expect(reopened.document.documentId).not.toBe(firstOpen.document.documentId);
expect(fs.existsSync(oldDocumentPath)).toBe(false);
expect(glyphNames(nextBridge)).toEqual(["A"]);
expect(storage.packageBinding(reopened.address)?.documentId).toBe(reopened.document.documentId);
expect(glyphNames(nextBridge)).toEqual(["A", "C"]);
});

it("orphans a dirty binding when the source package diverges", () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/utility/workspace/PackageOpener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
*
* @remarks
* Package opens resolve through a small action machine over the durable binding,
* the source fingerprint, and the saved draft metadata. Dirty matching drafts
* resume; clean drafts are replaceable; dirty divergent drafts are orphaned
* the source fingerprint, and the saved draft metadata. Matching drafts resume;
* clean divergent drafts are replaceable; dirty divergent drafts are orphaned
* before a fresh hydrate. Moved packages resume only when exactly one missing
* old path matches the package id and base fingerprint.
*/
Expand Down Expand Up @@ -76,8 +76,8 @@ export class PackageOpener {
);
}

if (!draft.dirty) return { kind: "replace", binding };
if (draft.baseFingerprint === identity.fingerprint) return { kind: "resume", binding };
if (!draft.dirty) return { kind: "replace", binding };

return { kind: "orphan", binding };
}
Expand Down
19 changes: 15 additions & 4 deletions apps/desktop/src/utility/workspace/WorkspaceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,16 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => {
targetShell: ShellChannel,
sourcePath: string,
): Promise<WorkspaceSnapshot> {
const state = await targetShell.call("workspace.open", { path: sourcePath });
const request =
path.extname(sourcePath).toLowerCase() === ".shift"
? {
path: sourcePath,
packageIdentity: await targetShell.call("workspace.inspectPackage", {
path: sourcePath,
}),
}
: { path: sourcePath };
const state = await targetShell.call("workspace.open", request);
const snapshot = await sync.call("workspace.snapshot", undefined);
if (!snapshot) throw new Error("workspace.open did not create a snapshot");
expect(snapshot.documentId).toBe(state.documentId);
Expand Down Expand Up @@ -232,6 +241,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => {
const snapshot = await createWorkspace(sync);
const glyph = createGlyphALayer(snapshot.sources[0]!.id);
await applyWorkspace(sync, { intents: glyph.intents });
await shell.call("workspace.prepareAuthoredGlyphCompilation", undefined);

const atlas = await sync.call("workspace.slugAtlasPrepare", { alignment: 256 });
const bytes = await streamSlugAtlas(sync, atlas.generation, 64);
Expand Down Expand Up @@ -493,13 +503,14 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => {
});
});

it("opening a clean package binding hydrates a fresh document", async () => {
it("opening a clean package binding resumes its document", async () => {
const source = await connectSyncLane();
const created = await createWorkspace(source);
await source.call("workspace.apply", { intents: [createGlyphA()], label: "Add Glyph" });
const savePath = path.join(tmpRoot, "CleanBinding.shift");
await source.call("workspace.saveAs", { path: savePath });
const oldStorePath = path.join(tmpRoot, "documents", created.documentId, "document.sqlite");
await shell.call("workspace.close", { discard: false });

const lane = new MessageChannel();
const restartedShell: ShellChannel = new Channel(nodePortTransport(lane.port1));
Expand All @@ -509,9 +520,9 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => {

const opened = await openWorkspace(restarted, restartedShell, savePath);

expect(opened.documentId).not.toBe(created.documentId);
expect(opened.documentId).toBe(created.documentId);
expect(opened.glyphs.map((glyph) => glyph.name)).toEqual(["A"]);
expect(fs.existsSync(oldStorePath)).toBe(false);
expect(fs.existsSync(oldStorePath)).toBe(true);
await expect(restartedShell.call("document.state", undefined)).resolves.toMatchObject({
packageId: expect.stringMatching(/^package_/),
canonicalPath: fs.realpathSync(savePath),
Expand Down
26 changes: 19 additions & 7 deletions apps/desktop/src/utility/workspace/WorkspaceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export class WorkspaceHost {
this.#shell = serveChannel<ShellCallMap, ShellEventMap>(this.#shellTransport, {
"workspace.create": () => this.#serialize(() => this.#create()),
"workspace.inspectPackage": ({ path }) => this.#serialize(() => this.#inspectPackage(path)),
"workspace.open": ({ path }) => this.#serialize(() => this.#open(path)),
"workspace.open": ({ path, packageIdentity }) =>
this.#serialize(() => this.#open(path, packageIdentity)),
"workspace.prepareAuthoredGlyphCompilation": () =>
this.#serialize(() => this.#bridge.prepareAuthoredGlyphCompilation()),
"workspace.close": ({ discard }) => this.#serialize(() => this.#close(discard)),
"workspace.connect": (_payload, context) => {
this.#connectSyncLane(context.ports);
Expand Down Expand Up @@ -212,10 +215,14 @@ export class WorkspaceHost {
};
}

#open(sourcePath: string): WorkspaceDocumentState {
#open(
sourcePath: string,
packageIdentity: WorkspacePackageIdentity | undefined,
): WorkspaceDocumentState {
if (isShiftPackagePath(sourcePath)) {
return this.#openPackage(sourcePath);
return this.#openPackage(sourcePath, packageIdentity);
}
if (packageIdentity) throw new Error("package identity requires a .shift source path");

const document = this.#documents.createDocument();
this.#bridge.openWorkspace(sourcePath, document.storePath);
Expand All @@ -226,8 +233,11 @@ export class WorkspaceHost {
return this.#emitDocumentChanged();
}

#openPackage(sourcePath: string): WorkspaceDocumentState {
const identity = this.#inspectPackage(sourcePath);
#openPackage(
sourcePath: string,
packageIdentity: WorkspacePackageIdentity | undefined,
): WorkspaceDocumentState {
const identity = packageIdentity ?? this.#inspectPackage(sourcePath);
const opened = this.#packageOpener.open(identity);

this.#adoptDocument(opened.document, opened.address);
Expand Down Expand Up @@ -288,8 +298,10 @@ export class WorkspaceHost {
this.#documentId = null;
this.#packageAddress = null;

if (address) this.#documents.removePackageBinding(address);
this.#documents.deleteDocument(documentId);
if (!address || discard) {
if (address) this.#documents.removePackageBinding(address);
this.#documents.deleteDocument(documentId);
}
this.#shell?.emit("document.changed", null);
this.#sync?.emit("document.changed", null);

Expand Down
3 changes: 2 additions & 1 deletion crates/shift-bridge/__test__/index.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,10 @@ describe("Bridge", () => {
bridge.resumeWorkspaceForSource(storePath, packagePath);
}

it("acquires every lazy glyph payload before preparing the complete Slug atlas", () => {
it("consumes a prewarmed authored compilation when preparing the complete Slug atlas", () => {
resumeMutatorPackage();

bridge.prepareAuthoredGlyphCompilation();
const atlas = bridge.prepareSlugAtlas(256);
expect(atlas.glyphs).toHaveLength(bridge.getGlyphs().length);
expect(atlas.curveCount).toBeGreaterThan(0);
Expand Down
Loading