diff --git a/BENUTZERHANDBUCH.md b/BENUTZERHANDBUCH.md index 680220e..4231438 100644 --- a/BENUTZERHANDBUCH.md +++ b/BENUTZERHANDBUCH.md @@ -147,6 +147,13 @@ 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. - Es findet keine Übertragung an Dritte statt. ## Deinstallation @@ -170,8 +177,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. diff --git a/README.md b/README.md index e35f5ae..0851748 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/src/errors-preload.js b/src/errors-preload.js index 613de65..b50c122 100644 --- a/src/errors-preload.js +++ b/src/errors-preload.js @@ -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'), }); diff --git a/src/errors/app.js b/src/errors/app.js index f48e29f..fd06ffa 100644 --- a/src/errors/app.js +++ b/src/errors/app.js @@ -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,21 @@ const SOURCE_LABEL = { local: 'Lokal', oauth: 'Server', app: 'App', + refresh: 'Token', }; +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 +87,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(); }); diff --git a/src/errors/index.html b/src/errors/index.html index 6794637..22115f2 100644 --- a/src/errors/index.html +++ b/src/errors/index.html @@ -33,7 +33,9 @@ diff --git a/src/errors/styles.css b/src/errors/styles.css index 4cf4ed9..415c6bf 100644 --- a/src/errors/styles.css +++ b/src/errors/styles.css @@ -224,6 +224,10 @@ dd[data-severity="critical"] .status-label { color: var(--accent); } +.log-source[data-source="refresh"] { + color: var(--good); +} + .log-message { color: var(--text-secondary); line-height: 1.4; @@ -245,6 +249,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 { diff --git a/src/main/collector.js b/src/main/collector.js index b701bec..ee20af1 100644 --- a/src/main/collector.js +++ b/src/main/collector.js @@ -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; @@ -184,6 +189,31 @@ 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 }; + } + } + async _poll() { if (this._polling) { this._pollAgain = true; diff --git a/src/main/index.js b/src/main/index.js index 321e7e4..ff66e6e 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -406,6 +406,10 @@ 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.handle('settings:load', () => ({ config, diff --git a/src/main/oauth-refresh.js b/src/main/oauth-refresh.js new file mode 100644 index 0000000..ea0262d --- /dev/null +++ b/src/main/oauth-refresh.js @@ -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 }; diff --git a/src/main/oauth-usage.js b/src/main/oauth-usage.js index 61dfb66..5a39cde 100644 --- a/src/main/oauth-usage.js +++ b/src/main/oauth-usage.js @@ -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; } diff --git a/src/main/store.js b/src/main/store.js index 4bb25bf..c8e903c 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -23,6 +23,8 @@ 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: [], @@ -51,6 +53,7 @@ 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 || {}) }, errors: Array.isArray(parsed.errors) ? parsed.errors : [], }; } catch (err) {