Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011e5b6816 | ||
|
|
a01f4266ea | ||
|
|
39f19ab261 | ||
|
|
489b15860f | ||
|
|
bb311e76c2 | ||
|
|
e413a36506 | ||
|
|
628a269a14 |
+34
-4
@@ -74,6 +74,7 @@ Position wird gemerkt.
|
|||||||
| Element | Bedeutung |
|
| Element | Bedeutung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten |
|
| Statuspunkt | Zeigt den Zustand der Datenquellen, siehe unten |
|
||||||
|
| Pfeil-Symbol (nur bei neuem Release) | Öffnet die Release-Seite im Browser |
|
||||||
| Zahnrad | Öffnet die Einstellungen |
|
| Zahnrad | Öffnet die Einstellungen |
|
||||||
| `–` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) |
|
| `–` | Blendet das Fenster aus (Rückkehr über das Tray-Symbol) |
|
||||||
|
|
||||||
@@ -121,12 +122,19 @@ Das Zahnrad öffnet den Einstellungsdialog:
|
|||||||
der Anmeldung.
|
der Anmeldung.
|
||||||
- **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal
|
- **Serverabfrage** — kann abgeschaltet werden, falls die Anzeige rein lokal
|
||||||
arbeiten soll (dann sind alle Werte Näherungen statt exakter Zahlen).
|
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
|
- **Bezugsgrößen, Gewichtung, Wochenfenster** — fortgeschrittene Feineinstellungen
|
||||||
für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht;
|
für den Fall, dass die Serverabfrage dauerhaft nicht zur Verfügung steht;
|
||||||
im Normalbetrieb müssen Sie hier nichts anpassen.
|
im Normalbetrieb müssen Sie hier nichts anpassen.
|
||||||
|
|
||||||
Änderungen wirken sofort, ein Neustart ist nicht nötig. `Strg`+`S` speichert,
|
Änderungen wirken sofort, ein Neustart ist nicht nötig (Ausnahme: „Nach
|
||||||
`Esc` schließt den Dialog ohne zu speichern.
|
Updates suchen", siehe oben). `Strg`+`S` speichert, `Esc` schließt den
|
||||||
|
Dialog ohne zu speichern.
|
||||||
|
|
||||||
## Tray-Menü
|
## Tray-Menü
|
||||||
|
|
||||||
@@ -147,6 +155,17 @@ und Beenden.
|
|||||||
- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten;
|
- Diese Serverabfrage lässt sich in den Einstellungen jederzeit abschalten;
|
||||||
die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten
|
die Anwendung funktioniert dann weiter, nur mit geschätzten statt exakten
|
||||||
Werten.
|
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.
|
- Es findet keine Übertragung an Dritte statt.
|
||||||
|
|
||||||
## Deinstallation
|
## 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
|
**Der Statuspunkt ist gelb.** Nur die optionale Serverabfrage ist gerade
|
||||||
gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes
|
gestört, meist vorübergehend (z. B. Netzwerkproblem oder ein abgelaufenes
|
||||||
Zugriffstoken, das Claude Code beim nächsten eigenen Start erneuert). Die
|
Zugriffstoken). Ein Klick auf „Zugriffstoken erneuern" im
|
||||||
Anzeige selbst funktioniert währenddessen weiter.
|
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
|
**Windows warnt beim ersten Start.** Normal bei unsignierter Software, siehe
|
||||||
Abschnitt „Installation“ oben.
|
Abschnitt „Installation“ oben.
|
||||||
@@ -179,3 +200,12 @@ Abschnitt „Installation“ oben.
|
|||||||
**Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt:
|
**Nach einem Update sehe ich eine Benachrichtigung.** Das ist beabsichtigt:
|
||||||
Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf
|
Die Anwendung erkennt neue Versionen automatisch und weist einmalig darauf
|
||||||
hin. Ihre Einstellungen und Kalibrierung bleiben davon unberührt.
|
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
@@ -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.
|
||||||
@@ -140,9 +140,15 @@ Prozentwerte, Schweregrade und Reset-Zeitpunkte **exakt** — sekundengenau und
|
|||||||
ohne Schätzung.
|
ohne Schätzung.
|
||||||
|
|
||||||
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
|
Das Zugriffstoken wird aus `~/.claude/.credentials.json` gelesen, nie
|
||||||
gespeichert, nie protokolliert und nur an diesen Host gesendet. Es gibt bewusst
|
protokolliert und nur an den konfigurierten Anthropic-Host gesendet. Läuft es
|
||||||
keinen eigenen Refresh-Flow: Läuft das Token ab, pausiert die Abfrage, bis
|
ab, pausiert diese Abfrage, bis ein neues Token vorliegt — entweder
|
||||||
Claude Code es erneuert hat. Nach drei Fehlversuchen schaltet sie sich für die
|
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.
|
Sitzung ab.
|
||||||
|
|
||||||
Abschalten über `config.json`:
|
Abschalten über `config.json`:
|
||||||
@@ -169,6 +175,10 @@ Fenster bekannt — daraus folgt die Bezugsgröße unmittelbar. Auch das
|
|||||||
Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht
|
Wochenfenster wird aus dem gemeldeten Reset-Zeitpunkt abgeleitet, nicht
|
||||||
geraten.
|
geraten.
|
||||||
|
|
||||||
|
Der erste automatische Kalibrierungsschritt einer Sitzung erscheint als
|
||||||
|
Eintrag im Ereignisprotokoll ("Automatisch kalibriert …") — spätere
|
||||||
|
Aktualisierungen laufen bewusst lautlos im Hintergrund weiter.
|
||||||
|
|
||||||
Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht**
|
Die folgenden Abschnitte betreffen nur den Fall, dass die Abfrage **nicht**
|
||||||
verfügbar ist.
|
verfügbar ist.
|
||||||
|
|
||||||
@@ -270,6 +280,19 @@ npm run dist
|
|||||||
`npm version` legt standardmäßig auch einen Git-Commit und -Tag an; ohne das
|
`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.
|
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
|
## Release bauen
|
||||||
|
|
||||||
Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht
|
Gebaut wird jeweils auf dem Zielsystem — electron-builder kann `.deb` nicht
|
||||||
|
|||||||
@@ -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": "Serverabfrage für exakte Werte. Standardmäßig aktiv; mit enabled:false abschalten. endpoint nur setzen, um den Standard zu überschreiben.",
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"enabled": true
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-live-dashboard",
|
"name": "claude-live-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-live-dashboard",
|
"name": "claude-live-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^43.2.0",
|
"electron": "^43.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-live-dashboard",
|
"name": "claude-live-dashboard",
|
||||||
"productName": "Claude Live Dashboard",
|
"productName": "Claude Live Dashboard",
|
||||||
"version": "1.1.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux",
|
"description": "Always-on-Top Mini-Dashboard für Claude-Plan-Auslastung unter Windows und Linux",
|
||||||
"main": "src/main/index.js",
|
"main": "src/main/index.js",
|
||||||
|
|||||||
@@ -15,4 +15,6 @@ contextBridge.exposeInMainWorld('errorsBridge', {
|
|||||||
clear: () => ipcRenderer.send('errors:clear'),
|
clear: () => ipcRenderer.send('errors:clear'),
|
||||||
/** Schließt das Fenster. */
|
/** Schließt das Fenster. */
|
||||||
close: () => ipcRenderer.send('errors:close'),
|
close: () => ipcRenderer.send('errors:close'),
|
||||||
|
/** Stößt den manuellen OAuth-Token-Refresh an. @returns {Promise<{ok: boolean, error?: string}>} */
|
||||||
|
refreshToken: () => ipcRenderer.invoke('oauth:refresh'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const $ = (id) => document.getElementById(id);
|
|||||||
const el = {
|
const el = {
|
||||||
close: $('btn-close'),
|
close: $('btn-close'),
|
||||||
clear: $('btn-clear'),
|
clear: $('btn-clear'),
|
||||||
|
refresh: $('btn-refresh-token'),
|
||||||
|
feedback: $('feedback'),
|
||||||
statusLocal: $('status-local'),
|
statusLocal: $('status-local'),
|
||||||
statusOauth: $('status-oauth'),
|
statusOauth: $('status-oauth'),
|
||||||
logList: $('log-list'),
|
logList: $('log-list'),
|
||||||
@@ -21,8 +23,22 @@ const SOURCE_LABEL = {
|
|||||||
local: 'Lokal',
|
local: 'Lokal',
|
||||||
oauth: 'Server',
|
oauth: 'Server',
|
||||||
app: 'App',
|
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) {
|
function fmtTime(ts) {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -72,6 +88,16 @@ function render(s) {
|
|||||||
|
|
||||||
el.close.addEventListener('click', () => window.errorsBridge.close());
|
el.close.addEventListener('click', () => window.errorsBridge.close());
|
||||||
el.clear.addEventListener('click', () => window.errorsBridge.clear());
|
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) => {
|
document.addEventListener('keydown', (ev) => {
|
||||||
if (ev.key === 'Escape') window.errorsBridge.close();
|
if (ev.key === 'Escape') window.errorsBridge.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
|
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
<button class="btn ghost" id="btn-clear">Log löschen</button>
|
<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="spacer"></span>
|
||||||
|
<span class="feedback" id="feedback" hidden></span>
|
||||||
<span class="count" id="log-count"></span>
|
<span class="count" id="log-count"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--good: #0ca30c;
|
--good: #0ca30c;
|
||||||
--warning: #fab219;
|
--warning: #fab219;
|
||||||
--critical: #d03b3b;
|
--critical: #d03b3b;
|
||||||
|
--update: #9b6bd6;
|
||||||
|
|
||||||
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
@@ -224,6 +225,14 @@ dd[data-severity="critical"] .status-label {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-source[data-source="refresh"] {
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-source[data-source="update"] {
|
||||||
|
color: var(--update);
|
||||||
|
}
|
||||||
|
|
||||||
.log-message {
|
.log-message {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -245,6 +254,17 @@ dd[data-severity="critical"] .status-label {
|
|||||||
padding: 11px 16px;
|
padding: 11px 16px;
|
||||||
border-top: 1px solid var(--hairline);
|
border-top: 1px solid var(--hairline);
|
||||||
flex: 0 0 auto;
|
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 {
|
.btn {
|
||||||
|
|||||||
+76
-3
@@ -15,6 +15,7 @@ const { TranscriptReader, readSubscription, PROJECTS_DIR } = require('./jsonl');
|
|||||||
const { UsageAggregator, MINUTE_MS } = require('./blocks');
|
const { UsageAggregator, MINUTE_MS } = require('./blocks');
|
||||||
const { LimitModel, weekAnchor, severity } = require('./limits');
|
const { LimitModel, weekAnchor, severity } = require('./limits');
|
||||||
const { OAuthUsage } = require('./oauth-usage');
|
const { OAuthUsage } = require('./oauth-usage');
|
||||||
|
const { OAuthRefresh } = require('./oauth-refresh');
|
||||||
|
|
||||||
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
|
/** Dateiereignisse werden gebündelt, damit ein Schwall nicht viele Polls auslöst. */
|
||||||
const DEBOUNCE_MS = 300;
|
const DEBOUNCE_MS = 300;
|
||||||
@@ -63,6 +64,10 @@ class Collector extends EventEmitter {
|
|||||||
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
|
sessionMs: this.config.sessionHours ? this.config.sessionHours * 60 * MINUTE_MS : undefined,
|
||||||
});
|
});
|
||||||
this.oauth = new OAuthUsage(this.config.oauth || {}, store);
|
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.subscription = { subscriptionType: null, rateLimitTier: null };
|
||||||
this.limits = null;
|
this.limits = null;
|
||||||
@@ -72,6 +77,15 @@ class Collector extends EventEmitter {
|
|||||||
this.errors = Array.isArray(store.data.errors) ? store.data.errors : [];
|
this.errors = Array.isArray(store.data.errors) ? store.data.errors : [];
|
||||||
this._localOk = true;
|
this._localOk = true;
|
||||||
this._lastOauthError = null;
|
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;
|
||||||
|
// Merkt sich pro Woche/Block, ob in dieser Sitzung bereits eine
|
||||||
|
// Kalibrierung aus einer Serverantwort übernommen wurde — für eine
|
||||||
|
// einmalige Rückmeldung im Ereignisprotokoll, siehe _rememberServerLimit().
|
||||||
|
this._calibratedOnce = { week: false, block: false };
|
||||||
if (configError) this._note(configError, 'local');
|
if (configError) this._note(configError, 'local');
|
||||||
if (versionEvent) {
|
if (versionEvent) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -184,6 +198,49 @@ class Collector extends EventEmitter {
|
|||||||
this._recompute();
|
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() {
|
async _poll() {
|
||||||
if (this._polling) {
|
if (this._polling) {
|
||||||
this._pollAgain = true;
|
this._pollAgain = true;
|
||||||
@@ -230,6 +287,12 @@ class Collector extends EventEmitter {
|
|||||||
if (!previous || Math.abs(next - previous) / previous > 0.005) {
|
if (!previous || Math.abs(next - previous) / previous > 0.005) {
|
||||||
store.serverLimits[kind] = Math.round(next);
|
store.serverLimits[kind] = Math.round(next);
|
||||||
this.store.save();
|
this.store.save();
|
||||||
|
|
||||||
|
if (!this._calibratedOnce[kind]) {
|
||||||
|
this._calibratedOnce[kind] = true;
|
||||||
|
const label = kind === 'week' ? 'Woche' : 'Zeitfenster';
|
||||||
|
this._note(`Automatisch kalibriert (${label}) aus Serverantwort`, 'oauth');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,13 +330,17 @@ class Collector extends EventEmitter {
|
|||||||
const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit);
|
const block = this.limits.evaluate('block', snapshot.block.weighted, server?.blockLimit);
|
||||||
const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit);
|
const week = this.limits.evaluate('week', snapshot.week.weighted, server?.weekLimit);
|
||||||
|
|
||||||
// Ein exakter Prozentwert vom Server sticht die lokale Rechnung.
|
// Ein exakter Prozentwert vom Server sticht die lokale Rechnung. Der
|
||||||
|
// Endpunkt liefert bereits einen echten Prozentwert (0–100), nicht einen
|
||||||
|
// Bruch — eine frühere "<=1 → *100"-Heuristik verfälschte z. B. 1 % zu
|
||||||
|
// 100 %, siehe _rememberServerLimit() unten, die weekPercent schon immer
|
||||||
|
// konsistent als 0–100-Wert behandelt.
|
||||||
if (server?.blockPercent != null) {
|
if (server?.blockPercent != null) {
|
||||||
block.percent = server.blockPercent <= 1 ? server.blockPercent * 100 : server.blockPercent;
|
block.percent = server.blockPercent;
|
||||||
block.confidence = 'exact';
|
block.confidence = 'exact';
|
||||||
}
|
}
|
||||||
if (server?.weekPercent != null) {
|
if (server?.weekPercent != null) {
|
||||||
week.percent = server.weekPercent <= 1 ? server.weekPercent * 100 : server.weekPercent;
|
week.percent = server.weekPercent;
|
||||||
week.confidence = 'exact';
|
week.confidence = 'exact';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +448,12 @@ class Collector extends EventEmitter {
|
|||||||
local: { ok: this._localOk },
|
local: { ok: this._localOk },
|
||||||
oauth: { ok: !oauthError, text: this.oauth.status().text },
|
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(),
|
errors: this.errors.slice(),
|
||||||
stats: { lastPollMs: this._lastPollMs },
|
stats: { lastPollMs: this._lastPollMs },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { Store, loadConfig } = require('./store');
|
|||||||
const { Collector } = require('./collector');
|
const { Collector } = require('./collector');
|
||||||
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
|
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
|
||||||
const { getAutostart, setAutostart: applyAutostart } = require('./autostart');
|
const { getAutostart, setAutostart: applyAutostart } = require('./autostart');
|
||||||
|
const { checkForUpdate } = require('./update-check');
|
||||||
|
|
||||||
const IS_LINUX = process.platform === 'linux';
|
const IS_LINUX = process.platform === 'linux';
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ const SETTINGS_HEIGHT = 660;
|
|||||||
const ERRORS_WIDTH = 420;
|
const ERRORS_WIDTH = 420;
|
||||||
const ERRORS_HEIGHT = 480;
|
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 win = null;
|
||||||
let settingsWin = null;
|
let settingsWin = null;
|
||||||
let errorsWin = null;
|
let errorsWin = null;
|
||||||
@@ -406,6 +411,14 @@ function registerIpc() {
|
|||||||
ipcMain.on('open-error-log', openErrorLog);
|
ipcMain.on('open-error-log', openErrorLog);
|
||||||
ipcMain.on('errors:clear', () => collector && collector.clearErrors());
|
ipcMain.on('errors:clear', () => collector && collector.clearErrors());
|
||||||
ipcMain.on('errors:close', () => errorsWin && errorsWin.close());
|
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', () => ({
|
ipcMain.handle('settings:load', () => ({
|
||||||
config,
|
config,
|
||||||
@@ -505,6 +518,45 @@ async function main() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[collector] Start fehlgeschlagen:', 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.
|
// Das Widget lebt im Tray weiter, auch wenn kein Fenster offen ist.
|
||||||
|
|||||||
@@ -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
@@ -20,8 +20,13 @@
|
|||||||
*
|
*
|
||||||
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
|
* Umgang mit dem Token: Es wird ausschließlich gelesen, nie geloggt, nie
|
||||||
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
|
* gespeichert und nie irgendwohin außer an den konfigurierten Anthropic-Host
|
||||||
* gesendet. Ein eigener Refresh-Flow existiert bewusst nicht — Claude Code
|
* gesendet. Diese Stufe erneuert das Token nicht selbst — bei Ablauf
|
||||||
* erneuert das Token selbst; bei Ablauf pausiert diese Stufe.
|
* 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');
|
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. */
|
/** Liest das aktuelle Zugriffstoken, sofern es noch gültig ist. */
|
||||||
async _token() {
|
async _token() {
|
||||||
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
const raw = await fsp.readFile(CREDENTIALS_FILE, 'utf8');
|
||||||
const oauth = JSON.parse(raw).claudeAiOauth;
|
const oauth = JSON.parse(raw).claudeAiOauth;
|
||||||
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
|
if (!oauth || !oauth.accessToken) throw new Error('kein Zugriffstoken vorhanden');
|
||||||
if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
|
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;
|
return oauth.accessToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ const DEFAULT_STATE = {
|
|||||||
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
|
calibration: { blockMax: 0, weekMax: 0, samples: 0, serverLimits: {} },
|
||||||
window: { x: null, y: null, expanded: false },
|
window: { x: null, y: null, expanded: false },
|
||||||
oauth: { lastFetch: 0, pausedUntil: 0 },
|
oauth: { lastFetch: 0, pausedUntil: 0 },
|
||||||
|
// Sperrt Mehrfachklicks auf "Zugriffstoken erneuern" über Neustarts hinweg.
|
||||||
|
oauthRefresh: { lastAttempt: 0 },
|
||||||
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
|
// Zeitgestempeltes Ereignis-/Fehlerprotokoll (lokale Transkripte,
|
||||||
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
|
// Serverabfrage, App-Lebenszyklus) — überdauert bewusst Neustarts.
|
||||||
errors: [],
|
errors: [],
|
||||||
// Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung.
|
// Zuletzt gestartete App-Version, für die Neuinstallations-/Update-Erkennung.
|
||||||
appVersion: null,
|
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 {
|
class Store {
|
||||||
@@ -51,6 +56,8 @@ class Store {
|
|||||||
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
|
calibration: { ...DEFAULT_STATE.calibration, ...(parsed.calibration || {}) },
|
||||||
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
|
window: { ...DEFAULT_STATE.window, ...(parsed.window || {}) },
|
||||||
oauth: { ...DEFAULT_STATE.oauth, ...(parsed.oauth || {}) },
|
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 : [],
|
errors: Array.isArray(parsed.errors) ? parsed.errors : [],
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -24,4 +24,6 @@ contextBridge.exposeInMainWorld('dashboard', {
|
|||||||
openSettings: () => ipcRenderer.send('open-settings'),
|
openSettings: () => ipcRenderer.send('open-settings'),
|
||||||
/** Öffnet das Fehler-/Ereignis-Log. */
|
/** Öffnet das Fehler-/Ereignis-Log. */
|
||||||
openErrorLog: () => ipcRenderer.send('open-error-log'),
|
openErrorLog: () => ipcRenderer.send('open-error-log'),
|
||||||
|
/** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */
|
||||||
|
openReleasePage: () => ipcRenderer.send('open-release-page'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const el = {
|
|||||||
planLabel: $('plan-label'),
|
planLabel: $('plan-label'),
|
||||||
staleBadge: $('stale-badge'),
|
staleBadge: $('stale-badge'),
|
||||||
btnEndpointStatus: $('btn-endpoint-status'),
|
btnEndpointStatus: $('btn-endpoint-status'),
|
||||||
|
btnUpdateAvailable: $('btn-update-available'),
|
||||||
btnHide: $('btn-hide'),
|
btnHide: $('btn-hide'),
|
||||||
btnSettings: $('btn-settings'),
|
btnSettings: $('btn-settings'),
|
||||||
btnExpand: $('btn-expand'),
|
btnExpand: $('btn-expand'),
|
||||||
@@ -284,6 +285,15 @@ function renderEndpointStatus(s) {
|
|||||||
`Serverabfrage: ${ep.oauth.text || '—'}`;
|
`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) {
|
function renderWeek(week, burn) {
|
||||||
renderMeter('week', week);
|
renderMeter('week', week);
|
||||||
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
|
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
|
||||||
@@ -397,6 +407,7 @@ function render(s) {
|
|||||||
renderWeek(s.week, s.burn);
|
renderWeek(s.week, s.burn);
|
||||||
renderSpend(s.spend);
|
renderSpend(s.spend);
|
||||||
renderEndpointStatus(s);
|
renderEndpointStatus(s);
|
||||||
|
renderUpdateAvailable(s);
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
renderModels(s.block);
|
renderModels(s.block);
|
||||||
@@ -462,6 +473,7 @@ el.btnExpand.addEventListener('click', () => applyExpanded(!expanded));
|
|||||||
el.btnHide.addEventListener('click', () => window.dashboard.hide());
|
el.btnHide.addEventListener('click', () => window.dashboard.hide());
|
||||||
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
|
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
|
||||||
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
|
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
|
||||||
|
el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage());
|
||||||
|
|
||||||
window.dashboard.onState(render);
|
window.dashboard.onState(render);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status">
|
<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>
|
<span class="status-icon" aria-hidden="true"></span>
|
||||||
</button>
|
</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">
|
<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">
|
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
--status-good: #0ca30c;
|
--status-good: #0ca30c;
|
||||||
--status-warning: #fab219;
|
--status-warning: #fab219;
|
||||||
--status-critical: #d03b3b;
|
--status-critical: #d03b3b;
|
||||||
|
--status-update: #9b6bd6;
|
||||||
|
|
||||||
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
|
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
|
||||||
--series-1: #3987e5;
|
--series-1: #3987e5;
|
||||||
@@ -152,6 +153,10 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btn-update-available {
|
||||||
|
color: var(--status-update);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Meter ───────────────────────────────────────────────────────────── */
|
/* ── Meter ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.meters {
|
.meters {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const el = {
|
|||||||
clickThrough: $('clickThrough'),
|
clickThrough: $('clickThrough'),
|
||||||
autostart: $('autostart'),
|
autostart: $('autostart'),
|
||||||
autostartLabel: $('autostart-label'),
|
autostartLabel: $('autostart-label'),
|
||||||
|
updateCheckEnabled: $('updateCheckEnabled'),
|
||||||
|
|
||||||
oauthEnabled: $('oauthEnabled'),
|
oauthEnabled: $('oauthEnabled'),
|
||||||
oauthEndpoint: $('oauthEndpoint'),
|
oauthEndpoint: $('oauthEndpoint'),
|
||||||
@@ -84,6 +85,9 @@ function fill(config, meta) {
|
|||||||
el.autostartLabel.textContent =
|
el.autostartLabel.textContent =
|
||||||
meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten';
|
meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten';
|
||||||
|
|
||||||
|
const updateCheck = config.updateCheck || {};
|
||||||
|
el.updateCheckEnabled.checked = updateCheck.enabled !== false;
|
||||||
|
|
||||||
const oauth = config.oauth || {};
|
const oauth = config.oauth || {};
|
||||||
el.oauthEnabled.checked = oauth.enabled !== false;
|
el.oauthEnabled.checked = oauth.enabled !== false;
|
||||||
el.oauthEndpoint.value = oauth.endpoint || '';
|
el.oauthEndpoint.value = oauth.endpoint || '';
|
||||||
@@ -137,6 +141,7 @@ function collect() {
|
|||||||
|
|
||||||
patch.opacity = Number(el.opacity.value) / 100;
|
patch.opacity = Number(el.opacity.value) / 100;
|
||||||
patch.clickThrough = el.clickThrough.checked;
|
patch.clickThrough = el.clickThrough.checked;
|
||||||
|
patch.updateCheck = { enabled: el.updateCheckEnabled.checked };
|
||||||
|
|
||||||
patch.oauth = { enabled: el.oauthEnabled.checked };
|
patch.oauth = { enabled: el.oauthEnabled.checked };
|
||||||
const endpoint = el.oauthEndpoint.value.trim();
|
const endpoint = el.oauthEndpoint.value.trim();
|
||||||
|
|||||||
@@ -42,6 +42,17 @@
|
|||||||
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
|
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<section class="group">
|
<section class="group">
|
||||||
|
|||||||
Reference in New Issue
Block a user