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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 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: application lifecycle, window management, menus, document
- **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: 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:** Only `.ufo` is a writable format (`DocumentState.isWritableFormat`). Saving a non-UFO file always triggers Save As. Autosave skips non-UFO files silently.
- **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.

## Codemap

Expand All @@ -33,7 +33,7 @@ src/main/
- `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)
- `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`)
- `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`, `.designspace`)

## How it works

Expand All @@ -55,11 +55,11 @@ Files arrive via three paths: CLI launch args (`handleLaunchArgs`), second-insta

### Save and autosave

`DocumentState.save` checks `isWritableFormat` -- only `.ufo` can be saved in-place. Non-UFO files and Save As always show the save dialog with a UFO filter. On save, the main process sends `menu:save-font` to the renderer, which does the actual write and calls back `document:saveCompleted`. Autosave runs on a 30-second interval (`AUTOSAVE_INTERVAL_MS`) and only fires if dirty and the file is writable UFO.
`DocumentState.save` checks `isWritableFormat` -- `.designspace` and `.ufo` can be saved in-place. Other imported formats and Save As show the save dialog with Designspace as the default filter. On save, the main process sends `menu:save-font` to the renderer, which does the actual write and calls back `document:saveCompleted`. Autosave runs on a 30-second interval (`AUTOSAVE_INTERVAL_MS`) and only fires if dirty and the file is writable.

### Menu

`MenuManager.create` rebuilds the entire menu template each time it is called (to update radio/checkbox state). It includes File (open/save), Edit (undo/redo/delete/select-all forwarded to renderer), View (zoom, theme, devtools), and a Debug menu (only in dev builds) for React Scan, debug panel, snapshot dumps, and overlay toggles.
`MenuManager.create` rebuilds the entire menu template each time it is called (to update radio/checkbox state). It includes File (new/open/save), Edit (undo/redo/delete/select-all forwarded to renderer), View (zoom, theme, devtools), and a Debug menu (only in dev builds) for React Scan, debug panel, snapshot dumps, and overlay toggles. File -> New Font and File -> Open Font both run through `DocumentState.confirmClose` before asking the renderer to replace the current document.

### IPC registration

Expand Down Expand Up @@ -104,7 +104,7 @@ IPC handlers are split across managers. `WindowManager` registers window-control
- `npx vitest run apps/desktop/src/main/managers/openFontPath.test.ts` -- openFontPath unit tests
- Manual: launch with a font path argument, verify it opens
- Manual: edit a document, Cmd+Q, verify save dialog appears
- Manual: open a .ttf, Cmd+S, verify Save As dialog forces .ufo
- Manual: open a .ttf, Cmd+S, verify Save As dialog defaults to .designspace

## Related

Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
function formatDetails(details: unknown[]): string {
if (details.length === 0) return "";

try {
return ` ${JSON.stringify(details)}`;
} catch {
return ` ${details.map(String).join(" ")}`;
}
}

export function mainLog(scope: string, message: string, ...details: unknown[]): void {
const timestamp = new Date().toISOString();
process.stdout.write(`[shift:${scope}] ${timestamp} ${message}${formatDetails(details)}\n`);
}
15 changes: 9 additions & 6 deletions apps/desktop/src/main/managers/DocumentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class DocumentState {

private isWritableFormat(filePath: string | null): boolean {
if (!filePath) return false;
return filePath.endsWith(".ufo");
return filePath.endsWith(".designspace") || filePath.endsWith(".ufo");
}

async save(saveAs = false): Promise<boolean> {
Expand All @@ -56,24 +56,27 @@ export class DocumentState {
let savePath = this.filePath;

if (!savePath || saveAs || !this.isWritableFormat(savePath)) {
let defaultPath = "Untitled.ufo";
let defaultPath = "Untitled.designspace";
if (this.filePath) {
const baseName = path.basename(this.filePath, path.extname(this.filePath));
defaultPath = `${baseName}.ufo`;
defaultPath = `${baseName}.designspace`;
}

const result = await dialog.showSaveDialog({
defaultPath,
filters: [{ name: "UFO Files", extensions: ["ufo"] }],
filters: [
{ name: "Designspace Files", extensions: ["designspace"] },
{ name: "UFO Files", extensions: ["ufo"] },
],
});

if (result.canceled || !result.filePath) {
return false;
}

savePath = result.filePath;
if (!savePath.endsWith(".ufo")) {
savePath += ".ufo";
if (!savePath.endsWith(".designspace") && !savePath.endsWith(".ufo")) {
savePath += ".designspace";
}
}

Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/src/main/managers/MenuManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WindowManager } from "./WindowManager";
import type { ThemeName, DebugOverlays, Debug } from "../../shared/ipc/types";
import type { IpcEvents } from "../../shared/ipc/channels";
import * as ipc from "../../shared/ipc/main";
import { mainLog } from "../logger";

export class MenuManager {
private documentState: DocumentState;
Expand Down Expand Up @@ -38,7 +39,12 @@ export class MenuManager {
...args: Parameters<IpcEvents[K]>
): void {
const webContents = this.windowManager.getWindow()?.webContents;
if (!webContents) return;
if (!webContents) {
mainLog("menu", `drop ${String(channel)}: no renderer window`);
return;
}

mainLog("menu", `send ${String(channel)}`, ...args);
ipc.send(webContents, channel, ...args);
}

Expand Down Expand Up @@ -113,10 +119,19 @@ export class MenuManager {
{
label: "File",
submenu: [
{
label: "New Font",
accelerator: "CmdOrCtrl+N",
click: async () => {
if (!(await this.documentState.confirmClose())) return;
this.sendToRenderer("document:new");
},
},
{
label: "Open Font...",
accelerator: "CmdOrCtrl+O",
click: async () => {
if (!(await this.documentState.confirmClose())) return;
const result = await dialog.showOpenDialog({
properties: ["openFile", "openDirectory"],
filters: [
Expand All @@ -142,6 +157,26 @@ export class MenuManager {
accelerator: "CmdOrCtrl+Shift+S",
click: () => this.documentState.save(true),
},
{
label: "Export TTF...",
accelerator: "CmdOrCtrl+E",
click: async () => {
const baseName = this.documentState
.getFileName()
.replace(/\.[^.]+$/, "")
.replace(/\.designspace$/, "");
const result = await dialog.showSaveDialog({
defaultPath: `${baseName || "Untitled"}.ttf`,
filters: [{ name: "TrueType Font", extensions: ["ttf"] }],
});
if (!result.canceled && result.filePath) {
const exportPath = result.filePath.endsWith(".ttf")
? result.filePath
: `${result.filePath}.ttf`;
this.sendToRenderer("menu:export-font", exportPath);
}
},
},
{ type: "separator" },
isMac ? { role: "close" } : { role: "quit" },
],
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/managers/openFontPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ describe("openFontPath", () => {
expect(isSupportedFontPath("/tmp/font.glyphs")).toBe(true);
expect(isSupportedFontPath("/tmp/font.glyphspackage")).toBe(true);
expect(isSupportedFontPath("/tmp/font.GLYPHSPACKAGE")).toBe(true);
expect(isSupportedFontPath("/tmp/font.designspace")).toBe(true);
expect(isSupportedFontPath("/tmp/font.DESIGNSPACE")).toBe(true);
});

it("rejects unsupported extensions", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ const electronAPI: ElectronAPI = {
pathsExist: invoke("fs:pathsExist"),

// Events
onDocumentNew: on("document:new"),
onMenuOpenFont: on("menu:open-font"),
onExternalOpenFont: on("external:open-font"),
onMenuSaveFont: on("menu:save-font"),
onMenuExportFont: on("menu:export-font"),
onMenuUndo: on("menu:undo"),
onMenuRedo: on("menu:redo"),
onMenuDelete: on("menu:delete"),
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/renderer/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ export const App = () => {

const unsubscribeOpen = window.electronAPI?.onMenuOpenFont(handleOpenFont);
const unsubscribeExternalOpen = window.electronAPI?.onExternalOpenFont(handleOpenFont);
const unsubscribeNew = window.electronAPI?.onDocumentNew(() => {
fontDocument.createFont();
didOpenFont = true;
navigateToHome();
});

const unsubscribeSave = window.electronAPI?.onMenuSaveFont(async (savePath) => {
try {
Expand All @@ -110,16 +115,26 @@ export const App = () => {
}
});

const unsubscribeExport = window.electronAPI?.onMenuExportFont(async (exportPath) => {
try {
await fontDocument.exportFont(exportPath);
} catch (error) {
console.error("Failed to export font:", error);
}
});

const unsubscribePatternDump = window.electronAPI?.onDebugDumpSelectionPatterns(() => {
dumpSelectionPatternsToConsole();
});

return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
documentPersistence.dispose();
if (unsubscribeNew) unsubscribeNew();
if (unsubscribeOpen) unsubscribeOpen();
if (unsubscribeExternalOpen) unsubscribeExternalOpen();
if (unsubscribeSave) unsubscribeSave();
if (unsubscribeExport) unsubscribeExport();
if (unsubscribePatternDump) unsubscribePatternDump();
};
}, []);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/src/app/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export class Document {
await this.#notifySaveCompleted(savePath);
}

async exportFont(path: string): Promise<void> {
await this.editor.exportFont(path);
}

close(): void {
this.#persistence.closeDocument();
this.editor.closeFont();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const WorkspaceLayout = () => {
<div className="h-full w-full">
<Routes>
<Route path="/home" element={<Home />} />
<Route path="/editor/glyph/:glyphName" element={<Editor />} />
<Route path="/editor/:glyphId" element={<Editor />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/renderer/src/assets/minus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 2 additions & 4 deletions apps/desktop/src/renderer/src/assets/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 19 additions & 9 deletions apps/desktop/src/renderer/src/components/editor/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,26 @@ import { StaticScene } from "./StaticScene";
import { DebugPanel } from "../debug/DebugPanel";
import { TextInput } from "../text/HiddenTextInput";
import { Vec2 } from "@shift/geo";
import type { GlyphName } from "@shift/types";
import type { GlyphHandle } from "@shift/bridge";

const GLYPH_ID_RE = /^[0-9a-f]+$/i;

function handleFromGlyphId(glyphId: string | undefined): GlyphHandle | null {
if (!glyphId || !GLYPH_ID_RE.test(glyphId)) return null;

const parsed = Number.parseInt(glyphId, 16);
if (Number.isNaN(parsed)) return null;

return getEditor().font.glyphHandleForUnicode(parsed);
}

interface EditorViewProps {
glyphId: string;
glyphId?: string;
glyphName?: GlyphName;
}

export const EditorView: FC<EditorViewProps> = ({ glyphId }) => {
export const EditorView: FC<EditorViewProps> = ({ glyphId, glyphName }) => {
const editor = getEditor();
const debug = useDebugSafe();
const containerRef = useRef<HTMLDivElement>(null);
Expand All @@ -35,13 +47,11 @@ export const EditorView: FC<EditorViewProps> = ({ glyphId }) => {

useEffect(() => {
if (!fontLoaded) return undefined;
if (!GLYPH_ID_RE.test(glyphId)) return undefined;

const parsed = Number.parseInt(glyphId, 16);
if (Number.isNaN(parsed)) return undefined;

const unicode = parsed;
const handle = editor.font.glyphHandleForUnicode(unicode);
const handle = glyphName
? editor.font.glyphHandleForName(glyphName)
: handleFromGlyphId(glyphId);
if (!handle) return undefined;

const source = editor.font.sourceAtOrDefault(editor.font.defaultLocation());

Expand All @@ -58,7 +68,7 @@ export const EditorView: FC<EditorViewProps> = ({ glyphId }) => {
toolManager.reset();
editor.close();
};
}, [editor, fontLoaded, glyphId]);
}, [editor, fontLoaded, glyphId, glyphName]);

useEffect(() => {
const element = containerRef.current;
Expand Down
Loading
Loading