Skip to content

Latest commit

 

History

History
469 lines (401 loc) · 20.2 KB

File metadata and controls

469 lines (401 loc) · 20.2 KB

Mediant macOS Menu Bar App — Phase 6 Plan

Phase 6: Package Mediant as a macOS Menu Bar App

Context

Phases 1–5 are shipped: the Yjs server runs cleanly, the PWA builds, bidirectional sync with AGENDA.org works, and the UI connects over WebSocket. The remaining friction is boot-strapping the server. Today you need bun run start in a terminal, bunx vite build to produce dist/, and a browser tab pointed at localhost:3000/?yjs=1. That works, but it’s three things to remember every time you reboot, and the server process goes away when you close the terminal.

*The goal of Phase 6 is to make Mediant into a single Mediant.app in /Applications that, when launched, puts a monochrome icon in the macOS menu bar, starts the embedded Yjs server in the background, and opens a window with the agenda when clicked.* No terminal, no dock icon, no port to remember. The user configures AGENDA.org once via a native file picker and never thinks about it again.

This is strictly packaging — the server, the bridge, the frontend, and the Yjs protocol are all finished. The only code change to the existing codebase is a small refactor of server/index.ts so its startup logic can be imported as a function instead of only executed as a top-level script.

Recommendation: Electron + electron-builder

Three approaches were considered: Electron, Tauri v2 with a compiled-bun sidecar, and native Swift + WKWebView + sidecar. Electron wins for this specific codebase and personal-use target:

  • Zero rewrites of the server. server/index.ts uses node:http, node:fs/promises, ws, yjs, y-protocols, y-protocols/awareness, and server/bridge.ts — all of which run unchanged inside Electron’s main process (which is Node). No IPC, no sidecar lifecycle, no cross-process shared state headaches for the Y.Doc.
  • Tauri saves less than it looks like it would. bun build --compile server/index.ts bundles a ~90 MB Bun runtime into the sidecar, so the “~30 MB total” claim evaporates to ~60–90 MB, and you add a Rust main process, sidecar IPC, and two languages to debug. Worth it for a mass-distributed app. Not worth it here.
  • Native Swift has the same sidecar problem plus Xcode, Swift, and a hand-rolled tray + file-picker + prefs layer. Overkill for a personal tool.

The real cost of Electron is the ~150 MB binary and ~200 MB idle RAM. Both are irrelevant for a tool one user runs on one machine. Ship Electron.

Architecture

┌─ Mediant.app (single process tree) ──────────────────────────┐
│                                                               │
│  Electron main  ◄── imports ──►  server-embed.ts              │
│   ├─ Tray                          ├─ startServer({orgFile})  │
│   │   • template icon              │   listens on 127.0.0.1:0 │
│   │   • context menu               │   returns {port, stop,   │
│   │                                │            flush}        │
│   ├─ BrowserWindow                 │                          │
│   │   loadURL(`http://127.0.0.1:${port}/`)                    │
│   │                                                           │
│   └─ settings.ts                   ├─ YFileBridge              │
│       userData/settings.json       │   (unchanged)            │
│                                    └─ WebSocket sync          │
│                                                               │
└───────────────────────────────────────────────────────────────┘
  • One process. No child spawns, no sidecars. The server runs on the Electron main process’s event loop.
  • Frontend is the existing =dist/= build, unchanged. Loaded from http://127.0.0.1:<ephemeral>/, NOT from file://. This matters — src/net/bootYjs.ts:89-92 returns null for file:// pages and skips the WebSocket provider entirely. Loading over HTTP preserves the existing resolveWebsocketUrl() fallback (line 89) which derives ws://${location.host} — zero frontend changes required.
  • Ephemeral port via httpServer.listen(0, "127.0.0.1"). Solves relaunch collisions and, critically, avoids macOS’s “accept incoming network connections” firewall dialog because binding to loopback doesn’t count as a network service.

Window vs popover: full BrowserWindow

Mediant’s 7-day agenda is ~900 px wide and scannable; it’s not a compact “what time is it right now” glance. Tray popovers are right for small ephemeral UIs (volume, clipboard, next meeting). A week view is neither. Use a standard BrowserWindow that the tray icon toggles (show/hide/focus). Remember size + position across launches. LSUIElement: true keeps the dock clean regardless.

Wire Tray + BrowserWindow directly — skip the menubar npm package. It’s ~40 lines of plumbing and more flexible than the wrapper, which biases toward popover mode.

Directory layout to add

