← back to Desktop Dotbar
.claude/worktrees/dotbar-keepalive
153 lines
commit 0a572ef4b9a857fd60b8ff7a8a3906ed1bd21042
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 26 10:45:56 2026 -0700
dotbar: keep-alive watchdog + hardened launchd plist — bar always shows (Steve 2026-09-26)
- keepAliveTick every 5s: server /health dead 3x -> exit(1) for launchd relaunch; missing/stale
strips -> rebuild; hidden strip -> showInactive; crashed renderer -> reload
- render-process-gone / unresponsive / did-fail-load handlers reload the strip in place
- createWindows failure now exits(1) instead of idling with zero windows
- plist: ProcessType Interactive, LimitLoadToSessionType Aqua, ThrottleInterval 5 (committed copy)
- test/keepalive.test.js incl. negative cases (45/45 pass)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpJCWWtvGsxiNt5v4Do7EV
diff --git a/electron-main.js b/electron-main.js
index b9289f9..3d45d5a 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -339,6 +339,14 @@ function createBarFor(display) {
win.webContents.on('did-finish-load', () => { raise(win); pushBarH(win); pushCfg(win); applyBoundsFor(win); dbg('did-finish-load'); });
win.webContents.on('did-fail-load', (_e, code, desc) => dbg('FAIL:' + code + ':' + desc));
win.on('moved', () => { onWinMoved(win); dbg('moved'); }); // user dragged the bar -> capture free position
+ // Keep-alive: a dead/hung renderer (GPU exits are common in bar.log) leaves a live process with a blank
+ // strip, which launchd KeepAlive can't see — reload it in place.
+ win.webContents.on('render-process-gone', (_e, d) => {
+ console.log('DOTBAR: renderer gone', d && d.reason, '— reloading');
+ setTimeout(() => { try { if (!win.isDestroyed()) win.webContents.reload(); } catch {} }, 500);
+ });
+ win.on('unresponsive', () => { console.log('DOTBAR: strip unresponsive — reloading'); try { win.webContents.forcefullyCrashRenderer(); win.webContents.reload(); } catch {} });
+ win.webContents.on('did-fail-load', () => setTimeout(() => { try { if (!win.isDestroyed()) win.loadURL(`http://127.0.0.1:${PORT}/`); } catch {} }, 3000));
wins.push(win);
return win;
}
@@ -351,11 +359,48 @@ function rebuildBars() {
for (const d of screen.getAllDisplays()) createBarFor(d);
}
+// ── Keep-alive watchdog (Steve 2026-09-26: "nav bar needs to always show up and keep alive") ──
+// launchd KeepAlive only restarts us when the PROCESS exits. These cover the cases where the process stays
+// up but nothing useful is on screen: server died, a strip got destroyed/hidden, a renderer crashed.
+// Anything we can't heal in place → exit(1) so launchd relaunches start-bar.command (server + bar, fresh).
+const KEEPALIVE_MS = 5000, SERVER_MISS_LIMIT = 3; // ~15s of a dead /health before a full relaunch
+let serverMisses = 0, tickBusy = false;
+function exitForRelaunch(why) {
+ console.log('DOTBAR: relaunching via launchd —', why);
+ try { flushCfg(); } catch {}
+ app.exit(1);
+}
+function keepAliveHeal(liveWins, displayIds) { // pure: what should the watchdog do? (unit-tested)
+ if (liveWins.length !== displayIds.length) return 'rebuild';
+ const have = new Set(liveWins.map(w => String(w.displayId)));
+ if (displayIds.some(id => !have.has(String(id)))) return 'rebuild';
+ return 'ok';
+}
+async function keepAliveTick() {
+ if (tickBusy) return; tickBusy = true;
+ try {
+ if (await alive(PORT)) serverMisses = 0;
+ else if (++serverMisses >= SERVER_MISS_LIMIT) return exitForRelaunch(`server /health down ${serverMisses}x on :${PORT}`);
+ const live = wins.filter(w => !w.isDestroyed());
+ const ids = screen.getAllDisplays().map(d => d.id);
+ if (keepAliveHeal(live.map(w => ({ displayId: w._displayId })), ids) === 'rebuild') {
+ console.log(`DOTBAR: ${live.length} strip(s) for ${ids.length} display(s) — rebuilding`);
+ return rebuildBars();
+ }
+ for (const w of live) {
+ if (!w.isVisible()) { console.log('DOTBAR: strip hidden — showing'); w.showInactive(); }
+ if (w.webContents.isCrashed()) { console.log('DOTBAR: strip crashed — reloading'); w.webContents.reload(); }
+ }
+ } catch (e) { console.log('DOTBAR: keepAlive tick error', e && e.stack || e); }
+ finally { tickBusy = false; }
+}
+
async function createWindows() {
PORT = await ensureServer();
for (const d of screen.getAllDisplays()) createBarFor(d);
setInterval(() => wins.forEach(raise), 2000); // re-assert top above other apps, every strip
setInterval(pollAutohide, POLL_MS); // auto-hide reveal/conceal
+ setInterval(keepAliveTick, KEEPALIVE_MS); // the bar must ALWAYS be up (Steve, 2026-09-26)
// React to monitor changes (a display added/removed, or geometry changed e.g. arrangement swap).
screen.on('display-added', rebuildBars);
screen.on('display-removed', rebuildBars);
@@ -387,9 +432,9 @@ if (process.versions.electron) { // run the app under Electron;
app.on('before-quit', () => { flushCfg(); if (serverProc) try { serverProc.kill(); } catch {} });
app.whenReady().then(() => {
if (app.dock) app.dock.hide(); // accessory app: panels float above active apps
- createWindows().catch(e => console.log('DOTBAR: createWindows ERROR', e && e.stack || e));
+ createWindows().catch(e => { console.log('DOTBAR: createWindows ERROR', e && e.stack || e); exitForRelaunch('createWindows failed'); });
});
process.on('uncaughtException', e => console.log('DOTBAR: uncaught', e && e.stack || e));
}
-module.exports = { cfg, floatingPosFor, clampPosToDisplay, writeCfg, flushCfg, CFG_FILE };
+module.exports = { cfg, keepAliveHeal, floatingPosFor, clampPosToDisplay, writeCfg, flushCfg, CFG_FILE };
diff --git a/launchd/com.steve.desktop-dotbar.plist b/launchd/com.steve.desktop-dotbar.plist
new file mode 100644
index 0000000..72805dd
--- /dev/null
+++ b/launchd/com.steve.desktop-dotbar.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- Installed copy lives at ~/Library/LaunchAgents/com.steve.desktop-dotbar.plist.
+ Always-on (Steve 2026-09-26): RunAtLoad = launch at every login; KeepAlive = relaunch whenever it exits;
+ the in-app keepAliveTick exits(1) on anything it can't heal in place so launchd brings it back fresh.
+ Reinstall after edits: launchctl bootout gui/$(id -u)/com.steve.desktop-dotbar; launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.desktop-dotbar.plist -->
+<plist version="1.0">
+<dict>
+ <key>Label</key><string>com.steve.desktop-dotbar</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/bash</string>
+ <string>/Users/macstudio3/Projects/desktop-dotbar/start-bar.command</string>
+ </array>
+ <key>RunAtLoad</key><true/>
+ <key>KeepAlive</key><true/>
+ <key>ThrottleInterval</key><integer>5</integer>
+ <key>ProcessType</key><string>Interactive</string>
+ <key>LimitLoadToSessionType</key><string>Aqua</string>
+ <key>StandardOutPath</key><string>/Users/macstudio3/Projects/desktop-dotbar/launchagent.log</string>
+ <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/desktop-dotbar/launchagent.log</string>
+</dict>
+</plist>
diff --git a/test/keepalive.test.js b/test/keepalive.test.js
new file mode 100644
index 0000000..a1cd45d
--- /dev/null
+++ b/test/keepalive.test.js
@@ -0,0 +1,21 @@
+'use strict';
+// Keep-alive watchdog (Steve 2026-09-26): one live strip per connected display, else rebuild.
+process.env.DOTBAR_TEST_CFG = '.tmp-keepalive-test.barcfg';
+const path = require('node:path'), fs = require('node:fs'), test = require('node:test'), assert = require('node:assert');
+const dm = require('../electron-main.js');
+test.after(() => { try { dm.flushCfg(); } catch {} try { fs.unlinkSync(path.join(__dirname, '..', process.env.DOTBAR_TEST_CFG)); } catch {} });
+const W = (...ids) => ids.map(displayId => ({ displayId }));
+
+test('healthy: one strip per display -> ok', () => {
+ assert.equal(dm.keepAliveHeal(W(1, 2), [1, 2]), 'ok');
+ assert.equal(dm.keepAliveHeal(W('5'), [5]), 'ok'); // display ids compared as strings
+});
+test('NEGATIVE: every strip gone (process alive, nothing on screen) -> rebuild', () => {
+ assert.equal(dm.keepAliveHeal([], [1]), 'rebuild');
+});
+test('NEGATIVE: a display lost its strip -> rebuild', () => {
+ assert.equal(dm.keepAliveHeal(W(1), [1, 2]), 'rebuild');
+});
+test('NEGATIVE: strip bound to a stale display id (macOS renumbered 5->6) -> rebuild', () => {
+ assert.equal(dm.keepAliveHeal(W(5), [6]), 'rebuild');
+});