diff --git a/desktop/installer/cat-cafe.iss b/desktop/installer/cat-cafe.iss index ce0d8e5348..acc6eb1934 100644 --- a/desktop/installer/cat-cafe.iss +++ b/desktop/installer/cat-cafe.iss @@ -195,9 +195,26 @@ Filename: "powershell.exe"; \ StatusMsg: "Generating desktop configuration..."; \ Flags: runhidden waituntilterminated -; Offer to launch after install +; Offer to launch after interactive install Filename: "{app}\desktop-dist\{#MyAppExeName}"; \ Description: "Launch {#MyAppName}"; Flags: postinstall nowait skipifsilent +; F258: Auto-restart after silent upgrade (in-app updater uses /SILENT). +; runasoriginaluser is critical — without it, the app + Redis + API all run +; as elevated admin, which breaks user-data paths and is a security concern. +Filename: "{app}\desktop-dist\{#MyAppExeName}"; \ + Flags: nowait runasoriginaluser; Check: WizardSilent + +; ── F258: Clean previous version's tar-extracted dirs before upgrade ─── +; These are NOT tracked by Inno's file registry (created by tar.exe in [Run]). +; Without this, old module files / deleted packages persist across upgrades — +; an extremely hard-to-debug class of staleness bugs. +; The junction must be removed first (rmdir only deletes the link, not target). +; Recovery: if install fails after delete, rerunning the installer restores everything. +[InstallDelete] +Type: filesandordirs; Name: "{app}\scripts\node_modules" +Type: filesandordirs; Name: "{app}\packages" +Type: filesandordirs; Name: "{app}\desktop-dist" +Type: filesandordirs; Name: "{app}\node" [UninstallRun] Filename: "powershell.exe"; \ @@ -213,3 +230,25 @@ Type: filesandordirs; Name: "{app}\node" Type: filesandordirs; Name: "{app}\bundled" ; scripts/node_modules junction created by mklink /J in [Run] Type: filesandordirs; Name: "{app}\scripts\node_modules" + +; ── F258: Defensive process cleanup before upgrade ──────────────────── +; The in-app updater calls quitApp() → stopAll() before spawning the installer, +; but if stopAll() times out or the user manually reruns Setup.exe, child +; processes (node, redis-server) may still hold file locks in {app}\. +; PrepareToInstall kills ONLY processes whose executable path is under {app} — +; no risk of killing the user's own node/redis instances running elsewhere. +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ExitCode: Integer; + Cmd: String; +begin + Result := ''; + NeedsRestart := False; + Cmd := 'Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith(''' + + ExpandConstant('{app}') + + ''') } | Stop-Process -Force -ErrorAction SilentlyContinue'; + Exec('powershell.exe', + '-NoProfile -ExecutionPolicy Bypass -Command "' + Cmd + '"', + '', SW_HIDE, ewWaitUntilTerminated, ExitCode); +end; diff --git a/desktop/main.js b/desktop/main.js index ec769fcb07..6e8390c85b 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1,12 +1,13 @@ // Clowder AI Desktop — Electron main process // Launches backend services (Redis, API, Web) then shows the web UI. -const { app, BrowserWindow, Menu, Tray, dialog } = require('electron'); +const { app, BrowserWindow, Menu, Tray, dialog, net, shell, Notification } = require('electron'); const path = require('node:path'); const fs = require('node:fs'); const os = require('node:os'); const { resolveProjectRootFromDir } = require('./project-root'); const ServiceManager = require('./service-manager'); +const UpdateManager = require('./update-manager'); // macOS install-location guard. // @@ -68,10 +69,9 @@ const FRONTEND_PORT = 3003; const API_PORT = 3004; const APP_URL = `http://localhost:${FRONTEND_PORT}`; // Main process log in the user data directory alongside API + desktop logs. -const IS_MAC_MAIN = process.platform === 'darwin'; -const userDataRoot = IS_MAC_MAIN - ? path.join(process.env.HOME || os.homedir(), 'Library', 'Application Support', 'Clowder AI') - : path.join(process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local'), 'Clowder AI'); +// Single source of truth: service-manager.js resolveUserDataDir() reads +// electron-builder productName and handles legacy data directory migration. +const userDataRoot = ServiceManager.USER_DATA_DIR; const mainLogDir = path.join(userDataRoot, 'data', 'logs'); try { fs.mkdirSync(mainLogDir, { recursive: true }); @@ -92,6 +92,7 @@ let mainWindow = null; let splashWindow = null; let tray = null; let services = null; +let updater = null; let isQuitting = false; function createSplashWindow() { @@ -152,6 +153,32 @@ function createMainWindow() { }); } +function showAboutDialog() { + const version = app.getVersion(); + dialog + .showMessageBox({ + type: 'info', + buttons: ['Check for Updates', 'OK'], + defaultId: 1, + cancelId: 1, + title: 'About Clowder AI', + message: `Clowder AI v${version}`, + detail: [ + 'Multi-Agent Collaboration Platform', + '', + `Version: ${version}`, + `Electron: ${process.versions.electron}`, + `Node: ${process.versions.node}`, + '', + 'License: AGPL-3.0', + 'https://github.com/zts212653/clowder-ai', + ].join('\n'), + }) + .then((result) => { + if (result.response === 0) updater?.checkForUpdates(); + }); +} + function createTray() { const iconPath = path.join(__dirname, 'assets', 'icon.ico'); try { @@ -162,6 +189,9 @@ function createTray() { const contextMenu = Menu.buildFromTemplate([ { label: 'Show Clowder AI', click: () => mainWindow?.show() }, { type: 'separator' }, + { label: 'About', click: () => showAboutDialog() }, + { label: 'Check for Updates', click: () => updater?.checkForUpdates() }, + { type: 'separator' }, { label: 'Quit', click: () => quitApp() }, ]); tray.setToolTip('Clowder AI'); @@ -215,17 +245,61 @@ app.on('ready', async () => { createSplashWindow(); createTray(); + // macOS: set application menu with About entry (standard mac UX) + if (process.platform === 'darwin') { + const appMenu = Menu.buildFromTemplate([ + { + label: app.name, + submenu: [ + { label: 'About Clowder AI', click: () => showAboutDialog() }, + { label: 'Check for Updates…', click: () => updater?.checkForUpdates() }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ]); + Menu.setApplicationMenu(appMenu); + } + services = new ServiceManager(PROJECT_ROOT, { frontendPort: FRONTEND_PORT, apiPort: API_PORT, onStatus: sendSplashStatus, }); + // F258: Initialize updater — check pending upgrade result BEFORE services + // (spec §3.2: "main.js 早期、服务启动前检测") + updater = new UpdateManager({ + app, + net, + showDialog: (opts) => dialog.showMessageBox(opts).then((r) => r.response), + showNotification: (title, body) => { + try { + new Notification({ title, body }).show(); + } catch {} + }, + setProgressBar: (p) => { + try { + mainWindow?.setProgressBar(p); + } catch {} + }, + openExternal: (url) => shell.openExternal(url), + openPath: (p) => shell.openPath(p), + quitApp, + dbg, + userDataRoot, + platform: process.platform, + arch: process.arch, + }); + await updater.checkPendingUpgrade(); + try { dbg('startAll() called'); await services.startAll(); dbg('startAll() done — creating main window'); createMainWindow(); + // F258: Start update check after services are up + updater.startSchedule(); } catch (err) { dbg(`startAll() FAILED: ${err.message}`); dialog.showErrorBox( diff --git a/desktop/package.json b/desktop/package.json index e338118b90..86b4e00a3e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -57,6 +57,10 @@ "preload.js", "project-root.js", "service-manager.js", + "update-checker.js", + "update-downloader.js", + "update-installer.js", + "update-manager.js", "splash.html", "assets/**/*" ], diff --git a/desktop/scripts/generate-desktop-config.ps1 b/desktop/scripts/generate-desktop-config.ps1 index 5ec147e78e..67e055e6b8 100644 --- a/desktop/scripts/generate-desktop-config.ps1 +++ b/desktop/scripts/generate-desktop-config.ps1 @@ -41,6 +41,11 @@ $config = @{ installedAt = (Get-Date -Format "o") } +# Only write installType if explicitly provided (fail-safe: missing = no auto-install) +if ($InstallType -ne "") { + $config.installType = $InstallType +} + $configPath = Join-Path $AppDir ".cat-cafe\desktop-config.json" $configDir = Split-Path -Parent $configPath if (-not (Test-Path $configDir)) { diff --git a/desktop/service-manager.js b/desktop/service-manager.js index f1ca111f74..cb7bbeae4c 100644 --- a/desktop/service-manager.js +++ b/desktop/service-manager.js @@ -21,13 +21,19 @@ const ARCH_SEG = process.arch === 'arm64' ? 'arm64' : 'x64'; // Resolve the per-user writable data root. Extracted to module level so // LOG_FILE can be set before ServiceManager is instantiated. +// Reads brand name from electron-builder config — single source of truth +// shared with main.js (which imports USER_DATA_DIR from this module). function resolveUserDataDir() { - if (IS_MAC) { - const home = process.env.HOME || os.homedir(); - return path.join(home, 'Library', 'Application Support', 'Clowder AI'); + let brandName; + try { + brandName = require('./package.json').build?.productName || 'Clowder AI'; + } catch { + brandName = 'Clowder AI'; } - const localAppData = process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local'); - return path.join(localAppData, 'Clowder AI'); + const base = IS_MAC + ? path.join(process.env.HOME || os.homedir(), 'Library', 'Application Support') + : process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || '', 'AppData', 'Local'); + return path.join(base, brandName); } // Desktop log alongside API logs in the user data directory. @@ -804,4 +810,6 @@ class ServiceManager { } } +// Expose data root for main.js — single source of truth for userData path. +ServiceManager.USER_DATA_DIR = USER_DATA_DIR; module.exports = ServiceManager; diff --git a/desktop/update-checker.js b/desktop/update-checker.js new file mode 100644 index 0000000000..71958a7940 --- /dev/null +++ b/desktop/update-checker.js @@ -0,0 +1,220 @@ +// F258: Desktop In-App Update — update checker (pure logic, no Electron deps) +// +// Responsibilities: +// 1. Parse & compare semver tags (vX.Y.Z format) +// 2. Select best update target from GitHub Releases API response +// 3. Extract asset four-tuple {id, name, size, digest} per platform +// 4. Persist update settings (autoCheck, skippedVersion, etc.) +// +// Design decisions (from spec): +// - Feed: GET /repos/{owner}/{repo}/releases?per_page=10 +// - NOT /releases/latest (not a semver selector — Codex review) +// - Asset digest comes from GitHub API response (not .sha256 sidecar) +// - Win asset = CatCafe-Setup-{v}.exe (Inno Setup, single arch) +// - Mac asset = CatCafe-{v}-{arm64|x64}.dmg (per process.arch) + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +// ── Semver parsing & comparison ──────────────────────────────────────── + +const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)$/; + +/** + * Parse a version string (with or without 'v' prefix) into components. + * @param {string} tag — e.g. 'v0.11.1' or '0.11.1' + * @returns {{ major: number, minor: number, patch: number } | null} + */ +function parseVersion(tag) { + if (typeof tag !== 'string') return null; + const m = tag.match(VERSION_RE); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; +} + +/** + * Compare two version strings. Returns positive if a > b, negative if a < b, 0 if equal. + * @param {string} a + * @param {string} b + * @returns {number} + * @throws {Error} if either version is invalid + */ +function compareSemver(a, b) { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa) throw new Error(`Invalid version: ${a}`); + if (!pb) throw new Error(`Invalid version: ${b}`); + if (pa.major !== pb.major) return pa.major - pb.major; + if (pa.minor !== pb.minor) return pa.minor - pb.minor; + return pa.patch - pb.patch; +} + +// ── Asset name resolution ────────────────────────────────────────────── + +/** + * Strip 'v' prefix from a version string. + * @param {string} version + * @returns {string} + */ +function stripV(version) { + return version.startsWith('v') ? version.slice(1) : version; +} + +/** + * Get expected asset filename for a given version/platform/arch. + * Matches electron-builder artifactName + Inno Setup OutputBaseFilename. + * + * @param {string} version — e.g. 'v0.12.0' or '0.12.0' + * @param {'win32' | 'darwin'} platform + * @param {'arm64' | 'x64'} arch + * @returns {string} + */ +function resolveAssetName(version, platform, arch) { + const v = stripV(version); + if (platform === 'win32') { + // Inno Setup: OutputBaseFilename=CatCafe-Setup-{#MyAppVersion} + return `CatCafe-Setup-${v}.exe`; + } + // electron-builder: artifactName=CatCafe-${version}-${arch}.${ext} + return `CatCafe-${v}-${arch}.dmg`; +} + +// ── Asset four-tuple extraction ──────────────────────────────────────── + +/** + * Extract asset metadata from a GitHub API asset object. + * Returns null if digest is missing or falsy (integrity cannot be verified). + * Preserves browser_download_url for the download transport layer. + * + * @param {{ id: number, name: string, size: number, digest?: string, browser_download_url?: string }} apiAsset + * @returns {{ id: number, name: string, size: number, digest: string, browser_download_url: string | null } | null} + */ +function extractAssetQuad(apiAsset) { + if (!apiAsset || !apiAsset.digest) return null; + return { + id: apiAsset.id, + name: apiAsset.name, + size: apiAsset.size, + digest: apiAsset.digest, + browser_download_url: apiAsset.browser_download_url || null, + }; +} + +// ── Update target selection ──────────────────────────────────────────── + +/** + * Select the best update target from a GitHub Releases API response. + * + * Algorithm: + * 1. Filter out draft + prerelease + * 2. Parse & validate tag_name as semver + * 3. Sort descending by semver + * 4. For each candidate (highest first): + * a. Skip if <= currentVersion + * b. Skip if == skippedVersion (user chose "skip this version") + * c. Find the platform-specific asset by name + * d. Extract four-tuple; skip if digest missing + * e. Return first match (= highest valid) + * 5. Return null if no valid candidate + * + * @param {Array} releases — GitHub API /releases response + * @param {string} currentVersion — e.g. '0.10.1' + * @param {'win32' | 'darwin'} platform + * @param {'arm64' | 'x64'} arch + * @param {{ skippedVersion?: string | null }} [options] + * @returns {{ version: string, asset: { id, name, size, digest }, releaseNotes: string } | null} + */ +function selectUpdateTarget(releases, currentVersion, platform, arch, options) { + const skipped = options?.skippedVersion || null; + + // Step 1-2: filter + parse + const candidates = []; + for (const rel of releases) { + if (rel.draft || rel.prerelease) continue; + const parsed = parseVersion(rel.tag_name); + if (!parsed) continue; + candidates.push({ release: rel, parsed, version: stripV(rel.tag_name) }); + } + + // Step 3: sort descending + candidates.sort((a, b) => { + if (a.parsed.major !== b.parsed.major) return b.parsed.major - a.parsed.major; + if (a.parsed.minor !== b.parsed.minor) return b.parsed.minor - a.parsed.minor; + return b.parsed.patch - a.parsed.patch; + }); + + // Step 4: find first valid + for (const c of candidates) { + // 4a: skip if not newer + if (compareSemver(c.version, currentVersion) <= 0) continue; + // 4b: skip if user chose to skip this version + if (skipped && c.version === stripV(skipped)) continue; + // 4c: find platform asset + const expectedName = resolveAssetName(c.version, platform, arch); + const apiAsset = c.release.assets.find((a) => a.name === expectedName); + if (!apiAsset) continue; + // 4d: extract quad (requires digest) + const quad = extractAssetQuad(apiAsset); + if (!quad) continue; + // 4e: found! + return { + version: c.version, + asset: quad, + releaseNotes: c.release.body || '', + }; + } + + return null; +} + +// ── Settings persistence ─────────────────────────────────────────────── + +const DEFAULT_SETTINGS = { + autoCheck: true, + skippedVersion: null, + lastCheckAt: null, + etag: null, +}; + +/** + * Load update settings from disk. Returns defaults on missing/corrupt file. + * @param {string} settingsPath — absolute path to update-settings.json + * @returns {{ autoCheck: boolean, skippedVersion: string|null, lastCheckAt: string|null, etag: string|null }} + */ +function loadSettings(settingsPath) { + try { + const raw = fs.readFileSync(settingsPath, 'utf-8'); + const parsed = JSON.parse(raw); + return { + autoCheck: typeof parsed.autoCheck === 'boolean' ? parsed.autoCheck : DEFAULT_SETTINGS.autoCheck, + skippedVersion: parsed.skippedVersion ?? DEFAULT_SETTINGS.skippedVersion, + lastCheckAt: parsed.lastCheckAt ?? DEFAULT_SETTINGS.lastCheckAt, + etag: parsed.etag ?? DEFAULT_SETTINGS.etag, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +/** + * Save update settings to disk. Creates parent directory if needed. + * @param {string} settingsPath + * @param {{ autoCheck: boolean, skippedVersion: string|null, lastCheckAt: string|null, etag: string|null }} settings + */ +function saveSettings(settingsPath, settings) { + const dir = path.dirname(settingsPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); +} + +module.exports = { + parseVersion, + compareSemver, + resolveAssetName, + extractAssetQuad, + selectUpdateTarget, + loadSettings, + saveSettings, +}; diff --git a/desktop/update-checker.test.js b/desktop/update-checker.test.js new file mode 100644 index 0000000000..696dab3fd5 --- /dev/null +++ b/desktop/update-checker.test.js @@ -0,0 +1,337 @@ +// F258 Phase A — update-checker unit tests (TDD) +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { describe, test, beforeEach, afterEach } = require('node:test'); + +const { + parseVersion, + compareSemver, + resolveAssetName, + extractAssetQuad, + selectUpdateTarget, + loadSettings, + saveSettings, +} = require('./update-checker'); + +function makeAsset(name, id, size, digest) { + return { id, name, size, browser_download_url: `https://github.com/download/${name}`, digest }; +} + +const FIXTURE_RELEASES = [ + { + tag_name: 'v0.12.0', + draft: false, + prerelease: false, + body: 'New features in 0.12.0', + assets: [ + makeAsset('CatCafe-Setup-0.12.0.exe', 201, 802000000, 'sha256:aaa111'), + makeAsset('CatCafe-0.12.0-arm64.dmg', 202, 622000000, 'sha256:bbb222'), + makeAsset('CatCafe-0.12.0-x64.dmg', 203, 632000000, 'sha256:ccc333'), + ], + }, + { + tag_name: 'v0.13.0-beta.1', + draft: false, + prerelease: true, // ← should be filtered + body: 'Beta release', + assets: [ + makeAsset('CatCafe-Setup-0.13.0-beta.1.exe', 301, 810000000, 'sha256:ddd444'), + makeAsset('CatCafe-0.13.0-beta.1-arm64.dmg', 302, 625000000, 'sha256:eee555'), + makeAsset('CatCafe-0.13.0-beta.1-x64.dmg', 303, 635000000, 'sha256:fff666'), + ], + }, + { + tag_name: 'v0.14.0', + draft: true, // ← should be filtered + prerelease: false, + body: 'Draft release', + assets: [ + makeAsset('CatCafe-Setup-0.14.0.exe', 401, 820000000, 'sha256:ggg777'), + makeAsset('CatCafe-0.14.0-arm64.dmg', 402, 640000000, 'sha256:hhh888'), + makeAsset('CatCafe-0.14.0-x64.dmg', 403, 650000000, 'sha256:iii999'), + ], + }, + { + tag_name: 'v0.11.1', + draft: false, + prerelease: false, + body: 'Patch release', + assets: [ + makeAsset('CatCafe-Setup-0.11.1.exe', 101, 802000000, 'sha256:jjj000'), + makeAsset('CatCafe-0.11.1-arm64.dmg', 102, 622000000, 'sha256:kkk111'), + makeAsset('CatCafe-0.11.1-x64.dmg', 103, 632000000, 'sha256:lll222'), + ], + }, + { + // Release with incomplete assets (missing Setup.exe) — should be skipped + tag_name: 'v0.11.2', + draft: false, + prerelease: false, + body: 'Incomplete release', + assets: [ + makeAsset('CatCafe-0.11.2-arm64.dmg', 501, 622000000, 'sha256:mmm333'), + // missing Setup.exe and x64.dmg + ], + }, +]; + +describe('parseVersion', () => { + test('parses v-prefixed tag', () => { + assert.deepEqual(parseVersion('v0.11.1'), { major: 0, minor: 11, patch: 1 }); + }); + + test('parses bare version string', () => { + assert.deepEqual(parseVersion('0.11.1'), { major: 0, minor: 11, patch: 1 }); + }); + + test('parses major version', () => { + assert.deepEqual(parseVersion('v1.0.0'), { major: 1, minor: 0, patch: 0 }); + }); + + test('parses double-digit components', () => { + assert.deepEqual(parseVersion('v12.34.56'), { major: 12, minor: 34, patch: 56 }); + }); + + test('returns null for invalid input', () => { + assert.equal(parseVersion('invalid'), null); + assert.equal(parseVersion(''), null); + assert.equal(parseVersion('v1.2'), null); + assert.equal(parseVersion('v1.2.3.4'), null); + assert.equal(parseVersion('v1.2.beta'), null); + }); +}); + +describe('compareSemver', () => { + test('greater major version', () => { + assert.ok(compareSemver('1.0.0', '0.99.99') > 0); + }); + + test('greater minor version', () => { + assert.ok(compareSemver('0.12.0', '0.11.1') > 0); + }); + + test('greater patch version', () => { + assert.ok(compareSemver('0.10.2', '0.10.1') > 0); + }); + + test('equal versions', () => { + assert.equal(compareSemver('0.11.1', '0.11.1'), 0); + }); + + test('lesser version returns negative', () => { + assert.ok(compareSemver('0.10.1', '0.11.1') < 0); + }); + + test('handles v-prefix on either side', () => { + assert.ok(compareSemver('v0.12.0', '0.11.1') > 0); + assert.ok(compareSemver('0.12.0', 'v0.11.1') > 0); + assert.equal(compareSemver('v0.11.1', 'v0.11.1'), 0); + }); + + test('throws on invalid version', () => { + assert.throws(() => compareSemver('invalid', '0.11.1')); + assert.throws(() => compareSemver('0.11.1', 'garbage')); + }); +}); + +describe('resolveAssetName', () => { + test('Windows Setup.exe', () => { + assert.equal(resolveAssetName('0.12.0', 'win32', 'x64'), 'CatCafe-Setup-0.12.0.exe'); + }); + + test('Windows ignores arch (single installer)', () => { + assert.equal(resolveAssetName('0.12.0', 'win32', 'arm64'), 'CatCafe-Setup-0.12.0.exe'); + }); + + test('mac arm64 DMG', () => { + assert.equal(resolveAssetName('0.12.0', 'darwin', 'arm64'), 'CatCafe-0.12.0-arm64.dmg'); + }); + + test('mac x64 DMG', () => { + assert.equal(resolveAssetName('0.12.0', 'darwin', 'x64'), 'CatCafe-0.12.0-x64.dmg'); + }); + + test('strips v-prefix from version', () => { + assert.equal(resolveAssetName('v0.12.0', 'win32', 'x64'), 'CatCafe-Setup-0.12.0.exe'); + assert.equal(resolveAssetName('v0.12.0', 'darwin', 'arm64'), 'CatCafe-0.12.0-arm64.dmg'); + }); +}); + +describe('extractAssetQuad', () => { + test('extracts asset info including browser_download_url', () => { + const asset = makeAsset('CatCafe-Setup-0.12.0.exe', 201, 802000000, 'sha256:aaa111'); + const result = extractAssetQuad(asset); + assert.equal(result.id, 201); + assert.equal(result.name, 'CatCafe-Setup-0.12.0.exe'); + assert.equal(result.size, 802000000); + assert.equal(result.digest, 'sha256:aaa111'); + assert.equal(result.browser_download_url, 'https://github.com/download/CatCafe-Setup-0.12.0.exe'); + }); + + test('returns null when digest is missing', () => { + const asset = makeAsset('test.exe', 1, 100, undefined); + assert.equal(extractAssetQuad(asset), null); + }); + + test('returns null when digest is empty string', () => { + const asset = makeAsset('test.exe', 1, 100, ''); + assert.equal(extractAssetQuad(asset), null); + }); +}); + +describe('selectUpdateTarget', () => { + test('selects highest non-draft non-prerelease with complete assets (win)', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.10.1', 'win32', 'x64'); + assert.notEqual(result, null); + assert.equal(result.version, '0.12.0'); + assert.equal(result.asset.name, 'CatCafe-Setup-0.12.0.exe'); + assert.equal(result.asset.id, 201); + assert.equal(result.asset.digest, 'sha256:aaa111'); + assert.equal(result.releaseNotes, 'New features in 0.12.0'); + // P1-2: browser_download_url must survive through selectUpdateTarget + assert.equal(result.asset.browser_download_url, 'https://github.com/download/CatCafe-Setup-0.12.0.exe'); + }); + + test('selects highest for mac arm64', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.10.1', 'darwin', 'arm64'); + assert.notEqual(result, null); + assert.equal(result.version, '0.12.0'); + assert.equal(result.asset.name, 'CatCafe-0.12.0-arm64.dmg'); + assert.equal(result.asset.id, 202); + }); + + test('selects highest for mac x64', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.10.1', 'darwin', 'x64'); + assert.notEqual(result, null); + assert.equal(result.version, '0.12.0'); + assert.equal(result.asset.name, 'CatCafe-0.12.0-x64.dmg'); + assert.equal(result.asset.id, 203); + }); + + test('returns null when current version >= all releases', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.12.0', 'win32', 'x64'); + assert.equal(result, null); + }); + + test('returns null when current version > all releases', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '1.0.0', 'win32', 'x64'); + assert.equal(result, null); + }); + + test('filters out draft releases', () => { + // v0.14.0 is draft — should not be selected even though it's highest + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.12.0', 'win32', 'x64'); + // 0.12.0 current, 0.14.0 is draft → no update available + assert.equal(result, null); + }); + + test('filters out prerelease', () => { + // v0.13.0-beta.1 is prerelease + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.12.0', 'win32', 'x64'); + assert.equal(result, null); + }); + + test('skips releases with incomplete assets for the platform', () => { + // v0.11.2 missing Setup.exe → skipped for win32 + // So win from 0.10.1 should get 0.12.0 (not 0.11.2) + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.10.1', 'win32', 'x64'); + assert.equal(result.version, '0.12.0'); + }); + + test('skips release when platform asset has no digest', () => { + const releasesNoDigest = [ + { + tag_name: 'v0.12.0', + draft: false, + prerelease: false, + body: 'No digest', + assets: [ + makeAsset('CatCafe-Setup-0.12.0.exe', 201, 802000000, null), + makeAsset('CatCafe-0.12.0-arm64.dmg', 202, 622000000, 'sha256:bbb222'), + makeAsset('CatCafe-0.12.0-x64.dmg', 203, 632000000, 'sha256:ccc333'), + ], + }, + ]; + const result = selectUpdateTarget(releasesNoDigest, '0.10.1', 'win32', 'x64'); + assert.equal(result, null, 'should skip release when target asset has no digest'); + }); + + test('respects skippedVersion option', () => { + const result = selectUpdateTarget(FIXTURE_RELEASES, '0.10.1', 'win32', 'x64', { + skippedVersion: '0.12.0', + }); + // 0.12.0 is skipped → falls back to 0.11.1 + assert.notEqual(result, null); + assert.equal(result.version, '0.11.1'); + }); + + test('handles empty releases array', () => { + const result = selectUpdateTarget([], '0.10.1', 'win32', 'x64'); + assert.equal(result, null); + }); + + test('handles releases with no assets', () => { + const emptyAssets = [{ tag_name: 'v0.12.0', draft: false, prerelease: false, body: '', assets: [] }]; + const result = selectUpdateTarget(emptyAssets, '0.10.1', 'win32', 'x64'); + assert.equal(result, null); + }); + + test('handles malformed tag_name gracefully', () => { + const badTags = [{ tag_name: 'not-a-version', draft: false, prerelease: false, body: '', assets: [] }]; + const result = selectUpdateTarget(badTags, '0.10.1', 'win32', 'x64'); + assert.equal(result, null); + }); +}); + +describe('settings persistence', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'update-settings-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('loadSettings returns defaults when file does not exist', () => { + const settingsPath = path.join(tempDir, 'update-settings.json'); + const settings = loadSettings(settingsPath); + assert.equal(settings.autoCheck, true); + assert.equal(settings.skippedVersion, null); + assert.equal(settings.lastCheckAt, null); + assert.equal(settings.etag, null); + }); + + test('saveSettings writes and loadSettings reads back', () => { + const settingsPath = path.join(tempDir, 'update-settings.json'); + const data = { + autoCheck: false, + skippedVersion: '0.12.0', + lastCheckAt: '2026-07-07T00:00:00.000Z', + etag: '"abc123"', + }; + saveSettings(settingsPath, data); + const loaded = loadSettings(settingsPath); + assert.deepEqual(loaded, data); + }); + + test('loadSettings tolerates corrupted JSON', () => { + const settingsPath = path.join(tempDir, 'update-settings.json'); + writeFileSync(settingsPath, 'not valid json!!!'); + const settings = loadSettings(settingsPath); + // Should return defaults, not throw + assert.equal(settings.autoCheck, true); + assert.equal(settings.skippedVersion, null); + }); + + test('saveSettings creates parent directory if needed', () => { + const settingsPath = path.join(tempDir, 'subdir', 'update-settings.json'); + const data = { autoCheck: true, skippedVersion: null, lastCheckAt: null, etag: null }; + saveSettings(settingsPath, data); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + assert.equal(content.autoCheck, true); + }); +}); diff --git a/desktop/update-downloader.js b/desktop/update-downloader.js new file mode 100644 index 0000000000..d46a2404c3 --- /dev/null +++ b/desktop/update-downloader.js @@ -0,0 +1,151 @@ +// F258: Desktop In-App Update — download & journal layer +// +// This module handles: +// 1. pendingUpdate journal — crash-safe state machine for upgrade tracking +// 2. File integrity verification (sha256 digest + size) +// 3. Updates directory management +// +// HTTP download logic lives in the Electron integration layer (update-manager.js, +// Phase B/C) because it requires Electron's `net` module for system proxy support. +// +// Design decisions (from spec): +// - Journal at {userData}/updates/pending-update.json +// - Installer + log MUST stay in {userData}/updates/ (never temp!) +// - Failed state: never auto-clean (user may need to rerun installer manually) +// - Success state: clear journal + clean old files +// - Digest format: "sha256:{hex}" from GitHub API + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { compareSemver } = require('./update-checker'); + +const JOURNAL_FILENAME = 'pending-update.json'; + +/** + * Get the updates directory path for a given userData root. + * @param {string} userDataRoot + * @returns {string} + */ +function updatesDir(userDataRoot) { + return path.join(userDataRoot, 'updates'); +} + +// ── Journal persistence ──────────────────────────────────────────────── +// The journal is a small JSON file that records the state of a pending upgrade. +// It is written BEFORE spawning the installer and checked on next startup. +// +// State machine (spec §3.2): +// spawn → write journal → quit → installer runs → next startup: +// current >= target → "success" (clear journal, notify user) +// current < target → "failed" (show recovery dialog) + +/** + * Write a pending update journal entry. + * Creates the directory if needed. + * + * @param {string} dir — directory to write journal in (typically updatesDir) + * @param {object} data + * @param {string} data.targetVersion + * @param {number} data.assetId + * @param {string} data.assetName + * @param {string} data.digest + * @param {string} data.installerPath + * @param {string} data.logPath + * @param {string} data.startedAt — ISO8601 timestamp + */ +function writeJournal(dir, data) { + fs.mkdirSync(dir, { recursive: true }); + const journalPath = path.join(dir, JOURNAL_FILENAME); + fs.writeFileSync(journalPath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Read the pending update journal. Returns null if missing or corrupt. + * @param {string} dir + * @returns {object | null} + */ +function readJournal(dir) { + try { + const raw = fs.readFileSync(path.join(dir, JOURNAL_FILENAME), 'utf-8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Clear the journal (upgrade succeeded or user chose "ignore"). + * Safe to call when no journal exists. + * @param {string} dir + */ +function clearJournal(dir) { + try { + fs.unlinkSync(path.join(dir, JOURNAL_FILENAME)); + } catch { + // file didn't exist — fine + } +} + +/** + * Check the result of a previous upgrade attempt. + * + * @param {string} dir — journal directory + * @param {string} currentVersion — app.getVersion() at startup + * @returns {'none' | 'success' | 'failed'} + */ +function checkUpgradeResult(dir, currentVersion) { + const journal = readJournal(dir); + if (!journal) return 'none'; + try { + // current >= target means upgrade succeeded + if (compareSemver(currentVersion, journal.targetVersion) >= 0) { + return 'success'; + } + return 'failed'; + } catch { + // Invalid version in journal — treat as no journal + return 'none'; + } +} + +// ── File integrity verification ──────────────────────────────────────── + +/** + * Verify a downloaded file's integrity against the expected digest and size. + * Uses streaming SHA256 (spec §2: "node:crypto streaming sha256"). + * + * @param {string} filePath — path to the downloaded file + * @param {string} expectedDigest — e.g. "sha256:abc123..." + * @param {number} expectedSize — expected byte count + * @returns {boolean} + */ +function verifyFileIntegrity(filePath, expectedDigest, expectedSize) { + try { + const stat = fs.statSync(filePath); + if (stat.size !== expectedSize) return false; + + // Parse digest format "sha256:{hex}" + const colonIdx = expectedDigest.indexOf(':'); + if (colonIdx === -1) return false; + const expectedHash = expectedDigest.slice(colonIdx + 1); + + const content = fs.readFileSync(filePath); + const actualHash = createHash('sha256').update(content).digest('hex'); + return actualHash === expectedHash; + } catch { + return false; + } +} + +module.exports = { + JOURNAL_FILENAME, + updatesDir, + writeJournal, + readJournal, + clearJournal, + checkUpgradeResult, + verifyFileIntegrity, +}; diff --git a/desktop/update-downloader.test.js b/desktop/update-downloader.test.js new file mode 100644 index 0000000000..e67219c2a4 --- /dev/null +++ b/desktop/update-downloader.test.js @@ -0,0 +1,226 @@ +// F258 Phase B — update-downloader unit tests (TDD) +// Tests: download state machine, journal persistence, digest verification, +// resume logic, disk space check, cleanup. +// Note: actual HTTP is NOT tested here — net.request is injected/mocked. +// Integration tests go in Phase E. + +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { describe, test, beforeEach, afterEach } = require('node:test'); + +const { + JOURNAL_FILENAME, + readJournal, + writeJournal, + clearJournal, + checkUpgradeResult, + verifyFileIntegrity, + updatesDir, +} = require('./update-downloader'); + +// ── Journal persistence ──────────────────────────────────────────────── + +describe('journal persistence', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'update-journal-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('writeJournal creates file with all required fields', () => { + const journalPath = path.join(tempDir, JOURNAL_FILENAME); + const data = { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'CatCafe-Setup-0.12.0.exe', + digest: 'sha256:aaa111', + installerPath: path.join(tempDir, 'CatCafe-Setup-0.12.0.exe'), + logPath: path.join(tempDir, 'install.log'), + startedAt: '2026-07-07T08:00:00.000Z', + }; + writeJournal(tempDir, data); + assert.ok(existsSync(journalPath)); + const persisted = JSON.parse(readFileSync(journalPath, 'utf-8')); + assert.equal(persisted.targetVersion, '0.12.0'); + assert.equal(persisted.assetId, 201); + assert.equal(persisted.digest, 'sha256:aaa111'); + }); + + test('readJournal returns data when file exists', () => { + writeJournal(tempDir, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'CatCafe-Setup-0.12.0.exe', + digest: 'sha256:aaa111', + installerPath: '/fake/path.exe', + logPath: '/fake/log', + startedAt: '2026-07-07T08:00:00.000Z', + }); + const j = readJournal(tempDir); + assert.notEqual(j, null); + assert.equal(j.targetVersion, '0.12.0'); + }); + + test('readJournal returns null when no journal', () => { + assert.equal(readJournal(tempDir), null); + }); + + test('readJournal returns null on corrupted JSON', () => { + const journalPath = path.join(tempDir, JOURNAL_FILENAME); + writeFileSync(journalPath, '{corrupted!!!'); + assert.equal(readJournal(tempDir), null); + }); + + test('clearJournal removes the file', () => { + writeJournal(tempDir, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'test.exe', + digest: 'sha256:abc', + installerPath: '/fake', + logPath: '/fake', + startedAt: '2026-07-07T08:00:00.000Z', + }); + assert.ok(existsSync(path.join(tempDir, JOURNAL_FILENAME))); + clearJournal(tempDir); + assert.ok(!existsSync(path.join(tempDir, JOURNAL_FILENAME))); + }); + + test('clearJournal is safe when no journal exists', () => { + // Should not throw + clearJournal(tempDir); + }); + + test('writeJournal creates updates directory if missing', () => { + const nested = path.join(tempDir, 'sub', 'updates'); + writeJournal(nested, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'test.exe', + digest: 'sha256:abc', + installerPath: '/fake', + logPath: '/fake', + startedAt: '2026-07-07T08:00:00.000Z', + }); + assert.ok(existsSync(path.join(nested, JOURNAL_FILENAME))); + }); +}); + +// ── checkUpgradeResult ───────────────────────────────────────────────── + +describe('checkUpgradeResult', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'upgrade-result-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('returns "none" when no journal exists', () => { + assert.equal(checkUpgradeResult(tempDir, '0.11.1'), 'none'); + }); + + test('returns "success" when current >= target', () => { + writeJournal(tempDir, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'test.exe', + digest: 'sha256:abc', + installerPath: '/fake', + logPath: '/fake', + startedAt: '2026-07-07T08:00:00.000Z', + }); + assert.equal(checkUpgradeResult(tempDir, '0.12.0'), 'success'); + }); + + test('returns "success" when current > target', () => { + writeJournal(tempDir, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'test.exe', + digest: 'sha256:abc', + installerPath: '/fake', + logPath: '/fake', + startedAt: '2026-07-07T08:00:00.000Z', + }); + assert.equal(checkUpgradeResult(tempDir, '0.13.0'), 'success'); + }); + + test('returns "failed" when current < target', () => { + writeJournal(tempDir, { + targetVersion: '0.12.0', + assetId: 201, + assetName: 'test.exe', + digest: 'sha256:abc', + installerPath: '/fake', + logPath: '/fake', + startedAt: '2026-07-07T08:00:00.000Z', + }); + assert.equal(checkUpgradeResult(tempDir, '0.11.1'), 'failed'); + }); +}); + +// ── verifyFileIntegrity ──────────────────────────────────────────────── + +describe('verifyFileIntegrity', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'verify-integrity-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('returns true for matching digest and size', () => { + const content = Buffer.from('hello world test content for integrity check'); + const filePath = path.join(tempDir, 'test.exe'); + writeFileSync(filePath, content); + const hash = createHash('sha256').update(content).digest('hex'); + const result = verifyFileIntegrity(filePath, `sha256:${hash}`, content.length); + assert.equal(result, true); + }); + + test('returns false for digest mismatch', () => { + const content = Buffer.from('hello'); + const filePath = path.join(tempDir, 'bad.exe'); + writeFileSync(filePath, content); + const result = verifyFileIntegrity(filePath, 'sha256:0000000000000000', content.length); + assert.equal(result, false); + }); + + test('returns false for size mismatch', () => { + const content = Buffer.from('hello'); + const filePath = path.join(tempDir, 'size.exe'); + writeFileSync(filePath, content); + const hash = createHash('sha256').update(content).digest('hex'); + // Wrong size + const result = verifyFileIntegrity(filePath, `sha256:${hash}`, content.length + 100); + assert.equal(result, false); + }); + + test('returns false when file does not exist', () => { + const result = verifyFileIntegrity(path.join(tempDir, 'nope.exe'), 'sha256:abc', 100); + assert.equal(result, false); + }); +}); + +// ── updatesDir ───────────────────────────────────────────────────────── + +describe('updatesDir', () => { + test('returns {userData}/updates path', () => { + const result = updatesDir('/fake/userData'); + assert.equal(result, path.join('/fake/userData', 'updates')); + }); +}); diff --git a/desktop/update-installer.js b/desktop/update-installer.js new file mode 100644 index 0000000000..60fc5c73bb --- /dev/null +++ b/desktop/update-installer.js @@ -0,0 +1,181 @@ +// F258: Desktop In-App Update — HTTP transport layer +// +// Separated from update-manager.js for file-size compliance (350 line limit). +// Uses Electron's `net` module for system proxy support. +// Resume: Range + If-Range; discard partial on ETag mismatch (spec §2). + +'use strict'; + +const fs = require('node:fs'); + +const GITHUB_OWNER = 'zts212653'; +const GITHUB_REPO = 'clowder-ai'; +const RELEASES_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=10`; + +/** + * Fetch releases from GitHub API. Returns null on 304 / error. + * @param {object} net — Electron net module + * @param {string} appVersion — for User-Agent + * @param {string|null} etag — If-None-Match + * @returns {Promise<{ data: Array, etag: string|null } | null>} + */ +function fetchReleases(net, appVersion, etag) { + return new Promise((resolve) => { + try { + const request = net.request(RELEASES_URL); + request.setHeader('Accept', 'application/vnd.github+json'); + request.setHeader('User-Agent', `ClowderAI/${appVersion}`); + if (etag) request.setHeader('If-None-Match', etag); + + let body = ''; + request.on('response', (response) => { + if (response.statusCode === 304) { + resolve(null); + return; + } + if (response.statusCode !== 200) { + resolve(null); + return; + } + const newEtag = response.headers.etag || null; + response.on('data', (chunk) => { + body += chunk.toString(); + }); + response.on('end', () => { + try { + resolve({ data: JSON.parse(body), etag: newEtag }); + } catch { + resolve(null); + } + }); + }); + request.on('error', () => resolve(null)); + request.end(); + } catch { + resolve(null); + } + }); +} + +/** + * Download a release asset with progress and resume support. + * + * Resume logic (spec §2): + * - Save ETag from first response in {dest}.meta + * - On retry: send Range + If-Range + * - If server returns 200 (not 206): discard partial, restart + * - If ETag changed: discard partial, restart + * + * @param {object} net — Electron net module + * @param {{ name: string, size: number, browser_download_url?: string }} asset + * @param {string} destPath — target file path + * @param {string} appVersion — for User-Agent + * @param {Function} setProgressBar — (0..1 | -1) + * @param {Function} dbg — logger + */ +function downloadAsset(net, asset, destPath, appVersion, setProgressBar, dbg) { + return new Promise((resolve, reject) => { + const url = + asset.browser_download_url || `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${asset.name}`; + const metaPath = `${destPath}.meta`; + let existingSize = 0; + let savedEtag = null; + + // Check for partial download + try { + existingSize = fs.statSync(destPath).size; + savedEtag = JSON.parse(fs.readFileSync(metaPath, 'utf-8')).etag; + } catch { + existingSize = 0; + } + + const request = net.request(url); + request.setHeader('User-Agent', `ClowderAI/${appVersion}`); + if (existingSize > 0 && savedEtag) { + request.setHeader('Range', `bytes=${existingSize}-`); + request.setHeader('If-Range', savedEtag); + } + + request.on('response', (response) => { + let isResume = response.statusCode === 206; + if (response.statusCode !== 200 && !isResume) { + reject(new Error(`HTTP ${response.statusCode}`)); + return; + } + // 200 with existing partial = server doesn't support resume or ETag changed + if (!isResume && existingSize > 0) { + dbg('Resume rejected — restarting download'); + existingSize = 0; + } + // Validate Content-Range on 206: server must resume from our byte offset. + // Mismatch → discard partial + meta so next attempt starts clean (spec §2). + // We must NOT consume the truncated 206 body — it's only a suffix. + if (isResume) { + const cr = response.headers['content-range']; + const m = cr?.match(/bytes (\d+)-/); + if (!m || Number(m[1]) !== existingSize) { + dbg(`Content-Range mismatch (expected start=${existingSize}, got "${cr}") — discarding partial`); + try { + fs.unlinkSync(destPath); + } catch {} + try { + fs.unlinkSync(metaPath); + } catch {} + response.destroy(); + reject(new Error(`Content-Range mismatch: expected start=${existingSize}, got "${cr}"`)); + return; + } + } + + // Validate ETag consistency on 206 resume (spec §2 / AC-4). + // If the server returns a different ETag, the resource changed — the + // existing partial is stale and appending would produce a mixed file. + const serverEtag = response.headers.etag || null; + if (isResume && savedEtag && serverEtag && serverEtag !== savedEtag) { + dbg(`ETag mismatch on 206 (saved="${savedEtag}", got="${serverEtag}") — discarding partial`); + try { + fs.unlinkSync(destPath); + } catch {} + try { + fs.unlinkSync(metaPath); + } catch {} + response.destroy(); + reject(new Error(`ETag mismatch on resume: saved="${savedEtag}", got="${serverEtag}"`)); + return; + } + if (serverEtag) { + try { + fs.writeFileSync(metaPath, JSON.stringify({ etag: serverEtag }), 'utf-8'); + } catch {} + } + + let downloaded = existingSize; + const ws = fs.createWriteStream(destPath, { flags: isResume ? 'a' : 'w' }); + + response.on('data', (chunk) => { + ws.write(chunk); + downloaded += chunk.length; + setProgressBar(asset.size > 0 ? downloaded / asset.size : -1); + }); + + response.on('end', () => { + ws.end(() => { + try { + fs.unlinkSync(metaPath); + } catch {} + resolve(); + }); + }); + + response.on('error', (err) => { + ws.end(); + reject(err); + }); + }); + + request.on('error', reject); + request.end(); + }); +} + +module.exports = { fetchReleases, downloadAsset }; diff --git a/desktop/update-installer.test.js b/desktop/update-installer.test.js new file mode 100644 index 0000000000..758cae535d --- /dev/null +++ b/desktop/update-installer.test.js @@ -0,0 +1,210 @@ +// F258 — update-installer unit tests +// Tests: HTTP transport (downloadAsset) with mock net module. +// Covers: full download, resume, Content-Range mismatch rejection. + +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const { describe, test, beforeEach, afterEach } = require('node:test'); + +const { downloadAsset } = require('./update-installer'); + +// ── Mock helpers ─────────────────────────────────────────────────────── + +/** Create a mock Electron net.request that returns a canned response. */ +function mockNet({ statusCode, headers, body }) { + return { + request(_url) { + const req = new EventEmitter(); + req.setHeader = () => {}; + req.end = () => { + const res = new EventEmitter(); + res.statusCode = statusCode; + res.headers = headers || {}; + res.destroy = () => {}; + process.nextTick(() => { + req.emit('response', res); + if (body !== undefined) { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(body); + process.nextTick(() => { + res.emit('data', buf); + process.nextTick(() => res.emit('end')); + }); + } + }); + }; + return req; + }, + }; +} + +const noop = () => {}; + +// ── downloadAsset ────────────────────────────────────────────────────── + +describe('downloadAsset', () => { + let tempDir; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'dl-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('200 full download writes file', async () => { + const dest = path.join(tempDir, 'app.dmg'); + const content = 'FULL_FILE_DATA_HERE'; + const net = mockNet({ + statusCode: 200, + headers: { etag: '"abc"' }, + body: content, + }); + + await downloadAsset(net, { name: 'app.dmg', size: content.length }, dest, '0.10.0', noop, noop); + + assert.equal(readFileSync(dest, 'utf-8'), content); + // meta file cleaned up after successful download + assert.equal(existsSync(`${dest}.meta`), false); + }); + + test('206 resume with correct Content-Range appends', async () => { + const dest = path.join(tempDir, 'app.dmg'); + const partial = 'PARTIAL_'; + const rest = 'REST_DATA'; + + // Simulate existing partial download + saved meta + writeFileSync(dest, partial); + writeFileSync(`${dest}.meta`, JSON.stringify({ etag: '"e1"' })); + + const net = mockNet({ + statusCode: 206, + headers: { + 'content-range': `bytes ${partial.length}-${partial.length + rest.length - 1}/${partial.length + rest.length}`, + etag: '"e1"', + }, + body: rest, + }); + + await downloadAsset(net, { name: 'app.dmg', size: partial.length + rest.length }, dest, '0.10.0', noop, noop); + + assert.equal(readFileSync(dest, 'utf-8'), partial + rest); + }); + + test('206 with mismatched Content-Range rejects and cleans up', async () => { + const dest = path.join(tempDir, 'app.dmg'); + const partial = 'PARTIAL_16_BYTES'; + + // Simulate existing partial + meta + writeFileSync(dest, partial); + writeFileSync(`${dest}.meta`, JSON.stringify({ etag: '"e1"' })); + + // Server responds with wrong start byte (50 instead of 16) + const net = mockNet({ + statusCode: 206, + headers: { + 'content-range': `bytes 50-61/112`, + etag: '"e1"', + }, + body: 'WRONG_SUFFIX', + }); + + await assert.rejects( + () => downloadAsset(net, { name: 'app.dmg', size: 112 }, dest, '0.10.0', noop, noop), + (err) => { + assert.match(err.message, /Content-Range mismatch/); + return true; + }, + ); + + // Partial file and meta must be deleted + assert.equal(existsSync(dest), false, 'partial file should be deleted'); + assert.equal(existsSync(`${dest}.meta`), false, 'meta file should be deleted'); + }); + + test('206 with missing Content-Range header rejects', async () => { + const dest = path.join(tempDir, 'app.dmg'); + writeFileSync(dest, 'PARTIAL'); + writeFileSync(`${dest}.meta`, JSON.stringify({ etag: '"e1"' })); + + const net = mockNet({ + statusCode: 206, + headers: { etag: '"e1"' }, // no content-range + body: 'DATA', + }); + + await assert.rejects( + () => downloadAsset(net, { name: 'app.dmg', size: 100 }, dest, '0.10.0', noop, noop), + (err) => { + assert.match(err.message, /Content-Range mismatch/); + return true; + }, + ); + + assert.equal(existsSync(dest), false); + assert.equal(existsSync(`${dest}.meta`), false); + }); + + test('non-2xx status rejects', async () => { + const dest = path.join(tempDir, 'app.dmg'); + const net = mockNet({ statusCode: 404, headers: {} }); + + await assert.rejects( + () => downloadAsset(net, { name: 'app.dmg', size: 100 }, dest, '0.10.0', noop, noop), + (err) => { + assert.match(err.message, /HTTP 404/); + return true; + }, + ); + }); + + test('206 with correct Content-Range but changed ETag rejects (AC-4)', async () => { + const dest = path.join(tempDir, 'app.dmg'); + const partial = 'PARTIAL_'; + + writeFileSync(dest, partial); + writeFileSync(`${dest}.meta`, JSON.stringify({ etag: '"old-etag"' })); + + // Server returns 206 with correct byte range but different ETag + const net = mockNet({ + statusCode: 206, + headers: { + 'content-range': `bytes ${partial.length}-15/16`, + etag: '"new-etag"', + }, + body: 'REST_NEW', + }); + + await assert.rejects( + () => downloadAsset(net, { name: 'app.dmg', size: 16 }, dest, '0.10.0', noop, noop), + (err) => { + assert.match(err.message, /ETag mismatch/); + return true; + }, + ); + + // Stale partial and meta must be deleted for clean retry + assert.equal(existsSync(dest), false, 'stale partial should be deleted'); + assert.equal(existsSync(`${dest}.meta`), false, 'meta should be deleted'); + }); + + test('200 with existing partial overwrites (resume rejected by server)', async () => { + const dest = path.join(tempDir, 'app.dmg'); + writeFileSync(dest, 'OLD_PARTIAL'); + writeFileSync(`${dest}.meta`, JSON.stringify({ etag: '"old"' })); + + const fullContent = 'BRAND_NEW_FULL_FILE'; + const net = mockNet({ + statusCode: 200, // server ignores Range, sends full + headers: { etag: '"new"' }, + body: fullContent, + }); + + await downloadAsset(net, { name: 'app.dmg', size: fullContent.length }, dest, '0.10.0', noop, noop); + + assert.equal(readFileSync(dest, 'utf-8'), fullContent); + }); +}); diff --git a/desktop/update-manager.js b/desktop/update-manager.js new file mode 100644 index 0000000000..1f16e67140 --- /dev/null +++ b/desktop/update-manager.js @@ -0,0 +1,315 @@ +// F258: Desktop In-App Update — orchestrator (Electron main process) +// +// Lifecycle: startup check → scheduled checks → download → install +// Pure logic: update-checker.js | Journal/verify: update-downloader.js +// Install execution: update-installer.js + +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const checker = require('./update-checker'); +const dl = require('./update-downloader'); +const { fetchReleases, downloadAsset } = require('./update-installer'); + +const CHECK_DELAY_MS = 3 * 60 * 1000; +const GITHUB_OWNER = 'zts212653'; +const GITHUB_REPO = 'clowder-ai'; + +class UpdateManager { + /** + * @param {object} deps — injected Electron dependencies + * @param {object} deps.app + * @param {object} deps.net — Electron net module + * @param {Function} deps.showDialog — (opts) => Promise + * @param {Function} deps.showNotification — (title, body) => void + * @param {Function} deps.setProgressBar — (progress: number) => void + * @param {Function} deps.openExternal — (url) => void + * @param {Function} deps.openPath — (filePath) => void + * @param {Function} deps.quitApp — () => Promise + * @param {Function} deps.dbg — (msg) => void + * @param {string} deps.userDataRoot + * @param {string} deps.platform + * @param {string} deps.arch + */ + constructor(deps) { + this._d = deps; + this._updatesDir = dl.updatesDir(deps.userDataRoot); + this._settingsPath = path.join(deps.userDataRoot, 'update-settings.json'); + this._checkTimer = null; + this._downloading = false; + } + + /** Check result of a previous upgrade attempt (called early at startup). */ + async checkPendingUpgrade() { + const { dbg, showDialog, showNotification, openPath } = this._d; + const currentVersion = this._d.app.getVersion(); + const result = dl.checkUpgradeResult(this._updatesDir, currentVersion); + + if (result === 'success') { + dbg(`Upgrade to ${currentVersion} succeeded`); + dl.clearJournal(this._updatesDir); + this._cleanOldFiles(); + showNotification('Clowder AI Updated', `Updated to v${currentVersion}`); + return; + } + + if (result !== 'failed') return; + + const journal = dl.readJournal(this._updatesDir); + dbg(`Upgrade to ${journal?.targetVersion} FAILED`); + const btn = await showDialog({ + type: 'warning', + buttons: ['Retry Install', 'Open Installer Location', 'View Log', 'Ignore'], + defaultId: 0, + cancelId: 3, + title: 'Clowder AI — Update Failed', + message: `Update to v${journal?.targetVersion} did not complete`, + detail: 'The update was interrupted. You can retry or dismiss this.', + }); + + if (btn === 0) await this._retryInstall(journal); + else if (btn === 1) openPath(this._updatesDir); + else if (btn === 2) openPath(journal?.logPath || this._updatesDir); + else { + dl.clearJournal(this._updatesDir); + dbg('User cleared failed journal'); + } + } + + /** Run a single startup update check after a short delay (3min). */ + startSchedule() { + const settings = checker.loadSettings(this._settingsPath); + if (!settings.autoCheck) { + this._d.dbg('Auto-check disabled'); + return; + } + this._checkTimer = setTimeout(() => { + this._checkTimer = null; + this.checkForUpdates(); + }, CHECK_DELAY_MS); + } + + stopSchedule() { + if (this._checkTimer) { + clearTimeout(this._checkTimer); + this._checkTimer = null; + } + } + + /** Check for updates (scheduled or manual from tray). */ + async checkForUpdates() { + const { dbg, net, platform, arch } = this._d; + const currentVersion = this._d.app.getVersion(); + const settings = checker.loadSettings(this._settingsPath); + dbg(`Checking for updates (current: ${currentVersion})`); + + try { + const releases = await fetchReleases(net, currentVersion, settings.etag); + if (!releases) { + dbg('No new release data'); + return; + } + + const target = checker.selectUpdateTarget(releases.data, currentVersion, platform, arch, { + skippedVersion: settings.skippedVersion, + }); + + checker.saveSettings(this._settingsPath, { + ...settings, + lastCheckAt: new Date().toISOString(), + etag: releases.etag || settings.etag, + }); + + if (!target) { + dbg('No update available'); + return; + } + dbg(`Update available: v${target.version}`); + await this._promptUpdate(target, settings); + } catch (err) { + dbg(`Update check failed (silent): ${err.message}`); + } + } + + async _promptUpdate(target, settings) { + const notes = target.releaseNotes?.slice(0, 500) || ''; + const detail = `Current: v${this._d.app.getVersion()}${notes ? `\n\n${notes}` : ''}`; + const btn = await this._d.showDialog({ + type: 'info', + buttons: ['Download', 'Later', 'Skip This Version'], + defaultId: 0, + cancelId: 1, + title: 'Update Available', + message: `Clowder AI v${target.version} is available`, + detail, + }); + + if (btn === 0) await this.downloadAndInstall(target); + else if (btn === 2) { + checker.saveSettings(this._settingsPath, { ...settings, skippedVersion: target.version }); + this._d.dbg(`Skipped v${target.version}`); + } + } + + /** Download, verify, then prompt install. */ + async downloadAndInstall(target) { + if (this._downloading) return; + this._downloading = true; + const { dbg, setProgressBar } = this._d; + const destPath = path.join(this._updatesDir, target.asset.name); + fs.mkdirSync(this._updatesDir, { recursive: true }); + + try { + await downloadAsset(this._d.net, target.asset, destPath, this._d.app.getVersion(), setProgressBar, dbg); + const valid = dl.verifyFileIntegrity(destPath, target.asset.digest, target.asset.size); + if (!valid) { + dbg('Integrity check FAILED'); + try { + fs.unlinkSync(destPath); + } catch {} + setProgressBar(-1); + const r = await this._d.showDialog({ + type: 'error', + buttons: ['Retry', 'Cancel'], + title: 'Download Failed', + message: 'Integrity verification failed', + detail: 'The file may be corrupted.', + }); + if (r === 0) { + // Reset guard before retry — recursive call checks _downloading + this._downloading = false; + await this.downloadAndInstall(target); + } + return; + } + setProgressBar(-1); + await this._executeInstall(target, destPath); + } catch (err) { + dbg(`Download failed: ${err.message}`); + setProgressBar(-1); + } finally { + this._downloading = false; + } + } + + async _executeInstall(target, installerPath) { + const { platform, dbg, showDialog, quitApp, openExternal, openPath } = this._d; + const { spawn } = require('node:child_process'); + + if (platform === 'win32') { + if (this._getInstallType() !== 'installer') { + dbg('Non-installer — opening release page'); + openExternal(`https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tag/v${target.version}`); + return; + } + const btn = await showDialog({ + type: 'info', + buttons: ['Restart & Upgrade', 'Later'], + defaultId: 0, + cancelId: 1, + title: 'Ready to Install', + message: `v${target.version} is ready`, + detail: 'The app will close and the installer will run.\nYour data will be preserved.', + }); + if (btn !== 0) return; + const logPath = path.join(this._updatesDir, 'install.log'); + dl.writeJournal(this._updatesDir, { + targetVersion: target.version, + assetId: target.asset.id, + assetName: target.asset.name, + digest: target.asset.digest, + installerPath, + logPath, + startedAt: new Date().toISOString(), + }); + spawn(installerPath, ['/SILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', `/LOG=${logPath}`], { + detached: true, + stdio: 'ignore', + }).unref(); + await quitApp(); + } else if (platform === 'darwin') { + const btn = await showDialog({ + type: 'info', + buttons: ['Quit & Install', 'Later'], + defaultId: 0, + cancelId: 1, + title: 'Ready to Install', + message: `v${target.version} downloaded`, + detail: 'Drag Clowder AI into Applications to replace the old version.\nYour data will not be affected.', + }); + if (btn !== 0) return; + dl.writeJournal(this._updatesDir, { + targetVersion: target.version, + assetId: target.asset.id, + assetName: target.asset.name, + digest: target.asset.digest, + installerPath, + logPath: '', + startedAt: new Date().toISOString(), + }); + spawn('open', [installerPath], { detached: true, stdio: 'ignore' }).unref(); + await quitApp(); + } + } + + async _retryInstall(journal) { + if (!journal?.installerPath) return; + if (!fs.existsSync(journal.installerPath)) { + await this._d.showDialog({ + type: 'error', + buttons: ['OK'], + title: 'Cannot Retry', + message: 'Installer file not found', + detail: `Expected: ${journal.installerPath}`, + }); + dl.clearJournal(this._updatesDir); + return; + } + const stat = fs.statSync(journal.installerPath); + if (!dl.verifyFileIntegrity(journal.installerPath, journal.digest, stat.size)) { + dl.clearJournal(this._updatesDir); + return; + } + const { spawn } = require('node:child_process'); + if (this._d.platform === 'win32') { + spawn(journal.installerPath, ['/SILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', `/LOG=${journal.logPath}`], { + detached: true, + stdio: 'ignore', + }).unref(); + } else { + spawn('open', [journal.installerPath], { detached: true, stdio: 'ignore' }).unref(); + } + await this._d.quitApp(); + } + + _getInstallType() { + // app.getAppPath() → {installDir}/desktop-dist/resources/app.asar + // dirname → {installDir}/desktop-dist/resources + // ../.. → {installDir} (where .cat-cafe/desktop-config.json lives) + const appPath = this._d.app.getAppPath(); + const candidates = [ + path.join(path.dirname(appPath), '..', '..', '.cat-cafe', 'desktop-config.json'), + path.join(this._d.userDataRoot, '.cat-cafe', 'desktop-config.json'), + ]; + for (const p of candidates) { + try { + const c = JSON.parse(fs.readFileSync(p, 'utf-8')); + if (c.installType) return c.installType; + } catch {} + } + return 'unknown'; // fail-safe: don't auto-install + } + + _cleanOldFiles() { + try { + for (const f of fs.readdirSync(this._updatesDir)) { + try { + fs.unlinkSync(path.join(this._updatesDir, f)); + } catch {} + } + } catch {} + } +} + +module.exports = UpdateManager; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1e96925577..63d461cb54 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -87,3 +87,4 @@ created: 2026-02-26 | F254 | Side-Effect Freshness Gate — 副作用出口 freshness 拦截(猫发消息前自动检查"有没有你没看过的新消息"→有就 hold 让猫先看;三 surface 一子系统:A Freshness Gate held draft + B Content-Free Notice 防无视 + C Runtime Descriptor 结构化;基于 DeliveryCursorStore seq 游标 + F233 事件流;来源 Raft 0.63.7 teardown + opus-48 修正) | in-progress | Ragdoll (Opus-4.6) | internal (operator 2026-06-27 signoff;Raft teardown 提炼) | [F254](features/F254-side-effect-freshness-gate.md) | | F255 | Auto Dream — 会做梦的猫/猫猫日记(后台做梦引擎给 F231 闲置养熟循环"通水" + 前台挂 F229 日记本/provoke 气泡;F255 produce / F229 surface;小而完整垂直切片不做脚手架)| spec | Ragdoll (opus-48) | internal (operator 2026-06-29 "现在立项!") | [F255](features/F255-auto-dream.md) | | F256 | Memory Search Strategy Evolution — 从被动召回到主动探索(session hook 升级 + skill link + retrieval expansion hints 投影到默认搜索 + F242 extractor 扩展 + eval 闭环;operator prompting 策略沉淀为猫自主搜索策略)| spec | Ragdoll (opus-4.6) | internal (operator 2026-06-29 confirmed direction) | [F256](features/F256-memory-search-strategy-evolution.md) | +| F258 | Desktop In-App Update — 应用内检查更新 + 原地升级(GitHub Releases API digest 四元组校验 + `/releases` max-semver feed + Win Inno Setup 静默覆盖装 + pendingUpdate journal 失败恢复 + mac 无签名半自动引导;operator 拍板不购 Apple Developer;Codex spec review r1→r2 放行)| spec | Ragdoll (Opus 开发,Fable 方案) | internal (operator 2026-07-07 用户反馈"不知道怎么升级"立项) | [F258](features/F258-desktop-in-app-update.md) | diff --git a/docs/features/F258-desktop-in-app-update.md b/docs/features/F258-desktop-in-app-update.md new file mode 100644 index 0000000000..ace4bf0923 --- /dev/null +++ b/docs/features/F258-desktop-in-app-update.md @@ -0,0 +1,228 @@ +--- +feature_ids: [F258] +related_features: [F179, F180] +topics: [desktop, electron, auto-update, inno-setup, dmg, github-releases, installer, opensource-ops] +doc_kind: spec +created: 2026-07-07 +--- + +# F258: Desktop In-App Update — 应用内检查更新 + 原地升级(无签名约束版) + +> **Status**: approved — ready for dev(r2 经 Codex 复核放行 2026-07-07) | **方案设计**: 宪宪(Fable/Ragdoll) | **开发 Owner**: 布偶猫(Opus) | **Reviewer**: 缅因猫(Codex:r1 REQUEST CHANGES → r2 放行) | **Priority**: P1 + +## Why + +**用户反馈触发(2026-07-07 operator 转述)**:我们从 v0.9.1 起提供 dmg + Windows 安装包(F179 pipeline),但用户反馈**不知道怎么升级**。当前不支持原地升级——唯一升级方式是用户自己发现新 release → 手动下载 600–800MB → 手动重装,且没有任何地方告诉用户数据会不会丢。 + +**现状证据链(2026-07-07 调研 + review 核验)**: + +| 事实 | 证据 | +|------|------| +| 主进程/preload/service-manager 零 update 代码 | grep `updat\|upgrade` 零命中 | +| `dmg.writeUpdateInfo: false`,mac `identity: null` 未签名 | `desktop/package.json` build 段 | +| Win 是 Inno Setup(非 NSIS),固定 AppId,覆盖装即原地升级 | `desktop/installer/cat-cafe.iss` | +| v0.11.1 包体:dmg 622–632MB / Setup.exe 802MB;下载 30+10+93 次 | `gh release view v0.11.1` | +| **GitHub release asset API 直接返回 `digest`(sha256)**,v0.11.1 全部 4 个 asset 实测有 | `gh api releases/latest`(Codex review 发现,Fable 复核) | +| GitHub asset 下载支持 Range(`bytes=0-0` 实测返回 206) | Codex review 实测 | +| 用户数据在 userData(mac `~/Library/Application Support/Clowder AI`,win `%LOCALAPPDATA%\Clowder AI`),与安装目录完全分离 | `service-manager.js:24-31` | +| `quitApp()` → `services.stopAll()` 有完整子进程树清理(redis/api,`_killProcessTree` + timeout) | `main.js:172-180, 244-258`,`service-manager.js:740+` | +| `generate-desktop-config.ps1` 现状只写 `version/installedAt`,且 version **硬编码 "0.10.1"**(存量 bug) | 该脚本 L10-13 | + +## User Journey + +**Scope unit**:桌面安装包用户的版本升级旅程(Win installer / mac dmg / Win portable 三类用户 + 失败恢复)。 + +**Journey 1 — Win installer 用户(主路径,准全自动)** +1. 正常使用中,新版本发布后 ≤6h(或点 tray「检查更新」)弹窗:「发现新版本 vX.Y.Z」+ release notes 摘要 + [跳过此版本 / 稍后 / 下载] +2. 点 [下载] → 任务栏进度条 + tray tooltip 百分比,期间正常使用不受影响 +3. 下载完成(digest+size 校验通过)→ [稍后 / 重启并升级] → 确认 → UAC 点一次「是」 +4. 看到安装进度条跑完 → app 自动以原用户权限重开新版本 → 「已更新到 vX.Y.Z」通知 → 聊天记录/数据完好 +5. **失败分支**:UAC 取消 / 安装中断 → 下次打开 app 出恢复对话框 [重试安装(不重下)/ 打开安装包位置 / 查看日志 / 忽略并清除];app 打不开的最坏情形 → 按 README/release notes 指引到 `%LOCALAPPDATA%\Clowder AI\updates\` 直接重跑安装包即修复 + +**Journey 2 — mac 用户(半自动,无签名上限)** +1. 同样收到提示 → [下载] → 进度可见 → 完成校验 +2. [退出并安装] → Finder 自动打开 dmg(拖拽指引布局,同首装心智)→ 拖入 Applications 替换 → 右键打开新版(quarantine,同首装)→ 数据完好,收到「已更新」确认 + +**Journey 3 — Win portable 用户(fail-safe)** +1. 收到新版本提示 → 打开 release 页自行下载 zip(绝不自动安装;`installType` 缺失/unknown 同此路径) + +**Journey 4 — 存量老版本用户(无 updater 代码)** +1. 不感知本 feature;README Upgrading 章节提供手动升级步骤 + 数据安全声明,完成一次手动升级后进入 Journey 1/2 + +## 已拍板约束(operator 2026-07-07) + +1. **不买 Apple Developer($99/年)** → electron-updater 的 mac 路线不可用(其底层 Squirrel.Mac 强制校验 Apple code signing,未签名 app 无法自动替换;electron-builder 官方文档确认,Codex review 复核)。mac 上限 = 半自动引导升级。 +2. Win 保留 Inno Setup:electron-updater 官方只支持 NSIS/Squirrel/MSI/AppX;迁移 NSIS 需重写全部 install 逻辑(tar 解压、junction、post-install、hook sync)且老用户卸载表项割裂。Inno Setup 本身天然支持原地覆盖升级(同 AppId)+ `/SILENT` 静默 + post-install 逻辑升级时自动重跑(F180 hook sync 复用)。 +3. Update feed = **GitHub Releases API**(现有发布渠道,零新增基础设施)。 + +## What — 架构设计 + +四个新模块(`desktop/` 下)+ installer 改造 + 失败恢复 journal + 文档。**总原则:永远先询问用户再下载/安装(800MB 流量不偷跑);一切网络失败静默降级;升级是一个有 journal 的事务,任何一步失败用户都有明确的恢复入口。** + +### 1. `desktop/update-checker.js` — 检查器(纯逻辑,可单测) + +- **Feed:`GET /repos/zts212653/clowder-ai/releases?per_page=10`** → 过滤 `draft/prerelease` → **max semver** → 校验 asset 完整性(两个 dmg + Setup.exe 均在且带 digest)→ 作为升级目标。 + - 不用 `releases/latest`:latest 按发布时间选最近 release,不是 semver 选择器(GitHub 官方语义,Codex review 指出)。max-semver 选择器下 backport hotfix(如 v0.12.0 之后发 v0.11.2)不会误导新版本用户;撤版运维 = 把坏 release 标 prerelease 或删除,选择器自动回退到上一稳定版。 + - 仅当 target > current(`app.getVersion()`)才提示,天然不提示降级。 +- 每个候选 asset 记录 **四元组 `{ id, name, size, digest }`**(digest 直接来自 API response),作为后续下载与校验的绑定凭据。 +- 触发时机:启动后延迟 3min 首查(不抢启动带宽)→ 之后每 6h → tray 菜单「检查更新」手动触发。带 `If-None-Match` ETag 缓存;匿名限额 60 req/h/IP,频率远低于此。 +- 版本比较:自写 ~30 行 semver 数字比较(tag 规范固定 `vX.Y.Z`,不引第三方依赖)。 +- 平台 asset 解析:win → `CatCafe-Setup-{v}.exe`;mac → `CatCafe-{v}-{arm64|x64}.dmg`(按 `process.arch`)。 +- 设置持久化 `{userData}/update-settings.json`:`{ autoCheck: true, skippedVersion: null, lastCheckAt, etag }`。 +- 失败(断网/超时/API 变更/rate limit):log-only 静默。 + +### 2. `desktop/update-downloader.js` — 下载器 + +- Electron `net` 模块(走系统代理设置,对国内用户重要)下载到 `{userData}/updates/`。 +- 下载前检查磁盘空间 ≥ 2× asset size;下载中主窗口 `setProgressBar()` + tray tooltip 显示百分比。 +- **校验 = 四元组绑定**:完成后 `node:crypto` streaming sha256 对照 API `digest` + 字节数对照 `size`,任一不匹配 → 删除 + 提示重试。 +- **威胁边界(明确声明)**:digest 来自 api.github.com,asset 字节来自下载域(objects.githubusercontent.com 等),跨源比对可防传输损坏与下载链路篡改;**不防 GitHub 账号/release 本身被替换**——该威胁下源码同样可投毒,信任等级与源码信任一致。不引入 minisign/ed25519(见 Resolved Questions #4)。 +- **断点续传(MVP 含)**:首次响应记录 `ETag` + total size;中断重试用 `Range` + `If-Range: `;若响应非 206、或 `Content-Range`/ETag 与记录不一致 → **丢弃 partial 全量重下**(正确性优先于流量)。 +- 清理策略:升级成功确认后(见 journal 状态机)清空 `updates/` 内旧文件。 + +### 3. Windows 升级执行器(准全自动 + 事务化恢复) + +#### 3.1 正常路径 + +``` +downloader 完成+四元组校验通过 + → dialog [稍后 / 重启并升级] + → 写 pendingUpdate journal(见 3.2) + → spawn(setupExe, ['/SILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-','/LOG={userData}\updates\install.log'], + { detached: true, stdio: 'ignore' }).unref() + → quitApp() // stopAll() 干净关闭 redis/api/exe → 释放全部文件锁 + → UAC 弹窗一次(PrivilegesRequired=admin,不可避免,用户点一次"是") + → Inno Setup 原地覆盖安装 → 静默模式装完自动以原用户权限重启 app(iss 改造 b) +``` + +`/SILENT`(显示进度条、无向导页)而非 `/VERYSILENT`:用户能看见升级在进行,不误以为 app 消失。 + +#### 3.2 pendingUpdate journal — 失败恢复(Codex review P1) + +spawn 前落盘 `{userData}/updates/pending-update.json`: + +```json +{ "targetVersion": "0.12.0", "assetId": 123, "assetName": "CatCafe-Setup-0.12.0.exe", + "digest": "sha256:…", "installerPath": "…\\updates\\CatCafe-Setup-0.12.0.exe", + "logPath": "…\\updates\\install.log", "startedAt": "…" } +``` + +**下次启动时状态机**(main.js 早期、服务启动前检测): + +| 状态 | 判定 | 动作 | +|------|------|------| +| 升级成功 | journal 存在且 `app.getVersion() >= targetVersion` | 清 journal + 清理 `updates/`,一次性「已更新到 vX.Y.Z」通知 | +| 失败/取消 | journal 存在且 `app.getVersion() < targetVersion` | 恢复 dialog:**[重试安装 / 打开安装包位置 / 查看安装日志 / 忽略并清除]**;重试 = 重新校验已下载 installer(digest 复核)→ 重新 spawn,**不重下 800MB** | + +覆盖场景:UAC 取消、tar 解压失败、安装中途断电/杀进程、installer 启动失败。 + +**App 外恢复路径(硬要求,Codex r2 提醒)**:`[InstallDelete]` 之后中断的最坏情形(旧 runtime 已删、新版未装完)下 app 可能起不来,**恢复 dialog 不可达**。因此:① installer 与 `install.log` 固定保留在 `%LOCALAPPDATA%\Clowder AI\updates\`(**禁止**放系统 temp;失败态绝不清理);② 该路径写进三处用户可见位置——恢复 dialog、README Upgrading 章节、release notes 模板——并明确「**不需要打开 app,直接重新运行该安装包即可修复**」。 + +#### 3.3 `cat-cafe.iss` 四处改造(本 feature 最需要 review 的部分) + +a. **`[InstallDelete]` 清理 tar 解压残留** — 现状:运行时文件(`packages/`、`desktop-dist/`、`node/`)是 `[Run]` 段 tar.exe 解压产物,**不在 Inno 文件注册表内**,覆盖安装不清理 → 旧版本多出的文件(被删的模块、旧 node_modules 结构)永久残留,属极难排查的 bug 温床。加: + ```ini + [InstallDelete] + Type: filesandordirs; Name: "{app}\packages" + Type: filesandordirs; Name: "{app}\desktop-dist" + Type: filesandordirs; Name: "{app}\node" + Type: filesandordirs; Name: "{app}\scripts\node_modules" ; junction — 见 d + ``` + Tradeoff:升级耗时增加 + 删除后安装中途失败旧版不可用——由 3.2 journal 恢复路径兜底(重跑 installer 即修复),把"脏状态风险"换成"干净但可恢复的失败"。 + +b. **静默升级后自动重启 app** — 现有 `[Run]` postinstall 项带 `skipifsilent`(静默装完不启动)。加一条仅静默模式生效: + ```ini + Filename: "{app}\desktop-dist\{#MyAppExeName}"; Flags: nowait runasoriginaluser; Check: WizardSilent + ``` + `runasoriginaluser` 关键:否则 app 及其全部子进程(redis/api)以 elevated admin 运行。 + +c. **`[Code]` 防御性进程清理** — updater 正常路径已先 quit;兜底手动重跑 Setup.exe / stopAll 超时残留:`PrepareToInstall` 里 PowerShell 精确 kill 安装目录路径下的进程(`Get-Process | Where-Object { $_.Path -like "{app}\*" } | Stop-Process -Force`,路径过滤避免误杀用户自己的 node/redis)。 + +d. **junction 幂等**(Codex review 指出)— `[Run]` 的 `mklink /J {app}\scripts\node_modules` 在覆盖安装时目标已存在会失败;且 `[InstallDelete]` 清掉 `packages/` 后旧 junction 变 dangling。处理:junction 纳入 `[InstallDelete]`(见 a,`rmdir` junction 只删链接不删目标内容),mklink 必然在干净状态下执行。 + +#### 3.4 Portable zip 用户(明确开发项——当前代码无此能力) + +- **现状**:`generate-desktop-config.ps1` 只写 `version/installedAt`,且 version 硬编码 `"0.10.1"`(存量 bug)。 +- **开发项**:脚本加 `-Version` + `-InstallType` 参数;iss `[Run]` 调用传 `-InstallType installer -Version {#MyAppVersion}`(修掉硬编码);`start-portable.bat` 路径传 `-InstallType portable`。 +- **Fail-safe**:`installType` 字段缺失/unknown(含任何旧包场景)→ **一律不自动安装**,仅提示新版本 + 打开 release 页。portable 检测同此路径。 + +### 4. macOS 升级执行器(半自动——无签名的上限) + +``` +按 process.arch 下载对应 dmg → 四元组校验 + → dialog:「新版本 vX.Y.Z 已下载。点击"退出并安装"会打开安装盘, + 请把 Clowder AI 拖入 Applications 替换旧版本,然后从 Applications 重新打开。 + 你的聊天记录和数据不会受影响。」 [稍后 / 退出并安装] + → spawn('open', [dmgPath]) → quitApp() + → Finder 展示 dmg(electron-builder 默认布局含 Applications 拖拽 symlink,与首装心智一致) + → 用户拖拽替换 → 新版首启右键打开(quarantine,与首装体验相同,dialog + 文档明示) +``` + +- mac 侧同样写 journal:成功判定同 3.2(下次启动版本比对),失败态提示「安装盘已下载在 …,可随时手动完成安装」。 +- 已有 install-location guard(`main.js` 拒绝从 /Volumes 直接运行)继续兜底"没拖就双击"的误操作。 + +**明确不做:无签名自动替换 `.app`**(下载 zip → 解压 → `xattr -dr com.apple.quarantine` → 原子替换 → relaunch)。技术上可行,但:(a) 程序化清 quarantine 属"绕过 Gatekeeper"模式,macOS 政策收紧风险不可控;(b) 替换中断 = app 损坏且无签名无法校验完整性;(c) 开源项目声誉风险。Rejected alternative。 + +### 5. CI / 发布侧 + +- **主校验源 = API digest,CI 无新增必做项**(Codex review P1 修订:不再造 `.sha256` sidecar 弱机制)。可选 P2:为浏览器手动下载用户附 sidecar 供人工核对,不阻塞本 feature。 +- release notes 模板(opensource-ops 侧)加 "How to upgrade" 固定章节。 +- 运维说明一条:撤回坏版本 = 将该 release 标 prerelease 或删除,max-semver 选择器自动回退。 + +### 6. 文档 + +README / README.zh-CN 加 **Upgrading** 章节:数据存放位置 + 覆盖升级不丢数据声明 + 各平台手动升级步骤(in-app 通道的兜底;也服务存量老版本用户——他们没有 updater 代码,永远需要一次手动升级到首个带 updater 的版本)+ Win 升级中断恢复说明(重跑安装包即修复)。 + +## 明确不做(scope 边界) + +| 不做 | 理由 | +|------|------| +| 差分/增量更新(blockmap) | 依赖 electron-updater+NSIS 体系;全量 600–800MB 可接受,体积优化未来独立立项 | +| 分层热更新(只换 packages 产物) | 版本兼容矩阵 + 回滚机制复杂度高,收益不匹配当前阶段 | +| minisign/ed25519 manifest 签名 | API digest 四元组绑定已覆盖损坏+下载链路篡改;更强威胁 = GitHub 沦陷,彼时源码同样可投毒 | +| `.sha256` sidecar 作为主校验 | API digest 更强且零 CI 改动;sidecar 降级为可选人工兜底 P2 | +| Linux 包 | 当前不发布 | +| 不询问的静默自动升级 | 永远先问(尊重用户 + 大流量不偷跑) | +| 国内下载镜像 prefix 设置 | ghproxy 类镜像是新供应链风险;`net` 走系统代理已覆盖主要场景,观察反馈再议 | + +## Resolved Questions(r1 review 收敛,双方共识) + +1. **国内镜像** → P2 不做,镜像本身是新供应链风险。 +2. **`[InstallDelete]` 全清** → 做,配 3.2 journal 恢复 + 3.3d junction 幂等。 +3. **断点续传** → 进 MVP,配 ETag/Content-Range 一致性校验,不匹配全量重下(Range 206 已实测)。 +4. **checksum** → GitHub API digest 为主校验源(四元组绑定),威胁边界如 §2 声明,不上 minisign。 +5. **feed 选择器** → MVP 直接 `/releases` + max semver(放弃 latest,成本差异极小,消除对发布顺序的隐含假设)。 +6. **频率/UI** → 启动 +3min、每 6h、tray 手动检查;UI 只用 Electron 原生(tray/dialog/taskbar progress),不动 preload/web UI。 + +## Phase 拆分(开发:布偶猫 Opus) + +- **Phase A — update-core**:checker + `/releases` max-semver 选择器 + semver compare + asset 四元组解析 + settings 持久化,`node --test` 单测(沿用 `desktop/*.test.js` 既有模式),mock API fixture +- **Phase B — Win 全链路**:downloader(进度/四元组校验/断点续传一致性)+ pendingUpdate journal 状态机 + iss 四处改造 + spawn→quit 时序 + portable/installType 开发项(含 config 脚本参数化修硬编码)。**实现注意(Codex)**:app 外恢复路径是硬要求——最坏情形下用户必须能在不打开 app 的前提下从 `updates/` 目录重跑 installer 修复(§3.2) +- **Phase C — mac 半自动链路**:arch 选择 + 下载校验 + open dmg + 指引 dialog + quit + journal 成功/失败态 +- **Phase D — UX 与文档**:tray「检查更新」菜单 + skip version + 进度展示 + README 双语 Upgrading + release notes 模板 + 撤版运维说明 +- **Phase E — 验收**:本地 mock feed 端到端(真实旧版安装 → mock releases → 完整升级链 + 全部失败注入场景)+ 下个正式 release 的 field validation(沿 F179 Phase B 模式) + +## Acceptance Criteria + +- [ ] AC-1: 旧版运行中出现新版本(mock `/releases`)→ ≤6h 自动或手动检查提示;`skippedVersion` 不再提示,更新的版本恢复提示;feed 含更高 semver 的 prerelease/draft 或 asset 不全的 release 时被正确跳过 +- [ ] AC-2 (Win): 真实旧版安装 → 一键升级端到端:下载(进度可见)→四元组校验→UAC→静默覆盖装→自动以原用户权限重启新版→userData 数据完好→旧 tar 残留已清理→junction 重建正确 +- [ ] AC-3 (Win 失败恢复): ① UAC 取消 → 下次启动恢复 dialog,重试安装成功且不重新下载;② 安装中途杀死 installer → 下次可达路径上恢复 dialog 或按文档重跑 installer 修复 +- [ ] AC-4: 篡改下载文件(digest 不符)或截断(size 不符)→ 拒绝安装 + 可重试;断点续传中 ETag 变化 → 丢弃 partial 全量重下 +- [ ] AC-5 (mac): 下载→校验→打开 dmg→指引 dialog→退出;拖拽替换后新版启动、数据完好、journal 判定成功并清理 +- [ ] AC-6: 断网/API 5xx/rate limit → 静默降级,desktop.log 可查,无用户打扰 +- [ ] AC-7 (portable/fail-safe): `installType=portable` 或字段缺失 → 仅提示 + 引导 release 页,绝不自动安装 +- [ ] AC-8: `generate-desktop-config.ps1` 参数化(-Version/-InstallType),iss 与 portable bat 正确传参,硬编码 0.10.1 修复 +- [ ] AC-9: 升级路径复用 post-install hook sync(F180)并生效 +- [ ] AC-10: README 双语 Upgrading 章节 + release notes 模板含升级指引与中断恢复说明 +- [ ] AC-11: 全程无签名新增告警面(不引入任何清 quarantine / 绕 Gatekeeper 行为) + +## Dependencies + +- 无强依赖。F179 pipeline(release → 自动构建 attach assets)是本 feature 的 feed 基础,已稳定运行。 +- F180(agent hook sync):升级路径自动复用其 post-install 同步,属收益非依赖。 + +## Review Log + +- **r1 (2026-07-07, Maine Coon/Codex)**: REQUEST CHANGES——P1×2(checksum 应以 API digest 为主源;Win 安装失败恢复缺 journal)+ P2×2(latest 非 semver 选择器;portable installType 不是现状)。外部一手验证:electron-builder mac 签名要求、GitHub latest 语义、asset digest 字段、Range 206 实测。 +- **r2 (2026-07-07, Fable)**: 全部采纳修订。digest 字段与 config 脚本现状均独立复核确认;P2-1 升格为 MVP 直接做 `/releases` max-semver;顺手纳入 config version 硬编码存量 bug 修复。 +- **r2 确认 (2026-07-07, Maine Coon/Codex)**: **放行**。复核确认四项 findings 均进入验收口径。附非阻塞实现提醒:`[InstallDelete]` 后中断时恢复 dialog 不一定可达,须保证 app 外恢复路径(保留 installer/日志于固定位置 + 文档写明"不打开 app 也能重跑安装包")→ 已固化进 §3.2 / §6 / Phase B。 diff --git a/docs/features/index.json b/docs/features/index.json index 14bf8628b7..a9e629627d 100644 --- a/docs/features/index.json +++ b/docs/features/index.json @@ -1523,6 +1523,12 @@ "name": "Memory Search Strategy Evolution — 从被动召回到主动探索", "status": "active | **Owner**: Ragdoll (opus-4.6) | **Priority**: P1", "file": "F256-memory-search-strategy-evolution.md" + }, + { + "id": "F258", + "name": "Desktop In-App Update — 应用内检查更新 + 原地升级(无签名约束版)", + "status": "approved — ready for dev | **Owner**: Ragdoll (Opus) | **Priority**: P1", + "file": "F258-desktop-in-app-update.md" } ] }