Skip to content

Desktop app (Electron) for macOS and Windows - #1

Merged
timbogdanov merged 4 commits into
mainfrom
feat/electron-desktop
Aug 18, 2026
Merged

Desktop app (Electron) for macOS and Windows#1
timbogdanov merged 4 commits into
mainfrom
feat/electron-desktop

Conversation

@timbogdanov

@timbogdanov timbogdanov commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Turns the editor into a real desktop application for macOS and Windows, without forking it.

app/patternfront.html stays the single source of truth. The same file is the browser build and
the desktop renderer — every native call sits behind a native() feature test, and there is a
test that boots the code with no window.pfNative at all and fails if anything reaches for it.

What you get

Native menus with real accelerators. .patternfront documents with Open / Save / Save As.
Double-click a file in Finder or Explorer and it opens. Recent files in the dock menu and jump
list. Exports through native Save dialogs instead of browser downloads. Remembered window
position, clamped to a display that still exists. An unsaved-changes prompt that will not let you
lose work.

Why the diff is small

Two seams already existed and did the heavy lifting:

  • Exports all funnelled through one download(blob, name). Redirecting them to a Save dialog
    was a single change to a single function.
  • Documents already had serialiseDoc(). restoreDoc() split into loadDocFromJSON(raw), so
    opening a file and restoring the autosave now go down the same path — the one the round-trip
    tests already covered.

Security

An open-source Electron app gets read by strangers, so this is deliberate:

  • contextIsolation: true, nodeIntegration: false, sandbox: true, webview disabled
  • navigation and popups denied; only http(s) ever reaches shell.openExternal
  • served over a registered app:// scheme, not file:// — a real origin, so localStorage
    works and a CSP can actually be enforced. Path traversal out of app/ is refused.
  • the preload exposes ten validated calls and nothing else, with a gate asserting the surface
    has not grown

Two details worth reviewing

Saves are atomic. writeAtomic writes a sibling temp file and renames over the target, so an
interrupted save leaves the previous version intact instead of a truncated file. Tested by making
the directory read-only mid-save and asserting the old contents survive.

Menu accelerators are owned by the menu. Electron dispatches ⌘Z before the page sees the
keydown. The renderer's own handler skips those combos on desktop — without that, every undo,
select-all and deselect fires twice. MENU_KEYS in the renderer and the accelerator list in
electron/menu.js have to stay in agreement; both are gated.

Verification

./tools/verify-all.sh — eleven suites, all green. Two are new:

Suite Checks
verify-electron.py 45 — sandbox flags, CSP directives, navigation blocking, preload surface, packaging targets
verify-documents.js 19 — validation, size limits, atomic writes, recent-files behaviour

Plus a smoke test (electron . --smoke) that launches headless and asserts the editor really
came up: 22 commands wired, 54 stamps built, canvas painted, no console errors. CI runs it under
xvfb. That is the difference between proving it packages and proving it works.

CI also fails if the generated stamp table or codec fixtures drift from their generators.

What I could not verify

CI is green on all four checks — verification, headless launch, and packaging on both
macos-latest and windows-latest. Every artifact builds: two .dmg, two .zip, three NSIS
installers and a portable .exe. But building is not running, and I am on macOS only:

  • The Windows installer is unbuilt and unrun by me. The package job in CI compiles it; nobody
    has launched it. Worth someone doing on real hardware before the first release.
  • The macOS .dmg does not build on my machine, though it does in CI. Locally it fails at
    hdiutil: convert failed - Resource temporarily unavailable, inside the dmgbuild copy
    electron-builder vendors — reproducible, and not caused by the config here (it fails with the
    default layout too). The package job settled it: both .dmg slices build fine on
    macos-latest, along with both .zips. So this is a local-environment problem, not a
    packaging one; the README notes the --mac zip workaround for anyone who hits it.
  • The arm64/x64 Mac slices both package, but only the one matching this machine has been run.

Also in this branch

--shot <path> renders the window offscreen to a PNG, which is where the README screenshot comes
from and gives CI a visual artifact on every run.

Builds are unsigned — see the README for the Gatekeeper and SmartScreen steps. The signing config
is wired but inert; enabling it later means setting secrets, not editing workflows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SPLUTYBHytZJX2BB6kCL9T

timbogdanov and others added 4 commits August 16, 2026 20:06
The editor becomes a real application rather than a wrapped web page: native
menus, .patternfront documents, Open/Save/Save As, double-click to open from
Finder or Explorer, recent files, exports through native Save dialogs, and an
unsaved-changes prompt.

app/patternfront.html stays the single source of truth. The same file is the
browser build and the desktop renderer — nothing is forked. Every native call
sits behind a native() feature test, and a test boots the code with no
window.pfNative at all and fails if anything reaches for it.

Two seams made this small. Exports already funnelled through one download()
function, so redirecting them to a Save dialog was a single change. Documents
already had serialiseDoc(); restoreDoc() split into loadDocFromJSON() so
opening a file and restoring the autosave go down the same tested path.

Security posture, since an open-source Electron app gets read by strangers:
context isolation on, node integration off, sandbox on, webview disabled,
navigation and popups denied, and the renderer served over a registered app://
scheme rather than file:// — a real origin, so localStorage works and a CSP
can actually be enforced. The preload exposes ten validated calls and nothing
else, asserted by a gate.

Saves are atomic. A rename over the target means an interrupted write leaves
the previous version intact rather than a truncated file, which is tested by
making the directory read-only mid-save.

Menu accelerators are owned by the menu. Electron dispatches them before the
page sees the keydown, so the renderer skips those combos on desktop — without
that, every undo fires twice.

Two new suites and a headless launch: 45 checks on the shell's configuration,
19 on document handling, and an Electron smoke test that asserts the editor
actually comes up rather than merely that it packaged.

Builds are unsigned. Signing is wired but inert; enabling it is setting
secrets, not editing config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPLUTYBHytZJX2BB6kCL9T
The smoke test aborted with "The SUID sandbox helper binary was found, but is
not configured correctly" — chrome-sandbox has to be owned by root with the
setuid bit, and npm cannot install it that way.

Fixed by setting the ownership rather than passing --no-sandbox. The whole
point of the smoke test is to exercise the configuration users actually get,
and this app deliberately runs with sandbox: true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPLUTYBHytZJX2BB6kCL9T
node_modules/electron/dist is populated lazily on first use, not by npm ci, so
the chown ran against a path that did not exist yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPLUTYBHytZJX2BB6kCL9T
`npx electron --version` starts the binary, which aborts on the same SUID
sandbox error the step exists to fix — so the chown never ran. Calling
install.js fetches dist/ without launching anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPLUTYBHytZJX2BB6kCL9T
@timbogdanov
timbogdanov merged commit a5ea58d into main Aug 18, 2026
4 checks passed
@timbogdanov
timbogdanov deleted the feat/electron-desktop branch August 18, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant