Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489b15860f | ||
|
|
bb311e76c2 | ||
|
|
e413a36506 | ||
|
|
628a269a14 |
+34
-4
@@ -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
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
/** Ö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'),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user