From bb311e76c208117bef6b6a14b4417398ea46647b Mon Sep 17 00:00:00 2001 From: "Winkler, Stefan" Date: Thu, 20 Aug 2026 19:59:06 +0200 Subject: [PATCH] Update-Pruefung gegen Gitea-Releases ergaenzen Die App prueft ca. 60 s nach dem Start einmalig, ob auf dem Gitea-Server ein neueres Release vorliegt. Bei einem Treffer erscheint eine Systembenachrichtigung, und ein Symbol im Titelbalken bleibt sichtbar, bis aktualisiert wird -- Klick auf beides oeffnet die Release-Seite im Browser. Ueber die neue Einstellung "Nach Updates suchen" abschaltbar (Standard: aktiv). --- BENUTZERHANDBUCH.md | 25 ++++++++++++-- README.md | 13 ++++++++ config.json | 4 +++ src/errors/app.js | 1 + src/errors/styles.css | 5 +++ src/main/collector.js | 29 ++++++++++++++++ src/main/index.js | 48 +++++++++++++++++++++++++++ src/main/store.js | 4 +++ src/main/update-check.js | 71 ++++++++++++++++++++++++++++++++++++++++ src/preload.js | 2 ++ src/renderer/app.js | 12 +++++++ src/renderer/index.html | 1 + src/renderer/styles.css | 5 +++ src/settings/app.js | 5 +++ src/settings/index.html | 11 +++++++ 15 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 src/main/update-check.js diff --git a/BENUTZERHANDBUCH.md b/BENUTZERHANDBUCH.md index 4231438..00de90c 100644 --- a/BENUTZERHANDBUCH.md +++ b/BENUTZERHANDBUCH.md @@ -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ü @@ -154,6 +162,10 @@ und Beenden. 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 @@ -188,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". diff --git a/README.md b/README.md index 0851748..eaaa954 100644 --- a/README.md +++ b/README.md @@ -276,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 diff --git a/config.json b/config.json index 51d642f..5e4d6d9 100644 --- a/config.json +++ b/config.json @@ -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 } } diff --git a/src/errors/app.js b/src/errors/app.js index fd06ffa..aebdad9 100644 --- a/src/errors/app.js +++ b/src/errors/app.js @@ -24,6 +24,7 @@ const SOURCE_LABEL = { oauth: 'Server', app: 'App', refresh: 'Token', + update: 'Update', }; let feedbackTimer = null; diff --git a/src/errors/styles.css b/src/errors/styles.css index 415c6bf..c5a067b 100644 --- a/src/errors/styles.css +++ b/src/errors/styles.css @@ -16,6 +16,7 @@ --good: #0ca30c; --warning: #fab219; --critical: #d03b3b; + --update: #9b6bd6; --font: system-ui, -apple-system, "Segoe UI", sans-serif; } @@ -228,6 +229,10 @@ dd[data-severity="critical"] .status-label { color: var(--good); } +.log-source[data-source="update"] { + color: var(--update); +} + .log-message { color: var(--text-secondary); line-height: 1.4; diff --git a/src/main/collector.js b/src/main/collector.js index ee20af1..6f52da4 100644 --- a/src/main/collector.js +++ b/src/main/collector.js @@ -77,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 = @@ -214,6 +219,24 @@ class Collector extends EventEmitter { } } + /** + * 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; @@ -411,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 }, }; diff --git a/src/main/index.js b/src/main/index.js index ff66e6e..61f15c5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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; @@ -410,6 +415,10 @@ function registerIpc() { 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, @@ -509,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. diff --git a/src/main/store.js b/src/main/store.js index c8e903c..a90f87f 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -30,6 +30,9 @@ const DEFAULT_STATE = { 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 { @@ -54,6 +57,7 @@ class Store { 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) { diff --git a/src/main/update-check.js b/src/main/update-check.js new file mode 100644 index 0000000..7222991 --- /dev/null +++ b/src/main/update-check.js @@ -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 ab + */ +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 }; diff --git a/src/preload.js b/src/preload.js index 7954490..cb4904a 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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'), }); diff --git a/src/renderer/app.js b/src/renderer/app.js index 937227f..9ddda2d 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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); diff --git a/src/renderer/index.html b/src/renderer/index.html index f7dcd12..3c7bfa1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -17,6 +17,7 @@ +