Ein neues Status-Icon im Titelbalken zeigt den Zustand der lokalen Transkripte und der Serverabfrage; ein Klick oeffnet ein eigenes Fenster mit persistiertem, zeitgestempeltem Ereignis-/Fehlerprotokoll (loeschbar). Ausserdem erkennt die App beim Start Erstinstallation vs. Update (plattformunabhaengig fuer Windows und Linux) und meldet Updates per nativer Benachrichtigung. Dazu: Versionierungs-Policy dokumentiert (package.json vor jedem Release erhoehen, sonst greift weder die Update-Erkennung noch bleiben alte Release-Dateien in release/ erhalten), sowie zwei neue Dokumente fuer Endanwender (BENUTZERHANDBUCH.md) und eine Download-Webseite (PRODUKTSEITE.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
506 lines
16 KiB
JavaScript
506 lines
16 KiB
JavaScript
'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();
|
|
});
|