desktop/
  main.ts              # Electron main — lifecycle, tray, window, IPC
  server-embed.ts      # Refactor entry: imports server startup as a fn
  preload.ts           # contextBridge exposing settings + port to renderer
  settings.ts          # JSON-file prefs in app.getPath('userData')
  tray-menu.ts         # buildMenu(): "Choose agenda file…", "Show", "Quit"
  assets/
    trayTemplate.png      # 22×22 monochrome, alpha-only
    trayTemplate@2x.png   # 44×44
electron-builder.yml   # mac target, dmg, LSUIElement, category, signing
tsconfig.desktop.json  # Desktop build config

Critical files, in build order

1. server/index.ts — refactor to export startServer()

The one change to existing code. Today everything is top-level:

const ORG_FILE = process.env.ORG_FILE;  // script-level
// ... create sharedDoc, wss, httpServer ...
httpServer.listen(PORT, () => ...);

Wrap all of that in an exported function returning a handle:

export interface ServerHandle {
  port: number;
  stop(): Promise<void>;
  flush(): Promise<void>;
}
export async function startServer(opts: {
  orgFile: string;
}): Promise<ServerHandle> { ... }

// Keep a CLI shim so `bun run server/index.ts` still works for browser dev:
if (import.meta.main) {
  const orgFile = process.env.ORG_FILE;
  if (!orgFile) { console.error("ORG_FILE required"); process.exit(1); }
  const h = await startServer({ orgFile });
  console.log(`listening on http://127.0.0.1:${h.port}`);
  process.on("SIGINT", () => h.stop().then(() => process.exit(0)));
  process.on("SIGTERM", () => h.stop().then(() => process.exit(0)));
}

startServer uses httpServer.listen(0, "127.0.0.1") and resolves the returned handle once the listening event fires, reading the OS-assigned port via (httpServer.address() as AddressInfo).port.

=server/bridge.ts:122-140= already cleans up =fs.watch=, debounce timers, and the update handler in =stop()= — no fix needed there. Verified during planning.

2. desktop/main.ts — Electron entry point

app.whenReady()
  ├─ load settings (orgFile, window bounds)
  ├─ if no orgFile: prompt via dialog.showOpenDialog before creating window
  ├─ startServer({ orgFile }) → { port, stop, flush }
  ├─ create BrowserWindow (hidden, restored bounds)
  │   • webPreferences: preload, contextIsolation
  │   • loadURL(`http://127.0.0.1:${port}/?yjs=1`)
  │   • on('close', e) → preventDefault + hide, unless app.isQuitting
  ├─ create Tray with template image
  │   • on click → toggle window
  │   • setContextMenu(buildMenu(...))
  └─ app.on('before-quit', async () => {
       await handle.flush();
       await handle.stop();
     });

The ?yjs=1 query flag activates Yjs mode (via isYjsEnabled() in src/net/bootYjs.ts:29) on first launch; subsequent loads hit the persisted localStorage flag.

3. desktop/server-embed.ts — thin wrapper

Pure re-export: export { startServer } from "../server/index.ts";. Exists only to give esbuild a clean entrypoint and avoid deep relative paths. Could arguably be inlined.

4. desktop/settings.ts — JSON prefs

Plain JSON at path.join(app.getPath('userData'), 'settings.json'). Schema:

interface Settings {
  orgFile: string | null;
  windowBounds?: { x: number; y: number; width: number; height: number };
  openAtLogin?: boolean;  // v2
}

No electron-store dep — it’s ~30 lines and avoids a native module that would need rebuilding per Electron version.

5. desktop/tray-menu.ts — context menu

[ "Show Mediant"         → toggleWindow() ]
[ separator ]
[ "Choose agenda file…"  → dialog.showOpenDialog({
                             filters: [{name:'Org', extensions:['org']}],
                             properties: ['openFile'],
                           })
                           → if picked: write settings, restart server,
                             win.reload() ]
[ "Show in Finder"       → shell.showItemInFolder(orgFile) ]
[ separator ]
[ "Quit Mediant"         → app.isQuitting = true; app.quit() ]

“Choose agenda file…” triggers a full server restart (stop → startServer({orgFile: newPath}) → win.loadURL(new port)) because YFileBridge is constructed with a fixed filePath. That’s ~200 ms of downtime — imperceptible.

6. electron-builder.yml — packaging config

appId: dev.rjsheperd.mediant
productName: Mediant
mac:
  target: [dmg, zip]
  category: public.app-category.productivity
  extendInfo:
    LSUIElement: true      # menu-bar-only, no dock icon
  identity: null           # unsigned v1
