diff --git a/desktop/package.json b/desktop/package.json index 8ce21cad..9f911f27 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -71,7 +71,7 @@ "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", - "notarize": true, + "notarize": false, "target": [ { "target": "dmg", diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 469c5ab4..6c8d54ef 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,4 +1,6 @@ import { app, BrowserWindow } from 'electron' +import fs from 'fs' +import path from 'path' // electron-updater is CommonJS: its members live on module.exports, with no // meaningful default export. Under module=commonjs + esModuleInterop, a named // import compiles to `electron_updater_1.autoUpdater` and resolves correctly, @@ -17,16 +19,59 @@ export type UpdateStatus = let getWindow: () => BrowserWindow | null = () => null +// Persist update logs to a file so a user hitting a silent "spinner never +// resolves" can just send us userData/logs/updater.log. We can't rely on a +// logging dep, so this is a tiny append-only writer, plus console for the +// in-app log view / terminal. +let logFile: string | null = null + +function initLogFile() { + try { + const dir = path.join(app.getPath('userData'), 'logs') + fs.mkdirSync(dir, { recursive: true }) + logFile = path.join(dir, 'updater.log') + } catch { + logFile = null + } +} + +function log(...parts: unknown[]) { + const line = `[${new Date().toISOString()}] [updater] ${parts + .map((p) => (typeof p === 'string' ? p : safeStringify(p))) + .join(' ')}` + // Console: shows up in the terminal (dev) and the packaged app's stdout. + console.log(line) + if (logFile) { + try { + fs.appendFileSync(logFile, line + '\n') + } catch { + // ignore disk errors — logging must never break the updater + } + } +} + +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v) + } catch { + return String(v) + } +} + function send(status: UpdateStatus) { getWindow()?.webContents.send('update-status', status) } export function initUpdater(windowGetter: () => BrowserWindow | null): void { getWindow = windowGetter + initLogFile() + + log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? ''}`) // In dev (not packaged) there's no update feed; skip wiring entirely so // electron-updater doesn't throw on the missing app-update.yml. if (!app.isPackaged) { + log('not packaged — updater wiring skipped') return } @@ -34,21 +79,51 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void { // download, rather than pulling bytes silently in the background. autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true + // The desktop channel ships pre-release-tagged builds (e.g. 0.0.8-test), so a + // current version like 0.0.7-test must be allowed to compare against, and be + // offered, other pre-release versions. Without this electron-updater's semver + // compare can silently skip pre-releases and neither "available" nor + // "not-available" fires — the UI just spins forever. + autoUpdater.allowPrerelease = true + autoUpdater.allowDowngrade = false + // Route electron-updater's own internal logging to our file too, so we + // capture the feed URL, parsed versions and any stack traces it logs. + autoUpdater.logger = { + info: (m: unknown) => log('eu-info:', m), + warn: (m: unknown) => log('eu-warn:', m), + error: (m: unknown) => log('eu-error:', m), + debug: (m: unknown) => log('eu-debug:', m), + } as unknown as typeof autoUpdater.logger - autoUpdater.on('checking-for-update', () => send({ state: 'checking' })) - autoUpdater.on('update-available', (info) => - send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined }) - ) - autoUpdater.on('update-not-available', () => send({ state: 'not-available' })) - autoUpdater.on('download-progress', (p) => + autoUpdater.on('checking-for-update', () => { + log(`checking-for-update: current=${app.getVersion()}`) + send({ state: 'checking' }) + }) + autoUpdater.on('update-available', (info) => { + log(`update-available: current=${app.getVersion()} remote=${info.version} -> update needed`) + send({ + state: 'available', + version: info.version, + notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + }) + }) + autoUpdater.on('update-not-available', (info) => { + log(`update-not-available: current=${app.getVersion()} remote=${info?.version ?? ''} -> up to date`) + send({ state: 'not-available' }) + }) + autoUpdater.on('download-progress', (p) => { + log(`download-progress: ${Math.round(p.percent)}% (${p.transferred}/${p.total} bytes, ${Math.round(p.bytesPerSecond / 1024)} KB/s)`) send({ state: 'downloading', percent: Math.round(p.percent) }) - ) - autoUpdater.on('update-downloaded', (info) => + }) + autoUpdater.on('update-downloaded', (info) => { + log(`update-downloaded: version=${info.version} -> ready to install`) send({ state: 'downloaded', version: info.version }) - ) - autoUpdater.on('error', (err) => - send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) }) - ) + }) + autoUpdater.on('error', (err) => { + const message = err == null ? 'unknown' : err.message || String(err) + log(`error: ${message}`, err instanceof Error && err.stack ? err.stack : '') + send({ state: 'error', message }) + }) } // Silent check shortly after launch. When not packaged there's no update feed, @@ -56,23 +131,31 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void { // reply "not-available" so the menu can show "up to date". export function checkForUpdates(): void { if (!app.isPackaged) { + log('checkForUpdates: not packaged, replying not-available') send({ state: 'not-available' }) return } + log(`checkForUpdates: requesting feed, current=${app.getVersion()}`) autoUpdater.checkForUpdates().catch((err) => { - send({ state: 'error', message: err?.message || String(err) }) + const message = err?.message || String(err) + log(`checkForUpdates: request failed: ${message}`, err instanceof Error && err.stack ? err.stack : '') + send({ state: 'error', message }) }) } export function startDownload(): void { if (!app.isPackaged) return + log('startDownload: user requested download') autoUpdater.downloadUpdate().catch((err) => { - send({ state: 'error', message: err?.message || String(err) }) + const message = err?.message || String(err) + log(`startDownload: failed: ${message}`, err instanceof Error && err.stack ? err.stack : '') + send({ state: 'error', message }) }) } export function quitAndInstall(): void { if (!app.isPackaged) return + log('quitAndInstall: relaunching to install update') // isSilent=false (show installer), isForceRunAfter=true (relaunch after). autoUpdater.quitAndInstall(false, true) }