[object Object]

← back to Desktop Dotbar

dotbar: floatingPosFor falls back to orphaned display pos on macOS display-ID churn (TK-12225)

4f3c60e6fabcb58f0f36d738e551085a20fdd2d1 · 2026-09-25 09:07:24 -0700 · Steve

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161EawVb2Kb9BdpD5NGbtN3

Files touched

Diff

commit 4f3c60e6fabcb58f0f36d738e551085a20fdd2d1
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 09:07:24 2026 -0700

    dotbar: floatingPosFor falls back to orphaned display pos on macOS display-ID churn (TK-12225)
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0161EawVb2Kb9BdpD5NGbtN3
---
 electron-main.js          | 51 ++++++++++++++++++++++++++++--------
 test/floating-pos.test.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 106 insertions(+), 11 deletions(-)

diff --git a/electron-main.js b/electron-main.js
index 661c817..1877c31 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -31,7 +31,9 @@ const REVEAL_MARGIN = 6;                              // keep revealed while cur
 const POLL_MS = 110;                                 // cursor poll cadence for auto-hide
 const HIDE_GRACE_MS = 320;                            // wait this long off the bar before hiding
 const BARH_FILE = path.join(DIR, '.barh');
-const CFG_FILE = path.join(DIR, '.barcfg');
+// DOTBAR_TEST_CFG lets tests point at a scratch file instead of the live bar's real config
+// (never touch the running bar's .barcfg from a test run).
+const CFG_FILE = path.join(DIR, process.env.DOTBAR_TEST_CFG || '.barcfg');
 const ORIENTS = ['top', 'left', 'right'];
 
 let wins = [];                                       // one strip per display
@@ -65,6 +67,7 @@ function readCfg() {
       for (const [k, v] of Object.entries(j.pos)) {
         if (v && ['x', 'y', 'w', 'h'].every(f => Number.isFinite(v[f]))) {
           d.pos[k] = { x: Math.round(v.x), y: Math.round(v.y), w: Math.round(v.w), h: Math.round(v.h) };
+          if (Number.isFinite(v.t)) d.pos[k].t = v.t;   // last-written-wins timestamp, survives reload (display-ID churn fallback)
         }
       }
     }
@@ -151,9 +154,28 @@ function clampPosToDisplay(pos, display) {
   const y = Math.min(Math.max(wa.y, pos.y), wa.y + wa.height - h);
   return { x, y, w, h };
 }