files:
  - desktop-dist/**
  - dist/**
  - package.json
directories:
  buildResources: desktop/assets

TypeScript compilation: esbuild (one pass, ~200 ms)

Why not tsc? ESM module resolution is stricter, and Electron’s main process prefers CJS for packaged apps. tsc with module: NodeNext works but emits a tree of files into desktop-dist/ that electron-builder has to glob correctly.

Why not bun build --target=node? Electron’s runtime is Node, not Bun. The output must run under Node. Bun’s --target=node is ostensibly Node-compatible but has enough quirks with ESM/CJS interop to be a bad bet for a packaged app.

Recommendation: esbuild.

// desktop/build.ts
await esbuild.build({
  entryPoints: ["desktop/main.ts"],
  bundle: true,
  platform: "node",
  format: "cjs",
  target: "node20",
  outfile: "desktop-dist/main.cjs",
  external: ["electron"],
  sourcemap: true,
});

This bundles desktop/main.ts + transitively server/index.ts, server/bridge.ts, server/serialize.ts, ws, yjs, y-protocols, lib0 — everything — into a single main.cjs. No node_modules shipping, no ESM/CJS interop drama in the bundle output. Preload is a second entry point → desktop-dist/preload.cjs. tsc --noEmit continues to provide typechecking.

Add to package.json:

"scripts": {
  "desktop:build":  "bun run scripts/build-desktop.ts && bunx vite build",
  "desktop:start":  "electron desktop-dist/main.cjs",
  "desktop:dist":   "bun run desktop:build && electron-builder"
}

Icons

macOS tray expects template images — monochrome PNG with alpha only. AppKit auto-inverts them in dark mode and on menu highlight. A single colored PNG will look wrong in at least half the system states.

Ship two files:

  • desktop/assets/trayTemplate.png — 22×22, black-on-transparent calendar glyph
  • desktop/assets/trayTemplate@2x.png — 44×44, same

Load explicitly with nativeImage.createFromPath(...).setTemplateImage(true) — Electron honors the Template filename suffix but being explicit prevents surprises.

scripts/gen-icons.ts from Phase 5 already produces the PWA manifest icons; extending it to emit the tray templates is ~20 lines of the same PNG encoder.

Gotchas specific to this app

  • =fs.watch= + user-chosen paths. Unsigned, un-sandboxed Electron apps have full FS access, so watching ~/org/AGENDA.org just works. If the Mac App Store ever becomes a target, fs.watch on arbitrary user paths breaks and you’d need security-scoped bookmarks. For v1, ship outside the App Store (DMG direct download). Document in the README.
  • Quit vs hide. On macOS, closing the window should hide, not quit — tray apps live forever. window.on('close', e => { if (!app.isQuitting) { e.preventDefault(); win.hide(); } }). Real quit only via the tray menu, which sets app.isQuitting = true before app.quit().
  • =before-quit= must await =flush()=. Otherwise the last debounced write to AGENDA.org is lost. The existing SIGINT handler in server/index.ts already does this; the refactor exposes flush on the handle so main.ts can call it from the before-quit hook.
  • Gatekeeper on unsigned builds. First launch requires right-click → Open, or xattr -dr com.apple.quarantine Mediant.app. Document it; don’t try to hack around it.
  • Firewall dialog. Binding httpServer.listen(0, "127.0.0.1") specifically to loopback avoids it. Do not bind to 0.0.0.0 or the default interface.
  • Window reload on file change. When the user switches agenda files via the tray, we stop the server, start a new one (new ephemeral port), and win.loadURL(newUrl). The Y.Doc in the browser’s IndexedDB is keyed on room name, not port, so the offline cache survives. Nothing to special-case.
  • Renderer already ready. isYjsEnabled() persists the flag to localStorage after the first ?yjs=1 hit, so subsequent loads don’t need the query param even if the URL changes.

Reuse analysis

ComponentStatus
src/ (frontend, incl. bootYjs, ydoc, main.ts)Unchanged.
server/bridge.tsUnchanged. Verified stop() releases fs.watch + timers + handler at server/bridge.ts:122-140.
server/serialize.ts, server/splice.tsUnchanged.
dist/ build pipeline (vite-plugin-pwa, SW)Unchanged.
All tests (209 passing)Unchanged.
server/index.tsMinor refactor: wrap top-level startup in exported startServer(opts), keep CLI shim for bun run dev:server.
src/net/bootYjs.ts:89-92 (resolveWebsocketUrl)Unchanged — already derives from location.host and returns null for file://, exactly right for “load via HTTP not file://”.
Frontend WebSocket URL constructionUnchanged. Confirmed during planning.

Rewrites: none. Everything else is new code under desktop/ + electron-builder.yml.

Critical files to modify or create

Modify (one file):

  • server/index.ts — wrap top-level code in exported startServer({ orgFile }) returning { port, stop, flush }. Retain CLI shim.

Create:

  • desktop/main.ts
  • desktop/server-embed.ts
  • desktop/preload.ts
  • desktop/settings.ts
  • desktop/tray-menu.ts
  • desktop/assets/trayTemplate.png
  • desktop/assets/trayTemplate@2x.png
  • electron-builder.yml
  • tsconfig.desktop.json
  • scripts/build-desktop.ts (esbuild invocation)
  • Extend scripts/gen-icons.ts to also emit tray templates

Deps to add:

  • electron (dev)
  • electron-builder (dev)
  • esbuild (dev)

Implementation order

  1. Refactor =server/index.ts= to export startServer. Verify bun run dev:server still works (CLI shim) and all existing tests pass. This unblocks everything else.
  2. =desktop/settings.ts= + =desktop/server-embed.ts=. Pure modules, unit-testable in isolation.
  3. =desktop/main.ts= — minimal version: starts server, shows a BrowserWindow, loads the URL. Run with electron desktop-dist/main.cjs after esbuild pass. Smoke test that the agenda renders in a frameless window connected to the local server.
  4. Tray + template icons. setTemplateImage(true), click toggles window, stub context menu with just “Quit”.
  5. Context menu wiring. “Choose agenda file…” via dialog.showOpenDialog, server restart flow.
  6. Quit/hide semantics. LSUIElement: true, window hide on close, before-quit → flush → stop.
  7. Window bounds persistence to settings.json.
  8. =electron-builder.yml= + packaging. First unsigned DMG.
  9. v1 ship. Document Gatekeeper first-launch ritual in README.

Verification

  1. Build: bun run desktop:build completes without errors, producing dist/ (Vite) and desktop-dist/main.cjs.
  2. Launch: bun run desktop:start opens a tray icon and a window. Window shows the 7-day agenda.
  3. First-run config: delete ~/Library/Application Support/Mediant/settings.json, relaunch, confirm a native file picker appears before the window opens.
  4. Bidirectional sync inside the app:
    • Append an entry via the UI → file on disk updated within ~200 ms.
    • Edit AGENDA.org in Emacs → agenda in the window updates within ~200 ms.
    • This is the same smoke test as scripts/smoke-ws.ts, just running inside the packaged app instead of against bun run server/index.ts.
  5. Quit hygiene: type a new entry, immediately hit Cmd+Q (via tray menu). Reopen the app, confirm the entry persisted — the before-quit flush worked.
  6. Window hide: click the close button (red traffic light). Window hides, tray icon stays, process survives. Click tray icon → window returns.
  7. File switch: tray → “Choose agenda file…” → pick a different .org. Window reloads with the new file’s contents. Old file is no longer written to (confirm via stat -f %m).
  8. Firewall dialog: clean macOS user, launch the packaged app. Confirm no “accept incoming network connections” prompt appears (because of listen(0, "127.0.0.1")).
  9. Relaunch port safety: launch, quit, launch again in quick succession. No EADDRINUSE — ephemeral port avoids collisions.
  10. Gatekeeper: download the unsigned DMG from another machine, copy to /Applications, first launch → right-click → Open. Document this in the README.
  11. =bun test= still passes 209/209 — the server/index.ts refactor must not break any test suite.

v2 additions (not in scope)

  • Signing + notarization. Developer ID cert → electron-builder’s afterSignnotarytool. Removes Gatekeeper friction entirely.
  • =openAtLogin= toggle in the tray menu, backed by app.setLoginItemSettings.
  • Global hotkey (Cmd+Shift+A) via globalShortcut.register to toggle the window from anywhere.
  • Multiple agenda files via a tray submenu.
  • Port to Tauri — only if binary size becomes a user complaint. The startServer({orgFile}) export makes this port straightforward: Tauri main replaces Electron main, Node server becomes a sidecar spawned by Tauri, everything else reuses.

Key tradeoffs

  • ~150 MB binary, ~200 MB idle RAM. A background Chromium for a file watcher + week view. Acceptable for a personal tool; not for mass distribution.
  • Unsigned v1 means friction on first launch. Acceptable for a single-user install.
  • One agenda file at a time. Matches the Phase 1–5 design assumption. Multi-file is a real v2 feature.
  • No autostart on login in v1. User launches manually. Trivial to add in v2.
  • Still no Mac App Store path because of the =fs.watch=/sandbox friction. Direct-download DMG is the distribution channel.