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).
This commit is contained in:
Winkler, Stefan
2026-08-20 19:59:06 +02:00
parent e413a36506
commit bb311e76c2
15 changed files with 234 additions and 2 deletions
+1
View File
@@ -24,6 +24,7 @@ const SOURCE_LABEL = {
oauth: 'Server',
app: 'App',
refresh: 'Token',
update: 'Update',
};
let feedbackTimer = null;
+5
View File
@@ -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;
+29
View File
@@ -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 },
};
+48
View File
@@ -13,6 +13,7 @@ const { Store, loadConfig } = require('./store');
const { Collector } = require('./collector');
const { CONFIG_FILE, STATE_FILE, USER_DATA_DIR, ensureUserConfig } = require('./paths');
const { getAutostart, setAutostart: applyAutostart } = require('./autostart');
const { checkForUpdate } = require('./update-check');
const IS_LINUX = process.platform === 'linux';
@@ -29,6 +30,10 @@ const SETTINGS_HEIGHT = 660;
const ERRORS_WIDTH = 420;
const ERRORS_HEIGHT = 480;
/** Wartezeit vor der einmaligen Update-Prüfung — verzögert, damit sie den
* Start nicht verlangsamt und nicht mit dem ersten Poll konkurriert. */
const UPDATE_CHECK_DELAY_MS = 60 * 1000;
let win = null;
let settingsWin = null;
let errorsWin = null;
@@ -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.
+4
View File
@@ -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) {
+71
View File
@@ -0,0 +1,71 @@
'use strict';
/**
* Einmalige Update-Prüfung, ca. 60 s nach dem Start ausgelöst (siehe
* index.js). Fragt das öffentliche Gitea-API des eigenen Repos nach dem
* neuesten Release ab — kein Zugriffstoken nötig, der Endpunkt ist ohne
* Authentifizierung erreichbar (verifiziert per curl).
*
* Bewusst zustandslos und ohne Wiederholungslogik: Ein Check pro Sitzung
* genügt der Anforderung „einmalig prüfen". Persistenz (welches Release
* schon einmal per Notification gemeldet wurde) lebt bewusst außerhalb
* dieses Moduls, in index.js/store.js.
*/
const RELEASES_URL =
'https://git.conatum.net/api/v1/repos/swinks/claude-live-dashboard/releases/latest';
const ALLOWED_HOST = 'git.conatum.net';
const REQUEST_TIMEOUT_MS = 10000;
/**
* Vergleicht zwei MAJOR.MINOR.PATCH-Versionen ohne Suffixe.
* @returns {number} negativ wenn a<b, 0 wenn gleich, positiv wenn a>b
*/
function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
/**
* Fragt das neueste Release ab und vergleicht dessen Tag gegen currentVersion.
* Drafts und Pre-Releases werden ignoriert.
* @param {string} currentVersion z. B. app.getVersion() (ohne führendes "v")
* @returns {Promise<{tagName: string, htmlUrl: string} | null>}
*/
async function checkForUpdate(currentVersion) {
const url = new URL(RELEASES_URL);
if (url.protocol !== 'https:' || url.hostname !== ALLOWED_HOST) {
throw new Error('Gitea-Endpunkt zeigt nicht auf den erwarteten Host');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(RELEASES_URL, {
method: 'GET',
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.draft || json.prerelease) return null;
const remoteVersion = String(json.tag_name || '').replace(/^v/, '').trim();
if (!remoteVersion) return null;
if (compareVersions(remoteVersion, currentVersion) <= 0) return null;
return { tagName: String(json.tag_name), htmlUrl: json.html_url };
} catch (err) {
throw new Error(err.name === 'AbortError' ? 'Zeitüberschreitung' : err.message);
} finally {
clearTimeout(timer);
}
}
module.exports = { checkForUpdate, compareVersions, RELEASES_URL };
+2
View File
@@ -24,4 +24,6 @@ contextBridge.exposeInMainWorld('dashboard', {
openSettings: () => ipcRenderer.send('open-settings'),
/** Öffnet das Fehler-/Ereignis-Log. */
openErrorLog: () => ipcRenderer.send('open-error-log'),
/** Öffnet die Release-Seite eines gefundenen neuen Updates im Browser. */
openReleasePage: () => ipcRenderer.send('open-release-page'),
});
+12
View File
@@ -47,6 +47,7 @@ const el = {
planLabel: $('plan-label'),
staleBadge: $('stale-badge'),
btnEndpointStatus: $('btn-endpoint-status'),
btnUpdateAvailable: $('btn-update-available'),
btnHide: $('btn-hide'),
btnSettings: $('btn-settings'),
btnExpand: $('btn-expand'),
@@ -284,6 +285,15 @@ function renderEndpointStatus(s) {
`Serverabfrage: ${ep.oauth.text || '—'}`;
}
/** Titelleisten-Symbol für ein gefundenes neues Release. */
function renderUpdateAvailable(s) {
const update = s.update || { available: false };
el.btnUpdateAvailable.hidden = !update.available;
if (update.available) {
el.btnUpdateAvailable.title = `Version ${update.tagName} verfügbar — Release-Seite öffnen`;
}
}
function renderWeek(week, burn) {
renderMeter('week', week);
renderProjection(el.weekProjection, week.percent, burn.weekProjectedPercent, week.severity);
@@ -397,6 +407,7 @@ function render(s) {
renderWeek(s.week, s.burn);
renderSpend(s.spend);
renderEndpointStatus(s);
renderUpdateAvailable(s);
if (expanded) {
renderModels(s.block);
@@ -462,6 +473,7 @@ el.btnExpand.addEventListener('click', () => applyExpanded(!expanded));
el.btnHide.addEventListener('click', () => window.dashboard.hide());
el.btnSettings.addEventListener('click', () => window.dashboard.openSettings());
el.btnEndpointStatus.addEventListener('click', () => window.dashboard.openErrorLog());
el.btnUpdateAvailable.addEventListener('click', () => window.dashboard.openReleasePage());
window.dashboard.onState(render);
+1
View File
@@ -17,6 +17,7 @@
<button class="icon-btn status" id="btn-endpoint-status" data-severity="ok" title="Endpunkt-Status" aria-label="Endpunkt-Status">
<span class="status-icon" aria-hidden="true"></span>
</button>
<button class="icon-btn" id="btn-update-available" hidden title="Neues Release verfügbar" aria-label="Neues Release verfügbar"></button>
<button class="icon-btn" id="btn-settings" title="Einstellungen" aria-label="Einstellungen">
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" focusable="false">
<path
+5
View File
@@ -28,6 +28,7 @@
--status-good: #0ca30c;
--status-warning: #fab219;
--status-critical: #d03b3b;
--status-update: #9b6bd6;
/* Kategoriale Slots (dunkle Stufen), fest je Modellfamilie vergeben */
--series-1: #3987e5;
@@ -152,6 +153,10 @@ body {
color: var(--text-primary);
}
#btn-update-available {
color: var(--status-update);
}
/* ── Meter ───────────────────────────────────────────────────────────── */
.meters {
+5
View File
@@ -21,6 +21,7 @@ const el = {
clickThrough: $('clickThrough'),
autostart: $('autostart'),
autostartLabel: $('autostart-label'),
updateCheckEnabled: $('updateCheckEnabled'),
oauthEnabled: $('oauthEnabled'),
oauthEndpoint: $('oauthEndpoint'),
@@ -84,6 +85,9 @@ function fill(config, meta) {
el.autostartLabel.textContent =
meta.platform === 'win32' ? 'Mit Windows starten' : 'Mit dem System starten';
const updateCheck = config.updateCheck || {};
el.updateCheckEnabled.checked = updateCheck.enabled !== false;
const oauth = config.oauth || {};
el.oauthEnabled.checked = oauth.enabled !== false;
el.oauthEndpoint.value = oauth.endpoint || '';
@@ -137,6 +141,7 @@ function collect() {
patch.opacity = Number(el.opacity.value) / 100;
patch.clickThrough = el.clickThrough.checked;
patch.updateCheck = { enabled: el.updateCheckEnabled.checked };
patch.oauth = { enabled: el.oauthEnabled.checked };
const endpoint = el.oauthEndpoint.value.trim();
+11
View File
@@ -42,6 +42,17 @@
<span class="hint">Wird sofort übernommen, unabhängig vom Speichern.</span>
</div>
</div>
<div class="row">
<label for="updateCheckEnabled">Nach Updates suchen</label>
<div class="control">
<input type="checkbox" id="updateCheckEnabled" />
<span class="hint">
Prüft rund eine Minute nach dem Start einmalig auf dem
Projekt-Server, ob eine neuere Version vorliegt.
</span>
</div>
</div>
</section>
<section class="group">