4 Commits
Author SHA1 Message Date
Winkler, Stefan 489b15860f Version 1.2.0 2026-08-20 20:01:17 +02:00
Winkler, Stefan bb311e76c2 Update-Pruefung gegen Gitea-Releases ergaenzen
Die App prueft ca. 60 s nach dem Start einmalig, ob auf dem Gitea-Server
ein neueres Release vorliegt. Bei einem Treffer erscheint eine
Systembenachrichtigung, und ein Symbol im Titelbalken bleibt sichtbar,
bis aktualisiert wird -- Klick auf beides oeffnet die Release-Seite im
Browser. Ueber die neue Einstellung "Nach Updates suchen" abschaltbar
(Standard: aktiv).
2026-08-20 19:59:06 +02:00
Winkler, Stefan e413a36506 Manuellen OAuth-Token-Refresh im Endpunkt-Status-Fenster ergaenzen
Ein Button "Zugriffstoken erneuern" tauscht das Refresh-Token aus
.credentials.json aktiv gegen ein neues Zugriffstoken und schreibt es in
dieselbe Datei zurueck, die auch Claude Code verwendet -- statt nur auf
den naechsten Start von Claude Code zu warten. Nur auf expliziten Klick,
nie automatisch im Hintergrund; Token wird dabei nie geloggt.
2026-08-20 19:30:01 +02:00
Winkler, Stefan 628a269a14 MIT-Lizenztext ergaenzen 2026-08-06 12:31:02 +02:00
22 changed files with 544 additions and 13 deletions
+34 -4
View File
@@ -74,6 +74,7 @@ Position wird gemerkt.
| Element | Bedeutung |
|---|---|
| Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten |
| Pfeil-Symbol (nur bei neuem Release) | Öffnet die Release-Seite im Browser |
| Zahnrad | Öffnet die Einstellungen |
| `` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) |
@@ -121,12 +122,19 @@ Das Zahnrad öffnet den Einstellungsdialog:
der Anmeldung.
- **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal
arbeiten soll (dann sind alle Werte Näherungen statt exakter Zahlen).
- **Nach Updates suchen** — prüft rund eine Minute nach dem Start einmalig,
ob auf dem Projekt-Server eine neuere Version vorliegt. Falls ja,
erscheint eine Systembenachrichtigung, und ein Pfeil-Symbol im
Titelbalken bleibt sichtbar, bis aktualisiert wird. Klick auf
Benachrichtigung oder Symbol öffnet die Release-Seite im Browser. Diese
Einstellung wirkt erst beim nächsten Start.
- **Bezugsgrößen, Gewichtung, Wochenfenster** — fortgeschrittene Feineinstellungen
für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht;
im Normalbetrieb müssen Sie hier nichts anpassen.
Änderungen wirken sofort, ein Neustart ist nicht nötig. `Strg`+`S` speichert,
`Esc` schließt den Dialog ohne zu speichern.
Änderungen wirken sofort, ein Neustart ist nicht nötig (Ausnahme: „Nach
Updates suchen", siehe oben). `Strg`+`S` speichert, `Esc` schließt den
Dialog ohne zu speichern.
## Tray-Menü
@@ -147,6 +155,17 @@ und Beenden.
- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten;
die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten
Werten.
- Über den Knopf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster lässt
sich das Zugriffstoken bei Bedarf manuell erneuern. Dabei wird das
Refresh-Token aus derselben Anmeldedatei gegen ein neues Zugriffstoken
eingetauscht und das Ergebnis dorthin zurückgeschrieben — Claude Code
selbst profitiert automatisch davon. Das geschieht ausschließlich auf
Klick, nie automatisch im Hintergrund, und auch dabei wird kein Token
protokolliert.
- Ist „Nach Updates suchen" aktiviert, fragt die Anwendung rund eine Minute
nach dem Start einmalig den selbst gehosteten Gitea-Server
(`git.conatum.net`) nach der neuesten Versionsnummer ab — ohne
Zugriffstoken und ohne weitere Daten. Abschaltbar in den Einstellungen.
- Es findet keine Übertragung an Dritte statt.
## Deinstallation
@@ -170,8 +189,10 @@ gelesen werden. Ein Klick auf den Punkt zeigt im Log den genauen Grund.
**Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade
gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes
Zugriffstoken, das Claude Code beim nächsten eigenen Start erneuert). Die
Anzeige selbst funktioniert währenddessen weiter.
Zugriffstoken). Ein Klick auf „Zugriffstoken erneuern" im
Endpunkt-Status-Fenster erneuert es sofort; andernfalls übernimmt das Claude
Code beim nächsten eigenen Start. Die Anzeige selbst funktioniert
währenddessen weiter.
**Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe
Abschnitt „Installation“ oben.
@@ -179,3 +200,12 @@ Abschnitt „Installation“ oben.
**Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt:
Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf
hin. Ihre Einstellungen und Kalibrierung bleiben davon unberührt.
**Ich bekomme eine Benachrichtigung über eine neue Version / ein
Pfeil-Symbol erscheint im Titelbalken.** Das ist eine andere Meldung als
oben: Die Anwendung hat rund eine Minute nach dem Start auf dem
Projekt-Server ein neueres Release gefunden. Ein Klick auf Benachrichtigung
oder Symbol öffnet die Release-Seite im Browser — heruntergeladen oder
installiert wird nichts automatisch. Das Symbol bleibt sichtbar, bis eine
neuere Version installiert wurde. Abschaltbar über die Einstellung „Nach
Updates suchen".
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stefan Winkler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22 -3
View File
@@ -140,9 +140,15 @@ Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
ohne Schätzung.
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die
protokolliert und nur an den konfigurierten Anthropic-Host gesendet. Läuft es
ab, pausiert diese Abfrage, bis ein neues Token vorliegt — entweder
automatisch durch Claude Code beim nächsten eigenen Start, oder sofort per
Klick auf „Zugriffstoken erneuern" im Endpunkt-Status-Fenster. Dieser
manuelle Refresh tauscht das Refresh-Token gegen ein neues Zugriffstoken bei
Anthropics OAuth-Endpunkt und schreibt das Ergebnis in dieselbe Datei zurück,
die auch Claude Code verwendet — die CLI profitiert automatisch mit. Auch
dabei wird kein Token protokolliert, nur Erfolg oder Fehlschlag als Text.
Nach drei Fehlversuchen der automatischen Abfrage schaltet sie sich für die
Sitzung ab.
Abschalten über `config.json`:
@@ -270,6 +276,19 @@ npm run dist
`npm version` legt standardmäßig auch einen Git-Commit und -Tag an; ohne das
genügt es, `version` in `package.json` von Hand zu ändern.
### Update-Prüfung beim Gitea-Server
Zusätzlich zur lokalen Erkennung oben prüft die Anwendung ca. 60 s nach dem
Start einmalig aktiv, ob auf dem selbst gehosteten Gitea-Server
(`git.conatum.net`, Repo `swinks/claude-live-dashboard`) ein neueres Release
vorliegt (`GET .../releases/latest`, öffentlich ohne Authentifizierung). Bei
einer neueren Version erscheint eine native Systembenachrichtigung, und ein
Symbol im Titelbalken (zwischen Endpunkt-Status und Zahnrad) bleibt
sichtbar, bis aktualisiert wird. Ein Klick auf Benachrichtigung oder Symbol
öffnet die Release-Seite im Standardbrowser. Über die Einstellung „Nach
Updates suchen" lässt sich dieser Netzwerkaufruf beim Start abschalten
(`config.json:updateCheck.enabled`, Standard `true`).
## Release bauen
Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht
+4
View File
@@ -26,5 +26,9 @@
"_oauth": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.",
"oauth": {
"enabled": true
},
"_updateCheck": "Prüft ca. 60 s nach dem Start einmalig beim Gitea-Server auf ein neueres Release. Standardmäßig aktiv; mit enabled:false abschalten.",
"updateCheck": {
"enabled": true
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-live-dashboard",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-live-dashboard",
"version": "1.0.0",
"version": "1.2.0",
"license": "MIT",
"devDependencies": {
"electron": "^43.2.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "claude-live-dashboard",
"productName": "Claude Live Dashboard",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux",
"main": "src/main/index.js",
+2
View File
@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('errorsBridge', {
clear: () => ipcRenderer.send('errors:clear'),
/** Schließt das Fenster. */
close: () => ipcRenderer.send('errors:close'),
/** Stößt den manuellen OAuth-Token-Refresh an. @returns {Promise<{ok: boolean, error?: string}>} */
refreshToken: () => ipcRenderer.invoke('oauth:refresh'),
});
+26
View File
@@ -10,6 +10,8 @@ const $ = (id) => document.getElementById(id);
const el = {
close: $('btn-close'),
clear: $('btn-clear'),
refresh: $('btn-refresh-token'),
feedback: $('feedback'),
statusLocal: $('status-local'),
statusOauth: $('status-oauth'),
logList: $('log-list'),
@@ -21,8 +23,22 @@ const SOURCE_LABEL = {
local: 'Lokal',
oauth: 'Server',
app: 'App',
refresh: 'Token',
update: 'Update',
};
let feedbackTimer = null;
/** Portiert aus settings/app.js say() für konsistentes Feedback-Verhalten. */
function say(text, kind = 'ok') {
el.feedback.hidden = false;
el.feedback.textContent = text;
el.feedback.dataset.kind = kind;
clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
el.feedback.hidden = true;
}, 4000);
}
function fmtTime(ts) {
const d = new Date(ts);
const now = new Date();
@@ -72,6 +88,16 @@ function render(s) {
el.close.addEventListener('click', () => window.errorsBridge.close());
el.clear.addEventListener('click', () => window.errorsBridge.clear());
el.refresh.addEventListener('click', async () => {
el.refresh.disabled = true;
say('Zugriffstoken wird erneuert …');
try {
const result = await window.errorsBridge.refreshToken();
say(result.ok ? 'Zugriffstoken erneuert.' : `Fehler: ${result.error}`, result.ok ? 'ok' : 'error');
} finally {
el.refresh.disabled = false;
}
});
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') window.errorsBridge.close();
});
+2
View File
@@ -33,7 +33,9 @@
<footer class="actions">
<button class="btn ghost" id="btn-clear">Log löschen</button>
<button class="btn ghost" id="btn-refresh-token">Zugriffstoken erneuern</button>
<span class="spacer"></span>
<span class="feedback" id="feedback" hidden></span>
<span class="count" id="log-count"></span>
</footer>
+20
View File
@@ -16,6 +16,7 @@
--good: #0ca30c;
--warning: #fab219;
--critical: #d03b3b;
--update: #9b6bd6;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@@ -224,6 +225,14 @@ dd[data-severity="critical"] .status-label {
color: var(--accent);
}
.log-source[data-source="refresh"] {
color: var(--good);
}
.log-source[data-source="update"] {
color: var(--update);
}
.log-message {
color: var(--text-secondary);
line-height: 1.4;
@@ -245,6 +254,17 @@ dd[data-severity="critical"] .status-label {
padding: 11px 16px;
border-top: 1px solid var(--hairline);
flex: 0 0 auto;
flex-wrap: wrap;
row-gap: 6px;
}
.feedback {
font-size: 11.5px;
color: var(--good);
}
.feedback[data-kind="error"] {
color: var(--critical);
}
.btn {
+59
View File
@@ -15,6 +15,7 @@ const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
const { UsageAggregator, MINUTE_MS } = require('./blocks');
const { LimitModel, weekAnchor, severity } = require('./limits');
const { OAuthUsage } = require('./oauth-usage');
const { OAuthRefresh } = require('./oauth-refresh');
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
const DEBOUNCE_MS = 300;
@@ -63,6 +64,10 @@ class Collector extends EventEmitter {
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
});
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
// Bewusst unabhängig von der Konfiguration und nicht in applyConfig()
// neu erzeugt — Cooldown/lastAttempt sollen Einstellungsänderungen
// überstehen, genau wie this.errors.
this.oauthRefresh = new OAuthRefresh(store);
this.subscription = { subscriptionType: null, rateLimitTier: null };
this.limits = null;
@@ -72,6 +77,11 @@ class Collector extends EventEmitter {
this.errors = Array.isArray(store.data.errors) ? store.data.errors : [];
this._localOk = true;
this._lastOauthError = null;
// Für das Titelleisten-Symbol: bleibt für die gesamte Sitzung gesetzt,
// sobald ein neueres Release gefunden wurde — bewusst nicht gedrosselt
// wie die Notification (store.data.updateCheck.notifiedTag), das Symbol
// soll sichtbar bleiben, bis aktualisiert wird.
this._updateAvailable = null;
if (configError) this._note(configError, 'local');
if (versionEvent) {
const msg =
@@ -184,6 +194,49 @@ class Collector extends EventEmitter {
this._recompute();
}
/**
* Manueller Token-Refresh — ausgelöst über den Knopf im
* Endpunkt-Status-Fenster. Schreibt bei Erfolg direkt in
* ~/.claude/.credentials.json (siehe oauth-refresh.js) und setzt danach die
* Serverabfrage zurück, damit sie das neue Token sofort nutzt.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async refreshOauthToken() {
try {
const { expiresAt } = await this.oauthRefresh.refresh();
const until = new Date(expiresAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
// Zeitstempel im Text macht jede Erfolgsmeldung eindeutig — _note()
// dedupliziert sonst nach exaktem Nachrichtentext, ein zweiter
// erfolgreicher Refresh würde sonst stumm verschluckt.
this._note(`Zugriffstoken manuell erneuert (gültig bis ${until})`, 'refresh');
this.oauth.resetAfterTokenRefresh();
await this._recompute();
return { ok: true };
} catch (err) {
this._note(`Token-Erneuerung fehlgeschlagen: ${err.message}`, 'refresh');
await this._recompute();
return { ok: false, error: err.message };
}
}
/**
* Hält ein gefundenes neueres Release fest — im Ereignisprotokoll (einmalig
* pro Tag, dedupliziert über _note()) und als Live-State fürs
* Titelleisten-Symbol (state.update, bleibt für die Sitzung gesetzt).
* Ausgelöst einmalig ca. 60 s nach dem Start (siehe index.js/update-check.js).
*/
noteUpdateAvailable(tagName, htmlUrl) {
this._updateAvailable = { tagName, htmlUrl };
this._note(`Neue Version verfügbar: ${tagName} (${htmlUrl})`, 'update');
this._recompute().catch(() => {});
}
/** Hält einen gescheiterten Update-Check fest — stört den Betrieb nie, nur Protokoll. */
noteUpdateCheckError(message) {
this._note(`Update-Prüfung fehlgeschlagen: ${message}`, 'update');
this._recompute().catch(() => {});
}
async _poll() {
if (this._polling) {
this._pollAgain = true;
@@ -381,6 +434,12 @@ class Collector extends EventEmitter {
local: { ok: this._localOk },
oauth: { ok: !oauthError, text: this.oauth.status().text },
},
// Titelleisten-Symbol: bleibt sichtbar, bis die Sitzung endet oder
// aktualisiert wird — bewusst kein „gesehen"-Zustand wie bei der
// Notification (store.data.updateCheck.notifiedTag).
update: this._updateAvailable
? { available: true, tagName: this._updateAvailable.tagName, htmlUrl: this._updateAvailable.htmlUrl }
: { available: false, tagName: null, htmlUrl: null },
errors: this.errors.slice(),
stats: { lastPollMs: this._lastPollMs },
};
+52
View File
@@ -13,6 +13,7 @@ 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 { checkForUpdate } = require('./update-check');
const IS_LINUX = process.platform === 'linux';
@@ -29,6 +30,10 @@ const SETTINGS_HEIGHT = 660;
const ERRORS_WIDTH = 420;
const ERRORS_HEIGHT = 480;
/** Wartezeit vor der einmaligen Update-Prüfung — verzögert, damit sie den
* Start nicht verlangsamt und nicht mit dem ersten Poll konkurriert. */
const UPDATE_CHECK_DELAY_MS = 60 * 1000;
let win = null;
let settingsWin = null;
let errorsWin = null;
@@ -406,6 +411,14 @@ function registerIpc() {
ipcMain.on('open-error-log', openErrorLog);
ipcMain.on('errors:clear', () => collector && collector.clearErrors());
ipcMain.on('errors:close', () => errorsWin && errorsWin.close());
ipcMain.handle('oauth:refresh', () => {
if (!collector) return { ok: false, error: 'Anwendung startet noch' };
return collector.refreshOauthToken();
});
ipcMain.on('open-release-page', () => {
const info = collector?.state?.update;
if (info?.available && info.htmlUrl) shell.openExternal(info.htmlUrl);
});
ipcMain.handle('settings:load', () => ({
config,
@@ -505,6 +518,45 @@ async function main() {
} catch (err) {
console.error('[collector] Start fehlgeschlagen:', err);
}
if (config.updateCheck?.enabled !== false) {
const updateTimer = setTimeout(() => {
checkForUpdate(app.getVersion())
.then((result) => {
console.log(result ? `[update-check] neue Version: ${result.tagName}` : '[update-check] keine neuere Version verfügbar');
if (!result || !collector) return;
// Titelleisten-Symbol: sofort sichtbar, unabhängig von der
// Notification-Drossel unten.
collector.noteUpdateAvailable(result.tagName, result.htmlUrl);
// Notification nur einmal pro neu erschienenem Tag — sonst würde sie
// bei jedem Start erneut aufpoppen, solange der Nutzer nicht
// aktualisiert (die App läuft typischerweise per Autostart täglich).
const alreadyNotified = store.data.updateCheck?.notifiedTag === result.tagName;
store.data.updateCheck = { notifiedTag: result.tagName };
store.save();
if (alreadyNotified || !Notification.isSupported()) return;
const notification = new Notification({
title: 'Neue Version verfügbar',
body: `${result.tagName} steht bereit. Klicken, um die Release-Seite zu öffnen.`,
icon: path.join(ROOT, 'assets', 'icon.png'),
});
notification.on('click', () => shell.openExternal(result.htmlUrl));
notification.show();
})
.catch((err) => {
console.warn('[update-check] fehlgeschlagen:', err.message);
if (collector) collector.noteUpdateCheckError(err.message);
});
}, UPDATE_CHECK_DELAY_MS);
// Darf den App-Exit nie blockieren — anders als collector._timers wird
// dieser einmalige Main-Prozess-Timer nicht von collector.stop() erfasst.
if (updateTimer.unref) updateTimer.unref();
} else {
console.log('[update-check] übersprungen (in den Einstellungen deaktiviert)');
}
}
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
+162
View File
@@ -0,0 +1,162 @@
'use strict';
/**
* Manueller OAuth-Token-Refresh, ausgelöst über den Knopf im
* Endpunkt-Status-Fenster (src/errors/).
*
* Anders als oauth-usage.js (reine Leseabfrage) schreibt dieses Modul in
* ~/.claude/.credentials.json zurück — dieselbe Datei, die auch die
* Claude-Code-CLI verwendet. Ein neues Token landet dort, sodass Claude Code
* automatisch mitprofitiert, ohne eigenen Neustart.
*
* Auslöser ist ausschließlich ein bewusster Klick — kein automatischer,
* stiller Refresh im Hintergrund.
*
* WICHTIG — nicht verifizierte Annahmen: Endpunkt, Client-ID und der exakte
* Feldname des Refresh-Tokens in der Credentials-Datei stammen aus
* allgemeinem, öffentlich bekanntem Wissen über den OAuth-Client der
* Claude-Code-CLI, nicht aus einer im Projekt verifizierten Quelle. Bricht
* Anthropic das Schema, meldet dieses Modul einen klaren Fehler statt
* stillschweigend falsche Daten zu schreiben.
*
* Umgang mit dem Token: nie geloggt (weder das alte noch das neue), nur
* Erfolg/Fehlschlag als Text im Ereignisprotokoll.
*/
const fsp = require('node:fs/promises');
const { CREDENTIALS_FILE } = require('./jsonl');
/** Vermuteter Endpunkt — nicht dokumentiert, siehe Kopfkommentar. */
const TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
/** Vermutete öffentliche Client-ID der Claude-Code-CLI. */
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REQUEST_TIMEOUT_MS = 10000;
/** Sperrt Mehrfachklicks — ein manueller Refresh braucht keine höhere Frequenz. */
const MIN_INTERVAL_MS = 30 * 1000;
class OAuthRefresh {
/** @param {import('./store').Store} store */
constructor(store = null) {
this.store = store;
const persisted = (store && store.data && store.data.oauthRefresh) || {};
this.lastAttempt = persisted.lastAttempt || 0;
this.inFlight = false;
}
_persist() {
if (!this.store) return;
this.store.data.oauthRefresh = { lastAttempt: this.lastAttempt };
this.store.save();
}
/**
* Tauscht das Refresh-Token gegen ein neues Zugriffstoken und schreibt das
* Ergebnis zurück in .credentials.json. Wirft bei jedem Fehler eine
* verständliche deutsche Fehlermeldung — nie Header, nie Token.
* @returns {Promise<{expiresAt: number}>}
*/
async refresh(now = Date.now()) {
if (this.inFlight) throw new Error('Erneuerung läuft bereits');
if (now - this.lastAttempt < MIN_INTERVAL_MS) {
const waitS = Math.ceil((MIN_INTERVAL_MS - (now - this.lastAttempt)) / 1000);
throw new Error(`Bitte kurz warten (${waitS}s) — der letzte Versuch war gerade erst`);
}
this.inFlight = true;
this.lastAttempt = now;
this._persist();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const { raw, oauth, stat } = await this._readCredentials();
// Feldname nicht verifiziert — beide plausiblen Schreibweisen probieren.
const refreshToken = oauth.refreshToken || oauth.refresh_token;
if (!refreshToken) {
throw new Error('Kein Refresh-Token gefunden — bitte „claude login" erneut ausführen');
}
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!res.ok) {
// Body nie ungefiltert loggen/anzeigen — könnte token-ähnliche Daten enthalten.
if (res.status === 400 || res.status === 401) {
throw new Error('Anmeldung abgelehnt (invalid_grant) — bitte „claude login" erneut ausführen');
}
throw new Error(`Token-Endpunkt: HTTP ${res.status}`);
}
const json = await res.json();
const accessToken = json.access_token || json.accessToken;
const newRefreshToken = json.refresh_token || json.refreshToken || refreshToken;
const expiresAt =
json.expires_at ||
json.expiresAt ||
(Number.isFinite(json.expires_in) ? now + json.expires_in * 1000 : null);
if (!accessToken || !expiresAt) {
throw new Error('Unerwartete Antwort vom Token-Endpunkt — Schema weicht ab');
}
await this._writeCredentials(raw, stat, { accessToken, refreshToken: newRefreshToken, expiresAt });
return { expiresAt };
} catch (err) {
if (err.name === 'AbortError') throw new Error('Zeitüberschreitung beim Token-Endpunkt');
throw err;
} finally {
clearTimeout(timer);
this.inFlight = false;
}
}
async _readCredentials() {
let raw;
try {
raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') throw new Error('Anmeldedatei nicht gefunden — bitte „claude login" ausführen');
throw new Error(`Anmeldedatei nicht lesbar: ${err.message}`);
}
const stat = await fsp.stat(CREDENTIALS_FILE);
let data;
try {
data = JSON.parse(raw);
} catch {
throw new Error('.credentials.json ist beschädigt (kein gültiges JSON)');
}
return { raw, oauth: data.claudeAiOauth || {}, stat };
}
/**
* Schreibt nur accessToken/refreshToken/expiresAt innerhalb von
* claudeAiOauth — alle anderen, auch unbekannte Felder bleiben unangetastet.
* Atomar über temp file + rename, damit Claude Code nie einen halb
* geschriebenen Zustand sieht.
*/
async _writeCredentials(raw, stat, patch) {
const data = JSON.parse(raw);
data.claudeAiOauth = { ...(data.claudeAiOauth || {}), ...patch };
const tmp = `${CREDENTIALS_FILE}.tmp`;
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
try {
// Rechte der Originaldatei übernehmen (unter Linux typischerweise 600) —
// sonst fällt die neue Datei auf die Umask-Vorgabe zurück.
await fsp.chmod(tmp, stat.mode);
} catch {
// Unter Windows meist wirkungslos, kein Abbruchgrund.
}
await fsp.rename(tmp, CREDENTIALS_FILE);
}
}
module.exports = { OAuthRefresh, TOKEN_ENDPOINT, CLIENT_ID, MIN_INTERVAL_MS };
+23 -3
View File
@@ -20,8 +20,13 @@
*
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
* gesendet. Diese Stufe erneuert das Token nicht selbst — bei Ablauf
* pausiert sie, bis ein neues vorliegt. Ein manueller Refresh ist möglich:
* über den Knopf "Zugriffstoken erneuern" im Endpunkt-Status-Fenster
* (siehe oauth-refresh.js), ausschließlich auf Klick, nie automatisch im
* Hintergrund. Nach einem erfolgreichen manuellen Refresh ruft der Collector
* resetAfterTokenRefresh() auf, damit diese Stufe sofort neu abfragt statt
* einen laufenden Cooldown abzuwarten.
*/
const fsp = require('node:fs/promises');
@@ -96,13 +101,28 @@ class OAuthUsage {
}
}
/**
* Setzt Fehlerzähler und Pause zurück, nachdem oauth-refresh.js extern ein
* neues Zugriffstoken geschrieben hat — die nächste Abfrage soll das sofort
* nutzen, statt einen laufenden Cooldown/MIN_INTERVAL_MS abzuwarten.
*/
resetAfterTokenRefresh() {
this.failures = 0;
this.pausedUntil = 0;
this.lastFetch = 0;
this.lastError = null;
this._persist();
}
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
async _token() {
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
const oauth = JSON.parse(raw).claudeAiOauth;
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
throw new Error('Zugriffstoken abgelaufen — Claude Code erneuert es beim nächsten Start');
throw new Error(
'Zugriffstoken abgelaufen — im Endpunkt-Status-Fenster erneuern oder Claude Code neu starten',
);
}
return oauth.accessToken;
}
+7
View File
@@ -23,11 +23,16 @@ const DEFAULT_STATE = {
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
window: { x: null, y: null, expanded: false },
oauth: { lastFetch: 0, pausedUntil: 0 },
// Sperrt Mehrfachklicks auf "Zugriffstoken erneuern" über Neustarts hinweg.
oauthRefresh: { lastAttempt: 0 },
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
errors: [],
// Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung.
appVersion: null,
// Zuletzt per Notification gemeldeter Release-Tag — verhindert, dass
// dieselbe neue Version bei jedem Start erneut als Popup erscheint.
updateCheck: { notifiedTag: null },
};
class Store {
@@ -51,6 +56,8 @@ class Store {
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
oauthRefresh: { ...DEFAULT_STATE.oauthRefresh, ...(parsed.oauthRefresh || {}) },
updateCheck: { ...DEFAULT_STATE.updateCheck, ...(parsed.updateCheck || {}) },
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
};
} catch (err) {
+71
View File
@@ -0,0 +1,71 @@
'use strict';
/**
* Einmalige Update-Prüfung, ca. 60 s nach dem Start ausgelöst (siehe
* index.js). Fragt das öffentliche Gitea-API des eigenen Repos nach dem
* neuesten Release ab — kein Zugriffstoken nötig, der Endpunkt ist ohne
* Authentifizierung erreichbar (verifiziert per curl).
*
* Bewusst zustandslos und ohne Wiederholungslogik: Ein Check pro Sitzung
* genügt der Anforderung „einmalig prüfen". Persistenz (welches Release
* schon einmal per Notification gemeldet wurde) lebt bewusst außerhalb
* dieses Moduls, in index.js/store.js.
*/
const RELEASES_URL =
'https://git.conatum.net/api/v1/repos/swinks/claude-live-dashboard/releases/latest';
const ALLOWED_HOST = 'git.conatum.net';
const REQUEST_TIMEOUT_MS = 10000;
/**
* Vergleicht zwei MAJOR.MINOR.PATCH-Versionen ohne Suffixe.
* @returns {number} negativ wenn a<b, 0 wenn gleich, positiv wenn a>b
*/
function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
/**
* Fragt das neueste Release ab und vergleicht dessen Tag gegen currentVersion.
* Drafts und Pre-Releases werden ignoriert.
* @param {string} currentVersion z. B. app.getVersion() (ohne führendes "v")
* @returns {Promise<{tagName: string, htmlUrl: string} | null>}
*/
async function checkForUpdate(currentVersion) {
const url = new URL(RELEASES_URL);
if (url.protocol !== 'https:' || url.hostname !== ALLOWED_HOST) {
throw new Error('Gitea-Endpunkt zeigt nicht auf den erwarteten Host');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(RELEASES_URL, {
method: 'GET',
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.draft || json.prerelease) return null;
const remoteVersion = String(json.tag_name || '').replace(/^v/, '').trim();
if (!remoteVersion) return null;
if (compareVersions(remoteVersion, currentVersion) <= 0) return null;
return { tagName: String(json.tag_name), htmlUrl: json.html_url };
} catch (err) {
throw new Error(err.name === 'AbortError' ? 'Zeitüberschreitung' : err.message);
} finally {
clearTimeout(timer);
}
}
module.exports = { checkForUpdate, compareVersions, RELEASES_URL };
+2
View File
@@ -24,4 +24,6 @@ contextBridge.exposeInMainWorld('dashboard', {
openSettings: () => ipcRenderer.send('open-settings'),
/** Öffnet das Fehler-/Ereignis-Log. */
openErrorLog: () => ipcRenderer.send('open-error-log'),
/** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */
openReleasePage: () => ipcRenderer.send('open-release-page'),
});
+12
View File
@@ -47,6 +47,7 @@ const el = {
planLabel: $('plan-label'),
staleBadge: $('stale-badge'),
btnEndpointStatus: $('btn-endpoint-status'),
btnUpdateAvailable: $('btn-update-available'),
btnHide: $('btn-hide'),
btnSettings: $('btn-settings'),
btnExpand: $('btn-expand'),
@@ -284,6 +285,15 @@ function renderEndpointStatus(s) {
`Serverabfrage: ${ep.oauth.text || '—'}`;
}
/** Titelleisten-Symbol für ein gefundenes neues Release. */
function renderUpdateAvailable(s) {
const update = s.update || { available: false };
el.btnUpdateAvailable.hidden = !update.available;
if (update.available) {
el.btnUpdateAvailable.title = `Version ${update.tagName} verfügbar — Release-Seite öffnen`;
}
}
function renderWeek(week, burn) {
renderMeter('week', week);
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
@@ -397,6 +407,7 @@ function render(s) {
renderWeek(s.week, s.burn);
renderSpend(s.spend);
renderEndpointStatus(s);
renderUpdateAvailable(s);
if (expanded) {
renderModels(s.block);
@@ -462,6 +473,7 @@ el.btnExpand.addEventListener('click', () => applyExpanded(!expanded));
el.btnHide.addEventListener('click', () => window.dashboard.hide());
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage());
window.dashboard.onState(render);
+1
View File
@@ -17,6 +17,7 @@
<button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status">
<span class="status-icon" aria-hidden="true"></span>
</button>
<button class="icon-btn" id="btn-update-available" hidden title="Neues Release verfügbar" aria-label="Neues Release verfügbar"></button>
<button class="icon-btn" id="btn-settings" title="Einstellungen" aria-label="Einstellungen">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
<path
+5
View File
@@ -28,6 +28,7 @@
--status-good: #0ca30c;
--status-warning: #fab219;
--status-critical: #d03b3b;
--status-update: #9b6bd6;
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
--series-1: #3987e5;
@@ -152,6 +153,10 @@ body {
color: var(--text-primary);
}
#btn-update-available {
color: var(--status-update);
}
/* ── Meter ───────────────────────────────────────────────────────────── */
.meters {
+5
View File
@@ -21,6 +21,7 @@ const el = {
clickThrough: $('clickThrough'),
autostart: $('autostart'),
autostartLabel: $('autostart-label'),
updateCheckEnabled: $('updateCheckEnabled'),
oauthEnabled: $('oauthEnabled'),
oauthEndpoint: $('oauthEndpoint'),
@@ -84,6 +85,9 @@ function fill(config, meta) {
el.autostartLabel.textContent =
meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten';
const updateCheck = config.updateCheck || {};
el.updateCheckEnabled.checked = updateCheck.enabled !== false;
const oauth = config.oauth || {};
el.oauthEnabled.checked = oauth.enabled !== false;
el.oauthEndpoint.value = oauth.endpoint || '';
@@ -137,6 +141,7 @@ function collect() {
patch.opacity = Number(el.opacity.value) / 100;
patch.clickThrough = el.clickThrough.checked;
patch.updateCheck = { enabled: el.updateCheckEnabled.checked };
patch.oauth = { enabled: el.oauthEnabled.checked };
const endpoint = el.oauthEndpoint.value.trim();
+11
View File
@@ -42,6 +42,17 @@
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
</div>
</div>
<div class="row">
<label for="updateCheckEnabled">Nach Updates suchen</label>
<div class="control">
<input type="checkbox" id="updateCheckEnabled" />
<span class="hint">
Prüft rund eine Minute nach dem Start einmalig auf dem
Projekt-Server, ob eine neuere Version vorliegt.
</span>
</div>
</div>
</section>
<section class="group">