From 89c03fef8b914627cd7997d0d88b2de8ab32ccc6 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Mon, 28 Sep 2026 15:20:41 +0200 Subject: [PATCH 1/2] feat(desktop): signed + notarized macOS app, arm64 only, updating itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS build is now signed with the Developer ID Application certificate (BKZ6Y9W9MF), notarized and stapled, and built for Apple Silicon only. With a signature in place the app updates itself through electron-updater (Squirrel.Mac) like Windows/Linux, and the app-triggered `brew upgrade --cask` flow is gone. Signing (electron-builder.yml, build/entitlements.mac.plist): - hardened runtime; entitlements = electron-builder's template plus automation.apple-events, for the app AND its helpers. server.js drives Terminal via osascript, and under the hardened runtime that Apple Event is checked against this app — without the key it is refused with no prompt. - mac targets pinned to arm64 (macOS 27 dropped Intel; Homebrew moved it to Tier 3). NSAppleEventsUsageDescription for the Automation prompt. - local notarization reads a notarytool keychain profile named in the gitignored electron-builder.env, which is also excluded from the bundle. Updates (electron/main.js, preload.js, the banner in index.html): - one electron-updater path on every OS; listeners registered once (per-click registration stacked a set on every Retry). - before-quit-for-update sets app.isQuiting: Squirrel closes the windows before quitting and close-to-tray would cancel the quit. Verified with two signed builds on a local feed: with it, 7.17.90 -> 7.17.91 staged, swapped, relaunched; without it the app stayed on 7.17.90 in the tray. - a dmg mount or an App-Translocated copy cannot be swapped in place; the banner says "move the app to Applications" instead of offering a button. - only an error during an install the user started turns the banner into Retry (update:failed) — a failed CHECK also emits `error`. CI (release-desktop.yml): - the mac leg FAILS without MAC_CSC_LINK, or with an incomplete APPLE_* set: Squirrel refuses unsigned updates, so there is no unsigned fallback. - bump-cask handles the single-arch cask and refuses to push when sed did not rewrite version + sha256 (sed exits 0 on no match). The Homebrew cask stays for about a month so installs from before this release, whose update button runs brew, can reach it; then it is retired (docs/electron-desktop/MAC-SIGNING.md). Tests: test/mac-signing.test.js (new, 12 checks) and a rewritten test/update-flow.test.js (35 checks, the banner run in a vm). Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 45 ++- .gitignore | 2 + CLAUDE.md | 34 ++- README.md | 4 +- README_RU.md | 4 +- README_UA.md | 4 +- build/entitlements.mac.plist | 22 ++ docs/electron-desktop/DESIGN.md | 19 +- docs/electron-desktop/MAC-SIGNING.md | 179 ++++++++++++ electron-builder.yml | 22 +- electron/main.js | 238 +++++----------- electron/preload.js | 3 + homebrew-tap/README.md | 14 +- package.json | 2 +- public/index.html | 64 +++-- test/mac-signing.test.js | 134 +++++++++ test/update-flow.test.js | 391 +++++++++++++++++--------- 17 files changed, 831 insertions(+), 350 deletions(-) create mode 100644 build/entitlements.mac.plist create mode 100644 docs/electron-desktop/MAC-SIGNING.md create mode 100644 test/mac-signing.test.js diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7854fbe7..c929f77f 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 354dd25d..2afa156a 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 dcc6ee19..d4cdcdd3 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 00000000..03041011 --- /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 79f540c2..ed791f62 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,244 @@ 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'); - -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'); -}); -check('returns null when there is no version field', () => { - assert.strictEqual(parseCaskVersion('cask "x" do\nend'), null); -}); -check('returns null on empty input instead of throwing', () => { - assert.strictEqual(parseCaskVersion(''), null); - assert.strictEqual(parseCaskVersion(null), null); +// ── 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('\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'); -}); +// ── 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 writable = () => true, readOnly = () => false; + check('/Applications is fine', () => assert.strictEqual(installBlocker(exe('/Applications'), writable), null)); + check('~/Applications is fine', () => assert.strictEqual(installBlocker(exe('/Users/me/Applications'), writable), null)); + check('a dist-desktop build is fine — Squirrel updates it in place', + () => assert.strictEqual(installBlocker(exe('/Users/me/proj/dist-desktop/mac-arm64'), writable), null)); + check('a Gatekeeper-translocated copy is blocked, whatever the write probe says', + () => assert.strictEqual(installBlocker(exe('/private/var/folders/ab/xy/T/AppTranslocation/0F1E-22/d'), writable), 'translocated')); + check('a read-only location (a mounted dmg) is blocked', + () => assert.strictEqual(installBlocker(exe('/Volumes/Claude Code Studio 7.18.0-arm64'), readOnly), 'read-only')); + check('the write probe is asked about the folder that HOLDS the .app', () => { + let asked = null; + installBlocker(exe('/Applications'), (dir) => { asked = dir; return true; }); + assert.strictEqual(asked, '/Applications'); + }); + check('an unbundled dev run (electron .) is not blocked', + () => assert.strictEqual(installBlocker('/Users/me/proj/node_modules/electron/dist/Electron', readOnly), null)); +} -// ─── 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); - } - 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 {} - } +// ── 3–4. getUpdater: listeners once, errors only during an install ─────────── +console.log('\ngetUpdater():'); +function harness() { + const fake = Object.assign(new EventEmitter(), { + autoDownload: true, autoInstallOnAppQuit: true, quitCalls: 0, + quitAndInstall() { this.quitCalls++; }, + }); + const upd = { updater: null, inFlight: false }; + const logs = [], failures = []; + const getUpdater = extract('getUpdater', { + require: (m) => { assert.strictEqual(m, 'electron-updater'); return { autoUpdater: fake }; }, + upd, + sendUpdateLog: (l) => logs.push(l), + sendUpdateFailed: (m) => failures.push(m), + }); + return { fake, upd, logs, failures, getUpdater }; +} +{ + 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', () => new Promise((resolve, reject) => { + h.fake.emit('update-downloaded', { version: '9.9.9' }); + setTimeout(() => { + try { assert.strictEqual(h.fake.quitCalls, 1); resolve(); } catch (e) { reject(e); } + }, 1200); + })); } -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}`); +// ── 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 user is notified', () => { - assert.ok(/notified/.test(noop.markers), 'no notification fired on a failed update'); -}); -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('the close-to-tray handler still honours app.isQuiting', () => { + assert.ok(/if \(!app\.isQuiting && trayReady\) \{ e\.preventDefault\(\);/.test(MAIN_CODE)); }); -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}`); +// ── startUpdate ────────────────────────────────────────────────────────────── +console.log('\nstartUpdate():'); +function start({ platform = 'darwin', blocker = null, download = () => Promise.resolve() } = {}) { + const upd = { updater: null, inFlight: false }; + let downloads = 0; + const startUpdate = extract('startUpdate', { + process: { platform }, + installBlocker: () => blocker, + appExePath: () => '/x/Claude Code Studio.app/Contents/MacOS/Claude Code Studio', + canWriteDir: () => true, + 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('no failure notification on success', () => { - assert.ok(!/notified/.test(real.markers), 'a successful update still nagged the user'); +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('the app is relaunched', () => { - assert.ok(/relaunched/.test(real.markers), 'the app was not reopened after a successful update'); +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); }); +// ── the banner ─────────────────────────────────────────────────────────────── +console.log('\nthe update banner (index.html) and its bridge (preload.js):'); +const BANNER = (/