Skip to content

Native macOS .app launcher + bundle - #16

Open
MuJiongan wants to merge 2 commits into
mainfrom
jiongan/mac-app-gui9u
Open

Native macOS .app launcher + bundle#16
MuJiongan wants to merge 2 commits into
mainfrom
jiongan/mac-app-gui9u

Conversation

@MuJiongan

@MuJiongan MuJiongan commented May 3, 2026

Copy link
Copy Markdown
Owner

What

A real macOS .app bundle so you can launch gorchestra from Spotlight/Dock/Launchpad instead of running make backend and make frontend in two terminals.

make app-install     # one-time: pywebview into the backend venv
make install-app     # build frontend + .app + drop into /Applications

Then Cmd+Spacegorchestra → Enter.

How

  • launcher.py — runs FastAPI in a daemon thread on pinned port 8765, mounts frontend/dist/ as an SPA fallback, opens a native WKWebView via pywebview. private_mode=False so localStorage (API keys, default models) persists across launches. Port pin is required because localStorage is origin-scoped — a shifting port would orphan settings every relaunch.
  • scripts/build_app.sh — produces gorchestra.app. The executable in Contents/MacOS/ is a tiny C wrapper compiled to native arm64 Mach-O that execvs the project's venv Python on launcher.py. A shell-script wrapper here would make LaunchServices misread the architecture and falsely demand Rosetta with a misleading -10669 error. Bundle is then ad-hoc signed so macOS 15+ App Management lets it launch.
  • Graceful teardown — runner child is spawned with start_new_session=True; events.cancel uses killpg() so cancellation reaps the runner's grandchildren (shell-tool subprocesses) too. The pywebview closing event reuses the same teardown, so closing the window kills any in-flight runs cleanly.
  • Force-quit teardown (parent-death watchdog) — the runner child holds the read end of a pipe whose write end the parent keeps open for the duration of the run. If the parent dies abruptly (SIGKILL, force-quit from Activity Monitor, segfault — anything that bypasses signal handlers and atexit), the kernel closes the write end, the child gets EOF instantly, and a daemon thread killpgs the child's own process group + _exits. Verified: SIGKILL on a parent with an active sleep grandchild reaps both within 2 seconds.

Targets

Target What it does
make app-install One-time: install pywebview into the backend venv
make app Dev mode: launch in a window without bundling
make app-bundle Build gorchestra.app in the project root
make install-app Above + copy into /Applications

Caveats

  • Bundle is hard-linked to this checkout — moves of the project directory require a make install-app rebuild. For a fully relocatable bundle, swap the C wrapper for py2app.
  • make dev runs the frontend in your browser and uses that browser's localStorage. The native-window launches (make app and gorchestra.app) both run via Homebrew's embedded Python.app and share a single WKWebView store under ~/Library/WebKit/org.python.python/, so settings carry between those two — but not from the browser.

Verification

  • make dev still works (backend on 8000, vite on 5173, vite proxy unchanged).
  • 51/51 backend pytest tests pass on both commits.
  • Empirical localStorage persistence test: write key in launch 1, read it back in launch 2 with same origin — value preserved.
  • make install-app end-to-end: builds, installs to /Applications, launches via open, FastAPI binds 8765, WKWebView loads UI.
  • Parent-death watchdog test: spawned a parent that ran a workflow whose node Popen'd sleep 60; SIGKILL on parent → both runner child and sleep grandchild gone within 2s.

Adds a one-window native Mac app so you can launch gorchestra from
Spotlight/Dock instead of running 'make backend' and 'make frontend' in
two terminals.

- launcher.py: pywebview window pointed at FastAPI on a pinned port
  (8765) so localStorage settings persist across launches. SPA mount
  serves frontend/dist/ from the same origin. private_mode=False keeps
  WKWebView's data store between runs.
- scripts/build_app.sh: builds gorchestra.app with a tiny C wrapper
  in Contents/MacOS/ (real arm64 Mach-O so LaunchServices reads the
  arch correctly and doesn't falsely demand Rosetta), then ad-hoc
  signs for macOS 15+ App Management.
- runner: child subprocess now spawned with start_new_session=True;
  events.cancel uses killpg() so cancellation reaps the runner's
  grandchildren (shell tools etc.) too. Window-close hook reuses the
  same teardown path.
- Makefile: app-install, app, app-bundle, install-app targets. The
  install-app target is the one-shot 'build everything + drop into
  /Applications'.
@MuJiongan
MuJiongan force-pushed the jiongan/mac-app-gui9u branch from d7ae94a to 80447e7 Compare May 3, 2026 22:55
Adds a pipe-EOF watchdog to the runner child so abrupt parent death
(SIGKILL, force-quit, segfault — anything that skips atexit and signal
handlers) is detected instantly and the child tears down its own
process group, taking any 'shell' tool grandchildren with it.

- runner.py: open a pipe before Popen; pass the read end via pass_fds
  with PARENT_DEATH_FD env var. Parent holds the write end open for the
  duration of the run and closes it in a finally block when the child
  has exited.
- child.py: install a daemon thread early in main() that blocks reading
  the inherited fd. EOF means parent is gone; we double-check via
  getppid()==1 to avoid spurious teardown, then SIGTERM our process
  group and _exit.
- README: fix incorrect bundle-id caveat. Both 'make app' and
  gorchestra.app run through Homebrew's embedded Python.app and share
  a WKWebView store under ~/Library/WebKit/org.python.python/, so
  localStorage carries between them. Only 'make dev' (browser) is
  separate. Also note the new force-quit teardown guarantee.

Verified end to end: SIGKILL'd a parent runner that had spawned a
node which had in turn spawned 'sleep 60'. Within 2s both the runner
child and the sleep grandchild were gone.

51/51 backend tests still pass.
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