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.
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.tsusesnode:http,node:fs/promises,ws,yjs,y-protocols,y-protocols/awareness, andserver/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.tsbundles 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.
┌─ 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 fromfile://. This matters —src/net/bootYjs.ts:89-92returnsnullforfile://pages and skips the WebSocket provider entirely. Loading over HTTP preserves the existingresolveWebsocketUrl()fallback (line 89) which derivesws://${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.
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.
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
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.
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.
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.
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.
[ "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.
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/assetsWhy 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"
}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 glyphdesktop/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.
- =fs.watch= + user-chosen paths. Unsigned, un-sandboxed Electron
apps have full FS access, so watching
~/org/AGENDA.orgjust works. If the Mac App Store ever becomes a target,fs.watchon 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 setsapp.isQuitting = truebeforeapp.quit(). - =before-quit= must await =flush()=. Otherwise the last debounced
write to
AGENDA.orgis lost. The existing SIGINT handler inserver/index.tsalready does this; the refactor exposesflushon the handle somain.tscan call it from thebefore-quithook. - 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 to0.0.0.0or 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=1hit, so subsequent loads don’t need the query param even if the URL changes.
| Component | Status |
|---|---|
src/ (frontend, incl. bootYjs, ydoc, main.ts) | Unchanged. |
server/bridge.ts | Unchanged. Verified stop() releases fs.watch + timers + handler at server/bridge.ts:122-140. |
server/serialize.ts, server/splice.ts | Unchanged. |
dist/ build pipeline (vite-plugin-pwa, SW) | Unchanged. |
| All tests (209 passing) | Unchanged. |
server/index.ts | Minor 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 construction | Unchanged. Confirmed during planning. |
Rewrites: none. Everything else is new code under desktop/ +
electron-builder.yml.
Modify (one file):
server/index.ts— wrap top-level code in exportedstartServer({ orgFile })returning{ port, stop, flush }. Retain CLI shim.
Create:
desktop/main.tsdesktop/server-embed.tsdesktop/preload.tsdesktop/settings.tsdesktop/tray-menu.tsdesktop/assets/trayTemplate.pngdesktop/assets/trayTemplate@2x.pngelectron-builder.ymltsconfig.desktop.jsonscripts/build-desktop.ts(esbuild invocation)- Extend
scripts/gen-icons.tsto also emit tray templates
Deps to add:
electron(dev)electron-builder(dev)esbuild(dev)
- Refactor =server/index.ts= to export
startServer. Verifybun run dev:serverstill works (CLI shim) and all existing tests pass. This unblocks everything else. - =desktop/settings.ts= + =desktop/server-embed.ts=. Pure modules, unit-testable in isolation.
- =desktop/main.ts= — minimal version: starts server, shows a
BrowserWindow, loads the URL. Run with
electron desktop-dist/main.cjsafteresbuildpass. Smoke test that the agenda renders in a frameless window connected to the local server. - Tray + template icons.
setTemplateImage(true), click toggles window, stub context menu with just “Quit”. - Context menu wiring. “Choose agenda file…” via
dialog.showOpenDialog, server restart flow. - Quit/hide semantics.
LSUIElement: true, window hide on close,before-quit→ flush → stop. - Window bounds persistence to
settings.json. - =electron-builder.yml= + packaging. First unsigned DMG.
- v1 ship. Document Gatekeeper first-launch ritual in README.
- Build:
bun run desktop:buildcompletes without errors, producingdist/(Vite) anddesktop-dist/main.cjs. - Launch:
bun run desktop:startopens a tray icon and a window. Window shows the 7-day agenda. - First-run config: delete
~/Library/Application Support/Mediant/settings.json, relaunch, confirm a native file picker appears before the window opens. - Bidirectional sync inside the app:
- Append an entry via the UI → file on disk updated within ~200 ms.
- Edit
AGENDA.orgin 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 againstbun run server/index.ts.
- Quit hygiene: type a new entry, immediately hit Cmd+Q (via tray
menu). Reopen the app, confirm the entry persisted — the
before-quitflush worked. - Window hide: click the close button (red traffic light). Window hides, tray icon stays, process survives. Click tray icon → window returns.
- 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 viastat -f %m). - 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")). - Relaunch port safety: launch, quit, launch again in quick
succession. No
EADDRINUSE— ephemeral port avoids collisions. - Gatekeeper: download the unsigned DMG from another machine,
copy to
/Applications, first launch → right-click → Open. Document this in the README. - =bun test= still passes 209/209 — the
server/index.tsrefactor must not break any test suite.
- Signing + notarization. Developer ID cert →
electron-builder’safterSign→notarytool. Removes Gatekeeper friction entirely. - =openAtLogin= toggle in the tray menu, backed by
app.setLoginItemSettings. - Global hotkey (
Cmd+Shift+A) viaglobalShortcut.registerto 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.
- ~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.