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.

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: 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:** `.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.
- **Architecture Invariant:** `.shift` is the only direct writable source format (`DocumentState.isWritableFormat`). Imported font formats can be opened, but saving them triggers Save As to a `.shift` package. Export is a separate workflow.

## 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`, `.designspace`)
- `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.shift`, `.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`, `.designspace`)

## How it works

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

### Save and autosave

`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.
`DocumentState.save` checks `isWritableFormat` -- only `.shift` packages can be saved in-place. Imported formats and Save As show the save dialog with Shift Source Package as the default filter. On save, the main process sends `menu:save-font` to the renderer, which writes through the Rust workspace 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

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 defaults to .designspace
- Manual: open a .ttf, Cmd+S, verify Save As dialog defaults to .shift

## Related

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/managers/AppLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class AppLifecycle {
filters: [
{
name: "Fonts",
extensions: ["ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"],
extensions: ["shift", "ttf", "otf", "ufo", "glyphs", "glyphspackage", "designspace"],
},
],
});
Expand Down
15 changes: 6 additions & 9 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(".designspace") || filePath.endsWith(".ufo");
return filePath.endsWith(".shift");
}

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

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

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

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

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

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 @@ -5,6 +5,8 @@ import { extractFirstFontPath, isSupportedFontPath, normalizeFontPath } from "./
describe("openFontPath", () => {
describe("isSupportedFontPath", () => {
it("accepts supported font extensions", () => {
expect(isSupportedFontPath("/tmp/font.shift")).toBe(true);
expect(isSupportedFontPath("/tmp/font.SHIFT")).toBe(true);
expect(isSupportedFontPath("/tmp/font.ufo")).toBe(true);
expect(isSupportedFontPath("/tmp/font.ttf")).toBe(true);
expect(isSupportedFontPath("/tmp/font.otf")).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/managers/openFontPath.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from "node:path";

const SUPPORTED_FONT_EXTENSIONS = new Set([
".shift",
".ufo",
".ttf",
".otf",
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/renderer/src/app/Document.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TestEditor } from "@/testing/TestEditor";
import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures";
import { MUTATORSANS_DESIGNSPACE, testSourcePath, testStorePath } from "@/testing";
import { Document } from "./Document";

function testDocument() {
Expand All @@ -9,6 +9,14 @@ function testDocument() {
let dirty = true;
const document = new Document(editor, {
createUntitledId: () => "untitled-1",
createWorkspacePaths: (id) => {
const sourcePath = testSourcePath(id);
return { sourcePath, storePath: `${sourcePath}/working.sqlite` };
},
workspacePathsForOpen: (path) => ({
sourcePath: path,
storePath: testStorePath("document-open"),
}),
setFilePath: (nextPath) => {
filePath = nextPath;
},
Expand Down
45 changes: 42 additions & 3 deletions apps/desktop/src/renderer/src/app/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ export type DocumentIdentity =
| { readonly kind: "untitled"; readonly id: string }
| { readonly kind: "file"; readonly path: string };

export interface WorkspacePaths {
readonly sourcePath: string;
readonly storePath: string;
}

export interface DocumentServices {
readonly setFilePath: (filePath: string | null) => void;
readonly clearDirty: () => void;
readonly createUntitledId?: () => string;
readonly createWorkspacePaths?: (id: string) => WorkspacePaths;
readonly workspacePathsForOpen?: (path: string) => WorkspacePaths;
readonly notifySaveCompleted?: (path: string) => Promise<void> | void;
}

Expand All @@ -24,6 +31,8 @@ export class Document {
readonly #setFilePath: (filePath: string | null) => void;
readonly #clearDirty: () => void;
readonly #createUntitledId: () => string;
readonly #createWorkspacePaths: (id: string) => WorkspacePaths;
readonly #workspacePathsForOpen: (path: string) => WorkspacePaths;
readonly #notifySaveCompleted: (path: string) => Promise<void> | void;

#identity: DocumentIdentity | null = null;
Expand All @@ -33,6 +42,8 @@ export class Document {
this.#setFilePath = services.setFilePath;
this.#clearDirty = services.clearDirty;
this.#createUntitledId = services.createUntitledId ?? createUntitledId;
this.#createWorkspacePaths = services.createWorkspacePaths ?? createWorkspacePaths;
this.#workspacePathsForOpen = services.workspacePathsForOpen ?? workspacePathsForOpen;
this.#notifySaveCompleted = services.notifySaveCompleted ?? (() => undefined);
}

Expand All @@ -46,15 +57,17 @@ export class Document {

createFont(): void {
const id = this.#createUntitledId();
this.editor.createFont();
const paths = this.#createWorkspacePaths(id);
this.editor.createFont(paths.sourcePath, paths.storePath);
this.#identity = { kind: "untitled", id };

this.#setFilePath(null);
this.#clearDirty();
}

openFont(path: string): void {
this.editor.loadFont(path);
const paths = this.#workspacePathsForOpen(path);
this.editor.loadFont(paths.sourcePath, paths.storePath);
this.#identity = { kind: "file", path };

this.#setFilePath(path);
Expand All @@ -67,7 +80,14 @@ export class Document {
throw new Error("Cannot save an untitled document without a file path");
}

await this.editor.saveFont(savePath);
if (
this.#identity?.kind === "file" &&
(path === undefined || savePath === this.#identity.path)
) {
await this.editor.saveFont();
} else {
await this.editor.saveFont(savePath);
}
this.#identity = { kind: "file", path: savePath };

this.#setFilePath(savePath);
Expand All @@ -91,3 +111,22 @@ export class Document {
function createUntitledId(): string {
return globalThis.crypto?.randomUUID?.() ?? `untitled-${Date.now().toString(36)}`;
}

function createWorkspacePaths(id: string): WorkspacePaths {
const root = `${appWorkspaceRoot()}/${id}`;
const sourcePath = `${root}/Untitled.shift`;
return { sourcePath, storePath: `${sourcePath}/working.sqlite` };
}

function workspacePathsForOpen(path: string): WorkspacePaths {
if (path.endsWith(".shift")) {
return { sourcePath: path, storePath: `${path}/working.sqlite` };
}

return { sourcePath: path, storePath: `${path}.working.sqlite` };
}

function appWorkspaceRoot(): string {
const homePath = typeof window === "undefined" ? null : window.electronAPI?.homePath;
return `${homePath ?? "/tmp"}/.shift/workspaces`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type { PointId } from "@shift/types";
import { ApplyPositionPatchCommand } from "./ApplyPositionPatchCommand";
import { Font } from "@/lib/model/Font";
import type { GlyphSource } from "@/lib/model/Glyph";
import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures";
import { MUTATORSANS_DESIGNSPACE, testStorePath } from "@/testing";
import type { CommandContext } from "../core";

function editableSource(): GlyphSource {
const bridge = createBridge();
const font = new Font(bridge);
font.load(MUTATORSANS_DESIGNSPACE);
font.load(MUTATORSANS_DESIGNSPACE, testStorePath("apply-position-patch"));

const handle = { name: "A", unicode: 65 };
const source = font.defaultSource;
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/src/lib/commands/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { signal, type Signal } from "@/lib/signals/signal";
import { Font } from "@/lib/model/Font";
import type { GlyphSource } from "@/lib/model/Glyph";
import { Point } from "@shift/glyph-state";
import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures";
import { MUTATORSANS_DESIGNSPACE, testStorePath } from "@/testing";
import type { CommandContext } from "./core";

export interface CommandSourceFixture {
Expand All @@ -16,7 +16,7 @@ export interface CommandSourceFixture {
export function commandSourceFixture(): CommandSourceFixture {
const bridge = createBridge();
const font = new Font(bridge);
font.load(MUTATORSANS_DESIGNSPACE);
font.load(MUTATORSANS_DESIGNSPACE, testStorePath("command-source"));

const handle = { name: "A", unicode: 65 };
const source = font.defaultSource;
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/renderer/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,8 +1031,8 @@ export class Editor {
* Creates a new loaded font document and resets editor placement to its
* default design location.
*/
public createFont(): void {
this.font.create();
public createFont(sourcePath: string, storePath: string): void {
this.font.create(sourcePath, storePath);
this.setDesignLocation(this.font.defaultLocation());
this.#events.emit("fontLoaded", { font: this.font });
}
Expand All @@ -1041,8 +1041,8 @@ export class Editor {
* Loads a font from disk and resets editor placement to its default design
* location.
*/
public loadFont(filePath: string): void {
this.font.load(filePath);
public loadFont(filePath: string, storePath: string): void {
this.font.load(filePath, storePath);
this.setDesignLocation(this.font.defaultLocation());
this.#events.emit("fontLoaded", { font: this.font });
}
Expand All @@ -1052,7 +1052,7 @@ export class Editor {
this.setDesignLocation(emptyAxisLocation());
}

public async saveFont(filePath: string): Promise<number> {
public async saveFont(filePath?: string): Promise<number> {
return this.font.save(filePath);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { signal } from "@/lib/signals/signal";
import { CommandHistory } from "@/lib/commands/core/CommandHistory";
import { Font } from "@/lib/model/Font";
import type { GlyphSource } from "@/lib/model/Glyph";
import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures";
import { MUTATORSANS_DESIGNSPACE, testStorePath } from "@/testing";
import { SourceEditDraft } from "./SourceEditDraft";

function editableSource(): GlyphSource {
const bridge = createBridge();
const font = new Font(bridge);
font.load(MUTATORSANS_DESIGNSPACE);
font.load(MUTATORSANS_DESIGNSPACE, testStorePath("source-edit-draft"));

const handle = { name: "A", unicode: 65 };
const source = font.defaultSource;
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/src/lib/editor/variation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Glyph, GlyphGeometry } from "@/lib/model/Glyph";
import { TestEditor, MUTATORSANS_DESIGNSPACE } from "@/testing";
import { TestEditor, MUTATORSANS_DESIGNSPACE, testStorePath } from "@/testing";
import { emptyAxisLocation, withAxisValue } from "@/lib/variation/location";
import type { AxisLocation } from "@/types/variation";

Expand All @@ -24,7 +24,7 @@ describe("Editor.open — variation-aware glyph reads", () => {
// master's stored coordinates, so the canvas didn't
// match what the user clicked when the slider was off-default.
const editor = new TestEditor();
editor.loadFont(MUTATORSANS_DESIGNSPACE);
editor.loadFont(MUTATORSANS_DESIGNSPACE, testStorePath("default"));

const atDefault = editor.getGlyph({ name: "A", unicode: 65 })!;
const defaultGeometry = atDefault.geometryAt(editor.designLocation);
Expand All @@ -45,7 +45,7 @@ describe("Editor.open — variation-aware glyph reads", () => {
// to the grid), then re-open the same glyph. The re-opened glyph should
// carry the edit, not revert to the unedited geometry.
const editor = new TestEditor();
editor.loadFont(MUTATORSANS_DESIGNSPACE);
editor.loadFont(MUTATORSANS_DESIGNSPACE, testStorePath("persist"));

const opened = editor.getGlyph({ name: "A", unicode: 65 })!;
const point = opened.contours[0].points[0];
Expand All @@ -68,7 +68,7 @@ describe("Editor.open — variation-aware glyph reads", () => {
// glyph closes, the registry's Glyph must reflect the edits the user
// just made. Otherwise the grid shows the pre-edit outline.
const editor = new TestEditor();
editor.loadFont(MUTATORSANS_DESIGNSPACE);
editor.loadFont(MUTATORSANS_DESIGNSPACE, testStorePath("registry"));

const opened = editor.getGlyph({ name: "A", unicode: 65 })!;
const point = opened.contours[0].points[0];
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/src/lib/model/Font.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createBridge } from "@shift/bridge";
import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures";
import { MUTATORSANS_DESIGNSPACE, testSourcePath, testStorePath } from "@/testing";
import {
axisLocationFromLocation,
axisValue,
Expand All @@ -14,7 +14,7 @@ import { Font } from "./Font";

function loadFont(): Font {
const font = new Font(createBridge());
font.load(MUTATORSANS_DESIGNSPACE);
font.load(MUTATORSANS_DESIGNSPACE, testStorePath("load"));
return font;
}

Expand Down Expand Up @@ -62,7 +62,8 @@ describe("Font", () => {
it("creates a fresh loaded font with a default source", () => {
const font = new Font(createBridge());

font.create();
const sourcePath = testSourcePath("create");
font.create(sourcePath, `${sourcePath}/working.sqlite`);

expect(font.loaded).toBe(true);
expect(font.sources).toHaveLength(1);
Expand Down
Loading
Loading