'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, Notification } = require('electron'); const { Store, loadConfig } = require('./store'); const { Collector } = require('./collector'); const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths'); const { getAutostart, setAutostart: applyAutostart } = require('./autostart'); const IS_LINUX = process.platform === 'linux'; 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; const ERRORS_WIDTH = 420; const ERRORS_HEIGHT = 480; let win = null; let settingsWin = null; let errorsWin = null; let tray = null; let collector = null; let store = null; let config = {}; /** * Erzwingt XWayland, sobald das Widget in einer Wayland-Sitzung startet. * * Electron wählt seit Version 36 von sich aus Wayland. Dort darf sich ein * Fenster aber weder selbst positionieren noch dauerhaft in den Vordergrund * legen — beides ist der Kern dieses Widgets. Über XWayland funktioniert es. * * Der Schalter muss auf der Kommandozeile stehen: Ozone wählt die Plattform, * bevor dieses Skript läuft, deshalb kommen `appendSwitch()` und * `ELECTRON_OZONE_PLATFORM_HINT` beide zu spät. Bleibt ein Neustart mit * ergänztem Argument. Das Argument ist zugleich die Abbruchbedingung — im * zweiten Durchlauf steht es in `argv` und der Zweig greift nicht mehr. * * Muss vor `requestSingleInstanceLock()` stehen, damit die verworfene Instanz * die Sperre gar nicht erst nimmt. */ function forceX11IfWayland() { if (!IS_LINUX || !process.env.WAYLAND_DISPLAY) return false; if (process.env.ELECTRON_OZONE_PLATFORM_HINT) return false; // ausdrücklich übersteuert if (process.argv.some((a) => a.startsWith('--ozone-platform'))) return false; const args = process.argv.slice(1).concat('--ozone-platform=x11'); // Ein AppImage hängt sich beim Start unter /tmp ein und wirft die Einhängung // beim Beenden wieder weg. `process.execPath` zeigt dorthin und wäre für den // Nachfolger schon verschwunden — neu gestartet werden muss das Abbild. if (process.env.APPIMAGE) app.relaunch({ execPath: process.env.APPIMAGE, args }); else app.relaunch({ args }); app.exit(0); return true; } if (!forceX11IfWayland()) { // Ohne diesen Schalter zeichnet Chromium unter Linux hinter das rahmenlose // Fenster einen schwarzen Kasten statt echter Transparenz. if (IS_LINUX) app.commandLine.appendSwitch('enable-transparent-visuals'); bootstrap(); } /** Verhindert, dass mehrere Instanzen dieselben Zustandsdateien beschreiben. */ function bootstrap() { if (!app.requestSingleInstanceLock()) { app.quit(); return; } app.on('second-instance', () => showWindow()); main().catch((err) => { console.error(err); app.quit(); }); } /** * 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) { applyAutostart(enabled); buildTrayMenu(); } function buildTrayMenu() { if (!tray) return; const autostart = getAutostart(); 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: IS_LINUX ? 'Mit dem System starten' : '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() { // In der GNOME-Leiste wirkt das 16-px-Symbol ausgefranst — dort das größere. const file = IS_LINUX ? 'tray@32.png' : 'tray.png'; const icon = nativeImage.createFromPath(path.join(ROOT, 'assets', file)); 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; }); } /** Fehler-/Ereignis-Log — ein Fenster, mehrfaches Öffnen holt es nur nach vorn. */ function openErrorLog() { if (errorsWin && !errorsWin.isDestroyed()) { errorsWin.show(); errorsWin.focus(); return; } const target = win && !win.isDestroyed() ? screen.getDisplayMatching(win.getBounds()) : screen.getPrimaryDisplay(); const area = target.workArea; errorsWin = new BrowserWindow({ width: ERRORS_WIDTH, height: ERRORS_HEIGHT, x: Math.round(area.x + (area.width - ERRORS_WIDTH) / 2), y: Math.round(area.y + Math.max(0, (area.height - ERRORS_HEIGHT) / 2)), minWidth: 360, minHeight: 320, 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, '..', 'errors-preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); errorsWin.loadFile(path.join(__dirname, '..', 'errors', 'index.html')); errorsWin.once('ready-to-show', () => errorsWin.show()); errorsWin.on('closed', () => { errorsWin = 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.on('open-error-log', openErrorLog); ipcMain.on('errors:clear', () => collector && collector.clearErrors()); ipcMain.on('errors:close', () => errorsWin && errorsWin.close()); ipcMain.handle('settings:load', () => ({ config, meta: { configPath: CONFIG_FILE, autostart: getAutostart(), platform: process.platform, version: app.getVersion(), }, })); 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(); // Neuinstallations-/Update-Erkennung: rein additiv, überschreibt oder // löscht keine bestehenden Einstellungsdaten — nur `store.data.appVersion` // wird gesetzt. Funktioniert identisch unter Windows (NSIS/portable) und // Linux (AppImage/deb), da app.getVersion() plattformunabhängig aus den // Paketmetadaten liest und state.json auf beiden Plattformen im selben // Nutzerprofil-Ordner liegt (siehe paths.js). const currentVersion = app.getVersion(); const previousVersion = store.data.appVersion; let versionEvent = null; if (previousVersion == null) { versionEvent = { type: 'install', previousVersion: null, currentVersion }; } else if (previousVersion !== currentVersion) { versionEvent = { type: 'update', previousVersion, currentVersion }; } store.data.appVersion = currentVersion; store.save(); 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, versionEvent }); // Hinweis auf ein Update über die native Systembenachrichtigung — der // Titelbalken ist mit dem Endpunkt-Status-Icon bereits eng, und eine // Notification ist naturgemäß einmalig. Eine Neuinstallation bekommt // bewusst keine Meldung: dafür gibt es nichts, worüber zu informieren wäre. if (versionEvent?.type === 'update' && Notification.isSupported()) { new Notification({ title: 'Claude Live Dashboard aktualisiert', body: `Version ${versionEvent.previousVersion} → ${versionEvent.currentVersion}`, icon: path.join(ROOT, 'assets', 'icon.png'), }).show(); } collector.on('update', (state) => { if (win && !win.isDestroyed()) win.webContents.send('state', state); if (errorsWin && !errorsWin.isDestroyed()) errorsWin.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(); });