-function floatingPosFor(display) {
-  const p = cfg.pos && cfg.pos[display.id];
-  return p ? clampPosToDisplay(p, display) : null;
+// TK-12225: macOS can renumber a display's id across a reboot/sleep (seen: 5 -> 6), leaving
+// cfg.pos keyed to an id nothing matches -> floatingPosFor fell through to null -> boundsCore
+// docked-left+autohide fallback -> bar sat 3px offscreen, invisible. connectedIds (optional,
+// for tests) defaults to the real live display list.
+function floatingPosFor(display, connectedIds) {
+  const direct = cfg.pos && cfg.pos[display.id];
+  if (direct) return clampPosToDisplay(direct, display);
+  if (!cfg.pos) return null;
+  const liveIds = new Set((connectedIds || screen.getAllDisplays().map(d => d.id)).map(String));
+  // Orphan = a saved pos whose display id is no longer connected. NEVER steal a pos that
+  // belongs to a display still plugged in. Prefer the most recently written orphan (highest t).
+  let best = null;
+  for (const [id, p] of Object.entries(cfg.pos)) {
+    if (liveIds.has(String(id))) continue;
+    if (!best || (p.t || 0) > (best.p.t || 0)) best = { id, p };
+  }
+  if (!best) return null;
+  const clamped = clampPosToDisplay(best.p, display);
+  delete cfg.pos[best.id];
+  cfg.pos[display.id] = { ...clamped, t: Date.now() };   // re-key so it sticks under the display's current id
+  writeCfg();
+  return clamped;
 }
 function floatingBounds(p, open) {                      // grow along orientation when the dropdown opens
   const b = { x: p.x, y: p.y, width: p.w, height: p.h };
@@ -189,6 +211,7 @@ function onWinMoved(w) {
   }
   const disp = screen.getDisplayMatching(b) || d;
   const pos = boundsToPos(b);
+  pos.t = Date.now();                                    // last-written-wins, for the display-ID-churn fallback
   if (openState.get(w.id)) {                            // dropdown was open — store the CLOSED thickness
     if (cfg.orientation === 'top') pos.h = Math.max(BAR_MIN, pos.h - PANEL_H);
     else pos.w = Math.max(BARW_MIN, pos.w - PANEL_W);
@@ -357,10 +380,16 @@ async function createWindows() {
   ipcMain.on('dotbar:quit', () => app.quit());
 }
 
-app.on('window-all-closed', () => app.quit());
-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));
-});
-process.on('uncaughtException', e => console.log('DOTBAR: uncaught', e && e.stack || e));
+// Only boot the real Electron app when this file is the entrypoint (electron .) — a test
+// require()ing this module for its pure helpers (floatingPosFor etc.) must not touch app/screen.
+if (require.main === module) {
+  app.on('window-all-closed', () => app.quit());
+  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));
+  });
+  process.on('uncaughtException', e => console.log('DOTBAR: uncaught', e && e.stack || e));
+}
+
+module.exports = { cfg, floatingPosFor, clampPosToDisplay, writeCfg, flushCfg, CFG_FILE };
diff --git a/test/floating-pos.test.js b/test/floating-pos.test.js
new file mode 100644
index 0000000..4673b97
--- /dev/null
+++ b/test/floating-pos.test.js
@@ -0,0 +1,66 @@
+'use strict';
+// TK-12225: floatingPosFor must survive macOS renumbering a display's id (seen: 5 -> 6 across a
+// reboot). Isolated from the real bar via DOTBAR_TEST_CFG so this never touches the live .barcfg.
+process.env.DOTBAR_TEST_CFG = '.tmp-floating-pos-test.barcfg';
+const path = require('node:path');
+const fs = require('node:fs');
+const test = require('node:test');
+const assert = require('node:assert');
+const dm = require('../electron-main.js');
+
+const TMP_CFG = path.join(__dirname, '..', process.env.DOTBAR_TEST_CFG);
+// writeCfg() debounces 200ms; flush synchronously first so no pending timer recreates the
+// scratch file after we delete it (must never leak into the live .barcfg either way).
+test.after(() => { try { dm.flushCfg(); } catch {} try { fs.unlinkSync(TMP_CFG); } catch {} });
+
+const display = (id, x, y, w, h) => ({ id, workArea: { x, y, width: w, height: h } });
+function resetCfg() { dm.cfg.pos = {}; dm.cfg.floating = true; }
+
+test('missing id + orphan pos -> returns the orphan, clamped, and re-keys it to the new id', () => {
+  resetCfg();
+  dm.cfg.pos['5'] = { x: 4139, y: 95, w: 149, h: 985, t: 1000 };   // old display id, no longer connected
+  const newDisplay = display(6, 2294, 0, 1920, 1080);              // same monitor, renumbered 5 -> 6
+  const p = dm.floatingPosFor(newDisplay, [6]);                    // only id 6 is live now
+  assert.ok(p, 'must fall back to the orphaned pos instead of returning null');
+  assert.equal(p.w, 149);
+  assert.equal(p.h, 985);
+  assert.ok(p.x >= newDisplay.workArea.x && p.x + p.w <= newDisplay.workArea.x + newDisplay.workArea.width,
+    'clamped into the new display work area');
+  assert.ok(dm.cfg.pos['6'], 're-keyed under the new display id so it sticks');
+  assert.equal(dm.cfg.pos['5'], undefined, 'orphan entry consumed, not duplicated');
+});
+
+test('a pos belonging to a display that IS currently connected is never stolen for a different display', () => {
+  resetCfg();
+  dm.cfg.pos['2'] = { x: 4139, y: 95, w: 149, h: 985, t: 1000 };   // display 2 is still plugged in
+  const otherDisplay = display(7, 0, 0, 1920, 1080);               // a different display with no saved pos
+  const p = dm.floatingPosFor(otherDisplay, [2, 7]);               // both 2 and 7 are live
+  assert.equal(p, null, 'display 2\'s pos must not be handed to display 7 while 2 is still connected');
+  assert.ok(dm.cfg.pos['2'], 'the connected display\'s saved pos is left untouched');
+});
+
+test('multiple orphans -> the most recently written one (highest t) wins', () => {
+  resetCfg();
+  dm.cfg.pos['5'] = { x: 100, y: 100, w: 149, h: 985, t: 1000 };   // stale
+  dm.cfg.pos['9'] = { x: 200, y: 200, w: 149, h: 985, t: 5000 };   // most recent
+  const newDisplay = display(6, 0, 0, 1920, 1080);
+  const p = dm.floatingPosFor(newDisplay, [6]);                    // neither 5 nor 9 is connected
+  assert.equal(p.x, 200, 'picked the higher-t orphan (9), not the stale one (5)');
+  assert.equal(dm.cfg.pos['9'], undefined, 'the winning orphan is consumed');
+  assert.ok(dm.cfg.pos['5'], 'the losing orphan is left in place (not silently dropped)');
+});
+
+// Negative/regression guard: if the orphan-fallback is ripped back out to the original
+// `return p ? clampPosToDisplay(p, display) : null;`, the two assertions below go RED because
+// cfg.pos[display.id] is never set for a renumbered display — proving this suite actually
+// exercises the fallback rather than passing vacuously.
+test('negative: without the fallback this would be null / wrong coords (fault-injection check)', () => {
+  resetCfg();
+  dm.cfg.pos['5'] = { x: 321, y: 654, w: 149, h: 985, t: 42 };
+  const newDisplay = display(6, 0, 0, 1920, 1080);
+  const naive = dm.cfg.pos && dm.cfg.pos[newDisplay.id];           // what the OLD one-line impl would see
+  assert.equal(naive, undefined, 'sanity: the id-only lookup the old code used finds nothing');
+  const p = dm.floatingPosFor(newDisplay, [6]);
+  assert.ok(p, 'the fallback must still produce a position');
+  assert.equal(p.x, 321, 'coords must come from the real orphan, not a made-up default');
+});

← 60473d3 auto-data-snapshot: 2026-09-23T13:13:50 (1 data files) — sta  ·  back to Desktop Dotbar  ·  dotbar: boot guard uses process.versions.electron (require.m c915cfa →