Skip to content
Closed
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
20 changes: 19 additions & 1 deletion apps/desktop/e2e/document-crash.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { ElectronApplication, Page } from "@playwright/test";
import type { GlyphName } from "@shift/types";
import { expect, workspaceTest as test, waitForWorkspaceReady } from "./fixtures/electronApp";
import {
expect,
navigateToEditor,
workspaceTest as test,
waitForWorkspaceReady,
} from "./fixtures/electronApp";
import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume";

test.setTimeout(90_000);
test.use({ scriptedDialogs: true });
Expand All @@ -19,6 +25,18 @@ test("reopens a crashed document renderer with completed edits", async ({ electr
await expect.poll(() => electronApp.windows().length).toBe(1);
});

test("reopens a crashed renderer on the last editor route", async ({ electronApp, page }) => {
await navigateToEditor(page, "41");
await page.waitForURL(/#\/editor\//);
await expect(page.locator("#interactive-canvas")).toBeVisible();
await persistCurrentEditorRoute(page);

const reopenedPage = await crashRendererAndWaitForWindow(electronApp, page);
await waitForAuthoredFontLoaded(reopenedPage);
await expect(reopenedPage).toHaveURL(/#\/editor\//, { timeout: 20_000 });
await expect(reopenedPage.locator("#interactive-canvas")).toBeVisible({ timeout: 20_000 });
});

test("reopens after a document render failure", async ({ electronApp, page }) => {
const glyphName = "reactDocumentRecovery" as GlyphName;
await page.evaluate((name) => {
Expand Down
33 changes: 30 additions & 3 deletions apps/desktop/e2e/document-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,21 +488,48 @@ for (const { format, sourcePath, sourceRoot } of [
}

convertiblePreviewTest(
"Save As replaces a preview glyph route with the new workspace Home",
"Save As restores the editor route after preview conversion",
async ({ electronApp, page, saveShiftPath, testRoot }) => {
const workspacePage = await openSelectedPreview(page, electronApp);
// Open one glyph with a real click so the window owns OS focus for the command,
// then route to "C": the converted document cannot open glyphs whose layers
// reference sources the conversion dropped (A, B, E, ... in this font).
await clickFirstCatalogGlyph(workspacePage);
await workspacePage.waitForURL(/#\/editor\//);
const glyphCId = await workspacePage.evaluate(async () => {
const font = window.shiftSession?.font;
const entry = font?.glyphEntries().find((glyph) => glyph.name === "C");
if (!entry) throw new Error("Expected glyph C in the preview font");

window.location.hash = `/editor/${encodeURIComponent(entry.id)}`;
return entry.id;
});
await workspacePage.waitForURL(new RegExp(`#/editor/${glyphCId}$`));
await expect(workspacePage.locator("#interactive-canvas")).toBeVisible();

await runCommand(workspacePage, electronApp, "file.saveAs");
await waitForWorkspaceReady(workspacePage);
await workspacePage.waitForFunction(
() => window.shiftSession?.mode === "workspace" && window.shift?.font.loaded === true,
undefined,
{ timeout: 20_000 },
);

await expect(workspacePage).toHaveURL(/#\/home$/);
await expect(workspacePage).toHaveURL(/#\/editor\//, { timeout: 20_000 });
await expect(workspacePage.locator("#interactive-canvas")).toBeVisible({ timeout: 20_000 });
await expect
.poll(() => workspacePage.evaluate(() => window.shiftSession?.mode))
.toBe("workspace");
expect(savedGlyphNames(saveShiftPath, testRoot)).toContain("A");
await expect
.poll(() =>
workspacePage.evaluate(() => {
const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1];
if (!glyphId) return null;
const decoded = decodeURIComponent(glyphId);
return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name;
}),
)
.toBe("C");
},
);

Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/e2e/document-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import type { GlyphName } from "@shift/types";
import { expect, recoveryTest as test } from "./fixtures/electronApp";
import { expect, navigateToEditor, recoveryTest as test } from "./fixtures/electronApp";
import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume";

test.setTimeout(90_000);

Expand Down Expand Up @@ -49,6 +50,22 @@ test("recovers undo past the last save as a dirty document that saves explicitly
expect(recoveryApp.canonicalGlyphNames()).not.toContain(glyphName);
});

test("relaunch recovery restores the last editor route", async ({ recoveryApp }) => {
// Recovery only runs for unsaved edits, so make one before leaving the catalog.
const glyphName = "recoveredRoute" as GlyphName;
await createGlyph(recoveryApp.page, glyphName);
await waitForGlyphsAndState(recoveryApp.page, [glyphName], true, true);
await navigateToEditor(recoveryApp.page, "41");
await recoveryApp.page.waitForURL(/#\/editor\//);
await expect(recoveryApp.page.locator("#interactive-canvas")).toBeVisible();
await persistCurrentEditorRoute(recoveryApp.page);

const recovered = await recoveryApp.crashAndRecover();
await waitForAuthoredFontLoaded(recovered);
await expect(recovered).toHaveURL(/#\/editor\//);
await expect(recovered.locator("#interactive-canvas")).toBeVisible();
});

test("recovers edits made after a previous recovery", async ({ recoveryApp }) => {
const firstGlyph = "recoveredFirst" as GlyphName;
const secondGlyph = "recoveredSecond" as GlyphName;
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/e2e/fixtures/electronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export const recoveryTest = test.extend<{ recoveryApp: RecoveryApp }>({
crashAndRecover: async () => {
await killApp(app);
app = await launch(false);
page = await readyWorkspacePage(app);
page = await readyRecoveredWorkspacePage(app);
return page;
},
crashAndReopenDocument: async () => {
Expand Down Expand Up @@ -321,6 +321,16 @@ async function readyWorkspacePage(app: ElectronApplication): Promise<Page> {
return page;
}

async function readyRecoveredWorkspacePage(app: ElectronApplication): Promise<Page> {
const page = await app.firstWindow();
await page.waitForLoadState("domcontentloaded");
await page.waitForURL(/#\/(home|editor\/)/, { timeout: 20_000 });
await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, {
timeout: 20_000,
});
return page;
}

function readCanonicalGlyphNames(documentPath: string, testRoot: string): string[] {
const bridge = createBridge();
const recoveryPath = path.join(testRoot, `${crypto.randomUUID()}.recovery.sqlite`);
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/e2e/fixtures/viewResume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Page } from "@playwright/test";

/** Ensures main has a view-resume payload matching the current editor route. */
export async function persistCurrentEditorRoute(page: Page): Promise<void> {
await page.evaluate(async () => {
const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1];
if (!glyphId || !window.shift) return;

const decoded = decodeURIComponent(glyphId);
const record = window.shift.font.glyphRecords().find((glyph) => glyph.id === decoded);
if (!record) return;

await window.shiftHost?.session.setViewResume({
route: {
glyphName: record.name,
unicode: record.unicodes[0] ?? null,
glyphId: record.id,
},
catalog: {
query: "",
categoryFilters: [],
selectedLanguageId: null,
scrollTop: 0,
},
});
});
}

export async function waitForAuthoredFontLoaded(page: Page): Promise<void> {
await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, {
timeout: 20_000,
});
}
103 changes: 99 additions & 4 deletions apps/desktop/src/main/app/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversi
import { OPEN_FONT_EXTENSIONS } from "../../shared/openFontExtensions";
import { RecentDocuments } from "../recents/RecentDocuments";
import type { RecentDocumentVisit } from "../../shared/recents";
import type { SessionViewResume } from "../../shared/viewResume";
import {
ViewResumePersistence,
workspaceLoadHashFromResume,
} from "../viewResume/persistViewResume";

const SLUG_ATLAS_PROFILING_ENABLED =
process.env.SHIFT_PROFILE_SLUG_ATLAS !== undefined &&
Expand Down Expand Up @@ -64,6 +69,7 @@ export class App {
#workspaces: WorkspaceManager;
#documentsRoot: string | null = null;
#recents: RecentDocuments | null = null;
#viewResume: ViewResumePersistence | null = null;
/** Launchers a font open is replacing; they stop receiving recents so no half-ready card flashes. */
#replacedLaunchers = new WeakSet<Window>();
#pendingOpenPaths: string[] = [];
Expand All @@ -74,7 +80,7 @@ export class App {
#applicationMenu = new ApplicationMenu(
(id, browserWindow) => {
const window = browserWindow
? this.#windows.windowForBrowserWindow(browserWindow)
? (this.#windows.windowForBrowserWindow(browserWindow) ?? undefined)
: undefined;
if (browserWindow && !window) return;

Expand All @@ -91,7 +97,7 @@ export class App {
},
(id, browserWindow) => {
const window = browserWindow
? this.#windows.windowForBrowserWindow(browserWindow)
? (this.#windows.windowForBrowserWindow(browserWindow) ?? undefined)
: undefined;
if (browserWindow && !window) return false;

Expand Down Expand Up @@ -150,6 +156,8 @@ export class App {
lifecycle: this.#lifecycle,
activeWindow: () => this.#windows.activeWindow(),
log: createShiftLogger("app.update"),
recordOpenSessionsForUpdateRestart: () => this.#recordOpenSessionsForUpdateRestart(),
clearOpenSessionsForUpdateRestart: () => this.#clearOpenSessionsForUpdateRestart(),
});
}

Expand Down Expand Up @@ -213,6 +221,11 @@ export class App {
path.join(app.getPath("userData"), "recent-documents.json"),
);
this.#recents.onChanged(() => this.#publishRecents());
this.#viewResume = new ViewResumePersistence(
path.join(app.getPath("userData"), "view-resume.json"),
);

await this.#reopenSessionsAfterUpdateRestart();

const restoredSessions = await this.#workspaces.restoreRecoveries();
for (const session of restoredSessions) {
Expand Down Expand Up @@ -385,6 +398,8 @@ export class App {
const session = this.#workspaces.getForBrowserWindow(owner.window);
if (!session?.document) throw new Error("document reopen requires an authored workspace");

await this.#flushViewResumeFromRenderer(owner);

const reopened = await this.#workspaces.reopenSession(session.workspaceId);
const bounds = owner.window.isDestroyed() ? undefined : owner.window.getBounds();
const window = this.#createWindow(false, bounds);
Expand All @@ -398,11 +413,72 @@ export class App {

#crashedWindows(session: FontSessionHost | null, failedWindow: Window | null): Window[] {
if (failedWindow) return [failedWindow];
return session?.allWindows() ?? [];
return session ? [...session.allWindows()] : [];
}

#loadWorkspace(window: Window): void {
this.#loadRenderer(window, "/home");
const session = this.#workspaces.getForBrowserWindow(window.window);
const resume = session && this.#viewResume ? this.#viewResume.get(session.workspaceId) : null;
const hash = workspaceLoadHashFromResume(resume);
this.#loadRenderer(window, hash);
}

async #flushViewResumeFromRenderer(window: Window): Promise<void> {
const webContents = window.window.webContents;
if (window.window.isDestroyed() || webContents.isDestroyed() || webContents.isCrashed()) {
return;
}

try {
await webContents.executeJavaScript(
`(async () => {
const flush = globalThis.__shiftViewResumeFlush;
if (typeof flush === "function") await flush();
})()`,
true,
);
} catch (error) {
this.#log.warn("failed to flush view resume from renderer", error);
}
}

#recordOpenSessionsForUpdateRestart(): void {
if (!this.#viewResume) return;

const openSessions = this.#workspaces
.list()
.filter((session) => session.mode === "workspace" && session.windows.size > 0)
.map((session) => ({
sessionId: session.workspaceId,
documentPath: session.document?.cachedDocumentState()?.saveTarget ?? null,
}));

this.#viewResume.setOpenSessionsAtQuit(openSessions);
}

#clearOpenSessionsForUpdateRestart(): void {
this.#viewResume?.clearOpenSessionsAtQuit();
}

async #reopenSessionsAfterUpdateRestart(): Promise<void> {
if (!this.#viewResume) return;

const pending = this.#viewResume.takeOpenSessionsAtQuit();
for (const entry of pending) {
if (!entry.documentPath) continue;

try {
const session = await this.#workspaces.openPath(entry.documentPath);
const window = this.#createWindow(false, undefined, true);
this.#workspaces.attachWindow(session.workspaceId, window);
if (entry.sessionId !== session.workspaceId) {
this.#viewResume.transfer(entry.sessionId, session.workspaceId);
}
this.#loadWorkspace(window);
} catch (error) {
this.#log.warn("failed to reopen session after update restart", entry, error);
}
}
}

#loadRenderer(window: Window, hash: string): void {
Expand Down Expand Up @@ -521,6 +597,22 @@ export class App {
ipc.handle(ipcMain, "errors.reportRenderer", (_event, report) => {
this.#log.warn("renderer error reported", report);
});
ipc.handle(ipcMain, "session.setViewResume", (event, resume: SessionViewResume) => {
const session = this.#fontSessionForSender(event.sender, "session.setViewResume");
this.#viewResume?.set(session.workspaceId, resume);
});
ipc.handle(ipcMain, "session.peekViewResume", (event) => {
const session = this.#fontSessionForSender(event.sender, "session.peekViewResume");
return this.#viewResume?.get(session.workspaceId) ?? null;
});
ipc.handle(ipcMain, "session.consumeViewResume", (event) => {
const session = this.#fontSessionForSender(event.sender, "session.consumeViewResume");
this.#viewResume?.consume(session.workspaceId);
});
ipc.handle(ipcMain, "session.takeViewResume", (event) => {
const session = this.#fontSessionForSender(event.sender, "session.takeViewResume");
return this.#viewResume?.consume(session.workspaceId) ?? null;
});
ipc.handle(ipcMain, "session.ready", (event) => {
if (SLUG_ATLAS_PROFILING_ENABLED) {
this.#log.info("[slug-atlas-profile]", {
Expand Down Expand Up @@ -629,6 +721,8 @@ export class App {
const sourcePath = preview.sourcePath;
if (!sourcePath || !isConvertiblePreviewPath(sourcePath)) return;

await this.#flushViewResumeFromRenderer(window);

const parsedSourcePath = path.parse(sourcePath);
const suggestedPath = path.join(parsedSourcePath.dir, `${parsedSourcePath.name}.shift`);
const documentPath = await this.#nativeDialogs.saveShiftDocument(window, suggestedPath);
Expand All @@ -650,6 +744,7 @@ export class App {
}

if (preview.windows.size === 0) this.#workspaces.unregister(preview.workspaceId);
this.#viewResume?.transfer(preview.workspaceId, authored.workspaceId);
this.#applicationMenu.updateCommandStates();
window.window.webContents.reload();
} catch (error) {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/docs/DOCS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Main

<!-- reviewed: 2026-09-26 -->
<!-- reviewed: 2026-10-02 -->

Electron main process: app startup, windows, menus, document dialogs, and workspace session ownership.

Expand All @@ -9,7 +9,8 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp
- **Architecture Invariant:** `WorkspaceManager` owns live font sessions. Windows attach to sessions; commands and IPC resolve the session from the focused window or sender. A desktop session's immutable mode is `"workspace"` or `"preview"`.
- **Architecture Invariant:** Every font session owns one `WorkspaceProcess`. Workspace sessions additionally own one `DocumentClient` and one `DocumentSession`; preview sessions deliberately have no authored document, persistence, dirty state, save target, or export workflow. Main never reads or mutates font data directly.
- **Architecture Invariant:** Every non-`.shift` font path opens as an immutable preview session. It uses the shared renderer sync lane and `/home` route, but never allocates a SQLite working document or authored Shift model.
- **Architecture Invariant:** Save or Save As may convert UFO, Designspace, Glyphs, and Glyphspackage previews into a new workspace session. The preview itself never changes mode: `workspace.createFromSource` fully imports and atomically publishes a separate canonical document, then main reattaches and reloads the active window. The renderer replaces a route whose glyph identity does not belong to the workspace session with Home. Cancel or failure leaves the preview and destination unchanged. TTF and OTF previews cannot convert.
- **Architecture Invariant:** Save or Save As may convert UFO, Designspace, Glyphs, and Glyphspackage previews into a new workspace session. The preview itself never changes mode: `workspace.createFromSource` fully imports and atomically publishes a separate canonical document, then main reattaches and reloads the active window. Main transfers the persisted `SessionViewResume` from the preview session id to the new workspace session id; the renderer remaps the last glyph route by stable name and unicode (falling back to Home when no glyph matches). Cancel or failure leaves the preview and destination unchanged. TTF and OTF previews cannot convert.
- **Architecture Invariant:** The renderer debounces catalog view state into `session.setViewResume`; main stores it in `view-resume.json` keyed by session id. Crash reopen, error reopen, relaunch recovery, and update restart compose the initial workspace hash from the stored route when the glyph id is still valid, and the renderer consumes the payload on `session.ready` to restore catalog filters, query, and scroll. Any restore failure falls back to today's Home landing and existing route guards.
- **Architecture Invariant:** Dirty state and save targets come from the utility-owned workspace state. Main obtains native choices through `NativeDialogs`, but state reads, saves, and exports go through the renderer document lane so pending edits flush first. Production uses Electron dialogs; E2E injects deterministic choices at this outer boundary.
- **Architecture Invariant:** Desktop dialog, update, crash, and settings-failure copy is keyed in `src/shared/messages/en.json` and formatted through `message()`. Native and recoverable failures never interpolate raw diagnostics; React error boundaries retain full diagnostics behind an explicit **Show details** disclosure and in renderer error reports.
- **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.
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/document/DocumentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ export class DocumentSession {
this.#updateWindowTitle();
}

/** Returns the last accepted document state snapshot owned by main. */
cachedDocumentState(): WorkspaceDocumentState | null {
return this.#state;
}

async #commitWorkspaceClose(discard: boolean, ownerReason: CloseReason | null): Promise<void> {
await Promise.resolve();

Expand Down
Loading
Loading