diff --git a/CLAUDE.md b/CLAUDE.md index 653d6b4..9669e74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ docs/ - **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell +- **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. ## API routes diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 4603283..77677dd 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -61,6 +61,10 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al - Pass `--arm64 --x64` explicitly. Passing a target on the CLI (`… dmg`) **overrides** the `arch` array in `package.json`, so `npm run dist:mac` builds only the host arch. +- The `mac.target` array now builds **both `dmg` and `zip`** for each arch. The + DMG is the first-install download; the **`.zip` is what electron-updater + installs from** (Squirrel.Mac can't apply a DMG). `latest-mac.yml` must + reference the zips, so ship the zips + their blockmaps in the Release too. - The signing identity is pinned in `package.json` → `build.mac.identity` as `"Valeriy Kovalsky (A933C2TJXU)"` — **without** the `Developer ID Application:` prefix (electron-builder rejects the prefix). @@ -69,24 +73,37 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al since electron-builder builds the DMG after the hook runs. **2b. Notarize + staple the DMG containers, then regenerate the update feed** -(the staple mutates the DMG, so blockmaps + `latest-mac.yml` must be recomputed): +(the staple mutates the DMG bytes, so any changed artifact's checksum/blockmap in +`latest-mac.yml` must be recomputed): ```bash for dmg in dist/codbash--arm64.dmg dist/codbash-.dmg; do xcrun notarytool submit "$dmg" --keychain-profile codbash-notary --wait xcrun stapler staple "$dmg" - ./node_modules/app-builder-bin/mac/app-builder_arm64 blockmap \ - --input "$dmg" --output "$dmg.blockmap" # prints the {size,sha512} for latest-mac.yml done -# then rewrite dist/latest-mac.yml with the new size+sha512 for both DMGs. +npm run refresh-update-feed # scripts/regenerate-latest-mac.js ``` -Publish: +`refresh-update-feed` parses electron-builder's own `dist/latest-mac.yml` and +refreshes `sha512`/`size`/`blockMapSize` only for entries whose bytes actually +changed on disk (detected by sha512 mismatch), then re-syncs the top-level +`sha512` to the `path` file. It's schema-preserving (never hand-writes the feed) +and idempotent. Since electron-updater's mac feed points at the untouched +`.zip`, the `.zip` entries are left as-is and only stapled DMG entries (if the +feed lists them) get recomputed. Pure transforms are covered by +`test/desktop-update-feed.test.js`; **validate against the real feed on the first +signed build** (electron-updater fails loudly on a checksum mismatch). + +Publish (include the **zips + their blockmaps** — electron-updater installs from +the zip, and `latest-mac.yml` points at it; a Release with only the DMGs makes +in-app update fail with a 404 for the zip): ```bash gh release create v --title "codbash (macOS desktop)" --target main \ - dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ - dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ + dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64-mac.zip dist/codbash--arm64-mac.zip.blockmap \ + dist/codbash--mac.zip dist/codbash--mac.zip.blockmap \ dist/latest-mac.yml ``` @@ -109,15 +126,52 @@ hdiutil attach /tmp/q.dmg -nobrowse; spctl -a -vvv -t exec "/Volumes/codbash .exe (+ .blockmap) and dist/latest.yml +gh release upload v \ + "dist/codbash Setup .exe" "dist/codbash Setup .exe.blockmap" \ + dist/latest.yml +``` + +> The `nsis` target (not `portable`) is required for electron-updater. +> +> **⚠️ Security — do not ship Windows *auto-update* to real users unsigned.** +> electron-updater's `verifyUpdateCodeSignature` compares the running app's +> Authenticode publisher against the downloaded installer's; with no Windows +> code-signing cert on either side that check is a no-op, leaving only the +> `sha512` in `latest.yml` (which comes from the same release pipeline an +> attacker would compromise). An unsigned *in-place auto-updater* is strictly +> worse than a manual download-and-run, because it removes the last human +> checkpoint. Until an OV/EV cert is in place, keep Windows on the notify-only +> fallback (open the releases page) rather than enabling silent download+install. +> `allowDowngrade` and `allowPrerelease` are pinned `false` in `main.js` so a +> mistagged or rolled-back release can't reach stable users regardless. ## CI note diff --git a/desktop/main.js b/desktop/main.js index b272059..87b9b40 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -10,7 +10,6 @@ const { app, BrowserWindow, shell, dialog, Menu, ipcMain } = require('electron'); const { spawn } = require('child_process'); const http = require('http'); -const https = require('https'); const net = require('net'); const path = require('path'); const fs = require('fs'); @@ -111,7 +110,11 @@ function startServer(port) { const entry = resolveServerEntry(); const nodeBin = resolveNodeBin(); serverProc = spawn(nodeBin, [entry, 'run', '--port=' + port, '--host=127.0.0.1', '--no-browser'], { - env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1' }), + // CODBASH_DESKTOP=1 tells the server it runs inside the Electron shell, so the + // web self-update route (`POST /api/update` → `npm i -g`) refuses: it would + // update an unrelated npm-global copy while the app keeps running its bundled + // server. In the desktop app, updates go through electron-updater (below). + env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1', CODBASH_DESKTOP: '1' }), stdio: ['ignore', 'pipe', 'pipe'], }); serverProc.stdout.on('data', function (d) { process.stdout.write('[codbash] ' + d); }); @@ -163,6 +166,66 @@ function registerIpc() { // The renderer decides a shortcut had no in-page meaning (e.g. Cmd+W outside // the Workspace) and asks us to close the window instead. ipcMain.on('codbash:close-window', function () { if (win) { try { win.close(); } catch (_e) {} } }); + + // ── In-app updater (electron-updater) ────────────────────────────────────── + // The renderer's update banner drives these. autoDownload is off, so the flow + // is: check → 'available' → user clicks Download → downloadUpdate() → progress + // → 'downloaded' → user clicks Restart → quitAndInstall(). See initAutoUpdater. + // + // Defense in depth, because these calls are powerful (force-download + + // quitAndInstall = force-relaunch of the whole app): + // 1. isTrustedSender — only honor calls from a frame served by our own local + // server, so a page the window somehow navigated to can't drive updates. + // 2. _updateState gating — download only when an update is 'available', + // install only once it's 'downloaded'. The renderer's button visibility is + // a UX convenience, NOT the security boundary; the main process enforces + // the sequence so an out-of-order/forged call can't force a relaunch. + ipcMain.handle('codbash:update-check', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (!getAutoUpdater()) return { unavailable: true }; + // Won't clobber an in-flight/downloaded update (see maybeCheckForUpdates). + return maybeCheckForUpdates().then(function () { return { ok: true }; }); + }); + ipcMain.handle('codbash:update-download', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'available') return { error: 'no update available' }; + if (_downloadInFlight) return { ok: true, already: true }; // second click before first progress + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + _downloadInFlight = true; // set synchronously so a fast double-click can't double-download + return u.downloadUpdate().then(function () { return { ok: true }; }) + .catch(function (e) { _downloadInFlight = false; return { error: String((e && e.message) || e) }; }); + }); + ipcMain.handle('codbash:update-install', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'downloaded') return { error: 'update not downloaded' }; + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + // Defer so the IPC reply is sent before the app tears down. before-quit sets + // app.isQuitting and kills the server child, so its exit handler stays quiet. + setImmediate(function () { try { u.quitAndInstall(); } catch (_e) {} }); + return { ok: true }; + }); + // Fallback when in-place update can't apply (e.g. unsigned build): open the + // GitHub releases page so the user can still grab the installer manually. + ipcMain.on('codbash:open-releases', function (event) { + if (!isTrustedSender(event)) return; + shell.openExternal('https://github.com/vakovalskii/codbash/releases/latest') + .catch(function (e) { process.stderr.write('[desktop] openExternal failed: ' + ((e && e.message) || e) + '\n'); }); + }); +} + +// Only trust IPC from a frame our own local server actually served. Blocks a +// page the window was somehow navigated to (see the will-navigate guard in +// createWindow — this is the second, independent layer) from reaching the +// updater bridge. serverPort is 0 until the server binds; reject until then. +function isTrustedSender(event) { + try { + const url = event && event.senderFrame && event.senderFrame.url; + return !!url && serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0; + } catch (_e) { + return false; + } } async function createWindow() { @@ -188,6 +251,18 @@ async function createWindow() { return { action: 'allow' }; }); + // Pin top-level navigation to our own local server. The preload bridge + // (window.codbashDesktop, incl. the powerful updater) is bound to the WINDOW, + // not an origin — so without this, navigating the window elsewhere (a stray + // location.href, a target=_top link, a future CSP regression) would hand that + // page the update-download/install IPC. External http(s) is opened in the real + // browser instead; anything else off-origin is simply blocked. + win.webContents.on('will-navigate', function (event, url) { + if (serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0) return; + event.preventDefault(); + if (/^https?:/i.test(url)) shell.openExternal(url).catch(function () {}); + }); + // Cmd/Ctrl+W would hit the native menu's "Close Window" before the page ever // sees the keystroke. Intercept it here so it can close the ACTIVE TAB instead // (Chrome-like). We forward it to the renderer, which closes a tab or — if @@ -211,65 +286,77 @@ async function createWindow() { } } -// Simple semver-ish "is a newer than b" (major.minor.patch). -function isNewerVersion(a, b) { - const pa = String(a).split('.').map(Number); - const pb = String(b).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] || 0) > (pb[i] || 0)) return true; - if ((pa[i] || 0) < (pb[i] || 0)) return false; +// ── In-app auto-update (electron-updater) ──────────────────────────────────── +// Real in-place update: the app downloads the new build from GitHub Releases +// (read via latest-mac.yml / latest.yml) and relaunches onto it — no manual DMG +// download. macOS in-place update REQUIRES a signed build (codbash is signed + +// notarized since v7.14.4); Windows uses the NSIS installer. autoDownload is off +// so the renderer's banner controls when the download starts (Download button) +// and when to relaunch (Restart button). If the updater can't apply (unsigned / +// dev / download error) we emit 'error' and the renderer falls back to opening +// the releases page (codbash:open-releases IPC). +let _autoUpdater = null; +let _autoUpdaterDisabled = false; // hard off for this run (dev/smoke) — never retry +// Lifecycle state, the security boundary for the download/install IPC calls (not +// the renderer's button state). Updated from the autoUpdater events below. +let _updateState = 'idle'; // idle | checking | available | downloading | downloaded | error +let _downloadInFlight = false; // guards against a double downloadUpdate() (fast double-click) +let _updateTimer = null; // 6h re-check interval id (so it can be cleared) + +// Lazy + guarded: electron-updater is a packaged runtime dep. In a from-source +// run (npm start) or an unpacked build it can't apply an update, so we skip it +// and let the renderer degrade to the manual releases page. A dev/smoke run is a +// permanent skip; a transient require() failure is NOT latched, so a later call +// can retry rather than silently disabling updates for the whole session. +// +// Event listeners are attached HERE, at creation, so any checkForUpdates() call +// — whoever triggers it and whenever — always has listeners (EventEmitter drops +// events that fire with none attached, which would silently lose a check result). +function getAutoUpdater() { + if (_autoUpdaterDisabled) return null; + if (_autoUpdater) return _autoUpdater; + if (SMOKE || !app.isPackaged) { _autoUpdaterDisabled = true; return null; } + try { + _autoUpdater = require('electron-updater').autoUpdater; + _autoUpdater.autoDownload = false; // renderer controls when to download + _autoUpdater.autoInstallOnAppQuit = true; + _autoUpdater.allowDowngrade = false; // never move users backwards + _autoUpdater.allowPrerelease = false; // stable channel only, explicit + _autoUpdater.on('checking-for-update', function () { _updateState = 'checking'; sendUpdateState('checking'); }); + _autoUpdater.on('update-available', function (info) { _updateState = 'available'; sendUpdateState('available', { version: info && info.version }); }); + _autoUpdater.on('update-not-available', function () { _updateState = 'idle'; sendUpdateState('none'); }); + _autoUpdater.on('download-progress', function (p) { _updateState = 'downloading'; sendUpdateState('downloading', { percent: p ? Math.round(p.percent) : 0 }); }); + _autoUpdater.on('update-downloaded', function (info) { _downloadInFlight = false; _updateState = 'downloaded'; sendUpdateState('downloaded', { version: info && info.version }); }); + _autoUpdater.on('error', function (err) { _downloadInFlight = false; _updateState = 'error'; sendUpdateState('error', { message: String((err && err.message) || err) }); }); + } catch (e) { + // Not latched: a corrupted node_modules today shouldn't kill updates forever. + process.stderr.write('[desktop] electron-updater load failed: ' + ((e && e.message) || e) + '\n'); + return null; } - return false; + return _autoUpdater; } -// Update check via GitHub Releases. We use a NOTIFY model (fetch the latest -// release, and if it's newer, offer to open the download page) rather than -// silent in-place replacement: silent auto-update on macOS requires a signed -// build, and codbash currently ships unsigned. Once a Developer ID signing -// identity is in place this can be swapped for electron-updater's silent flow. -function checkForUpdates(interactive) { - if (SMOKE) return; - const current = app.getVersion(); - const opts = { - host: 'api.github.com', - path: '/repos/vakovalskii/codbash/releases/latest', - headers: { 'User-Agent': 'codbash-desktop', 'Accept': 'application/vnd.github+json' }, - timeout: 8000, - }; - const req = https.get(opts, function (res) { - let d = ''; - res.on('data', function (c) { d += c; }); - res.on('end', function () { - let rel; - try { rel = JSON.parse(d); } catch (_e) { return; } - const latest = String(rel.tag_name || '').replace(/^v/, ''); - if (latest && isNewerVersion(latest, current)) { - const { dialog } = require('electron'); - dialog.showMessageBox({ - type: 'info', - message: 'codbash ' + latest + ' is available', - detail: 'You have ' + current + '. Open the download page?', - buttons: ['Download', 'Later'], - defaultId: 0, - cancelId: 1, - }).then(function (r) { - if (r.response === 0) shell.openExternal(rel.html_url || 'https://github.com/vakovalskii/codbash/releases/latest'); - }); - } else if (interactive) { - const { dialog } = require('electron'); - dialog.showMessageBox({ type: 'info', message: 'codbash is up to date', detail: 'Version ' + current + '.', buttons: ['OK'] }); - } - }); - }); - req.on('error', function () {}); - req.on('timeout', function () { req.destroy(); }); +function sendUpdateState(state, extra) { + if (!win || win.isDestroyed()) return; + try { win.webContents.send('codbash:update-state', Object.assign({ state: state }, extra || {})); } catch (_e) {} +} + +// A check would call update-not-available → 'none' → hide banner. If an update is +// already downloading or sitting downloaded-and-waiting-to-restart, that would +// wipe the user's "ready to restart" affordance. So skip checks in those states. +function maybeCheckForUpdates() { + const u = getAutoUpdater(); + if (!u) return Promise.resolve(); + if (_updateState === 'downloading' || _updateState === 'downloaded') return Promise.resolve(); + return u.checkForUpdates().catch(function () {}); } function initAutoUpdater() { - if (SMOKE) return; - checkForUpdates(false); - // Re-check every 6 hours while the app stays open. - setInterval(function () { checkForUpdates(false); }, 6 * 60 * 60 * 1000); + const u = getAutoUpdater(); + if (!u) return; // dev / smoke / unpacked — renderer stays on its default UI + // The initial check is triggered by the renderer (wireDesktopUpdater → check), + // which also re-triggers on page reload. Here we only own the periodic re-check. + _updateTimer = setInterval(function () { maybeCheckForUpdates(); }, 6 * 60 * 60 * 1000); } app.whenReady().then(async function () { diff --git a/desktop/package.json b/desktop/package.json index 6c7e74e..41043be 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -10,11 +10,16 @@ "scripts": { "start": "electron .", "smoke": "CODBASH_SMOKE=1 electron .", - "dist:mac": "electron-builder --mac dmg", - "release:mac": "electron-builder --mac dmg --publish always", + "dist:mac": "electron-builder --mac --arm64 --x64", + "dist:win": "electron-builder --win nsis", + "release:mac": "electron-builder --mac --arm64 --x64 --publish always", + "refresh-update-feed": "node scripts/regenerate-latest-mac.js", "pack": "electron-builder --dir", "dev": "bash dev.sh" }, + "dependencies": { + "electron-updater": "^6.8.9" + }, "devDependencies": { "@electron/notarize": "^2.5.0", "electron": "^33.2.0", @@ -63,6 +68,13 @@ "arm64", "x64" ] + }, + { + "target": "zip", + "arch": [ + "arm64", + "x64" + ] } ], "icon": "build/icon.png", @@ -74,6 +86,23 @@ }, "dmg": { "title": "codbash ${version}" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "build/icon.png" + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "allowToChangeInstallationDirectory": false } } } diff --git a/desktop/preload.js b/desktop/preload.js index da58087..f834c78 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -15,4 +15,17 @@ contextBridge.exposeInMainWorld('codbashDesktop', { onShortcut: (cb) => ipcRenderer.on('codbash:shortcut', (_e, name) => cb(name)), // Ask main to close the window (used when a shortcut has no in-page meaning). closeWindow: () => ipcRenderer.send('codbash:close-window'), + // In-app updater (electron-updater). The dashboard's update banner drives this: + // onState(cb) → receives {state, version?, percent?, message?} pushes + // download() → start downloading the available update + // install() → relaunch onto the downloaded update + // check() → force a check now + // openReleases() → fallback: open the GitHub releases page in the browser + updater: { + onState: (cb) => ipcRenderer.on('codbash:update-state', (_e, s) => cb(s)), + check: () => ipcRenderer.invoke('codbash:update-check'), + download: () => ipcRenderer.invoke('codbash:update-download'), + install: () => ipcRenderer.invoke('codbash:update-install'), + openReleases: () => ipcRenderer.send('codbash:open-releases'), + }, }); diff --git a/desktop/scripts/regenerate-latest-mac.js b/desktop/scripts/regenerate-latest-mac.js new file mode 100644 index 0000000..00217b9 --- /dev/null +++ b/desktop/scripts/regenerate-latest-mac.js @@ -0,0 +1,160 @@ +'use strict'; + +// Refresh electron-updater's macOS feed (dist/latest-mac.yml) after the DMG +// containers are notarized + stapled. +// +// Why: stapling mutates the DMG bytes, so any feed entry pointing at a file that +// changed on disk needs a fresh sha512/size/blockMapSize or the updater rejects +// the download. With the zip target #272 ships, the *.zip entries (the actual +// mac update artifact) are NOT touched by stapling, so this is a no-op for them +// — only a changed file (e.g. a stapled DMG that appears in the feed) is +// refreshed. Detection is by sha512 mismatch, so re-running is idempotent. +// +// Unlike a hand-authored feed, this PARSES electron-builder's own latest-mac.yml +// and only rewrites the numbers in place — it never invents the schema, the arch +// mapping, or the `path` choice, so it can't silently produce a broken feed. +// Generalises the idea from PR #271 (thanks @indapublic) to the zip+dmg feed. +// +// ⚠️ Validate on the next real signed build: run `npm run refresh-update-feed` +// after stapling and confirm `dist/latest-mac.yml` still matches the uploaded +// artifacts (electron-updater 404s / checksum-fails loudly if it doesn't). + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const DIST = path.join(ROOT, 'dist'); +const FEED = path.join(DIST, 'latest-mac.yml'); + +function sha512Base64(file) { + return crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64'); +} + +function findAppBuilder() { + const pkgPath = require.resolve('app-builder-bin/package.json', { paths: [ROOT] }); + const dir = path.dirname(pkgPath); + const candidates = [ + path.join(dir, 'mac', process.arch === 'arm64' ? 'app-builder_arm64' : 'app-builder_x64'), + path.join(dir, 'mac', 'app-builder'), + path.join(dir, 'mac', 'app-builder_arm64'), + path.join(dir, 'mac', 'app-builder_x64'), + ]; + const found = candidates.find((p) => fs.existsSync(p)); + if (!found) throw new Error('app-builder binary not found under ' + dir); + return found; +} + +function regenerateBlockmap(appBuilder, file) { + const blockmap = file + '.blockmap'; + execFileSync(appBuilder, ['blockmap', '--input', file, '--output', blockmap], { stdio: 'inherit' }); + return fs.statSync(blockmap).size; +} + +// ── Pure helpers (unit-tested; no disk/electron-builder needed) ─────────────── + +// Parse the files[] entries of a latest-mac.yml into [{url, sha512, size, +// blockMapSize}]. Tolerant of key order; a top-level (column-0) key ends a block. +function parseFeedEntries(text) { + const lines = text.split(/\r?\n/); + const entries = []; + let cur = null; + let itemIndent = -1; + for (const line of lines) { + const item = line.match(/^(\s*)-\s*url:\s*(.+?)\s*$/); + if (item) { + cur = { url: item[2] }; + itemIndent = item[1].length; + entries.push(cur); + continue; + } + if (/^\S/.test(line)) { cur = null; continue; } // column-0 key ends the block + if (!cur) continue; + const kv = line.match(/^(\s*)(sha512|size|blockMapSize):\s*(.+?)\s*$/); + if (kv && kv[1].length > itemIndent) { + if (kv[2] === 'size' || kv[2] === 'blockMapSize') cur[kv[2]] = Number(kv[3]); + else cur[kv[2]] = kv[3]; + } + } + return entries; +} + +// Rewrite sha512/size/blockMapSize for each url present in infoByUrl, and sync +// the top-level `sha512:` to the file named by the top-level `path:`. Any field +// left undefined in infoByUrl[url] is preserved as-is. +function rewriteFeedYaml(text, infoByUrl) { + const eol = text.indexOf('\r\n') !== -1 ? '\r\n' : '\n'; + const lines = text.split(/\r?\n/); + let curUrl = null; + let itemIndent = -1; + let pathUrl = null; + const out = lines.map((line) => { + const item = line.match(/^(\s*)-\s*url:\s*(.+?)\s*$/); + if (item) { curUrl = item[2]; itemIndent = item[1].length; return line; } + const topKey = line.match(/^(\S[^:]*):\s*(.*)$/); + if (topKey) { + curUrl = null; + if (topKey[1] === 'path') { pathUrl = topKey[2].trim(); return line; } + if (topKey[1] === 'sha512' && pathUrl && infoByUrl[pathUrl] && infoByUrl[pathUrl].sha512 != null) { + return 'sha512: ' + infoByUrl[pathUrl].sha512; + } + return line; + } + if (curUrl && infoByUrl[curUrl]) { + const kv = line.match(/^(\s*)(sha512|size|blockMapSize):\s*(.+?)\s*$/); + if (kv && kv[1].length > itemIndent) { + const info = infoByUrl[curUrl]; + if (kv[2] === 'sha512' && info.sha512 != null) return kv[1] + 'sha512: ' + info.sha512; + if (kv[2] === 'size' && info.size != null) return kv[1] + 'size: ' + info.size; + if (kv[2] === 'blockMapSize' && info.blockMapSize != null) return kv[1] + 'blockMapSize: ' + info.blockMapSize; + } + } + return line; + }); + return out.join(eol); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +function main() { + if (!fs.existsSync(FEED)) { + throw new Error('missing ' + FEED + ' — build the mac targets first (dmg + zip)'); + } + const text = fs.readFileSync(FEED, 'utf8'); + const entries = parseFeedEntries(text); + if (!entries.length) throw new Error('no files[] entries found in ' + FEED); + + let appBuilder = null; + const infoByUrl = {}; + let changed = 0; + for (const e of entries) { + const file = path.join(DIST, e.url); + if (!fs.existsSync(file)) throw new Error('feed references a missing artifact: ' + file); + const sha = sha512Base64(file); + const size = fs.statSync(file).size; + const info = { sha512: sha, size }; + if (sha !== e.sha512) { + // Bytes changed since build (e.g. a stapled DMG) — its blockmap is stale too. + const bm = file + '.blockmap'; + if (fs.existsSync(bm)) { + appBuilder = appBuilder || findAppBuilder(); + info.blockMapSize = regenerateBlockmap(appBuilder, file); + } + changed++; + console.log('[feed] refreshed ' + e.url + ' (bytes changed)'); + } else { + console.log('[feed] unchanged ' + e.url); + } + infoByUrl[e.url] = info; + } + + fs.writeFileSync(FEED, rewriteFeedYaml(text, infoByUrl)); + console.log('[feed] wrote ' + FEED + ' (' + changed + '/' + entries.length + ' entries refreshed)'); +} + +module.exports = { parseFeedEntries, rewriteFeedYaml }; + +if (require.main === module) { + try { main(); } catch (e) { console.error('[feed] ' + ((e && e.message) || e)); process.exit(1); } +} diff --git a/src/frontend/app.js b/src/frontend/app.js index 0fd1f8d..2f50aa7 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -3578,6 +3578,10 @@ function showExportDialog() { // ── Update check ────────────────────────────────────────────── +function _isDesktopUpdater() { + return !!(window.codbashDesktop && window.codbashDesktop.isDesktop && window.codbashDesktop.updater); +} + async function checkForUpdates() { try { var resp = await fetch('/api/version'); @@ -3605,6 +3609,15 @@ async function checkForUpdates() { } localStorage.setItem('codedash-last-version', data.current); + // Desktop app: updates are driven by electron-updater (real in-place update), + // NOT the npm-based `/api/update` route (which would update an unrelated + // npm-global copy while the bundled server keeps running the old version). + // The banner is state-driven via IPC — ignore the npm `updateAvailable` here. + if (_isDesktopUpdater()) { + wireDesktopUpdater(); + return; + } + if (data.updateAvailable) { if (badge) { badge.textContent = 'v' + data.current + ' → v' + data.latest; @@ -3629,7 +3642,115 @@ async function checkForUpdates() { } catch {} } +// ── Desktop in-app updater (electron-updater via IPC) ───────────────────────── +// State-driven banner: 'available' → Download → 'downloading' (%) → 'downloaded' +// → Restart. Mirrors what main.js emits over 'codbash:update-state'. +var _desktopUpdaterWired = false; +function wireDesktopUpdater() { + if (_desktopUpdaterWired || !_isDesktopUpdater()) return; + _desktopUpdaterWired = true; + // The npm command doesn't apply in the desktop app — hide the Copy button. + var copyBtn = document.getElementById('updateCopyBtn'); + if (copyBtn) copyBtn.style.display = 'none'; + window.codbashDesktop.updater.onState(function (s) { renderDesktopUpdateState(s || {}); }); + // Kick off a check now; periodic re-checks run in main.js. + try { window.codbashDesktop.updater.check(); } catch (e) {} +} + +function _setUpdatePrimary(label, handler, disabled) { + var btn = document.getElementById('updatePrimaryBtn'); + if (!btn) return; + btn.textContent = label; + btn.onclick = disabled ? null : handler; + btn.disabled = !!disabled; + btn.style.opacity = disabled ? '0.6' : '1'; +} + +// The banner's second button (the npm "Copy Command" button in browser mode) is +// repurposed as an optional secondary action in the desktop updater (e.g. the +// manual "Open download page" fallback next to "Check again" on error). +function _setUpdateSecondary(label, handler) { + var btn = document.getElementById('updateCopyBtn'); + if (!btn) return; + if (!label) { btn.style.display = 'none'; btn.onclick = null; return; } + btn.textContent = label; + btn.onclick = handler; + btn.style.display = ''; + btn.style.opacity = '0.7'; +} + +// True once we've surfaced an actual update to the user (available/downloading/ +// downloaded). Gates the error banner so a transient hiccup on the routine 6h +// background check doesn't pop an alarming "auto-update unavailable" out of +// nowhere — the error is only worth showing if the user was mid-flow. +var _desktopUpdateSurfaced = false; +function renderDesktopUpdateState(s) { + var banner = document.getElementById('updateBanner'); + var text = document.getElementById('updateText'); + var badge = document.getElementById('versionBadge'); + if (!banner || !text) return; + var U = window.codbashDesktop.updater; + switch (s.state) { + case 'available': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'v' + (s.version || '') + ' available'; + // Optimistically disable on click (before the IPC round-trip) so a fast + // double-click can't fire two downloads; main.js also guards server-side. + _setUpdatePrimary('Download', function () { _setUpdatePrimary('Downloading…', null, true); U.download(); }, false); + banner.style.display = 'flex'; + if (badge) { + badge.classList.add('update-available'); + badge.title = 'Download update'; + badge.onclick = function () { U.download(); }; + } + break; + case 'downloading': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'Downloading update… ' + (s.percent != null ? s.percent + '%' : ''); + _setUpdatePrimary('Downloading…', null, true); + banner.style.display = 'flex'; + break; + case 'downloaded': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'v' + (s.version || '') + ' ready — restart to apply'; + _setUpdatePrimary('Restart to update', function () { U.install(); }, false); + banner.style.display = 'flex'; + if (badge) { badge.title = 'Restart to update'; badge.onclick = function () { U.install(); }; } + break; + case 'error': + // In-place update couldn't apply. Only surface it if the user was already + // mid-flow (they clicked Download and it failed) — degrade gracefully with + // a retry plus a manual fallback. A background-check error with nothing + // offered stays silent. + if (!_desktopUpdateSurfaced) break; + text.textContent = 'Update failed — retry or open the download page'; + _setUpdatePrimary('Check again', function () { U.check(); }, false); + _setUpdateSecondary('Open download page', function () { U.openReleases(); }); + banner.style.display = 'flex'; + break; + case 'none': + // Re-check found nothing new: clear any stale banner we had shown. + _desktopUpdateSurfaced = false; + _setUpdateSecondary(null); + banner.style.display = 'none'; + break; + case 'checking': + default: + // Transient — leave the banner as it is. + break; + } +} + async function selfUpdate() { + // Desktop app: route to the in-place updater (download → restart), never the + // npm-based route which can't touch the running bundled server. + if (_isDesktopUpdater()) { + try { window.codbashDesktop.updater.download(); } catch (e) {} + return; + } if (!confirm('Update codbash to latest version? The page will reload.')) return; showToast('Updating...'); try { diff --git a/src/frontend/index.html b/src/frontend/index.html index 64268ac..15a88ec 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -368,8 +368,8 @@

