From e597cd926201db69be29b09aadff84f7a115b076 Mon Sep 17 00:00:00 2001 From: Vasilii Zolotukhin Date: Fri, 24 Jul 2026 16:55:12 +1000 Subject: [PATCH] feat: Add desktop OTA updater --- desktop/RELEASE.md | 28 ++--- desktop/main.js | 142 +++++++++++++++-------- desktop/package.json | 4 + desktop/preload.js | 10 ++ desktop/scripts/regenerate-latest-mac.js | 83 +++++++++++++ src/frontend/app.js | 113 +++++++++++++++--- src/frontend/index.html | 4 +- src/server.js | 16 ++- 8 files changed, 318 insertions(+), 82 deletions(-) create mode 100644 desktop/scripts/regenerate-latest-mac.js diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 4603283..83fb7a4 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -2,8 +2,8 @@ The build is **signed** with a Developer ID Application cert, **notarized** by the `afterSign` hook (`scripts/notarize.js`) + a follow-up pass for the DMG -containers, and **published** to GitHub Releases where the in-app notify-updater -picks it up. Releases since **v7.14.4** are signed + notarized (arm64 + x64) and +containers, and **published** to GitHub Releases where `electron-updater` picks +it up. Releases since **v7.14.4** are signed + notarized (arm64 + x64) and open with no Gatekeeper warning. With no credentials the build still succeeds and produces an unsigned DMG for local smoke-testing (the notarize hook no-ops). @@ -75,10 +75,8 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al 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 ``` Publish: @@ -109,15 +107,17 @@ hdiutil attach /tmp/q.dmg -nobrowse; spctl -a -vvv -t exec "/Volumes/codbash (pb[i] || 0)) return true; - if ((pa[i] || 0) < (pb[i] || 0)) return false; +function sendUpdateEvent(type, payload) { + const event = Object.assign({ type: type }, payload || {}); + if (win && win.webContents) { + try { win.webContents.send('codbash:update-event', event); } catch (_e) {} } - return false; } -// 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) { +function setUpdateState(status, payload) { + updateState = Object.assign({ status: status }, payload || {}); + sendUpdateEvent(status, updateState); + return updateState; +} + +function installDownloadedUpdate() { + if (SMOKE || updateState.status !== 'downloaded') return; + app.isQuitting = true; + if (serverProc) { try { serverProc.kill(); } catch (_e) {} } + autoUpdater.quitAndInstall(false, true); +} + +function promptInstallUpdate(info) { 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(); }); + dialog.showMessageBox(win || undefined, { + type: 'info', + message: 'codbash ' + info.version + ' is ready to install', + detail: 'Restart codbash now to finish the update?', + buttons: ['Restart to update', 'Later'], + defaultId: 0, + cancelId: 1, + }).then(function (r) { + if (r.response === 0) installDownloadedUpdate(); + }).catch(function () {}); +} + +function checkForUpdates(interactive) { + if (SMOKE) return Promise.resolve(updateState); + if (!app.isPackaged) { + const state = setUpdateState('unavailable', { reason: 'dev', current: app.getVersion() }); + if (interactive) { + dialog.showMessageBox(win || undefined, { + type: 'info', + message: 'Desktop auto-update is disabled in development', + detail: 'Packaged builds use GitHub Releases via electron-updater.', + buttons: ['OK'], + }).catch(function () {}); + } + return Promise.resolve(state); + } + if (updateCheckInFlight) return updateCheckInFlight; + updateCheckInFlight = autoUpdater.checkForUpdates() + .then(function () { return updateState; }) + .catch(function (e) { + return setUpdateState('error', { message: String((e && e.message) || e) }); + }) + .finally(function () { updateCheckInFlight = null; }); + return updateCheckInFlight; } function initAutoUpdater() { if (SMOKE) return; + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.on('checking-for-update', function () { + setUpdateState('checking', { current: app.getVersion() }); + }); + autoUpdater.on('update-available', function (info) { + setUpdateState('available', { version: info.version, current: app.getVersion() }); + }); + autoUpdater.on('update-not-available', function (info) { + setUpdateState('idle', { version: info.version, current: app.getVersion() }); + }); + autoUpdater.on('download-progress', function (progress) { + setUpdateState('downloading', { + current: app.getVersion(), + percent: progress && typeof progress.percent === 'number' ? progress.percent : null, + }); + }); + autoUpdater.on('update-downloaded', function (info) { + setUpdateState('downloaded', { version: info.version, current: app.getVersion() }); + promptInstallUpdate(info); + }); + autoUpdater.on('error', function (e) { + setUpdateState('error', { message: String((e && e.message) || e), current: app.getVersion() }); + }); checkForUpdates(false); // Re-check every 6 hours while the app stays open. setInterval(function () { checkForUpdates(false); }, 6 * 60 * 60 * 1000); diff --git a/desktop/package.json b/desktop/package.json index 6c7e74e..4f0a220 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -12,6 +12,7 @@ "smoke": "CODBASH_SMOKE=1 electron .", "dist:mac": "electron-builder --mac dmg", "release:mac": "electron-builder --mac dmg --publish always", + "refresh-update-feed": "node scripts/regenerate-latest-mac.js", "pack": "electron-builder --dir", "dev": "bash dev.sh" }, @@ -20,6 +21,9 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8" }, + "dependencies": { + "electron-updater": "^6.3.9" + }, "build": { "appId": "dev.codbash.desktop", "productName": "codbash", diff --git a/desktop/preload.js b/desktop/preload.js index da58087..e86237c 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -9,6 +9,16 @@ contextBridge.exposeInMainWorld('codbashDesktop', { isDesktop: true, // Resolves to the chosen absolute folder path, or null if the user cancels. pickFolder: () => ipcRenderer.invoke('codbash:pick-folder'), + // Desktop OTA is owned by the Electron main process. The page can request a + // check, install an already-downloaded update, and subscribe to status events. + checkForUpdates: () => ipcRenderer.invoke('codbash:update-check'), + installUpdate: () => ipcRenderer.invoke('codbash:update-install'), + onUpdateEvent: (cb) => { + if (typeof cb !== 'function') return () => {}; + const handler = (_e, event) => cb(event); + ipcRenderer.on('codbash:update-event', handler); + return () => ipcRenderer.removeListener('codbash:update-event', handler); + }, // Keyboard shortcuts the native menu would otherwise swallow (e.g. Cmd+W // closing the whole window). Main intercepts them and forwards the name here // so the page can act (close a tab instead). cb receives the shortcut name. diff --git a/desktop/scripts/regenerate-latest-mac.js b/desktop/scripts/regenerate-latest-mac.js new file mode 100644 index 0000000..7a56d63 --- /dev/null +++ b/desktop/scripts/regenerate-latest-mac.js @@ -0,0 +1,83 @@ +'use strict'; + +// Rebuild electron-updater's mac feed after DMG notarization/stapling. +// Stapling mutates the DMG bytes, so sha512, size, and blockmaps must match the +// final containers that are uploaded to GitHub Releases. +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 pkg = require(path.join(root, 'package.json')); +const version = pkg.version; + +function findAppBuilder() { + const pkgPath = require.resolve('app-builder-bin/package.json', { paths: [root] }); + const dir = path.dirname(pkgPath); + const candidates = process.platform === 'darwin' + ? [ + 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, process.platform === 'win32' ? 'win' : 'linux', process.platform === 'win32' ? 'app-builder.exe' : 'app-builder'), + ]; + const found = candidates.find((p) => fs.existsSync(p)); + if (!found) throw new Error('app-builder binary not found under ' + dir); + return found; +} + +function sha512(file) { + return crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64'); +} + +function rebuildBlockmap(appBuilder, dmg) { + const blockmap = dmg + '.blockmap'; + execFileSync(appBuilder, ['blockmap', '--input', dmg, '--output', blockmap], { stdio: 'inherit' }); + return blockmap; +} + +function fileInfo(appBuilder, filename) { + const dmg = path.join(dist, filename); + if (!fs.existsSync(dmg)) throw new Error('missing DMG: ' + dmg); + const blockmap = rebuildBlockmap(appBuilder, dmg); + return { + url: filename, + sha512: sha512(dmg), + size: fs.statSync(dmg).size, + blockMapSize: fs.statSync(blockmap).size, + }; +} + +function yamlString(files) { + const preferred = process.arch === 'arm64' + ? files.find((f) => /-arm64\.dmg$/.test(f.url)) || files[0] + : files.find((f) => !/-arm64\.dmg$/.test(f.url)) || files[0]; + const lines = [ + 'version: ' + version, + 'files:', + ]; + for (const f of files) { + lines.push(' - url: ' + f.url); + lines.push(' sha512: ' + f.sha512); + lines.push(' size: ' + f.size); + lines.push(' blockMapSize: ' + f.blockMapSize); + } + lines.push('path: ' + preferred.url); + lines.push('sha512: ' + preferred.sha512); + lines.push('releaseDate: ' + new Date().toISOString()); + lines.push(''); + return lines.join('\n'); +} + +const appBuilder = findAppBuilder(); +const files = [ + fileInfo(appBuilder, 'codbash-' + version + '-arm64.dmg'), + fileInfo(appBuilder, 'codbash-' + version + '.dmg'), +]; +const target = path.join(dist, 'latest-mac.yml'); +fs.writeFileSync(target, yamlString(files)); +console.log('[release] wrote ' + target); diff --git a/src/frontend/app.js b/src/frontend/app.js index 0fd1f8d..9d6f3a1 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -3578,6 +3578,75 @@ function showExportDialog() { // ── Update check ────────────────────────────────────────────── +var desktopUpdateReady = false; +var desktopUpdateEventsBound = false; + +function getDesktopUpdater() { + return window.codbashDesktop || null; +} + +function setUpdateBannerButtons(primaryText, copyVisible) { + var primary = document.getElementById('updatePrimaryBtn'); + var copy = document.getElementById('updateCopyBtn'); + if (primary && primaryText) primary.textContent = primaryText; + if (copy) copy.style.display = copyVisible ? '' : 'none'; +} + +function setUpdateBannerText(strongText, suffixText) { + var banner = document.getElementById('updateBanner'); + var text = document.getElementById('updateText'); + if (!banner || !text) return; + text.textContent = ''; + if (strongText) { + var strong = document.createElement('strong'); + strong.textContent = strongText; + text.appendChild(strong); + } + if (suffixText) text.appendChild(document.createTextNode(suffixText)); + banner.style.display = 'flex'; +} + +function handleDesktopUpdateEvent(event) { + if (!event || !event.status) return; + var badge = document.getElementById('versionBadge'); + if (event.status === 'checking') { + if (badge) badge.title = 'Checking for desktop updates...'; + } else if (event.status === 'available' || event.status === 'downloading') { + desktopUpdateReady = false; + if (badge) { + badge.classList.add('update-available'); + badge.title = 'Downloading desktop update'; + } + var suffix = event.status === 'downloading' && typeof event.percent === 'number' + ? ' downloading (' + Math.round(event.percent) + '%)' + : ' downloading'; + setUpdateBannerButtons('Downloading...', false); + setUpdateBannerText(event.version ? 'v' + event.version : 'Update', suffix); + } else if (event.status === 'downloaded') { + desktopUpdateReady = true; + if (badge) { + badge.textContent = 'v' + (event.current || '').replace(/^v/, '') + ' → v' + event.version; + badge.classList.add('update-available'); + badge.title = 'Click to restart and install'; + badge.onclick = function() { selfUpdate(); }; + } + setUpdateBannerButtons('Restart to Update', false); + setUpdateBannerText('v' + String(event.version || ''), ' ready to install'); + } else if (event.status === 'error') { + if (badge) badge.title = 'Desktop update check failed'; + } else if (event.status === 'idle') { + desktopUpdateReady = false; + if (badge) badge.title = 'Click to check for desktop updates'; + } +} + +function bindDesktopUpdateEvents() { + var desktop = getDesktopUpdater(); + if (desktopUpdateEventsBound || !(desktop && typeof desktop.onUpdateEvent === 'function')) return; + desktopUpdateEventsBound = true; + desktop.onUpdateEvent(handleDesktopUpdateEvent); +} + async function checkForUpdates() { try { var resp = await fetch('/api/version'); @@ -3605,6 +3674,20 @@ async function checkForUpdates() { } localStorage.setItem('codedash-last-version', data.current); + if (data.desktop) { + bindDesktopUpdateEvents(); + if (badge) { + badge.title = 'Click to check for desktop updates'; + badge.onclick = function() { + var desktop = getDesktopUpdater(); + if (desktop && typeof desktop.checkForUpdates === 'function') { + desktop.checkForUpdates().then(handleDesktopUpdateEvent).catch(function() {}); + } + }; + } + return; + } + if (data.updateAvailable) { if (badge) { badge.textContent = 'v' + data.current + ' → v' + data.latest; @@ -3612,24 +3695,28 @@ async function checkForUpdates() { badge.title = 'Click to update'; badge.onclick = function() { selfUpdate(); }; } - var banner = document.getElementById('updateBanner'); - var text = document.getElementById('updateText'); - if (banner && text) { - // Build via textContent/DOM nodes — never interpolate the npm-supplied - // version string into innerHTML, otherwise a tampered registry response - // can inject arbitrary HTML into our update banner. - text.textContent = ''; - var strong = document.createElement('strong'); - strong.textContent = 'v' + String(data.latest || ''); - text.appendChild(strong); - text.appendChild(document.createTextNode(' available')); - banner.style.display = 'flex'; - } + // Build via textContent/DOM nodes — never interpolate the npm-supplied + // version string into innerHTML, otherwise a tampered registry response + // can inject arbitrary HTML into our update banner. + setUpdateBannerButtons('Update Now', true); + setUpdateBannerText('v' + String(data.latest || ''), ' available'); } } catch {} } async function selfUpdate() { + var desktop = getDesktopUpdater(); + if (desktop && typeof desktop.installUpdate === 'function') { + if (desktopUpdateReady) { + desktop.installUpdate().catch(function(e) { showToast('Update failed: ' + e.message); }); + } else if (typeof desktop.checkForUpdates === 'function') { + showToast('Checking for desktop update...'); + desktop.checkForUpdates().then(handleDesktopUpdateEvent).catch(function(e) { + showToast('Update check failed: ' + e.message); + }); + } + 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..1ef2858 100644 --- a/src/server.js +++ b/src/server.js @@ -28,6 +28,8 @@ const { repoRefreshManager } = require('./repo-refresh'); const { handleRepoRefreshRoute } = require('./repo-refresh-routes'); const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; +const IS_DESKTOP = process.env.CODBASH_DESKTOP === '1'; +const DESKTOP_VERSION = process.env.CODBASH_DESKTOP_VERSION || ''; // The agent command currently running inside a pane's shell, for session // restore — so a hand-typed `HTTPS_PROXY=… claude …` comes back, not just the @@ -973,21 +975,29 @@ function startServer(host, port, openBrowser = true) { // ── Version check ──────────────────────── else if (req.method === 'GET' && pathname === '/api/version') { const pkg = require('../package.json'); - const current = pkg.version; + const current = IS_DESKTOP && DESKTOP_VERSION ? DESKTOP_VERSION : pkg.version; // `dev` marks a from-source run (NODE_ENV=development) so the UI can show a // DEV badge — an at-a-glance signal that you're looking at the live-editing // build (your uncommitted changes), NOT the installed DMG. const dev = process.env.NODE_ENV === 'development'; + if (IS_DESKTOP) { + json(res, { current, latest: null, updateAvailable: false, dev, desktop: true }); + return; + } // Fetch latest from npm registry fetchLatestVersion(pkg.name).then(latest => { - json(res, { current, latest, updateAvailable: latest && latest !== current && isNewer(latest, current), dev }); + json(res, { current, latest, updateAvailable: latest && latest !== current && isNewer(latest, current), dev, desktop: false }); }).catch(() => { - json(res, { current, latest: null, updateAvailable: false, dev }); + json(res, { current, latest: null, updateAvailable: false, dev, desktop: false }); }); } // ── Self-update ───────────────────────── else if (req.method === 'POST' && pathname === '/api/update') { + if (IS_DESKTOP) { + json(res, { ok: false, error: 'Desktop builds are updated by the Electron updater.' }, 409); + return; + } const pkg = require('../package.json'); log('UPDATE', `Starting self-update from v${pkg.version}...`); json(res, { ok: true, message: 'Updating... Page will reload.' });