diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 7854fbe..c929f77 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -10,7 +10,10 @@ name: Release Desktop
# (previously `bump-cask: needs: build` waited for the whole matrix and was
# skipped if any leg failed, so macOS users were stranded on the old version).
#
-# Unsigned by design (CSC_IDENTITY_AUTO_DISCOVERY=false).
+# macOS: Apple Silicon only, signed with the Developer ID certificate and notarized
+# (docs/electron-desktop/MAC-SIGNING.md). Without the MAC_CSC_* / APPLE_* secrets
+# the mac leg FAILS — the app self-updates via Squirrel.Mac, which refuses unsigned
+# updates. Windows/Linux stay unsigned (CSC_IDENTITY_AUTO_DISCOVERY=false).
on:
push:
tags: ['v*.*.*']
@@ -32,8 +35,31 @@ jobs:
- name: Build & publish desktop app (macOS)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CSC_IDENTITY_AUTO_DISCOVERY: 'false'
- run: npx electron-builder --mac --arm64 --x64 --publish always
+ # MAC_-prefixed secrets: electron-builder reads CSC_LINK on Windows too, so the
+ # name has to say which platform this certificate belongs to.
+ CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ set -euo pipefail
+ # No unsigned fallback. The app updates itself through Squirrel.Mac, which
+ # installs only an update signed by the same Developer ID — an unsigned
+ # release would be refused by every installed copy, and its dmg blocked by
+ # Gatekeeper for every new one.
+ if [ -z "${CSC_LINK:-}" ]; then
+ echo "::error::MAC_CSC_LINK is not set — refusing to publish an unsigned macOS build. See docs/electron-desktop/MAC-SIGNING.md."
+ exit 1
+ fi
+ if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
+ # electron-builder would sign, log "skipped macOS notarization" and publish.
+ # Gatekeeper refuses a signed-but-unnotarized download exactly like an
+ # unsigned one, so that build is a failure that looks like a success.
+ echo "::error::MAC_CSC_LINK is set but APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID are not — refusing to publish a signed but un-notarized dmg."
+ exit 1
+ fi
+ npx electron-builder --mac --arm64 --publish always
build-others:
strategy:
@@ -74,14 +100,19 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}"
curl -fL "$BASE/claude-code-studio-${VERSION}-arm64.dmg" -o arm64.dmg
- curl -fL "$BASE/claude-code-studio-${VERSION}-x64.dmg" -o x64.dmg
ARM_SHA="$(shasum -a 256 arm64.dmg | awk '{print $1}')"
- X64_SHA="$(shasum -a 256 x64.dmg | awk '{print $1}')"
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${GITHUB_REPOSITORY_OWNER}/homebrew-claude-code-studio.git" tap
CASK="tap/Casks/claude-code-studio.rb"
sed -i -E "s/^ version \".*\"/ version \"${VERSION}\"/" "$CASK"
- sed -i -E "s/(arm:[[:space:]]*\")[a-f0-9]{64}/\1${ARM_SHA}/" "$CASK"
- sed -i -E "s/(intel:[[:space:]]*\")[a-f0-9]{64}/\1${X64_SHA}/" "$CASK"
+ sed -i -E "s/^ sha256 \"[a-f0-9]{64}\"/ sha256 \"${ARM_SHA}\"/" "$CASK"
+ # sed exits 0 when its pattern matches nothing. A cask in any other shape
+ # (the old per-arch `sha256 arm:/intel:` stanza) would be pushed with the
+ # NEW version and the OLD checksum — every install then fails brew's sha
+ # check. Refuse instead of publishing that.
+ grep -q "^ version \"${VERSION}\"$" "$CASK" && grep -q "^ sha256 \"${ARM_SHA}\"$" "$CASK" || {
+ echo "::error::$CASK was not updated to ${VERSION} / ${ARM_SHA} — it must carry a single-arch \`sha256 \"…\"\` line."
+ exit 1
+ }
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
diff --git a/.gitignore b/.gitignore
index 354dd25..2afa156 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,8 @@ data/uploads/
# Environment
.env
+# Local macOS signing/notarization settings (docs/electron-desktop/MAC-SIGNING.md)
+electron-builder.env
# Workspace (user files, Claude working dir)
workspace/
diff --git a/CLAUDE.md b/CLAUDE.md
index dcc6ee1..09081cb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ docker compose up -d
docker compose logs -f claude-chat
```
-No linting and no build step configured. `npm test` chains 84 test files under `test/`: 19 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 65 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, the Kanban card's run-settings badges (`kanban-run-badges.test.js` pins the card's model/effort/engine chain against the one `startTask` actually resolves — the two live in different files and the card silently lies when they drift), the board-only `create_task` status (`task-backlog.test.js`), the Kanban group form (`kanban-group-button.test.js` RUNS the real `buildChainForm` in a `vm` context so a stray free variable in its template throws there instead of silently killing the modal — see #115 — and parses every inline `
diff --git a/test/mac-signing.test.js b/test/mac-signing.test.js
new file mode 100644
index 0000000..0304101
--- /dev/null
+++ b/test/mac-signing.test.js
@@ -0,0 +1,134 @@
+// macOS release: Developer ID signing + notarization, Apple Silicon only.
+//
+// Every failure this file guards against produces a build that LOOKS fine: the
+// dmg exists, the CI step is green, the app even launches on the machine that
+// built it. The damage only shows on a user's Mac:
+//
+// - osascript's Apple Events to Terminal.app are checked against THIS app (the
+// responsible process) under the hardened runtime. Without
+// `com.apple.security.automation.apple-events` they are refused (-1743) with no
+// prompt — "Open in Terminal" silently does nothing.
+// - electron-builder signs, logs "skipped macOS notarization" and publishes when
+// the certificate is present but the APPLE_* credentials are not. Gatekeeper
+// rejects that dmg exactly like an unsigned one.
+// - The tap bump rewrites the cask with `sed`, which exits 0 when nothing matched:
+// a new version with the old checksum, and every `brew install` fails.
+//
+// See docs/electron-desktop/MAC-SIGNING.md. Run: node test/mac-signing.test.js
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
+const YML = read('electron-builder.yml');
+const WF = read('.github/workflows/release-desktop.yml');
+
+let pass = 0, fail = 0;
+function check(label, fn) {
+ try { fn(); pass++; console.log(` ok ${label}`); }
+ catch (e) { fail++; console.error(` FAIL ${label} — ${e.message}`); }
+}
+
+// A top-level YAML block: from `name:` to the next unindented line.
+function yamlBlock(src, name) {
+ const m = new RegExp(`^${name}:\\n((?:(?:[ #].*)?\\n)*)`, 'm').exec(src);
+ assert.ok(m, `no top-level "${name}:" block`);
+ return m[1];
+}
+// A job inside the workflow: from ` :` to the next job at the same indent.
+function job(name) {
+ const m = new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:\\n|(?![\\s\\S]))`, 'm').exec(WF);
+ assert.ok(m, `no job "${name}" in release-desktop.yml`);
+ return m[1];
+}
+const MAC = yamlBlock(YML, 'mac');
+const macKey = (k) => { const m = new RegExp(`^ ${k}:\\s*(.*)$`, 'm').exec(MAC); return m && m[1].replace(/^"|"$/g, '').trim(); };
+
+// ── 1. entitlements ──────────────────────────────────────────────────────────
+const ENT_PATH = 'build/entitlements.mac.plist';
+const entKeys = () => (read(ENT_PATH).match(/([^<]+)<\/key>\s*/g) || [])
+ .map(s => s.replace(/([^<]+)<\/key>[\s\S]*/, '$1'));
+
+check('hardened runtime is on and both app and helpers use the project entitlements', () => {
+ assert.strictEqual(macKey('hardenedRuntime'), 'true');
+ assert.strictEqual(macKey('entitlements'), ENT_PATH);
+ // The server runs in a utilityProcess — inside a Helper bundle — so the helpers
+ // need the same Apple Events key, not electron-builder's template.
+ assert.strictEqual(macKey('entitlementsInherit'), ENT_PATH);
+ assert.ok(fs.existsSync(path.join(ROOT, ENT_PATH)), `${ENT_PATH} missing`);
+});
+check('entitlements keep V8 JIT working under the hardened runtime', () => {
+ assert.ok(entKeys().includes('com.apple.security.cs.allow-jit'), entKeys().join(', '));
+});
+check('server.js drives Terminal via osascript, so the Apple Events entitlement is present', () => {
+ const srv = read('server.js');
+ assert.ok(/osascript/.test(srv) && /tell application "Terminal"/.test(srv),
+ 'server.js no longer scripts Terminal — revisit whether the entitlement is still needed');
+ assert.ok(entKeys().includes('com.apple.security.automation.apple-events'), entKeys().join(', '));
+ assert.ok(/NSAppleEventsUsageDescription:\s*"[^"]{10,}"/.test(MAC),
+ 'mac.extendInfo.NSAppleEventsUsageDescription missing — the Automation prompt has no text');
+});
+check('notarization is not switched off in the config', () => {
+ assert.notStrictEqual(macKey('notarize'), 'false');
+});
+
+// ── 2. Apple Silicon only ────────────────────────────────────────────────────
+check('mac targets are arm64 only', () => {
+ const arches = [...MAC.matchAll(/^\s+arch:\s*\[?([^\]\n]+)\]?/gm)].flatMap(m => m[1].split(',').map(s => s.trim()));
+ assert.ok(arches.length >= 2, `expected an arch on every mac target, got ${JSON.stringify(arches)}`);
+ assert.deepStrictEqual([...new Set(arches)], ['arm64']);
+});
+check('the CI mac build asks for arm64 and nothing else', () => {
+ const run = /npx electron-builder --mac[^\n]*/.exec(job('build-mac'));
+ assert.ok(run, 'no electron-builder --mac invocation in build-mac');
+ assert.ok(/--arm64\b/.test(run[0]) && !/--x64\b|--universal\b/.test(run[0]), run[0]);
+});
+
+// ── 3. CI signing: credentials in, half-configured refused ───────────────────
+check('build-mac maps the MAC_CSC_* and APPLE_* secrets into electron-builder\'s env', () => {
+ const j = job('build-mac');
+ for (const [env, secret] of [
+ ['CSC_LINK', 'MAC_CSC_LINK'], ['CSC_KEY_PASSWORD', 'MAC_CSC_KEY_PASSWORD'],
+ ['APPLE_ID', 'APPLE_ID'], ['APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_APP_SPECIFIC_PASSWORD'],
+ ['APPLE_TEAM_ID', 'APPLE_TEAM_ID'],
+ ]) {
+ assert.ok(new RegExp(`^\\s+${env}: \\$\\{\\{ secrets\\.${secret} \\}\\}$`, 'm').test(j), `${env} ← secrets.${secret}`);
+ }
+});
+check('build-mac never switches signing off', () => {
+ // Squirrel.Mac (the in-app updater) refuses an update not signed by the same
+ // Developer ID, so an unsigned mac release is a broken release, not a fallback.
+ assert.ok(!/CSC_IDENTITY_AUTO_DISCOVERY/.test(job('build-mac')));
+});
+// The two guards, as the job's own shell runs them — before the build, both exit 1.
+function guard(re, what) {
+ const j = job('build-mac');
+ const g = re.exec(j);
+ assert.ok(g, `no ${what} guard`);
+ assert.ok(/exit 1/.test(g[0]), `${what} guard does not fail the job`);
+ assert.ok(j.indexOf(g[0]) < j.indexOf('npx electron-builder --mac'), `${what} guard must run before the build`);
+}
+check('no certificate fails the job', () =>
+ guard(/if \[ -z "\$\{CSC_LINK:-\}" \]; then[\s\S]*?\n\s*fi\n/, 'missing-certificate'));
+check('a certificate without notarization credentials fails the job', () =>
+ guard(/if[^\n]*APPLE_ID[^\n]*APPLE_APP_SPECIFIC_PASSWORD[^\n]*APPLE_TEAM_ID[\s\S]*?\n\s*fi\n/, 'partial-credentials'));
+
+// ── 4. tap bump ──────────────────────────────────────────────────────────────
+check('bump-cask fetches only the arm64 dmg and verifies what sed wrote', () => {
+ // Comment lines dropped: the one explaining the check names the old `intel:` stanza.
+ const j = job('bump-cask').split('\n').filter(l => !/^\s*#/.test(l)).join('\n');
+ assert.ok(/-arm64\.dmg/.test(j), 'arm64 dmg not fetched');
+ assert.ok(!/x64\.dmg|intel:/.test(j), 'still references the Intel build');
+ assert.ok(/grep -q "\^ sha256 \\"\$\{ARM_SHA\}\\"\$"/.test(j), 'no post-sed checksum verification');
+});
+
+// ── 5. local settings never ship or get committed ────────────────────────────
+check('electron-builder.env is excluded from the bundle and from git', () => {
+ assert.ok(/^\s+- "!electron-builder\.env"$/m.test(YML), 'not in the files: exclusions');
+ assert.ok(/^electron-builder\.env$/m.test(read('.gitignore')), 'not in .gitignore');
+});
+
+console.log(`\nmac-signing: ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/test/update-flow.test.js b/test/update-flow.test.js
index 79f540c..1d2aa7f 100644
--- a/test/update-flow.test.js
+++ b/test/update-flow.test.js
@@ -1,43 +1,55 @@
-// Regression guard for the macOS in-app update (electron/main.js).
+// The desktop app's in-app update (electron/main.js + the banner in public/index.html).
//
-// History: on 2026-08-20 v7.2.2 shipped and the desktop app went into an update loop —
-// it announced 7.2.2, quit, relaunched at 7.2.1 and announced 7.2.2 again. update.log
-// recorded four attempts (15:50–15:53) all ending in "Not upgrading …, the latest
-// version is already installed" followed by OK. Two independent defects:
+// Every OS now updates through electron-updater. On macOS that is Squirrel.Mac, which
+// needs a Developer ID signed app (docs/electron-desktop/MAC-SIGNING.md) and replaced
+// the app-triggered `brew upgrade --cask`. What this file pins is where Squirrel fails
+// quietly, and where the brew flow it replaced used to loop:
//
-// 1. checkUpdate() read the GitHub release, which is published the moment the tag
-// lands; the Homebrew cask — the only macOS install path — is bumped ~8 minutes
-// later, when the mac build finishes. The app offered what brew could not install.
-// 2. The upgrade shell decided success from `brew upgrade`'s exit code, but brew
-// exits 0 when it has nothing to do. A no-op was reported as OK, so the failure
-// notification never fired and the app silently reopened at the same version.
-//
-// This test EXECUTES the real shell that main.js builds, against a fake brew, rather
-// than pattern-matching its source — so it also catches a rewritten condition, a
-// dropped notification or a lost relaunch, not just a literal restore of the old line.
+// 1. Squirrel.Mac closes every window BEFORE it quits. The close-to-tray handler
+// hides a window unless app.isQuiting is set, which cancels that quit: the
+// update is staged, the app never restarts, and the banner spins forever.
+// 2. Squirrel replaces the bundle in place. It cannot do that from a mounted dmg or
+// from a Gatekeeper-translocated copy (both read-only), so it has to be caught
+// before the download and turned into "move the app to Applications".
+// 3. electron-updater's `error` event fires for a failed CHECK too (latest-mac.yml
+// is uploaded ~8 min after the release is published). Only an error during an
+// install the user started may turn the banner into "Update failed".
+// 4. Listeners are registered once. Registering them per click stacked a new set on
+// every Retry, so the second attempt logged and installed twice.
//
// Run: node test/update-flow.test.js
+'use strict';
const assert = require('assert');
const fs = require('fs');
-const os = require('os');
const path = require('path');
-const { execFileSync } = require('child_process');
+const { EventEmitter } = require('events');
let pass = 0, fail = 0;
+const pending = [];
function check(label, fn) {
- try { fn(); pass++; console.log(` ok ${label}`); }
- catch (e) { fail++; console.error(` FAIL ${label} — ${e.message}`); }
+ const done = (e) => {
+ if (e) { fail++; console.error(` FAIL ${label} — ${e.message}`); }
+ else { pass++; console.log(` ok ${label}`); }
+ };
+ try {
+ const r = fn();
+ if (r && typeof r.then === 'function') pending.push(r.then(() => done(), done));
+ else done();
+ } catch (e) { done(e); }
}
-const MAIN = fs.readFileSync(path.join(__dirname, '..', 'electron', 'main.js'), 'utf8');
+const ROOT = path.join(__dirname, '..');
+const MAIN = fs.readFileSync(path.join(ROOT, 'electron', 'main.js'), 'utf8');
+const PRELOAD = fs.readFileSync(path.join(ROOT, 'electron', 'preload.js'), 'utf8');
+const HTML = fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8');
+// Code only: the comments explain what was removed and would match its names.
+const MAIN_CODE = MAIN.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n');
-// ─── Extract a top-level function by name and make it callable ──────────────
+// ─── Extract a top-level function by name ────────────────────────────────────
// A guard that cannot find its target must fail loudly, never quietly pass.
-function extract(name) {
- const at = MAIN.indexOf(`function ${name}(`);
+function source(name) {
+ const at = MAIN.search(new RegExp(`(?:async )?function ${name}\\(`));
assert.notStrictEqual(at, -1, `function ${name} is gone — update this test`);
- // Skip the parameter list first: buildUpgradeShell destructures its argument, so
- // the first `{` after the name opens the parameters, not the body.
let i = MAIN.indexOf('(', at), paren = 0;
for (; i < MAIN.length; i++) {
if (MAIN[i] === '(') paren++;
@@ -50,125 +62,380 @@ function extract(name) {
else if (MAIN[j] === '}' && --depth === 0) { end = j + 1; break; }
}
assert.notStrictEqual(end, -1, `unbalanced braces in ${name}`);
- // CASK_NAME is the only module constant these two functions close over.
- return new Function('CASK_NAME', `${MAIN.slice(at, end)}; return ${name};`)('claude-code-studio');
+ return MAIN.slice(at, end);
+}
+// Build `name` with the given closure variables bound to the values passed.
+function extract(name, env = {}) {
+ const keys = Object.keys(env);
+ return new Function(...keys, `${source(name)}; return ${name};`)(...keys.map(k => env[k]));
}
-const parseCaskVersion = extract('parseCaskVersion');
-const buildUpgradeShell = extract('buildUpgradeShell');
+// ── 1. brew is gone ──────────────────────────────────────────────────────────
+console.log('\nno Homebrew in the update path:');
+for (const gone of ['brew', 'fetchTapCaskVersion', 'parseCaskVersion', 'buildUpgradeShell', 'CASK_NAME', 'brewManagedPath']) {
+ check(`main.js no longer references ${gone}`, () => assert.ok(!new RegExp(`\\b${gone}\\b`).test(MAIN_CODE)));
+}
+check('checkUpdate and startUpdate both go through getUpdater() on every OS', () => {
+ for (const fn of ['checkUpdate', 'startUpdate']) {
+ const src = source(fn);
+ assert.ok(/getUpdater\(\)/.test(src), `${fn} does not use getUpdater()`);
+ assert.ok(!/require\('electron-updater'\)/.test(src), `${fn} requires electron-updater itself`);
+ }
+});
-console.log('\ncask version parsing:');
-check('reads the version out of a real cask body', () => {
- const rb = 'cask "claude-code-studio" do\n version "7.2.2"\n sha256 arm: "dead"\nend\n';
- assert.strictEqual(parseCaskVersion(rb), '7.2.2');
+// ── 2. installBlocker ────────────────────────────────────────────────────────
+console.log('\nwhere Squirrel can replace the bundle:');
+{
+ const installBlocker = extract('installBlocker', { path });
+ const exe = (dir) => `${dir}/Claude Code Studio.app/Contents/MacOS/Claude Code Studio`;
+ const rw = () => false, ro = () => true; // isReadOnly(dir)
+ check('/Applications is fine', () => assert.strictEqual(installBlocker(exe('/Applications'), rw), null));
+ check('~/Applications is fine', () => assert.strictEqual(installBlocker(exe('/Users/me/Applications'), rw), null));
+ check('a dist-desktop build is fine — Squirrel updates it in place',
+ () => assert.strictEqual(installBlocker(exe('/Users/me/proj/dist-desktop/mac-arm64'), rw), null));
+ check('a Gatekeeper-translocated copy is blocked, whatever the probe says',
+ () => assert.strictEqual(installBlocker(exe('/private/var/folders/ab/xy/T/AppTranslocation/0F1E-22/d'), rw), 'translocated'));
+ check('a read-only volume (a mounted dmg) is blocked',
+ () => assert.strictEqual(installBlocker(exe('/Volumes/Claude Code Studio 7.18.0-arm64'), ro), 'read-only'));
+ check('the probe is asked about the folder that HOLDS the .app', () => {
+ let asked = null;
+ installBlocker(exe('/Applications'), (dir) => { asked = dir; return false; });
+ assert.strictEqual(asked, '/Applications');
+ });
+ check('an unbundled dev run (electron .) is not blocked',
+ () => assert.strictEqual(installBlocker('/Users/me/proj/node_modules/electron/dist/Electron', ro), null));
+
+ // Measured on this machine: access(W_OK) on a mounted dmg → EROFS; on /private/var/root
+ // → EACCES; on /System → EPERM. Only the first is a place the app cannot be updated
+ // from. A folder the user lacks permission for — /Applications on a standard account —
+ // is Squirrel's to deal with; "move the app to Applications" would be wrong advice
+ // for an app that is already there.
+ const fsWith = (code) => ({ constants: { W_OK: 2 }, accessSync() { if (code) { const e = new Error(code); e.code = code; throw e; } } });
+ check('isReadOnlyDir: a read-only volume (EROFS) is read-only',
+ () => assert.strictEqual(extract('isReadOnlyDir', { fs: fsWith('EROFS') })('/Volumes/x'), true));
+ check('isReadOnlyDir: a permission denial (EACCES) is NOT',
+ () => assert.strictEqual(extract('isReadOnlyDir', { fs: fsWith('EACCES') })('/Applications'), false));
+ check('isReadOnlyDir: nor is EPERM',
+ () => assert.strictEqual(extract('isReadOnlyDir', { fs: fsWith('EPERM') })('/Applications'), false));
+ check('isReadOnlyDir: a writable folder is not',
+ () => assert.strictEqual(extract('isReadOnlyDir', { fs: fsWith(null) })('/Applications'), false));
+}
+
+// ── progress and failures reach every window ─────────────────────────────────
+// Same-origin child windows get the preload and the banner too, and the one that
+// started the install need not be getAllWindows()[0].
+console.log('\nupdate messages are broadcast:');
+{
+ const win = (destroyed) => ({ got: [], isDestroyed: () => destroyed, webContents: { send(ch, m) { this.owner.got.push([ch, m]); } } });
+ const a = win(false), b = win(false), dead = win(true);
+ for (const w of [a, b, dead]) w.webContents.owner = w;
+ const broadcastUpdate = extract('broadcastUpdate', { BrowserWindow: { getAllWindows: () => [a, b, dead] } });
+ broadcastUpdate('update:failed', 'boom');
+ check('every live window receives it', () => {
+ assert.deepStrictEqual(a.got, [['update:failed', 'boom']]);
+ assert.deepStrictEqual(b.got, [['update:failed', 'boom']]);
+ });
+ check('a destroyed window is skipped', () => assert.deepStrictEqual(dead.got, []));
+ check('sendUpdateLog and sendUpdateFailed both go through it', () => {
+ assert.ok(/broadcastUpdate\('update:log'/.test(source('sendUpdateLog')));
+ assert.ok(/broadcastUpdate\('update:failed'/.test(source('sendUpdateFailed')));
+ });
+}
+
+// ── 3–4. getUpdater: listeners once, errors only during an install ───────────
+console.log('\ngetUpdater():');
+function harness() {
+ // Electron's own autoUpdater — the one Squirrel drives.
+ const native = new EventEmitter();
+ const fake = Object.assign(new EventEmitter(), {
+ autoDownload: true, autoInstallOnAppQuit: true, quitCalls: 0,
+ // What MacUpdater.quitAndInstall() does before Squirrel has the update: it adds
+ // its own native listener, and nothing removes it if Squirrel then fails.
+ quitAndInstall() { this.quitCalls++; native.on('update-downloaded', () => {}); },
+ });
+ const upd = { updater: null, inFlight: false, installing: false, nativeListeners: [] };
+ const logs = [], failures = [];
+ const getUpdater = extract('getUpdater', {
+ require: (m) => {
+ if (m === 'electron-updater') return { autoUpdater: fake };
+ if (m === 'electron') return { autoUpdater: native };
+ throw new Error('unexpected require ' + m);
+ },
+ upd,
+ sendUpdateLog: (l) => logs.push(l),
+ sendUpdateFailed: (m) => failures.push(m),
+ });
+ return { fake, native, upd, logs, failures, getUpdater };
+}
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+{
+ const h = harness();
+ const a = h.getUpdater(), b = h.getUpdater();
+ check('returns one instance', () => assert.strictEqual(a, b));
+ check('registers each listener exactly once', () => {
+ for (const ev of ['error', 'download-progress', 'update-downloaded']) {
+ assert.strictEqual(h.fake.listenerCount(ev), 1, `${ev}: ${h.fake.listenerCount(ev)} listeners`);
+ }
+ });
+ check('never downloads or installs on its own', () => {
+ assert.strictEqual(h.fake.autoDownload, false);
+ assert.strictEqual(h.fake.autoInstallOnAppQuit, false);
+ });
+ check('an error while only CHECKING is not reported as a failed update', () => {
+ h.upd.inFlight = false;
+ h.fake.emit('error', new Error('Cannot find latest-mac.yml'));
+ assert.deepStrictEqual(h.failures, []);
+ });
+ check('an error during an install is reported, and ends the install', () => {
+ h.upd.inFlight = true;
+ h.fake.emit('error', new Error('Code signature did not match'));
+ assert.deepStrictEqual(h.failures, ['Code signature did not match']);
+ assert.strictEqual(h.upd.inFlight, false);
+ });
+}
+check('a downloaded update is installed', async () => {
+ const h = harness(); h.getUpdater();
+ h.upd.inFlight = true;
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1200);
+ assert.strictEqual(h.fake.quitCalls, 1);
});
-check('returns null when there is no version field', () => {
- assert.strictEqual(parseCaskVersion('cask "x" do\nend'), null);
+// Seen in an end-to-end run: the same update downloaded twice fired update-downloaded
+// twice, and the second quitAndInstall() threw Squirrel's "The command is disabled and
+// cannot be executed" out of a timer — an uncaught exception in the main process, which
+// Electron shows the user as a crash dialog.
+check('a second update-downloaded does not install twice', async () => {
+ const h = harness(); h.getUpdater();
+ h.upd.inFlight = true;
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1200);
+ assert.strictEqual(h.fake.quitCalls, 1);
});
-check('returns null on empty input instead of throwing', () => {
- assert.strictEqual(parseCaskVersion(''), null);
- assert.strictEqual(parseCaskVersion(null), null);
+check('quitAndInstall() throwing becomes a reported failure, not a crash', async () => {
+ const h = harness(); h.getUpdater();
+ h.fake.quitAndInstall = () => { throw new Error('The command is disabled and cannot be executed'); };
+ h.upd.inFlight = true;
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1200); // an escaping throw would crash this process here
+ assert.deepStrictEqual(h.failures, ['The command is disabled and cannot be executed']);
+ assert.strictEqual(h.upd.inFlight, false);
});
-
-console.log('\nthe darwin check asks the cask, not the GitHub release:');
-check('no /releases/latest call is left in main.js', () => {
- assert.ok(!MAIN.includes('releases/latest'),
- 'the release API is back — it is ~8 min ahead of the cask and reopens the update loop');
-});
-check('checkUpdate awaits the cask version on darwin', () => {
- const at = MAIN.indexOf("if (process.platform === 'darwin')", MAIN.indexOf('async function checkUpdate'));
- assert.notStrictEqual(at, -1, 'darwin branch of checkUpdate not found');
- // Window widened from 300: the brewManagedPath guard now runs first (a build outside
- // /Applications cannot be upgraded by brew and must not be offered a cask update).
- // The invariant this test exists for — cask, never the release API — is asserted
- // independently above and is unaffected.
- assert.ok(MAIN.slice(at, at + 900).includes('fetchTapCaskVersion'), 'darwin branch no longer reads the cask');
+check('after a failed install the next attempt installs again', async () => {
+ const h = harness(); h.getUpdater();
+ h.upd.inFlight = true;
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1100);
+ h.fake.emit('error', new Error('Squirrel: code signature did not match'));
+ h.upd.inFlight = true; // the user pressed Retry
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1100);
+ assert.strictEqual(h.fake.quitCalls, 2);
});
-
-// ─── Run the generated shell against a fake brew ────────────────────────────
-function runShell(installedAfterUpgrade, fromVersion) {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-update-'));
- const bin = path.join(dir, 'bin');
- fs.mkdirSync(bin);
- const brew = path.join(bin, 'brew');
- // Mirrors real brew: `upgrade` exits 0 even when it has nothing to do.
- fs.writeFileSync(brew, `#!/bin/sh
-case "$1" in
- upgrade) echo "Warning: Not upgrading claude-code-studio, the latest version is already installed"; exit 0;;
- list) echo "claude-code-studio ${installedAfterUpgrade}"; exit 0;;
-esac
-exit 0
-`);
- for (const [name, marker] of [['open', 'relaunched'], ['osascript', 'notified']]) {
- fs.writeFileSync(path.join(bin, name), `#!/bin/sh\necho "${marker} $*" >> '${dir}/markers'\n`);
- fs.chmodSync(path.join(bin, name), 0o755);
+check('a failed Squirrel attempt leaves no library listener behind for the Retry', async () => {
+ const h = harness(); h.getUpdater();
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ h.upd.inFlight = true;
+ h.fake.emit('update-downloaded', { version: '9.9.9' });
+ await wait(1100);
+ assert.strictEqual(h.native.listenerCount('update-downloaded'), 1, `attempt ${attempt}: before the failure`);
+ h.fake.emit('error', new Error('Squirrel: code signature did not match'));
+ assert.strictEqual(h.native.listenerCount('update-downloaded'), 0, `attempt ${attempt}: after the failure`);
}
- fs.chmodSync(brew, 0o755);
- const logPath = path.join(dir, 'update.log');
- const sh = buildUpgradeShell({ brew, logPath, fromVersion });
- try {
- execFileSync('/bin/sh', ['-c', sh], { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
- const read = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '');
- return { log: read(logPath), markers: read(path.join(dir, 'markers')) };
- } finally {
- // Every other suite cleans its temp dir; this one used to leak one per call.
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
- }
-}
+});
-console.log('\na brew no-op must be reported as a failure, not as OK:');
-const noop = runShell('7.2.1', '7.2.1');
-check('log says FAILED when the version did not move', () => {
- assert.ok(/FAILED/.test(noop.log), `expected FAILED, log was:\n${noop.log}`);
- assert.ok(!/\bOK\b/.test(noop.log), `a no-op was still reported as OK:\n${noop.log}`);
+// ── checkUpdate trusts electron-updater's verdict ────────────────────────────
+// It returns updateInfo for a release it has REJECTED too (staged rollout,
+// minimumSystemVersion); comparing versions here offered an update whose
+// downloadUpdate() then failed with "Please check update first" on every Retry.
+console.log('\ncheckUpdate():');
+function checkWith(result) {
+ return extract('checkUpdate', {
+ app: { getVersion: () => '7.18.0' },
+ process: { platform: 'darwin' },
+ getUpdater: () => ({ checkForUpdates: async () => result }),
+ installBlocker: () => null, appExePath: () => '', isReadOnlyDir: () => false,
+ })();
+}
+check('a newer release electron-updater rejected is not offered', async () => {
+ const r = await checkWith({ isUpdateAvailable: false, updateInfo: { version: '99.0.0' } });
+ assert.strictEqual(r.available, false);
});
-check('the user is notified', () => {
- assert.ok(/notified/.test(noop.markers), 'no notification fired on a failed update');
+check('an accepted release is offered', async () => {
+ const r = await checkWith({ isUpdateAvailable: true, updateInfo: { version: '7.19.0' } });
+ assert.strictEqual(r.available, true);
+ assert.strictEqual(r.version, '7.19.0');
});
-check('the app is relaunched anyway — it must never just vanish', () => {
- assert.ok(/relaunched/.test(noop.markers), 'the app was not reopened after a failed update');
+check('an unpackaged run (null result) offers nothing', async () => {
+ const r = await checkWith(null);
+ assert.strictEqual(r.available, false);
});
-console.log('\na real upgrade is reported as success:');
-const real = runShell('7.2.2', '7.2.1');
-check('log records the version move', () => {
- assert.ok(/OK 7\.2\.1 -> 7\.2\.2/.test(real.log), `expected an OK line, log was:\n${real.log}`);
-});
-check('no failure notification on success', () => {
- assert.ok(!/notified/.test(real.markers), 'a successful update still nagged the user');
+// ── 1 (again). the quit must not be eaten by close-to-tray ───────────────────
+console.log('\nSquirrel.Mac can quit the app:');
+check('before-quit-for-update sets app.isQuiting before the windows close', () => {
+ const m = /autoUpdater\.on\('before-quit-for-update',[^\n]*\n?[^\n]*app\.isQuiting = true/.exec(MAIN_CODE);
+ assert.ok(m, 'no before-quit-for-update → app.isQuiting = true');
+ assert.ok(/require\('electron'\)/.test(MAIN_CODE), 'must be Electron\'s own autoUpdater — that is the one Squirrel drives');
});
-check('the app is relaunched', () => {
- assert.ok(/relaunched/.test(real.markers), 'the app was not reopened after a successful update');
+check('the close-to-tray handler still honours app.isQuiting', () => {
+ assert.ok(/if \(!app\.isQuiting && trayReady\) \{ e\.preventDefault\(\);/.test(MAIN_CODE));
});
+// ── startUpdate ──────────────────────────────────────────────────────────────
+console.log('\nstartUpdate():');
+function start({ platform = 'darwin', blocker = null, download = () => Promise.resolve() } = {}) {
+ const upd = { updater: null, inFlight: false, installing: false, nativeListeners: [] };
+ let downloads = 0;
+ const startUpdate = extract('startUpdate', {
+ process: { platform },
+ installBlocker: () => blocker,
+ appExePath: () => '/x/Claude Code Studio.app/Contents/MacOS/Claude Code Studio',
+ isReadOnlyDir: () => false,
+ getUpdater: () => ({ downloadUpdate: () => { downloads++; return download(); } }),
+ upd,
+ });
+ return { startUpdate, upd, downloads: () => downloads };
+}
+check('a blocked macOS install is refused before anything is downloaded', async () => {
+ const s = start({ blocker: 'translocated' });
+ const r = await s.startUpdate();
+ assert.strictEqual(r.installBlocked, 'translocated');
+ assert.strictEqual(s.downloads(), 0);
+ assert.strictEqual(s.upd.inFlight, false);
+});
+check('the macOS blocker is not consulted on Windows/Linux', async () => {
+ const s = start({ platform: 'win32', blocker: 'read-only' });
+ const r = await s.startUpdate();
+ assert.strictEqual(r.started, true);
+ assert.strictEqual(s.downloads(), 1);
+});
+check('an install in progress is marked in flight', async () => {
+ const s = start();
+ const r = await s.startUpdate();
+ assert.strictEqual(r.started, true);
+ assert.strictEqual(s.upd.inFlight, true);
+});
+check('a failed download is thrown to the IPC handler and ends the install', async () => {
+ const s = start({ download: () => Promise.reject(new Error('net::ERR_CONNECTION_RESET')) });
+ await assert.rejects(s.startUpdate(), /ERR_CONNECTION_RESET/);
+ assert.strictEqual(s.upd.inFlight, false);
+});
-// ── an .app brew does not manage must not be offered a cask upgrade ──────────
-// The loop this prevents, observed live: a 7.1.1 build left in dist-desktop/ from
-// `npm run dist` was what actually got launched. It read the tap cask (7.5.0),
-// offered the update, ran `brew upgrade --cask` — which correctly upgraded the
-// bundle in /Applications — then relaunched ITSELF, still 7.1.1, saw 7.5.0 again,
-// and repeated. update.log showed one clean `OK 7.4.0 -> 7.5.0`; nothing was broken
-// except that the running process was never the one brew was updating.
-console.log('\na build outside /Applications is not offered a cask update:');
-{
- check('checkUpdate consults brewManagedPath before reading the cask',
- () => assert.ok(/if \(!brewManagedPath\(\)\) \{/.test(MAIN)));
- check('...and the guard sits BEFORE the cask fetch',
- () => assert.ok(MAIN.indexOf('brewManagedPath()') < MAIN.indexOf('await fetchTapCaskVersion()')));
- check('the unmanaged reply is available:false, not a version comparison',
- () => assert.ok(/available: false,\s*\n\s*unmanaged: true/.test(MAIN)));
+// ── the banner ───────────────────────────────────────────────────────────────
+console.log('\nthe update banner (index.html) and its bridge (preload.js):');
+const BANNER = (/