Initial commit: Claude Live Dashboard

Always-on-Top Electron-Widget für die Claude-Plan-Auslastung unter Windows.
Liest Transkripte lokal, fragt optional den /usage-Endpunkt ab, bietet einen
Einstellungsdialog und lässt sich als NSIS-Installer oder portable Fassung
bauen.
This commit is contained in:
Winkler, Stefan
2026-07-22 12:59:54 +02:00
commit 868f31fa9a
28 changed files with 8971 additions and 0 deletions
+373
View File
@@ -0,0 +1,373 @@
'use strict';
/**
* Einstiegspunkt: rahmenloses Always-on-Top-Fenster, Tray-Symbol und die
* Verdrahtung zum Collector.
*/
const fs = require('node:fs');
const path = require('node:path');
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell } = require('electron');
const { Store, loadConfig } = require('./store');
const { Collector } = require('./collector');
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
const COMPACT_HEIGHT = 352;
const EXPANDED_HEIGHT = 640;
const WIDTH = 320;
const MARGIN = 16;
const ROOT = path.join(__dirname, '..', '..');
const SETTINGS_WIDTH = 520;
const SETTINGS_HEIGHT = 660;
let win = null;
let settingsWin = null;
let tray = null;
let collector = null;
let store = null;
let config = {};
/** Verhindert, dass mehrere Instanzen dieselben Zustandsdateien beschreiben. */
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => showWindow());
}
/**
* Stellt die gespeicherte Position wieder her — aber nur, wenn sie auf einem
* aktuell vorhandenen Bildschirm liegt. Nach einem Monitorwechsel läge das
* Fenster sonst unsichtbar außerhalb.
*/
function resolvePosition(width, height) {
const saved = store.data.window;
if (saved.x != null && saved.y != null) {
const displays = screen.getAllDisplays();
const visible = displays.some((d) => {
const b = d.workArea;
return (
saved.x + width > b.x + 40 &&
saved.x < b.x + b.width - 40 &&
saved.y + 40 > b.y &&
saved.y < b.y + b.height - 40
);
});
if (visible) return { x: saved.x, y: saved.y };
}
// Voreinstellung: rechts oben auf dem Hauptbildschirm
const area = screen.getPrimaryDisplay().workArea;
return { x: area.x + area.width - width - MARGIN, y: area.y + MARGIN };
}
function createWindow() {
const expanded = Boolean(store.data.window.expanded);
const height = expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT;
const { x, y } = resolvePosition(WIDTH, height);
win = new BrowserWindow({
width: WIDTH,
height,
x,
y,
frame: false,
transparent: true,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
skipTaskbar: true,
alwaysOnTop: true,
show: false,
backgroundColor: '#00000000',
icon: path.join(ROOT, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// 'screen-saver' legt das Fenster auch über Vollbildanwendungen.
win.setAlwaysOnTop(true, 'screen-saver');
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
win.once('ready-to-show', () => win.show());
// Position sichern, sobald der Nutzer das Fenster losgelassen hat.
win.on('moved', () => {
const [nx, ny] = win.getPosition();
store.data.window.x = nx;
store.data.window.y = ny;
store.save();
});
// Externe Links nie im Widget selbst öffnen.
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
win.on('closed', () => {
win = null;
});
}
function showWindow() {
if (!win) return createWindow();
win.show();
win.setAlwaysOnTop(true, 'screen-saver');
}
function toggleWindow() {
if (!win) return createWindow();
if (win.isVisible()) win.hide();
else showWindow();
}
function setOpacity(value) {
config.opacity = value;
if (win) win.setOpacity(value);
buildTrayMenu();
}
function setClickThrough(enabled) {
config.clickThrough = enabled;
// forward: true lässt Hover-Effekte weiterlaufen, ohne Klicks abzufangen.
if (win) win.setIgnoreMouseEvents(enabled, { forward: true });
buildTrayMenu();
}
function setAutostart(enabled) {
app.setLoginItemSettings({ openAtLogin: enabled, args: [] });
buildTrayMenu();
}
function buildTrayMenu() {
if (!tray) return;
const autostart = app.getLoginItemSettings().openAtLogin;
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Dashboard anzeigen', click: showWindow },
{ label: 'Ausblenden', click: () => win && win.hide() },
{ type: 'separator' },
{
label: 'Deckkraft',
submenu: [1, 0.9, 0.75, 0.6].map((v) => ({
label: `${Math.round(v * 100)} %`,
type: 'radio',
checked: (config.opacity ?? 1) === v,
click: () => setOpacity(v),
})),
},
{
label: 'Klicks durchlassen',
type: 'checkbox',
checked: Boolean(config.clickThrough),
click: (item) => setClickThrough(item.checked),
},
{
label: 'Mit Windows starten',
type: 'checkbox',
checked: autostart,
click: (item) => setAutostart(item.checked),
},
{ type: 'separator' },
{ label: 'Einstellungen …', click: openSettings },
{ label: 'Datenordner öffnen', click: () => shell.openPath(USER_DATA_DIR) },
{ type: 'separator' },
{ label: 'Beenden', click: () => app.quit() },
]),
);
}
function createTray() {
const icon = nativeImage.createFromPath(path.join(ROOT, 'assets', 'tray.png'));
tray = new Tray(icon);
tray.setToolTip('Claude Live Dashboard');
tray.on('click', toggleWindow);
buildTrayMenu();
}
/** Höhe an den Aufklappzustand anpassen, Position beibehalten. */
function applyExpanded(expanded) {
store.data.window.expanded = expanded;
store.save();
if (!win) return;
const [x, y] = win.getPosition();
win.setBounds({ x, y, width: WIDTH, height: expanded ? EXPANDED_HEIGHT : COMPACT_HEIGHT }, true);
}
/** Einstellungsdialog — ein Fenster, mehrfaches Öffnen holt es nur nach vorn. */
function openSettings() {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.show();
settingsWin.focus();
return;
}
// Auf dem Bildschirm zentrieren, auf dem das Widget steht — bei mehreren
// Monitoren landet der Dialog sonst irgendwo abseits.
const target = win && !win.isDestroyed()
? screen.getDisplayMatching(win.getBounds())
: screen.getPrimaryDisplay();
const area = target.workArea;
settingsWin = new BrowserWindow({
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
x: Math.round(area.x + (area.width - SETTINGS_WIDTH) / 2),
y: Math.round(area.y + Math.max(0, (area.height - SETTINGS_HEIGHT) / 2)),
minWidth: 440,
minHeight: 420,
frame: false,
resizable: true,
skipTaskbar: false,
// Über dem Widget, das selbst always-on-top ist — sonst verschwindet der
// Dialog dahinter.
alwaysOnTop: true,
show: false,
backgroundColor: '#1e1e22',
icon: path.join(ROOT, 'assets', 'icon.png'),
webPreferences: {
preload: path.join(__dirname, '..', 'settings-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
settingsWin.loadFile(path.join(__dirname, '..', 'settings', 'index.html'));
settingsWin.once('ready-to-show', () => settingsWin.show());
settingsWin.on('closed', () => {
settingsWin = null;
});
}
/**
* Übernimmt geänderte Einstellungen ohne Neustart und schreibt sie in die
* Datei. Unbekannte Felder und die `_`-Kommentare bleiben erhalten, weil die
* vorhandene Datei gelesen und nur ergänzt wird.
*/
function applySettings(patch) {
let raw = {};
try {
raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
if (err.code !== 'ENOENT') throw new Error(`config.json nicht lesbar: ${err.message}`);
}
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete raw[key];
else if (value && typeof value === 'object' && !Array.isArray(value)) {
const merged = { ...(raw[key] || {}), ...value };
// Leere Werte entfernen, damit "automatisch" auch wirklich automatisch ist.
for (const [k, v] of Object.entries(value)) {
if (v === undefined || v === '' || v === null) delete merged[k];
}
raw[key] = merged;
} else raw[key] = value;
}
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
config = raw;
if (win && !win.isDestroyed()) {
win.setOpacity(config.opacity ?? 1);
win.setIgnoreMouseEvents(Boolean(config.clickThrough), { forward: true });
}
if (collector) collector.applyConfig(config);
buildTrayMenu();
}
function registerIpc() {
ipcMain.handle('get-state', () => (collector ? collector.state : null));
ipcMain.handle('get-ui-state', () => ({ expanded: Boolean(store.data.window.expanded) }));
ipcMain.on('set-expanded', (_event, expanded) => applyExpanded(Boolean(expanded)));
ipcMain.on('hide-window', () => win && win.hide());
ipcMain.on('open-settings', openSettings);
ipcMain.handle('settings:load', () => ({
config,
meta: {
configPath: CONFIG_FILE,
autostart: app.getLoginItemSettings().openAtLogin,
},
}));
ipcMain.handle('settings:save', (_event, patch) => {
try {
applySettings(patch || {});
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
});
ipcMain.handle('settings:autostart', (_event, enabled) => {
setAutostart(Boolean(enabled));
return { ok: true };
});
ipcMain.on('settings:open-file', () => shell.openPath(CONFIG_FILE));
ipcMain.on('settings:close', () => settingsWin && settingsWin.close());
}
async function main() {
await app.whenReady();
// Beim ersten Start die mitgelieferte Vorlage ins Benutzerprofil kopieren —
// im Anwendungsverzeichnis wäre sie nach der Installation nicht beschreibbar.
ensureUserConfig();
const { config: loaded, error } = loadConfig(CONFIG_FILE);
config = loaded;
store = new Store(STATE_FILE);
store.load();
registerIpc();
createWindow();
createTray();
if (config.opacity != null) win.setOpacity(config.opacity);
if (config.clickThrough) win.setIgnoreMouseEvents(true, { forward: true });
collector = new Collector({ config, store, configError: error });
collector.on('update', (state) => {
if (win && !win.isDestroyed()) win.webContents.send('state', state);
if (tray) {
const parts = [`Claude · Woche ${Math.round(state.week.percent)} %`];
if (state.block.hasLimit) parts.push(`5h ${Math.round(state.block.percent)} %`);
if (state.burn.activeTimeLeftMs != null) {
const h = Math.floor(state.burn.activeTimeLeftMs / 3600000);
parts.push(h > 0 ? `noch ~${h} Std. Arbeit` : 'unter 1 Std. Arbeit');
}
tray.setToolTip(parts.join(' · '));
}
});
try {
await collector.start();
} catch (err) {
console.error('[collector] Start fehlgeschlagen:', err);
}
}
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
app.on('window-all-closed', () => {});
app.on('before-quit', () => {
if (collector) collector.stop();
});
main().catch((err) => {
console.error(err);
app.quit();
});