← back to Desktop Dotbar
electron-main.js
396 lines
// Electron shell: a true always-on-top strip that docks to ANY screen edge.
// Spawns the local server, then floats a frameless bar above every window
// (including fullscreen) at 'screen-saver' level. Grows only when a dropdown opens.
//
// MULTI-DISPLAY (Steve 2026-09-16): one strip PER display, each pinned to the same edge of
// its OWN screen, so the dot chips + the ⧉ Arrange button are reachable on every monitor.
// Each strip is an independent BrowserWindow; open-state (dropdown grow) is tracked per window,
// and grip-resize applies to all strips so they stay the same thickness.
//
// TK-11885 (Steve 2026-09-17): the bar now (a) EDGE AUTO-HIDES — when autohide is on it slides
// off its docked edge leaving a thin PEEK, and slides back in when the cursor hits that edge
// (cursor-polled, because the panel is non-focusable so DOM hover can't see an off-screen bar);
// and (b) supports ORIENTATION top<->left<->right, so a side bar can be reverted back to a top
// bar. Both are global prefs, applied to every strip and persisted. Scope: local Electron/CSS/JS.
'use strict';
const { app, BrowserWindow, ipcMain, screen } = require('electron');
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs');
const path = require('path');
const DIR = __dirname;
const PANEL_H = 360; // top-dock dropdown grows down by this
const PANEL_W = 340; // side-dock dropdown grows sideways by this
const BAR_DEFAULT = 48, BAR_MIN = 22, BAR_MAX = 140; // thickness of a TOP strip (its height)
const BARW_DEFAULT = 210, BARW_MIN = 120, BARW_MAX = 420; // thickness of a SIDE strip (its width)
const BARLEN_MIN = 260, BARLEN_MAX = 8000; // TOP strip horizontal LENGTH; 0 = span the full screen (Steve 2026-09-17)
const PEEK = 3; // px of bar left visible when auto-hidden
const EDGE_TRIGGER = 8; // cursor within this many px of the edge reveals
const REVEAL_MARGIN = 6; // keep revealed while cursor is within bounds+this
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');
// 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
let serverProc, PORT = null;
const openState = new Map(); // win.id -> is that strip's dropdown open
const revealed = new Map(); // win.id -> is that strip currently slid in (autohide)
const lastInside = new Map(); // win.id -> ts cursor was last over the bar (hide grace)
let barH = readBarH(); // resizable TOP-strip height (persisted, shared)
let cfg = readCfg(); // { orientation, autohide, barW } (persisted, shared)
let gripDragging = false; // suppress auto-hide while resizing
function clampBar(h) { return Math.max(BAR_MIN, Math.min(BAR_MAX, Math.round(h || BAR_DEFAULT))); }
function clampBarW(w) { return Math.max(BARW_MIN, Math.min(BARW_MAX, Math.round(w || BARW_DEFAULT))); }
// TOP strip length: 0 (or anything <=0/NaN) means "span the whole screen"; otherwise a clamped px length.
function clampBarLen(v) { const n = Math.round(Number(v) || 0); return n <= 0 ? 0 : Math.max(BARLEN_MIN, Math.min(BARLEN_MAX, n)); }
function readBarH() { try { return clampBar(parseInt(fs.readFileSync(BARH_FILE, 'utf8'), 10)); } catch { return BAR_DEFAULT; } }
function writeBarH() { try { fs.writeFileSync(BARH_FILE, String(barH)); } catch {} }
function readCfg() {
// floating = the bar has been dragged off its edge and lives at a free position;
// pos maps displayId -> { x, y, w, h } so EACH display's bar keeps its own free spot.
// Backward-compat: a .barcfg written by the pre-floating code has neither key and loads docked.
const d = { orientation: 'top', autohide: false, barW: BARW_DEFAULT, barLen: 0, floating: false, pos: {} };
try {
const j = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'));
if (ORIENTS.includes(j.orientation)) d.orientation = j.orientation;
d.autohide = !!j.autohide;
d.barW = clampBarW(j.barW);
d.barLen = clampBarLen(j.barLen);
d.floating = !!j.floating;
if (j.pos && typeof j.pos === 'object') {
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)
}
}
}
} catch {}
return d;
}
let _cfgTimer = null;
function writeCfg() { clearTimeout(_cfgTimer); _cfgTimer = setTimeout(() => { try { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg)); } catch {} }, 200); }
function flushCfg() { clearTimeout(_cfgTimer); try { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg)); } catch {} } // sync flush so a drag-then-quit within the debounce window isn't lost
function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
function alive(port) {
return new Promise(res => {
if (!port) return res(false);
const r = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 800 }, x => { x.resume(); res(x.statusCode === 200); });
r.on('error', () => res(false)); r.on('timeout', () => { r.destroy(); res(false); });
});
}
// The launcher (start-bar.command) starts the node server; we just wait for it.
async function ensureServer() {
for (let i = 0; i < 50; i++) {
const p = portFromFile();
if (await alive(p)) return p;
await new Promise(r => setTimeout(r, 200));
}
// last resort: spawn it ourselves via Electron-as-node
serverProc = spawn(process.execPath, [path.join(DIR, 'server.js')], {
stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } });
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 200));
const p = portFromFile();
if (await alive(p)) return p;
}
return portFromFile() || 9787;
}
// Pure bounds for a strip on a display, given dropdown-open + hidden(auto-hide) flags and the
// current orientation. Grows along the axis away from its docked edge when the dropdown is open;
// when hidden, slides off its edge leaving PEEK px visible (thickness stays the closed thickness).
function boundsCore(display, open, hidden) {
const wa = display.workArea;
const o = cfg.orientation;
if (o === 'top') {
const height = hidden ? barH : (open ? barH + PANEL_H : barH);
const y = hidden ? wa.y + PEEK - barH : wa.y; // slide up, PEEK visible at top
// barLen 0 = span the screen; otherwise a left-anchored length, clamped to this display's width.
const width = cfg.barLen > 0 ? Math.min(cfg.barLen, wa.width) : wa.width;
return { x: wa.x, y, width, height };
}
const bw = cfg.barW;
const width = hidden ? bw : (open ? bw + PANEL_W : bw);
if (o === 'left') {
const x = hidden ? wa.x + PEEK - bw : wa.x; // slide left, PEEK visible at left
return { x, y: wa.y, width, height: wa.height };
}
// right: closed sits flush with the right edge; open grows leftward; hidden slides off right.
let x;
if (hidden) x = wa.x + wa.width - PEEK;
else x = wa.x + wa.width - (open ? bw + PANEL_W : bw);
return { x, y: wa.y, width, height: wa.height };
}
function isHidden(w) { return cfg.autohide && w && !revealed.get(w.id) && !openState.get(w.id) && !gripDragging; }
// ---- Free-position (floating) mode ----
// Once the user drags a bar off its edge, cfg.floating flips on and the bar lives at
// cfg.pos[displayId]. boundsFor then returns that saved spot instead of the edge-docked math,
// so nothing re-snaps it to the edge. A display whose bar was never dragged has no pos entry
// and stays docked even while floating is on.
const MOVE_THRESH = 4; // px a bar must leave its docked spot to count as a drag
const progUntil = new Map(); // win.id -> ts through which 'moved' events are our own setBounds echoes
function setWinBounds(w, b) { // every programmatic setBounds goes through here so 'moved' can ignore it
if (!w || w.isDestroyed()) return;
progUntil.set(w.id, Date.now() + 250);
w.setBounds(b);
}
function boundsToPos(b) { return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; }
// Clamp a saved pos into a display's visible work area so a bar is never opened where it can't be
// seen or grabbed (off-screen, or its display shrank/vanished). Keeps the whole window on-screen.
function clampPosToDisplay(pos, display) {
const wa = display.workArea;
const w = Math.min(Math.max(BAR_MIN, pos.w), wa.width);
const h = Math.min(Math.max(BAR_MIN, pos.h), wa.height);
const x = Math.min(Math.max(wa.x, pos.x), wa.x + wa.width - w);
const y = Math.min(Math.max(wa.y, pos.y), wa.y + wa.height - h);
return { x, y, w, h };
}
// 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 };
if (open) { if (cfg.orientation === 'top') b.height += PANEL_H; else b.width += PANEL_W; }
return b;
}
function boundsFor(w, display, open) {
if (cfg.floating) { const p = floatingPosFor(display); if (p) return floatingBounds(p, open); }
return boundsCore(display, open, isHidden(w));
}
function displayOf(w) {
return screen.getAllDisplays().find(d => d.id === w._displayId) || screen.getPrimaryDisplay();
}
function raise(w) { try { if (!w.isDestroyed()) { w.setAlwaysOnTop(true, 'screen-saver'); w.moveTop(); } } catch {} }
function applyBoundsFor(w) {
if (!w || w.isDestroyed()) return;
setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
raise(w);
}
// A user drag ends in a 'moved' event. Ignore our own programmatic setBounds (progUntil) and any
// jitter that hasn't actually left the docked spot; otherwise flip to floating and remember where
// THIS display's bar now sits (re-keying to the display it was dropped on for a cross-monitor drag).
function onWinMoved(w) {
if (!w || w.isDestroyed()) return;
if (Date.now() < (progUntil.get(w.id) || 0)) return;
const b = w.getBounds();
const d = displayOf(w);
if (!cfg.floating) {
const docked = boundsCore(d, false, false);
if (Math.abs(b.x - docked.x) <= MOVE_THRESH && Math.abs(b.y - docked.y) <= MOVE_THRESH) return;
}
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);
}
w._displayId = disp.id;
cfg.floating = true;
cfg.pos[disp.id] = pos;
writeCfg();
}
function applyAll() { for (const w of wins) applyBoundsFor(w); }
function pushBarH(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:barH', barH); } catch {} }
function pushCfg(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:config', { orientation: cfg.orientation, autohide: cfg.autohide, barW: cfg.barW, barLen: cfg.barLen }); } catch {} }
// setBarH/setBarW fire on EVERY pointermove during a grip drag, so keep them cheap: resize each
// strip and mirror the value to its renderer live, but do NOT re-assert always-on-top per frame
// (the 2s raise interval covers that) and do NOT hit disk per frame — persist once, debounced.
// While floating, a grip drag resizes the free-positioned bar in place, so mirror the new size onto
// the saved pos (clamped on read) — a floating bar stays resizable and remembers its size.
function patchFloatPos(w, patch) {
const id = displayOf(w).id;
if (cfg.floating && cfg.pos[id]) cfg.pos[id] = { ...cfg.pos[id], ...patch };
}
let _persistTimer = null;
function setBarH(h) {
barH = clampBar(h);
for (const w of wins) {
if (w.isDestroyed()) continue;
patchFloatPos(w, { h: barH });
setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushBarH(w);
}
clearTimeout(_persistTimer); _persistTimer = setTimeout(() => { writeBarH(); if (cfg.floating) writeCfg(); }, 300);
}
let _persistWTimer = null;
function setBarW(wpx) {
cfg.barW = clampBarW(wpx);
for (const w of wins) {
if (w.isDestroyed()) continue;
patchFloatPos(w, { w: cfg.barW });
setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushCfg(w);
}
clearTimeout(_persistWTimer); _persistWTimer = setTimeout(writeCfg, 300);
}
// TOP-strip horizontal length (right-end grip drag). Dragging out to/past the screen width snaps back
// to "full" (barLen 0) so it re-spans the screen and survives a resolution change. Applies to all strips.
let _persistLTimer = null;
function setBarLen(px) {
const full = screen.getPrimaryDisplay().workArea.width;
cfg.barLen = (Math.round(Number(px) || 0) >= full - 4) ? 0 : clampBarLen(px);
for (const w of wins) {
if (w.isDestroyed()) continue;
patchFloatPos(w, { w: Math.max(BARLEN_MIN, Math.round(Number(px) || 0)) }); // floating top bar: length = width
setWinBounds(w, boundsFor(w, displayOf(w), !!openState.get(w.id)));
pushCfg(w);
}
clearTimeout(_persistLTimer); _persistLTimer = setTimeout(writeCfg, 300);
}
function setOrientation(o) {
if (!ORIENTS.includes(o)) return;
const exitingFloat = cfg.floating; // orientation button also re-docks a floating bar
if (o === cfg.orientation && !exitingFloat) return;
cfg.floating = false;
cfg.orientation = o;
revealed.clear(); // recompute reveal against the new edge
applyAll();
for (const w of wins) pushCfg(w);
writeCfg();
}
function setAutohide(on) {
cfg.autohide = !!on;
revealed.clear(); lastInside.clear();
applyAll(); // off -> everything shows; on -> hide (unless open)
for (const w of wins) pushCfg(w);
writeCfg();
}
// ---- Auto-hide: cursor-polled reveal/conceal (the panel is non-focusable, so DOM hover can't
// see it while it's slid off-screen; a global cursor poll is the reliable trigger). ----
function cursorNearEdge(display, c) {
const wa = display.workArea, o = cfg.orientation;
if (o === 'top') return c.x >= wa.x && c.x < wa.x + wa.width && c.y <= wa.y + EDGE_TRIGGER && c.y >= wa.y - 2;
if (o === 'left') return c.y >= wa.y && c.y < wa.y + wa.height && c.x <= wa.x + EDGE_TRIGGER && c.x >= wa.x - 2;
return c.y >= wa.y && c.y < wa.y + wa.height && c.x >= wa.x + wa.width - EDGE_TRIGGER; // right
}
function cursorOverRevealed(w, display, c) {
const b = boundsCore(display, !!openState.get(w.id), false); // full (shown) bounds
return c.x >= b.x - REVEAL_MARGIN && c.x < b.x + b.width + REVEAL_MARGIN
&& c.y >= b.y - REVEAL_MARGIN && c.y < b.y + b.height + REVEAL_MARGIN;
}
function pollAutohide() {
if (!cfg.autohide || cfg.floating) return; // a floating bar has no edge to slide off, so never hide it
let c; try { c = screen.getCursorScreenPoint(); } catch { return; }
const now = Date.now();
for (const w of wins) {
if (w.isDestroyed()) continue;
const d = displayOf(w);
const forceShow = openState.get(w.id) || gripDragging; // never yank an open dropdown / active drag
const inZone = cursorNearEdge(d, c) || cursorOverRevealed(w, d, c);
let want;
if (forceShow || inZone) { want = true; lastInside.set(w.id, now); }
else { want = (now - (lastInside.get(w.id) || 0)) < HIDE_GRACE_MS; } // grace before hiding
if (want !== (revealed.get(w.id) || false)) { revealed.set(w.id, want); applyBoundsFor(w); }
}
}
function createBarFor(display) {
const fp = cfg.floating ? floatingPosFor(display) : null; // restore free position (clamped on-screen) if set
const initBounds = fp ? floatingBounds(fp, false) : boundsCore(display, false, false);
const win = new BrowserWindow({
...initBounds,
type: 'panel', // NSPanel: floats above normal windows, non-activating
frame: false, resizable: false, movable: true, minimizable: false, maximizable: false,
fullscreenable: false, skipTaskbar: true, hasShadow: false, transparent: false,
focusable: false, backgroundColor: '#14161a',
webPreferences: { preload: path.join(DIR, 'preload.js'), contextIsolation: true, nodeIntegration: false },
});
win._displayId = display.id;
win.setAlwaysOnTop(true, 'screen-saver'); // above normal + fullscreen windows
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.loadURL(`http://127.0.0.1:${PORT}/`);
const dbg = (tag) => { if (process.env.DOTBAR_DEBUG !== '1') return; try { fs.appendFileSync(path.join(DIR, 'debug.log'),
JSON.stringify({ tag, id: win.id, t: new Date().toISOString(), bounds: win.isDestroyed() ? null : win.getBounds() }) + '\n'); } catch {} };
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
wins.push(win);
return win;
}
// Tear down and recreate every strip — used on monitor plug/unplug so a display always has its bar
// and a removed display leaves no orphan window.
function rebuildBars() {
for (const w of wins) { try { if (!w.isDestroyed()) w.destroy(); } catch {} }
wins = []; openState.clear(); revealed.clear(); lastInside.clear(); progUntil.clear();
for (const d of screen.getAllDisplays()) createBarFor(d);
}
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
// 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);
// Per-strip dropdown open/grow: grow the window that actually sent the event, not a global one.
ipcMain.on('dotbar:setOpen', (e, open) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (!w) return;
openState.set(w.id, !!open);
if (open) { revealed.set(w.id, true); lastInside.set(w.id, Date.now()); } // opening reveals
applyBoundsFor(w);
});
ipcMain.on('dotbar:setBarH', (_e, h) => setBarH(h)); // grip drag (top): applies to all strips
ipcMain.on('dotbar:setBarW', (_e, w) => setBarW(w)); // grip drag (side): applies to all strips
ipcMain.on('dotbar:setBarLen', (_e, px) => setBarLen(px)); // right-end grip (top): horizontal length, applies to all strips
ipcMain.on('dotbar:gripDrag', (_e, on) => { gripDragging = !!on; if (!on && cfg.autohide) revealed.clear(); });
ipcMain.on('dotbar:setOrientation', (_e, o) => setOrientation(o));
ipcMain.on('dotbar:cycleOrientation', () => {
if (cfg.floating) setOrientation(cfg.orientation); // floating: first press re-docks to the current edge
else setOrientation(ORIENTS[(ORIENTS.indexOf(cfg.orientation) + 1) % ORIENTS.length]);
});
ipcMain.on('dotbar:setAutohide', (_e, on) => setAutohide(on));
ipcMain.on('dotbar:quit', () => app.quit());
}
// Only boot the real Electron app when running under Electron (require.main is NOT this module there) — a test
// require()ing this module for its pure helpers (floatingPosFor etc.) must not touch app/screen.
if (process.versions.electron) { // run the app under Electron; plain-node tests just require() the helpers
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 };