Projects settings

diff --git a/src/server.js b/src/server.js index f8710db..0c3821c 100644 --- a/src/server.js +++ b/src/server.js @@ -988,6 +988,15 @@ function startServer(host, port, openBrowser = true) { // ── Self-update ───────────────────────── else if (req.method === 'POST' && pathname === '/api/update') { + // In the desktop app (Electron shell), this npm-based self-update is wrong: + // it would `npm i -g` an unrelated global copy while the app keeps running + // its bundled server, so the restart lands back on the old version. The + // desktop uses electron-updater instead — refuse here so nothing silently + // half-updates. CODBASH_DESKTOP=1 is set by desktop/main.js when it spawns us. + if (process.env.CODBASH_DESKTOP === '1') { + json(res, { ok: false, error: 'Use the built-in updater in the desktop app.' }, 400); + return; + } const pkg = require('../package.json'); log('UPDATE', `Starting self-update from v${pkg.version}...`); json(res, { ok: true, message: 'Updating... Page will reload.' }); diff --git a/test/desktop-update-feed.test.js b/test/desktop-update-feed.test.js new file mode 100644 index 0000000..6251802 --- /dev/null +++ b/test/desktop-update-feed.test.js @@ -0,0 +1,60 @@ +// Unit tests for the pure YAML transforms in desktop/scripts/regenerate-latest-mac.js. +// These cover the risky part (schema-preserving rewrite of electron-updater's +// mac feed) without needing a real signed build on disk. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseFeedEntries, rewriteFeedYaml } = require('../desktop/scripts/regenerate-latest-mac.js'); + +// A representative electron-builder mac feed: two zip entries (arm64 + x64), +// top-level path pointing at the x64 zip, and its sha512 mirrored top-level. +const SAMPLE = [ + 'version: 7.16.0', + 'files:', + ' - url: codbash-7.16.0-arm64-mac.zip', + ' sha512: OLD_ARM_SHA', + ' size: 111', + ' blockMapSize: 11', + ' - url: codbash-7.16.0-mac.zip', + ' sha512: OLD_X64_SHA', + ' size: 222', + ' blockMapSize: 22', + 'path: codbash-7.16.0-mac.zip', + 'sha512: OLD_X64_SHA', + "releaseDate: '2026-07-24T00:00:00.000Z'", + '', +].join('\n'); + +test('parseFeedEntries reads every files[] entry with typed numbers', () => { + const entries = parseFeedEntries(SAMPLE); + assert.equal(entries.length, 2); + assert.deepEqual(entries[0], { url: 'codbash-7.16.0-arm64-mac.zip', sha512: 'OLD_ARM_SHA', size: 111, blockMapSize: 11 }); + assert.deepEqual(entries[1], { url: 'codbash-7.16.0-mac.zip', sha512: 'OLD_X64_SHA', size: 222, blockMapSize: 22 }); +}); + +test('rewriteFeedYaml refreshes only the changed entry and syncs top-level sha512 to path', () => { + // Only the x64 zip changed; the arm64 entry must be left untouched. + const out = rewriteFeedYaml(SAMPLE, { + 'codbash-7.16.0-mac.zip': { sha512: 'NEW_X64_SHA', size: 999, blockMapSize: 99 }, + }); + const entries = parseFeedEntries(out); + const arm = entries.find((e) => e.url.includes('arm64')); + const x64 = entries.find((e) => !e.url.includes('arm64')); + + // Changed entry refreshed + assert.deepEqual(x64, { url: 'codbash-7.16.0-mac.zip', sha512: 'NEW_X64_SHA', size: 999, blockMapSize: 99 }); + // Untouched entry preserved + assert.deepEqual(arm, { url: 'codbash-7.16.0-arm64-mac.zip', sha512: 'OLD_ARM_SHA', size: 111, blockMapSize: 11 }); + // Top-level sha512 follows `path` (the x64 zip) → must be the new value + assert.match(out, /^sha512: NEW_X64_SHA$/m); + // Non-checksum metadata is preserved verbatim + assert.match(out, /^version: 7\.16\.0$/m); + assert.match(out, /^path: codbash-7\.16\.0-mac\.zip$/m); + assert.match(out, /^releaseDate: '2026-07-24T00:00:00\.000Z'$/m); +}); + +test('rewriteFeedYaml is a no-op when nothing is supplied for a url', () => { + const out = rewriteFeedYaml(SAMPLE, {}); + assert.equal(out.trim(), SAMPLE.trim()); +}); diff --git a/test/desktop-update-guard.test.js b/test/desktop-update-guard.test.js new file mode 100644 index 0000000..2f7bb2a --- /dev/null +++ b/test/desktop-update-guard.test.js @@ -0,0 +1,83 @@ +// Guards the desktop-mode refusal of the npm-based self-update route. +// +// In the Electron desktop app, `POST /api/update` (which runs `npm i -g +// codbash-app@latest`) is wrong: it would update an unrelated npm-global copy +// while the app keeps running its bundled server, so the "restart" lands back on +// the old version. desktop/main.js sets CODBASH_DESKTOP=1 when it spawns the +// server, and the server must refuse the route with 400. This is the single +// feature flag that makes the desktop use electron-updater instead — a future +// refactor of the spawn options must not silently drop it, so we pin it here. +// +// We only assert the desktop (guarded) path: the non-desktop path actually runs +// `npm i -g` + restart and is destructive, so it is deliberately NOT exercised. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('http'); +const net = require('net'); +const path = require('path'); +const { spawn } = require('child_process'); + +const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); + +function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +function waitForReady(port, deadlineMs) { + const deadline = Date.now() + deadlineMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get({ host: '127.0.0.1', port, path: '/api/version', timeout: 1500 }, (res) => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (Date.now() > deadline) reject(new Error('server did not become ready')); + else setTimeout(attempt, 200); + }); + req.on('timeout', () => { req.destroy(); if (Date.now() > deadline) reject(new Error('timeout')); else setTimeout(attempt, 200); }); + }; + attempt(); + }); +} + +function post(port, urlPath) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: urlPath, timeout: 5000 }, (res) => { + let buf = ''; + res.on('data', (c) => buf += c); + res.on('end', () => { + let body = null; + try { body = buf ? JSON.parse(buf) : null; } catch {} + resolve({ status: res.statusCode, body }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('request timeout')); }); + req.end(); + }); +} + +test('POST /api/update is refused (400) when CODBASH_DESKTOP=1', async () => { + const port = await freePort(); + const child = spawn(process.execPath, [CLI, 'run', `--port=${port}`, '--host=127.0.0.1', '--no-browser'], { + env: Object.assign({}, process.env, { CODBASH_DESKTOP: '1' }), + stdio: 'ignore', + }); + try { + await waitForReady(port, 15000); + const res = await post(port, '/api/update'); + assert.equal(res.status, 400, 'desktop mode must refuse the npm self-update route'); + assert.ok(res.body && res.body.ok === false, 'response should carry ok:false'); + } finally { + child.kill('SIGKILL'); + } +});