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 = (/