import type { AppContext, AppModule } from '@/app/app-context'; import { invokeTauri } from '@/services/tauri-bridge'; import { trackUpdateShown, trackUpdateClicked, trackUpdateDismissed } from '@/services/analytics'; import { escapeHtml } from '@/utils/sanitize'; import { getDismissed, setDismissed } from '@/utils/cross-domain-storage'; import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; interface DesktopRuntimeInfo { os: string; arch: string; } type UpdaterOutcome = 'no_update' | 'update_available' | 'open_failed' | 'fetch_failed'; type DesktopBuildVariant = 'full' | 'tech' | 'finance'; const DESKTOP_BUILD_VARIANT: DesktopBuildVariant = ( import.meta.env.VITE_VARIANT === 'tech' || import.meta.env.VITE_VARIANT === 'finance' ? import.meta.env.VITE_VARIANT : 'full' ); export class DesktopUpdater implements AppModule { private ctx: AppContext; private updateCheckIntervalId: ReturnType | null = null; private readonly UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; constructor(ctx: AppContext) { this.ctx = ctx; } init(): void { this.setupUpdateChecks(); } destroy(): void { if (this.updateCheckIntervalId) { clearInterval(this.updateCheckIntervalId); this.updateCheckIntervalId = null; } } private setupUpdateChecks(): void { if (!this.ctx.isDesktopApp || this.ctx.isDestroyed) return; setTimeout(() => { if (this.ctx.isDestroyed) return; void this.checkForUpdate(); }, 5000); if (this.updateCheckIntervalId) { clearInterval(this.updateCheckIntervalId); } this.updateCheckIntervalId = setInterval(() => { if (this.ctx.isDestroyed) return; void this.checkForUpdate(); }, this.UPDATE_CHECK_INTERVAL_MS); } private logUpdaterOutcome(outcome: UpdaterOutcome, context: Record = {}): void { const logger = outcome === 'open_failed' || outcome === 'fetch_failed' ? console.warn : console.info; logger('[updater]', outcome, context); } private getDesktopBuildVariant(): DesktopBuildVariant { return DESKTOP_BUILD_VARIANT; } private async checkForUpdate(): Promise { try { const res = await fetch('https://api.worldmonitor.app/api/version', { signal: AbortSignal.timeout(8000), }); if (!res.ok) { this.logUpdaterOutcome('fetch_failed', { status: res.status }); return; } const data = await res.json(); const remote = data.version as string; if (!remote) { this.logUpdaterOutcome('fetch_failed', { reason: 'missing_remote_version' }); return; } const current = __APP_VERSION__; if (!this.isNewerVersion(remote, current)) { this.logUpdaterOutcome('no_update', { current, remote }); return; } const dismissKey = `wm-update-dismissed-${remote}`; if (getDismissed(dismissKey)) { this.logUpdaterOutcome('update_available', { current, remote, dismissed: true }); return; } const releaseUrl = typeof data.url === 'string' && data.url ? data.url : 'https://github.com/koala73/worldmonitor/releases/latest'; this.logUpdaterOutcome('update_available', { current, remote, dismissed: false }); trackUpdateShown(current, remote); await this.showUpdateToast(remote, releaseUrl); } catch (error) { this.logUpdaterOutcome('fetch_failed', { error: error instanceof Error ? error.message : String(error), }); } } private isNewerVersion(remote: string, current: string): boolean { const r = remote.split('.').map(Number); const c = current.split('.').map(Number); for (let i = 0; i < Math.max(r.length, c.length); i++) { const rv = r[i] ?? 0; const cv = c[i] ?? 0; if (rv > cv) return true; if (rv < cv) return false; } return false; } private mapDesktopDownloadPlatform(os: string, arch: string): string | null { const normalizedOs = os.toLowerCase(); const normalizedArch = arch.toLowerCase() .replace('amd64', 'x86_64') .replace('x64', 'x86_64') .replace('arm64', 'aarch64'); if (normalizedOs === 'windows') { return normalizedArch === 'x86_64' ? 'windows-msi' : null; } if (normalizedOs === 'macos' || normalizedOs === 'darwin') { if (normalizedArch === 'aarch64') return 'macos-arm64'; if (normalizedArch === 'x86_64') return 'macos-x64'; return null; } if (normalizedOs === 'linux') { if (normalizedArch === 'x86_64') return 'linux-appimage'; if (normalizedArch === 'aarch64') return 'linux-appimage-arm64'; return null; } return null; } private async resolveUpdateDownloadUrl(releaseUrl: string): Promise { try { const runtimeInfo = await invokeTauri('get_desktop_runtime_info'); const platform = this.mapDesktopDownloadPlatform(runtimeInfo.os, runtimeInfo.arch); if (platform) { const variant = this.getDesktopBuildVariant(); return `https://api.worldmonitor.app/api/download?platform=${platform}&variant=${variant}`; } } catch { // Silent fallback to release page when desktop runtime info is unavailable. } return releaseUrl; } private async showUpdateToast(version: string, releaseUrl: string): Promise { const existing = document.querySelector('.update-toast'); if (existing?.dataset.version === version) return; existing?.remove(); const url = await this.resolveUpdateDownloadUrl(releaseUrl); const toast = document.createElement('div'); toast.className = 'update-toast'; toast.dataset.version = version; setTrustedHtml(toast, trustedHtml(`
Update Available
v${escapeHtml(__APP_VERSION__)} \u2192 v${escapeHtml(version)}
`, "legacy direct innerHTML migration")); const dismissToast = () => { setDismissed(`wm-update-dismissed-${version}`); toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); }; toast.addEventListener('click', (e) => { const target = e.target as HTMLElement; const action = target.closest('[data-action]')?.dataset.action; if (action === 'download') { trackUpdateClicked(version); if (this.ctx.isDesktopApp) { void invokeTauri('open_url', { url }).catch((error) => { this.logUpdaterOutcome('open_failed', { url, error: error instanceof Error ? error.message : String(error) }); window.open(url, '_blank', 'noopener,noreferrer'); }); } else { window.open(url, '_blank', 'noopener,noreferrer'); } dismissToast(); } else if (action === 'dismiss') { trackUpdateDismissed(version); dismissToast(); } }); document.body.appendChild(toast); requestAnimationFrame(() => { requestAnimationFrame(() => toast.classList.add('visible')); }); } }