diff --git a/README.md b/README.md index 60111ee..706e0ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Live Dashboard -Ein rahmenloses Always-on-Top-Widget für Windows, das die Auslastung des +Ein rahmenloses Always-on-Top-Widget für Windows und Linux, das die Auslastung des Claude-Plans live anzeigt: 5-Stunden-Fenster, Wochenverbrauch, Reset-Countdown, Tokenverbrauch je Modell und die Hochrechnung auf das Fensterende. @@ -10,8 +10,12 @@ mitgeschnitten und standardmäßig keine Netzwerkverbindung aufgebaut. ## Start -Node.js wird benötigt. Ist es nicht im `PATH` (etwa bei nvm-windows ohne -aktive Version), genügt der Versionspfad: +Node.js (ab Version 20) wird benötigt. + +### Windows + +Ist Node nicht im `PATH` (etwa bei nvm-windows ohne aktive Version), genügt der +Versionspfad: ```powershell $env:PATH = "$env:APPDATA\nvm\v22.22.1;$env:PATH" @@ -19,9 +23,51 @@ npm install npm start ``` +Alternativ `start.cmd` doppelklicken. + Das Tray-Symbol landet unter Windows zunächst im Überlaufbereich hinter dem `^` in der Taskleiste; per Drag lässt es sich dauerhaft sichtbar machen. +### Linux (Ubuntu) + +```bash +sudo apt install nodejs npm # falls noch nicht vorhanden +./start.sh +``` + +`start.sh` installiert fehlende Abhängigkeiten selbst und startet Electron; +`npm install && npm start` tut dasselbe. + +Das Tray-Symbol braucht unter GNOME die Erweiterung **AppIndicator** — beim +Ubuntu-Desktop ist sie vorinstalliert und aktiv. Auf einer Minimalinstallation +oder unter anderen Oberflächen: + +```bash +sudo apt install gnome-shell-extension-appindicator +``` + +Ohne sie läuft das Widget weiter, nur das Symbol in der oberen Leiste fehlt. +Anders als unter Windows ist im StatusNotifierItem-Standard nicht festgelegt, +welcher Klick als Aktivierung zählt — verlässlich ist deshalb das Kontextmenü +(Rechtsklick) mit „Dashboard anzeigen“ als erstem Eintrag. + +**Wayland:** Ab Electron 36 wählt Chromium in einer Wayland-Sitzung von sich +aus Wayland — dort darf sich ein Fenster aber weder selbst positionieren noch +dauerhaft in den Vordergrund legen, und genau das ist der Kern dieses Widgets. +Die App startet sich deshalb unter Wayland einmalig selbst mit +`--ozone-platform=x11` neu und läuft über XWayland. Das geschieht automatisch, +es ist nichts einzustellen. + +Der Schalter *muss* auf der Kommandozeile stehen: Ozone legt die Plattform +fest, bevor das Main-Skript läuft — `app.commandLine.appendSwitch()` und +`ELECTRON_OZONE_PLATFORM_HINT` kommen beide zu spät. Wer die Automatik +umgehen will, setzt eine der beiden Größen von Hand; dann greift der Neustart +nicht mehr: + +```bash +ELECTRON_OZONE_PLATFORM_HINT=wayland ./start.sh # nativ Wayland, ohne Positionierung +``` + ## Bedienung | Aktion | Wirkung | @@ -157,6 +203,7 @@ src/main/oauth-usage.js Serverabfrage, gekapselt und ausfallsicher src/main/calibrate.js Kalibrierung gegen die echte Anzeige src/main/store.js state.json, config.json src/main/paths.js Speicherorte für Konfiguration und Zustand +src/main/autostart.js Anmeldestart je Betriebssystem src/preload.js contextBridge fürs Widget src/settings-preload.js contextBridge für den Einstellungsdialog src/renderer/ Widget-Oberfläche @@ -178,6 +225,12 @@ Zwei Details, die nicht offensichtlich sind: ## Release bauen +Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht +sinnvoll von Windows aus erzeugen und umgekehrt keine signierten `.exe` unter +Linux. + +### Windows + ```powershell npm run dist # Installer + portable Fassung nach release/ npm run pack # nur entpackt nach release/win-unpacked (schneller Test) @@ -201,16 +254,49 @@ Start einen SmartScreen-Hinweis („Der Computer wurde durch Windows geschützt Weitergabe an andere wäre ein Zertifikat nötig; für den Eigenbedarf ist es verzichtbar. +### Linux + +```bash +npm run dist:linux # AppImage + .deb nach release/ +npm run pack:linux # nur entpackt nach release/linux-unpacked +``` + +Ergebnis in `release/`: + +| Datei | Zweck | +|---|---| +| `Claude Live Dashboard-1.0.0-x64.AppImage` | läuft ohne Installation | +| `Claude Live Dashboard-1.0.0-amd64.deb` | Installation per `sudo apt install ./…deb` | + +Das AppImage muss einmal ausführbar gemacht werden (`chmod +x`) und braucht +`libfuse2`. Es trägt sich nicht selbst ins Anwendungsmenü ein — der Autostart +über das Tray-Menü funktioniert trotzdem, weil die App den Pfad des Abbilds aus +`APPIMAGE` liest und nicht den temporären Einhängepunkt. Das `.deb` bringt +Menüeintrag und Symbol mit und installiert nach `/opt/Claude Live Dashboard`. + +> **Ubuntu 24.04 und AppImage.** Seit 24.04 beschränkt AppArmor unprivilegierte +> User-Namespaces; die Chromium-Sandbox kann dann beim Start fehlschlagen +> (`SUID sandbox helper binary … is not configured correctly`). Zwei Auswege: +> das AppImage mit `--no-sandbox` starten, oder ein AppArmor-Profil unter +> `/etc/apparmor.d/` hinterlegen. Beim `.deb` tritt das nicht auf — dessen +> `postinst` setzt das SUID-Bit auf `chrome-sandbox`. + ### Konfiguration und Zustand Beides liegt im Benutzerprofil, nicht im Programmverzeichnis — dort wäre es nach der Installation im asar-Archiv und schreibgeschützt: ``` -%APPDATA%\Claude Live Dashboard\config.json -%APPDATA%\Claude Live Dashboard\state.json +Windows: %APPDATA%\Claude Live Dashboard\config.json + %APPDATA%\Claude Live Dashboard\state.json + +Linux: ~/.config/Claude Live Dashboard/config.json + ~/.config/Claude Live Dashboard/state.json ``` +Der Autostart ist kein Feld dieser Dateien: unter Windows steckt er in der +Registry, unter Linux in `~/.config/autostart/claude-live-dashboard.desktop`. + Beim ersten Start wird `config.json` aus der mitgelieferten Vorlage angelegt. Erreichbar über das Tray-Menü (*Konfiguration öffnen* / *Datenordner öffnen*). diff --git a/assets/icon.png b/assets/icon.png index 3ef818f..f2d9a48 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/package.json b/package.json index 956fc0b..2d4f038 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "Claude Live Dashboard", "version": "1.0.0", "private": true, - "description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows", + "description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux", "main": "src/main/index.js", "scripts": { "start": "electron .", @@ -11,10 +11,14 @@ "calibrate": "node src/main/calibrate.js", "icons": "node tools/make-icon.js", "dist": "electron-builder --win", - "pack": "electron-builder --win --dir" + "pack": "electron-builder --win --dir", + "dist:linux": "electron-builder --linux", + "pack:linux": "electron-builder --linux --dir" }, "author": "", "license": "MIT", + "_homepage": "Platzhalter — nur nötig, weil das .deb-Paket ein Homepage-Feld verlangt.", + "homepage": "https://github.com/exilkieler/claude-live-dashboard", "devDependencies": { "electron": "^43.2.0", "electron-builder": "^25.1.8" @@ -66,6 +70,32 @@ }, "portable": { "artifactName": "${productName}-${version}-portable.${ext}" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + }, + { + "target": "deb", + "arch": [ + "x64" + ] + } + ], + "icon": "assets/icon.png", + "executableName": "claude-live-dashboard", + "category": "Utility", + "maintainer": "Stefan ", + "synopsis": "Auslastung des Claude-Abos im Blick", + "description": "Rahmenloses Always-on-Top-Widget, das die Auslastung des Claude-Abos aus den lokalen Claude-Code-Transkripten liest.", + "desktop": { + "StartupWMClass": "claude-live-dashboard" + }, + "artifactName": "${productName}-${version}-${arch}.${ext}" } } } diff --git a/src/main/autostart.js b/src/main/autostart.js new file mode 100644 index 0000000..2aadb98 --- /dev/null +++ b/src/main/autostart.js @@ -0,0 +1,84 @@ +'use strict'; + +/** + * Autostart — die einzige Stelle, die weiß, wie die jeweilige Arbeitsumgebung + * einen Anmeldestart hinterlegt. + * + * Unter Windows und macOS erledigt Electron das selbst. Unter Linux gibt es + * `app.setLoginItemSettings()` nicht; dort ist der Autostart eine + * `.desktop`-Datei in `~/.config/autostart`, deren bloße Existenz den Start + * auslöst. + */ + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { app } = require('electron'); + +const IS_LINUX = process.platform === 'linux'; + +const AUTOSTART_DIR = path.join( + process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), + 'autostart', +); +const DESKTOP_FILE = path.join(AUTOSTART_DIR, 'claude-live-dashboard.desktop'); + +/** + * Der Befehl, mit dem sich die Anwendung selbst wieder startet. + * + * Ein AppImage entpackt sich beim Start in ein temporäres Verzeichnis — + * `process.execPath` zeigt dann dorthin und wäre beim nächsten Anmelden + * verschwunden. Den Pfad zum Abbild selbst liefert nur `APPIMAGE`. + */ +function launchCommand() { + if (process.env.APPIMAGE) return `"${process.env.APPIMAGE}"`; + if (!app.isPackaged) return `"${process.execPath}" "${app.getAppPath()}"`; + return `"${process.execPath}"`; +} + +function desktopEntry() { + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=Claude Live Dashboard', + 'Comment=Auslastung des Claude-Abos im Blick', + `Exec=${launchCommand()}`, + 'Icon=claude-live-dashboard', + 'Terminal=false', + 'X-GNOME-Autostart-enabled=true', + '', + ].join('\n'); +} + +/** @returns {boolean} true, wenn die Anwendung beim Anmelden startet */ +function getAutostart() { + if (!IS_LINUX) return app.getLoginItemSettings().openAtLogin; + return fs.existsSync(DESKTOP_FILE); +} + +/** + * Schaltet den Anmeldestart ein oder aus. + * @param {boolean} enabled + */ +function setAutostart(enabled) { + if (!IS_LINUX) { + app.setLoginItemSettings({ openAtLogin: enabled, args: [] }); + return; + } + + try { + if (enabled) { + fs.mkdirSync(AUTOSTART_DIR, { recursive: true }); + fs.writeFileSync(DESKTOP_FILE, desktopEntry(), 'utf8'); + } else { + fs.rmSync(DESKTOP_FILE, { force: true }); + } + } catch (err) { + // Ein fehlgeschlagener Autostart ist kein Grund, die laufende Anwendung + // zu stören — der Haken im Menü zeigt beim nächsten Aufbau den echten Stand. + console.warn(`[autostart] konnte nicht gesetzt werden: ${err.message}`); + } +} + +module.exports = { getAutostart, setAutostart, DESKTOP_FILE }; diff --git a/src/main/index.js b/src/main/index.js index 823a740..f90766c 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -12,6 +12,9 @@ const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeImage, shell } = 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; @@ -30,11 +33,59 @@ 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. */ -if (!app.requestSingleInstanceLock()) { - app.quit(); -} else { +function bootstrap() { + if (!app.requestSingleInstanceLock()) { + app.quit(); + return; + } app.on('second-instance', () => showWindow()); + + main().catch((err) => { + console.error(err); + app.quit(); + }); } /** @@ -142,13 +193,13 @@ function setClickThrough(enabled) { } function setAutostart(enabled) { - app.setLoginItemSettings({ openAtLogin: enabled, args: [] }); + applyAutostart(enabled); buildTrayMenu(); } function buildTrayMenu() { if (!tray) return; - const autostart = app.getLoginItemSettings().openAtLogin; + const autostart = getAutostart(); tray.setContextMenu( Menu.buildFromTemplate([ @@ -171,7 +222,7 @@ function buildTrayMenu() { click: (item) => setClickThrough(item.checked), }, { - label: 'Mit Windows starten', + label: IS_LINUX ? 'Mit dem System starten' : 'Mit Windows starten', type: 'checkbox', checked: autostart, click: (item) => setAutostart(item.checked), @@ -186,7 +237,9 @@ function buildTrayMenu() { } function createTray() { - const icon = nativeImage.createFromPath(path.join(ROOT, 'assets', 'tray.png')); + // 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); @@ -297,7 +350,8 @@ function registerIpc() { config, meta: { configPath: CONFIG_FILE, - autostart: app.getLoginItemSettings().openAtLogin, + autostart: getAutostart(), + platform: process.platform, }, })); @@ -366,8 +420,3 @@ app.on('window-all-closed', () => {}); app.on('before-quit', () => { if (collector) collector.stop(); }); - -main().catch((err) => { - console.error(err); - app.quit(); -}); diff --git a/src/settings/app.js b/src/settings/app.js index 46cfc61..56b556d 100644 --- a/src/settings/app.js +++ b/src/settings/app.js @@ -20,6 +20,7 @@ const el = { opacityOut: $('opacity-out'), clickThrough: $('clickThrough'), autostart: $('autostart'), + autostartLabel: $('autostart-label'), oauthEnabled: $('oauthEnabled'), oauthEndpoint: $('oauthEndpoint'), @@ -80,6 +81,8 @@ function fill(config, meta) { el.opacityOut.textContent = `${opacity} %`; el.clickThrough.checked = Boolean(config.clickThrough); el.autostart.checked = Boolean(meta.autostart); + el.autostartLabel.textContent = + meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten'; const oauth = config.oauth || {}; el.oauthEnabled.checked = oauth.enabled !== false; diff --git a/src/settings/index.html b/src/settings/index.html index 80f0b0b..035ebb0 100644 --- a/src/settings/index.html +++ b/src/settings/index.html @@ -36,7 +36,7 @@
- +
Wird sofort übernommen, unabhängig vom Speichern. diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..9df6249 --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Startet das Dashboard aus dem Quellcode. Gegenstück zu start.cmd; unter +# Ubuntu liegt Node im PATH, deshalb genügt die Abhängigkeitsprüfung. + +set -euo pipefail +cd "$(dirname "$0")" + +if ! command -v node >/dev/null 2>&1; then + echo "Node wurde nicht gefunden. Installiere es z.B. mit: sudo apt install nodejs npm" + exit 1 +fi + +if [ ! -d node_modules/electron ]; then + echo "Abhaengigkeiten werden installiert..." + npm install +fi + +exec node_modules/.bin/electron . "$@" diff --git a/tools/make-icon.js b/tools/make-icon.js index ecbb589..317e2b3 100644 --- a/tools/make-icon.js +++ b/tools/make-icon.js @@ -116,9 +116,10 @@ function main() { console.log(`geschrieben: ${file}`); } - // Fenster- und Anwendungssymbol + // Fenster- und Anwendungssymbol. 512 px, weil electron-builder daraus die + // Linux-Symbolgrößen herunterrechnet und mindestens 256 px verlangt. const file = path.join(OUT_DIR, 'icon.png'); - fs.writeFileSync(file, makePng(256, ringShader(256, orange))); + fs.writeFileSync(file, makePng(512, ringShader(512, orange))); console.log(`geschrieben: ${file}`); }