Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions desktop/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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-<ver>-arm64.dmg dist/codbash-<ver>.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:
Expand Down Expand Up @@ -109,15 +107,17 @@ hdiutil attach /tmp/q.dmg -nobrowse; spctl -a -vvv -t exec "/Volumes/codbash <ve

## 4. Updates

- **Current model — notify-only.** On launch and every 6h the app queries the
GitHub `releases/latest` API and, if a newer version exists, offers to open
the download page (`main.js` → `checkForUpdates`). Robust and dependency-free;
just publish each release.
- **Silent auto-update (now possible — builds are signed).** Swap
`checkForUpdates` for `electron-updater`: `npm i electron-updater`, call
`autoUpdater.checkForUpdatesAndNotify()`, and ensure `latest-mac.yml` +
signed/stapled DMGs are in the Release (they are — steps 2b/publish above).
macOS silent in-place update requires the signature, which is now in place.
- **Current model — desktop OTA.** On launch and every 6h the app asks
`electron-updater` to check GitHub Releases, downloads a newer signed DMG in
the background, then prompts the user to restart and install. The bundled
server is marked with `CODBASH_DESKTOP=1`, so the web UI does not run the npm
self-updater inside a packaged app.
- **Required release assets.** Upload both DMGs, both `.blockmap` files, and the
regenerated `latest-mac.yml`. The feed must be regenerated after stapling
because stapling changes the DMG bytes and therefore the updater checksums.
- **CLI remains separate.** `codbash update` and `/api/update` still update the
global npm install when codbash is run as the CLI/source server. Packaged
desktop builds are updated only through Electron OTA.

## CI note

Expand Down
142 changes: 92 additions & 50 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
'use strict';

const { app, BrowserWindow, shell, dialog, Menu, ipcMain } = require('electron');
const { autoUpdater } = require('electron-updater');
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');
Expand All @@ -20,6 +20,8 @@ const { execFileSync } = require('child_process');
let serverProc = null;
let win = null;
let serverPort = 0;
let updateState = { status: 'idle' };
let updateCheckInFlight = null;
const SMOKE = !!process.env.CODBASH_SMOKE; // launch, verify, auto-quit (CI/local test)

// Grab an ephemeral loopback port the OS hands us, then release it for the server.
Expand Down Expand Up @@ -111,7 +113,12 @@ 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' }),
env: Object.assign({}, process.env, {
CODEDASH_HOST: '127.0.0.1',
CODBASH_DESKTOP: '1',
CODBASH_DESKTOP_VERSION: app.getVersion(),
CODBASH_DESKTOP_PACKAGED: app.isPackaged ? '1' : '0',
}),
stdio: ['ignore', 'pipe', 'pipe'],
});
serverProc.stdout.on('data', function (d) { process.stdout.write('[codbash] ' + d); });
Expand Down Expand Up @@ -160,6 +167,13 @@ function registerIpc() {
if (res.canceled || !res.filePaths || !res.filePaths.length) return null;
return res.filePaths[0];
});
ipcMain.handle('codbash:update-check', async function () {
return checkForUpdates(true);
});
ipcMain.handle('codbash:update-install', async function () {
installDownloadedUpdate();
return { ok: true };
});
// 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) {} } });
Expand Down Expand Up @@ -211,62 +225,90 @@ 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;
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);
Expand Down
4 changes: 4 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions desktop/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions desktop/scripts/regenerate-latest-mac.js
Original file line number Diff line number Diff line change
@@ -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);
Loading