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:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user