diff --git a/mac/README.md b/mac/README.md index 07e6b1d3..8f5a057b 100644 --- a/mac/README.md +++ b/mac/README.md @@ -18,6 +18,30 @@ codeburn menubar That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise. +If the process runs but its status item never appears, including after a +reinstall and reboot, macOS may have retained bad per-bundle-id placement state. +First refresh the installed app so it can safely transfer its own Login Item +state, then repair it without keeping a duplicate app: + +```bash +codeburn menubar --force +codeburn menubar --repair-placement +``` + +The installer verifies the official release first, replaces the existing app +at the same path with a locally re-signed copy using a fresh CodeBurn recovery +bundle id, and preserves that id across future `--force` updates. macOS may ask +for CodeBurn permissions again because the repaired app has a new local code +identity. A Developer-ID signed or notarized app cannot be re-identified +locally without invalidating its signature, so the command fails safely for +those artifacts instead of silently downgrading them. + +To return to the official bundle identity later, reinstall it explicitly: + +```bash +codeburn menubar --reset-placement +``` + ### Build from source For contributors running a local build instead of the packaged release: diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh index 5b47c81b..361541ec 100755 --- a/mac/Scripts/package-app.sh +++ b/mac/Scripts/package-app.sh @@ -86,6 +86,8 @@ cat > "${BUNDLE}/Contents/Info.plist" <AppIcon CFBundleIdentifier ${BUNDLE_ID} + CodeBurnLoginItemMaintenanceVersion + 1 CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f183ff19..12f0dbaf 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -3,6 +3,7 @@ import SwiftUI import AppKit import Observation import ServiceManagement +import Darwin private let refreshIntervalSeconds: UInt64 = 30 private let forceRefreshWatchdogSeconds: TimeInterval = 90 @@ -122,6 +123,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM } func applicationDidFinishLaunching(_ notification: Notification) { + runMaintenanceCommandIfRequested() ProcessInfo.processInfo.automaticTerminationSupportEnabled = false ProcessInfo.processInfo.disableSuddenTermination() // Deliberately NO app-lifetime beginActivity here. A permanent @@ -155,6 +157,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM Task { await updateChecker.checkIfNeeded() } } + /// Runs only from the installed source bundle during identity repair. The + /// exact installed code identity is required for SMAppService to address + /// the source registration. Packaged builds advertise this protocol in + /// Info.plist so older binaries are never launched with an unknown flag. + private func runMaintenanceCommandIfRequested() { + let arguments = ProcessInfo.processInfo.arguments + let statusRequested = arguments.contains("--codeburn-login-item-status") + let unregisterRequested = arguments.contains("--codeburn-unregister-login-item") + let registerRequested = arguments.contains("--codeburn-register-login-item") + guard statusRequested || unregisterRequested || registerRequested else { + return + } + + let key = "codeburn.loginItemRegistered" + let service = SMAppService.mainApp + do { + let state = LoginItemRegistrationPolicy.migrationState( + status: service.status, + wasPreviouslyRegistered: UserDefaults.standard.bool(forKey: key) + ) + if unregisterRequested { + switch service.status { + case .enabled, .requiresApproval: + try service.unregister() + case .notRegistered, .notFound: + break + @unknown default: + break + } + } else if registerRequested, service.status != .enabled { + try service.register() + } + let resultState: LoginItemMigrationState + if registerRequested { + switch service.status { + case .enabled: + resultState = .registered + case .requiresApproval: + resultState = .disabled + case .notRegistered, .notFound: + resultState = .notRegistered + @unknown default: + resultState = .unknown + } + } else { + // Unregister reports the state that was retired so the caller + // can verify it addressed the intended identity. + resultState = state + } + print(resultState.rawValue) + fflush(stdout) + Darwin.exit(EXIT_SUCCESS) + } catch { + let message = "CodeBurn Login Item maintenance failed: \(error.localizedDescription)\n" + FileHandle.standardError.write(Data(message.utf8)) + Darwin.exit(EXIT_FAILURE) + } + } + private func setupWakeObservers() { // Pause the refresh loop while the machine is asleep. Without this, // Task.sleep keeps a wakeup pending across the suspension and the @@ -285,15 +346,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM private func registerLoginItemIfNeeded() { let key = "codeburn.loginItemRegistered" - guard !UserDefaults.standard.bool(forKey: key) else { return } + let service = SMAppService.mainApp + let wasPreviouslyRegistered = UserDefaults.standard.bool(forKey: key) + guard LoginItemRegistrationPolicy.shouldRegister( + status: service.status, + wasPreviouslyRegistered: wasPreviouslyRegistered + ) else { + if LoginItemRegistrationPolicy.shouldRecordRegistration( + status: service.status, + wasPreviouslyRegistered: wasPreviouslyRegistered + ) { + UserDefaults.standard.set(true, forKey: key) + } + return + } // Registers in-process. The old path told System Events to make the login // item, which made macOS ask for Automation access on first launch (#1026). // No AppleScript fallback: a failure here must not bring that prompt back. do { - if SMAppService.mainApp.status != .enabled { - try SMAppService.mainApp.register() - } + try service.register() UserDefaults.standard.set(true, forKey: key) } catch { NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)") diff --git a/mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift b/mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift new file mode 100644 index 00000000..5cb7144e --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/LoginItemRegistrationPolicy.swift @@ -0,0 +1,56 @@ +import ServiceManagement + +enum LoginItemMigrationState: String, Equatable { + case registered + case disabled + case notRegistered = "not-registered" + case unknown +} + +enum LoginItemRegistrationPolicy { + static func shouldRegister( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> Bool { + switch status { + case .enabled, .requiresApproval: + return false + case .notRegistered, .notFound: + // Once registration has succeeded, .notRegistered represents the + // user's later choice in System Settings. Never fight that choice. + return !wasPreviouslyRegistered + @unknown default: + return false + } + } + + static func migrationState( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> LoginItemMigrationState { + switch status { + case .enabled: + return .registered + case .requiresApproval: + // Apple uses requiresApproval both while first approval is pending + // and after previously-granted consent is revoked. Released builds + // wrote the registration marker before approval, so the legacy + // marker plus no enabled observation is irreducibly ambiguous. + // Fail closed: never re-register a replacement identity if that + // could override the user's explicit System Settings choice. + return .disabled + case .notRegistered, .notFound: + return wasPreviouslyRegistered ? .disabled : .notRegistered + @unknown default: + return .unknown + } + } + + static func shouldRecordRegistration( + status: SMAppService.Status, + wasPreviouslyRegistered: Bool + ) -> Bool { + status == .enabled || wasPreviouslyRegistered + } + +} diff --git a/mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift b/mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift new file mode 100644 index 00000000..311a0e59 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LoginItemRegistrationPolicyTests.swift @@ -0,0 +1,52 @@ +import ServiceManagement +import Testing +@testable import CodeBurnMenubar + +@Suite("Login item registration policy") +struct LoginItemRegistrationPolicyTests { + @Test("registers once without overriding a later user disable") + func respectsStatusAndRegistrationHistory() { + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .enabled, wasPreviouslyRegistered: false)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .requiresApproval, wasPreviouslyRegistered: false)) + #expect(LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: false)) + #expect(LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: false)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notRegistered, wasPreviouslyRegistered: true)) + #expect(!LoginItemRegistrationPolicy.shouldRegister(status: .notFound, wasPreviouslyRegistered: true)) + } + + @Test("classifies revoked approval as disabled during identity migration") + func migrationStatePreservesConsent() { + #expect(LoginItemRegistrationPolicy.migrationState( + status: .enabled, + wasPreviouslyRegistered: true + ) == .registered) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .requiresApproval, + wasPreviouslyRegistered: true + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .requiresApproval, + wasPreviouslyRegistered: false + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .notRegistered, + wasPreviouslyRegistered: true + ) == .disabled) + #expect(LoginItemRegistrationPolicy.migrationState( + status: .notRegistered, + wasPreviouslyRegistered: false + ) == .notRegistered) + #expect(LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .enabled, + wasPreviouslyRegistered: false + )) + #expect(!LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .requiresApproval, + wasPreviouslyRegistered: false + )) + #expect(LoginItemRegistrationPolicy.shouldRecordRegistration( + status: .requiresApproval, + wasPreviouslyRegistered: true + )) + } +} diff --git a/src/main.ts b/src/main.ts index d4845808..4a773cc5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1392,11 +1392,22 @@ program .command('menubar') .description('Install and launch the menubar app on macOS and Windows (one command, no clone)') .option('--force', 'Reinstall even if a copy is already installed') - .action(async (opts: { force?: boolean }) => { + .option('--repair-placement', 'Repair a missing macOS menu bar item with a fresh local bundle identity') + .option('--reset-placement', 'Return the macOS menu bar app to its official bundle identity') + .action(async (opts: { force?: boolean; repairPlacement?: boolean; resetPlacement?: boolean }) => { try { - const result = await installMenubarApp({ force: opts.force, cliVersion: version }) + const result = await installMenubarApp({ + force: opts.force, + repairPlacement: opts.repairPlacement, + resetPlacement: opts.resetPlacement, + cliVersion: version, + }) // A cancelled Windows installer leaves nothing to point at. - if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`) + if (result.installedPath) { + console.log(result.launched + ? `\n Ready. ${result.installedPath}\n` + : `\n Installed. Open ${result.installedPath} manually.\n`) + } } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Menubar install failed: ${message}\n`) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 62494349..13fa96bb 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import { createWriteStream } from 'node:fs' import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { homedir, platform, tmpdir } from 'node:os' @@ -20,6 +20,14 @@ const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases const RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' +const RECOVERY_BUNDLE_ID_PREFIX = `${EXPECTED_BUNDLE_ID}.recovery.` +const RECOVERY_BUNDLE_ID_PATTERN = /^org\.agentseal\.codeburn-menubar\.recovery\.[0-9a-f]{16}$/ +const STATUS_ITEM_AUTOSAVE_NAME = 'CodeBurnMenubar.MainStatusItem' +const LOGIN_ITEM_MAINTENANCE_VERSION_KEY = 'CodeBurnLoginItemMaintenanceVersion' +const LOGIN_ITEM_STATUS_ARGUMENT = '--codeburn-login-item-status' +const LOGIN_ITEM_UNREGISTER_ARGUMENT = '--codeburn-unregister-login-item' +const LOGIN_ITEM_REGISTER_ARGUMENT = '--codeburn-register-login-item' +const LOGIN_ITEM_MAINTENANCE_TIMEOUT_MS = 5_000 const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' @@ -30,17 +38,86 @@ const WINDOWS_PRODUCT_NAME = 'CodeBurn Menubar' const WINDOWS_ASSET_PATTERN = /^CodeBurn\.Menubar_.+_x64_en-US\.msi$/ const MIN_MACOS_MAJOR = 14 const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') +const PERSISTED_MENUBAR_BUNDLE_ID = join( + homedir(), + 'Library', + 'Application Support', + 'CodeBurn', + 'menubar-bundle-id.v1', +) const PERSISTENT_CLI_REQUIRED_MESSAGE = 'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn' export type InstallResult = { installedPath: string; launched: boolean } +function isMenubarPlacementRecoveryBundleId(bundleID: string): boolean { + return RECOVERY_BUNDLE_ID_PATTERN.test(bundleID) +} + +export function isSupportedMenubarBundleId(bundleID: string): boolean { + return bundleID === EXPECTED_BUNDLE_ID || isMenubarPlacementRecoveryBundleId(bundleID) +} + +export function isAdHocMenubarSignatureDetails(details: string): boolean { + return /^Signature=adhoc$/m.test(details) && /^TeamIdentifier=not set$/m.test(details) +} + +export function isMissingDefaultsDomainError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /Domain \(?[^\n]+\)? (?:does not exist|not found)/i.test(message) || + /domain\/default pair of \([^\n]+\) does not exist/i.test(message) +} + +export function createMenubarPlacementRecoveryBundleId(suffix = randomBytes(8).toString('hex')): string { + if (!/^[0-9a-f]{16}$/.test(suffix)) { + throw new Error('Menubar placement recovery id suffix must be 16 lowercase hex characters.') + } + return `${RECOVERY_BUNDLE_ID_PREFIX}${suffix}` +} + +export function selectMenubarBundleId(options: { + repairPlacement?: boolean + resetPlacement?: boolean + persistedBundleId?: string + recoverySuffix?: string +} = {}): string { + if (options.repairPlacement && options.resetPlacement) { + throw new Error('--repair-placement and --reset-placement cannot be used together.') + } + if (options.repairPlacement) { + return createMenubarPlacementRecoveryBundleId(options.recoverySuffix) + } + if (options.resetPlacement) return EXPECTED_BUNDLE_ID + if (options.persistedBundleId && isMenubarPlacementRecoveryBundleId(options.persistedBundleId)) { + return options.persistedBundleId + } + return EXPECTED_BUNDLE_ID +} + +export function resolveActiveMenubarBundleId(options: { + installedBundleId?: string + persistedBundleId?: string +}): string { + if (options.installedBundleId !== undefined) { + if (!isSupportedMenubarBundleId(options.installedBundleId)) { + throw new Error(`Refusing unsupported installed menubar bundle id ${options.installedBundleId}.`) + } + return options.installedBundleId + } + if (options.persistedBundleId && isMenubarPlacementRecoveryBundleId(options.persistedBundleId)) { + return options.persistedBundleId + } + return EXPECTED_BUNDLE_ID +} + export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } /// `zip` is the platform's primary asset: the mac bundle zip, or the Windows .msi. export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } export type InstallOptions = { force?: boolean + repairPlacement?: boolean + resetPlacement?: boolean cliVersion?: string platform?: string windows?: WindowsInstallHooks @@ -460,6 +537,54 @@ async function captureCommand(command: string, args: string[]): Promise }) } +async function captureCommandWithTimeout( + command: string, + args: string[], + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let out = '' + let err = '' + let settled = false + const finish = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + callback() + } + const timer = setTimeout(() => { + proc.kill('SIGTERM') + const forceKill = setTimeout(() => proc.kill('SIGKILL'), 250) + forceKill.unref() + finish(() => reject(new Error(`${command} timed out after ${timeoutMs}ms`))) + }, timeoutMs) + timer.unref() + proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() }) + proc.on('error', error => finish(() => reject(error))) + proc.on('close', code => finish(() => { + if (code === 0) resolve(out.trim()) + else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`)) + })) + }) +} + +async function captureCommandStreams(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve({ stdout: stdout.trim(), stderr: stderr.trim() }) + else reject(new Error(`${command} exited with status ${code}${stderr ? `: ${stderr.trim()}` : ''}`)) + }) + }) +} + async function verifyBundleIdentity(appPath: string): Promise { const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [ '-c', @@ -472,6 +597,286 @@ async function verifyBundleIdentity(appPath: string): Promise { await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) } +export async function reidentifyMenubarBundleForPlacementRecovery( + appPath: string, + bundleID: string, +): Promise { + if (!isMenubarPlacementRecoveryBundleId(bundleID)) { + throw new Error(`Refusing unsupported recovery bundle id ${bundleID}.`) + } + + const signature = await captureCommandStreams('/usr/bin/codesign', [ + '-dvvv', + '--verbose=4', + appPath, + ]) + const signatureDetails = `${signature.stdout}\n${signature.stderr}` + if (!isAdHocMenubarSignatureDetails(signatureDetails)) { + throw new Error( + 'This CodeBurn Menubar build is Developer-ID signed or notarized and cannot be safely ' + + 're-identified locally without invalidating its signature. Placement repair is unavailable ' + + 'for this artifact; use `--reset-placement` to keep the official identity.' + ) + } + + const infoPlist = join(appPath, 'Contents', 'Info.plist') + await runCommand('/usr/libexec/PlistBuddy', [ + '-c', + `Set :CFBundleIdentifier ${bundleID}`, + infoPlist, + ]) + // The official bundle has already passed checksum, identity, and signature + // verification in stageMenubarApp. Changing Info.plist invalidates that + // signature, so apply a local ad-hoc signature and verify the resulting + // bundle before it can replace the installed copy. + await runCommand('/usr/bin/codesign', [ + '--force', + '--sign', + '-', + '--preserve-metadata=entitlements,flags,runtime', + '--timestamp=none', + '--deep', + appPath, + ]) + const writtenBundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + infoPlist, + ]) + if (writtenBundleID !== bundleID) { + throw new Error(`Menubar placement recovery wrote ${writtenBundleID}; expected ${bundleID}.`) + } + await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath]) + await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', appPath]).catch(() => {}) +} + +export async function migrateMenubarPreferencesForPlacementRecovery( + sourceBundleID: string, + targetBundleID: string, + stagingDir: string, +): Promise { + const migration = await prepareMenubarPreferenceMigration( + sourceBundleID, + targetBundleID, + stagingDir, + ) + await migration.apply() + await migration.commit() +} + +export type MenubarPreferenceMigration = { + apply: () => Promise + rollback: () => Promise + commit: () => Promise +} + +async function readPreferenceKeysFromPlist(plistPath: string): Promise { + const json = await captureCommand('/usr/bin/plutil', [ + '-convert', + 'json', + '-o', + '-', + plistPath, + ]) + const value: unknown = JSON.parse(json) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Preference export ${plistPath} was not a dictionary.`) + } + return Object.keys(value) +} + +export async function prepareMenubarPreferenceMigration( + sourceBundleID: string, + targetBundleID: string, + stagingDir: string, + options: { preserveLoginDisable?: boolean } = {}, +): Promise { + if (!isSupportedMenubarBundleId(sourceBundleID) || !isSupportedMenubarBundleId(targetBundleID)) { + throw new Error('Refusing to migrate preferences for an unsupported menubar bundle id.') + } + if (sourceBundleID === targetBundleID) { + return { + apply: async () => {}, + rollback: async () => {}, + commit: async () => {}, + } + } + + const sourcePreferencesPath = join(stagingDir, 'menubar-preferences-source.plist') + const targetPreferencesPath = join(stagingDir, 'menubar-preferences-target.plist') + let hasSourcePreferences = false + let hadTargetPreferences = false + let sourcePreferenceKeys: string[] = [] + let targetPreferenceKeys: string[] = [] + let applied = false + + try { + const exported = await captureCommand('/usr/bin/defaults', ['export', sourceBundleID, '-']) + await writeFile(sourcePreferencesPath, `${exported}\n`, { mode: 0o600 }) + await chmod(sourcePreferencesPath, 0o600) + sourcePreferenceKeys = await readPreferenceKeysFromPlist(sourcePreferencesPath) + // `defaults export` can emit an empty dictionary with status 0 for a + // nonexistent domain. Treat that as no source rather than importing an + // empty plist over an existing target domain. + hasSourcePreferences = sourcePreferenceKeys.length > 0 + } catch (error) { + // A first install can have no source preference domain yet. + if (!isMissingDefaultsDomainError(error)) throw error + } + + try { + const exported = await captureCommand('/usr/bin/defaults', ['export', targetBundleID, '-']) + await writeFile(targetPreferencesPath, `${exported}\n`, { mode: 0o600 }) + await chmod(targetPreferencesPath, 0o600) + targetPreferenceKeys = await readPreferenceKeysFromPlist(targetPreferencesPath) + hadTargetPreferences = targetPreferenceKeys.length > 0 + } catch (error) { + // A fresh recovery identity intentionally has no target domain. + if (!isMissingDefaultsDomainError(error)) throw error + } + + return { + apply: async () => { + if (applied) return + if (hasSourcePreferences) { + await runCommand('/usr/bin/defaults', ['import', targetBundleID, sourcePreferencesPath]) + } + applied = true + + // Preserve product settings, never any AppKit status-item placement + // state. Older builds had no autosaveName, so strip every legacy + // NSStatusItem key as well as the documented stable-name keys. + const importedKeys = hasSourcePreferences ? sourcePreferenceKeys : targetPreferenceKeys + const keysToDelete = new Set([ + `NSStatusItem Preferred Position ${STATUS_ITEM_AUTOSAVE_NAME}`, + `NSStatusItem Visible ${STATUS_ITEM_AUTOSAVE_NAME}`, + // Registration history belongs to the bundle identity. Excluding it + // lets the new identity register exactly once while the source + // identity's marker still preserves a user's later disable choice. + 'codeburn.loginItemRegistered', + ...importedKeys.filter(key => key.startsWith('NSStatusItem ')), + ]) + for (const key of keysToDelete) { + try { + await captureCommand('/usr/bin/defaults', ['delete', targetBundleID, key]) + } catch (error) { + if (!isMissingDefaultsDomainError(error)) throw error + } + } + if (options.preserveLoginDisable) { + await runCommand('/usr/bin/defaults', [ + 'write', targetBundleID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + } + }, + rollback: async () => { + if (!applied) return + if (hadTargetPreferences) { + await runCommand('/usr/bin/defaults', ['import', targetBundleID, targetPreferencesPath]) + } else { + try { + await captureCommand('/usr/bin/defaults', ['delete', targetBundleID]) + } catch (error) { + if (!isMissingDefaultsDomainError(error)) throw error + } + } + applied = false + }, + commit: async () => { + // Retain the canonical preference domain as the user's reversible + // fallback, but bound recovery residue to the currently selected ID. + if (isMenubarPlacementRecoveryBundleId(sourceBundleID)) { + await runCommand('/usr/bin/defaults', ['delete', sourceBundleID]).catch(() => {}) + } + }, + } +} + +export type MenubarLoginItemState = + | 'registered' + | 'disabled' + | 'not-registered' + | 'unknown' + | 'unsupported' + +type MenubarLoginItemMaintenanceAction = 'status' | 'unregister' | 'register' + +export function planMenubarLoginItemMigration(state: MenubarLoginItemState): { + preserveDisable: boolean + retirePrevious: boolean + restoreOnFailure: boolean +} { + return { + preserveDisable: state === 'disabled', + retirePrevious: state === 'registered' || state === 'disabled', + restoreOnFailure: state === 'registered', + } +} + +export function isRestoredMenubarLoginItemState( + state: MenubarLoginItemState, + originalState: MenubarLoginItemState, +): boolean { + return originalState === 'registered' && state === 'registered' +} + +export async function installedMenubarSupportsLoginItemMaintenance( + installedAppPath: string, +): Promise { + try { + const version = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + `Print :${LOGIN_ITEM_MAINTENANCE_VERSION_KEY}`, + join(installedAppPath, 'Contents', 'Info.plist'), + ]) + return Number.parseInt(version, 10) >= 1 + } catch { + return false + } +} + +export async function runInstalledMenubarLoginItemMaintenance( + installedAppPath: string, + expectedBundleID: string, + action: MenubarLoginItemMaintenanceAction, + options: { timeoutMs?: number } = {}, +): Promise { + if (!isSupportedMenubarBundleId(expectedBundleID)) { + throw new Error(`Refusing Login Item maintenance for unsupported bundle id ${expectedBundleID}.`) + } + if (!(await installedMenubarSupportsLoginItemMaintenance(installedAppPath))) { + return 'unsupported' + } + + const actualBundleID = await captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(installedAppPath, 'Contents', 'Info.plist'), + ]) + if (actualBundleID !== expectedBundleID) { + throw new Error( + `Installed CodeBurn Menubar identity is ${actualBundleID}; expected ${expectedBundleID}.`, + ) + } + + const argument = action === 'status' + ? LOGIN_ITEM_STATUS_ARGUMENT + : action === 'unregister' + ? LOGIN_ITEM_UNREGISTER_ARGUMENT + : LOGIN_ITEM_REGISTER_ARGUMENT + const executablePath = join(installedAppPath, 'Contents', 'MacOS', APP_PROCESS_NAME) + const result = await captureCommandWithTimeout( + executablePath, + [argument], + options.timeoutMs ?? LOGIN_ITEM_MAINTENANCE_TIMEOUT_MS, + ) + if (result === 'registered' || result === 'disabled' || + result === 'not-registered' || result === 'unknown') { + return result + } + throw new Error(`CodeBurn Menubar returned an unexpected Login Item state: ${result || '(empty)'}`) +} + async function resolvePersistentCodeburnPath(): Promise { let output = '' try { @@ -495,6 +900,120 @@ async function persistCodeburnPath(): Promise { await chmod(PERSISTED_CLI_PATH, 0o600) } +async function readPersistedMenubarBundleId(): Promise { + try { + const bundleID = (await readFile(PERSISTED_MENUBAR_BUNDLE_ID, 'utf8')).trim() + return isMenubarPlacementRecoveryBundleId(bundleID) ? bundleID : undefined + } catch { + return undefined + } +} + +async function readInstalledMenubarBundleId(appPath: string): Promise { + return captureCommand('/usr/libexec/PlistBuddy', [ + '-c', + 'Print :CFBundleIdentifier', + join(appPath, 'Contents', 'Info.plist'), + ]) +} + +async function persistMenubarBundleId(bundleID: string): Promise { + if (bundleID === EXPECTED_BUNDLE_ID) { + await rm(PERSISTED_MENUBAR_BUNDLE_ID, { force: true }) + return + } + if (!isMenubarPlacementRecoveryBundleId(bundleID)) { + throw new Error(`Refusing to persist unsupported menubar bundle id ${bundleID}.`) + } + const supportDir = join(homedir(), 'Library', 'Application Support', 'CodeBurn') + await mkdir(supportDir, { recursive: true, mode: 0o700 }) + await chmod(supportDir, 0o700) + const temporaryDir = await mkdtemp(join(supportDir, '.menubar-bundle-id-')) + const temporaryPath = join(temporaryDir, 'value') + try { + await writeFile(temporaryPath, `${bundleID}\n`, { mode: 0o600 }) + await chmod(temporaryPath, 0o600) + await rename(temporaryPath, PERSISTED_MENUBAR_BUNDLE_ID) + } finally { + await rm(temporaryDir, { recursive: true, force: true }).catch(() => {}) + } +} + +export async function replaceMenubarBundleWithRollback(options: { + stagedPath: string + targetPath: string + commitState: () => Promise + restoreState: () => Promise + launch: () => Promise +}): Promise { + const backupPath = `${options.targetPath}.codeburn-backup-${process.pid}` + const failedPath = `${options.targetPath}.codeburn-failed-${process.pid}` + const hadPreviousBundle = await exists(options.targetPath) + let installedNewBundle = false + + if (hadPreviousBundle) { + await rm(backupPath, { recursive: true, force: true }) + await rename(options.targetPath, backupPath) + } + + try { + await rename(options.stagedPath, options.targetPath) + installedNewBundle = true + await options.commitState() + } catch (installError) { + const rollbackErrors: unknown[] = [] + if (installedNewBundle) { + try { + // Preserve the failed candidate until the previous app is back in its + // canonical path. A rollback must never delete both runnable copies. + await rm(failedPath, { recursive: true, force: true }) + await rename(options.targetPath, failedPath) + } catch (error) { + rollbackErrors.push(error) + } + } + if (hadPreviousBundle) { + try { + await rename(backupPath, options.targetPath) + } catch (error) { + rollbackErrors.push(error) + } + } + if (await exists(options.targetPath)) { + await rm(failedPath, { recursive: true, force: true }).catch(() => {}) + } + try { + await options.restoreState() + } catch (restoreError) { + rollbackErrors.push(restoreError) + } + if (rollbackErrors.length > 0) { + throw new AggregateError( + [installError, ...rollbackErrors], + 'Menubar replacement failed and its previous bundle or identity state could not be fully restored.', + ) + } + throw installError + } + + if (hadPreviousBundle) { + try { + await rm(backupPath, { recursive: true, force: true }) + } catch (error) { + // The new app is already installed and running. This private path does + // not end in .app, so Finder/LaunchServices will not treat it as a + // second installation; report cleanup without turning success into a + // misleading failed-install result. + console.warn(`CodeBurn Menubar installed, but its private rollback backup could not be removed: ${String(error)}`) + } + } + + // LaunchServices failure does not undo an otherwise committed install. The + // caller can retry launch or tell the user exactly which installed app to + // open without restoring the poisoned identity that prompted the repair. + await options.launch() +} + async function isAppRunning(): Promise { return new Promise((resolve) => { const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME]) @@ -673,15 +1192,43 @@ async function installWindowsMenubarApp(options: InstallOptions): Promise { - if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options) + if ((options.platform ?? platform()) === 'win32') { + if (options.repairPlacement || options.resetPlacement) { + throw new Error('--repair-placement and --reset-placement are only available for the macOS menu bar app.') + } + return installWindowsMenubarApp(options) + } await ensureSupportedPlatform() await persistCodeburnPath() const appsDir = userApplicationsDir() const targetPath = join(appsDir, APP_BUNDLE_NAME) const alreadyInstalled = await exists(targetPath) + const persistedBundleId = await readPersistedMenubarBundleId() + const installedBundleId = alreadyInstalled + ? await readInstalledMenubarBundleId(targetPath) + : undefined + const previousBundleId = resolveActiveMenubarBundleId({ + installedBundleId, + persistedBundleId, + }) + const selectedBundleId = selectMenubarBundleId({ + repairPlacement: options.repairPlacement, + resetPlacement: options.resetPlacement, + persistedBundleId: isMenubarPlacementRecoveryBundleId(previousBundleId) + ? previousBundleId + : undefined, + }) - if (alreadyInstalled && !options.force) { + if (alreadyInstalled && previousBundleId !== selectedBundleId && + !(await installedMenubarSupportsLoginItemMaintenance(targetPath))) { + throw new Error( + 'This installed CodeBurn Menubar predates safe Login Item identity transfer. ' + + 'Run `codeburn menubar --force` once, then rerun the placement repair command.', + ) + } + + if (alreadyInstalled && !options.force && !options.repairPlacement && !options.resetPlacement) { if (!(await isAppRunning())) { await runCommand('/usr/bin/open', [targetPath]) } @@ -710,18 +1257,167 @@ export async function installMenubarApp(options: InstallOptions = {}): Promise {}) + throw new AggregateError( + [error, restoreError], + 'Placement repair stopped before installation and could not restore the previous Login Item.', + ) + } + } + if (wasRunning) await runCommand('/usr/bin/open', [targetPath]).catch(() => {}) + throw error + } + let launched = false + try { + await replaceMenubarBundleWithRollback({ + stagedPath: unpackedApp, + targetPath, + commitState: async () => { + await preferenceMigration.apply() + await persistMenubarBundleId(selectedBundleId) + }, + restoreState: async () => { + await preferenceMigration.rollback() + await persistMenubarBundleId(previousBundleId) + if (loginItemWasUnregistered) { + const restoredState = await runInstalledMenubarLoginItemMaintenance( + targetPath, + previousBundleId, + 'register', + ) + if (!isRestoredMenubarLoginItemState(restoredState, loginItemState)) { + throw new Error('CodeBurn could not restore the previous Login Item identity.') + } + loginItemWasUnregistered = false + } + if (wasRunning) await runCommand('/usr/bin/open', [targetPath]) + }, + launch: async () => { + console.log('Launching CodeBurn Menubar...') + if (options.repairPlacement) { + console.log('macOS may ask for CodeBurn permissions again because placement repair uses a new local identity.') + } else if (options.resetPlacement) { + console.log('Restored the official CodeBurn Menubar bundle identity.') + } + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await runCommand('/usr/bin/open', [targetPath]) + launched = true + return + } catch (error) { + if (attempt === 1) { + await new Promise(resolve => setTimeout(resolve, 250)) + continue + } + console.warn( + `CodeBurn Menubar was installed at ${targetPath}, but macOS did not launch it. ` + + `Open that app manually. ${String(error)}`, + ) + } + } + }, + }) + } catch (error) { + if (loginItemWasUnregistered) { + try { + const restoredState = await runInstalledMenubarLoginItemMaintenance( + targetPath, + previousBundleId, + 'register', + ) + if (!isRestoredMenubarLoginItemState(restoredState, loginItemState)) { + throw new Error(`Unexpected state ${restoredState}`) + } + loginItemWasUnregistered = false + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + 'Menubar replacement failed and its previous Login Item could not be restored.', + ) + } + } + throw error + } + await preferenceMigration.commit() + return { installedPath: targetPath, launched } } finally { await rm(stagingDir, { recursive: true, force: true }) } diff --git a/tests/menubar-installer-windows.test.ts b/tests/menubar-installer-windows.test.ts index 0ac4c55e..641f7728 100644 --- a/tests/menubar-installer-windows.test.ts +++ b/tests/menubar-installer-windows.test.ts @@ -203,6 +203,23 @@ describe('installMenubarApp on windows', () => { expect(installerCalls).toHaveLength(1) }) + it('rejects macOS placement repair flags before touching the Windows installer', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + repairPlacement: true, + windows: hooks(), + })).rejects.toThrow(/only available for the macOS menu bar app/) + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + resetPlacement: true, + windows: hooks(), + })).rejects.toThrow(/only available for the macOS menu bar app/) + expect(installerCalls).toEqual([]) + expect(launched).toEqual([]) + }) + it('aborts on a checksum mismatch without running the installer', async () => { await expect(installMenubarApp({ platform: 'win32', diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts index 6e9b9f41..eebf58c5 100644 --- a/tests/menubar-installer.test.ts +++ b/tests/menubar-installer.test.ts @@ -1,28 +1,132 @@ import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildPersistentCodeburnLookupPath, downloadToFile, formatGitHubReleaseLookupError, + createMenubarPlacementRecoveryBundleId, + isSupportedMenubarBundleId, + isAdHocMenubarSignatureDetails, + isMissingDefaultsDomainError, + installedMenubarSupportsLoginItemMaintenance, + isRestoredMenubarLoginItemState, + planMenubarLoginItemMigration, + selectMenubarBundleId, isMissingDirectAssetError, resolveLatestMenubarReleaseAssets, resolveMenubarReleaseAssets, resolvePersistentCodeburnPathFromWhichOutput, resolveProxyUrlForUrl, + resolveActiveMenubarBundleId, + reidentifyMenubarBundleForPlacementRecovery, + migrateMenubarPreferencesForPlacementRecovery, + prepareMenubarPreferenceMigration, + runInstalledMenubarLoginItemMaintenance, + replaceMenubarBundleWithRollback, resolveVersionedMenubarReleaseAssets, shouldFallbackToReleaseApi, verifyChecksum, type ReleaseResponse, } from '../src/menubar-installer.js' +const execFileAsync = promisify(execFile) + function asset(name: string) { return { name, browser_download_url: `https://example.test/${name}` } } describe('resolveMenubarReleaseAssets', () => { + it('fails closed for an ambiguous legacy approval state', () => { + expect(planMenubarLoginItemMigration('disabled')).toEqual({ + preserveDisable: true, + retirePrevious: true, + restoreOnFailure: false, + }) + }) + + it('requires rollback to restore the source consent level exactly', () => { + expect(isRestoredMenubarLoginItemState('registered', 'registered')).toBe(true) + expect(isRestoredMenubarLoginItemState('disabled', 'registered')).toBe(false) + }) + + it('distinguishes an absent defaults domain from an operational export failure', () => { + expect(isMissingDefaultsDomainError(new Error('Domain org.example does not exist.'))).toBe(true) + expect(isMissingDefaultsDomainError(new Error('Domain (org.example) not found.'))).toBe(true) + expect(isMissingDefaultsDomainError(new Error( + 'The domain/default pair of (org.example, MissingKey) does not exist', + ))).toBe(true) + expect(isMissingDefaultsDomainError(new Error('defaults exited with status 1: permission denied'))).toBe(false) + }) + + it('accepts only the canonical or namespaced placement-recovery bundle ids', () => { + expect(isSupportedMenubarBundleId('org.agentseal.codeburn-menubar')).toBe(true) + expect(isSupportedMenubarBundleId( + 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + )).toBe(true) + expect(isSupportedMenubarBundleId( + 'org.agentseal.codeburn-menubar.recovery.not-random' + )).toBe(false) + expect(isSupportedMenubarBundleId('org.attacker.codeburn-menubar')).toBe(false) + }) + + it('creates a stable recovery namespace from validated entropy', () => { + expect(createMenubarPlacementRecoveryBundleId('0123456789abcdef')).toBe( + 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + ) + expect(() => createMenubarPlacementRecoveryBundleId('../escape')).toThrow(/16 lowercase hex/) + }) + + it('persists repaired identity across updates and rotates only on explicit repair', () => { + const repaired = 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + expect(selectMenubarBundleId({ persistedBundleId: repaired })).toBe(repaired) + expect(selectMenubarBundleId({ + repairPlacement: true, + persistedBundleId: repaired, + recoverySuffix: 'fedcba9876543210', + })).toBe('org.agentseal.codeburn-menubar.recovery.fedcba9876543210') + expect(selectMenubarBundleId({ persistedBundleId: 'org.attacker.injected' })).toBe( + 'org.agentseal.codeburn-menubar' + ) + expect(selectMenubarBundleId({ + resetPlacement: true, + persistedBundleId: repaired, + })).toBe('org.agentseal.codeburn-menubar') + expect(() => selectMenubarBundleId({ + repairPlacement: true, + resetPlacement: true, + })).toThrow(/cannot be used together/) + }) + + it('uses the installed bundle identity when the persistence sidecar is missing or stale', () => { + const repaired = createMenubarPlacementRecoveryBundleId('abcdefabcdefabcd') + expect(resolveActiveMenubarBundleId({ installedBundleId: repaired })).toBe(repaired) + expect(resolveActiveMenubarBundleId({ + installedBundleId: 'org.agentseal.codeburn-menubar', + persistedBundleId: repaired, + })).toBe('org.agentseal.codeburn-menubar') + expect(() => resolveActiveMenubarBundleId({ + installedBundleId: 'org.attacker.injected', + })).toThrow(/unsupported installed menubar bundle id/) + }) + + it('allows re-identification only for the ad-hoc release signature', () => { + expect(isAdHocMenubarSignatureDetails(` +CodeDirectory v=20400 flags=0x2(adhoc) +Signature=adhoc +TeamIdentifier=not set +`)).toBe(true) + expect(isAdHocMenubarSignatureDetails(` +Authority=Developer ID Application: AgentSeal +TeamIdentifier=ABCDE12345 +Runtime Version=26.0.0 +`)).toBe(false) + }) + it('ignores dev zips and pairs the checksum with the versioned zip', () => { const release: ReleaseResponse = { tag_name: 'mac-v0.9.8', @@ -437,3 +541,338 @@ describe('release asset download retry', () => { expect((captured as Error).cause).toBe(original) }) }) + +describe.runIf(process.platform === 'darwin')('placement repair bundle re-identification', () => { + let sandbox: string + let appPath: string + + beforeEach(async () => { + sandbox = await mkdtemp(join(tmpdir(), 'menubar-reidentify-')) + appPath = join(sandbox, 'CodeBurnMenubar.app') + const contents = join(appPath, 'Contents') + const executable = join(contents, 'MacOS', 'CodeBurnMenubar') + await mkdir(join(contents, 'MacOS'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\nexit 0\n') + await chmod(executable, 0o755) + await writeFile(join(contents, 'Info.plist'), ` + + +CFBundleExecutableCodeBurnMenubar +CFBundleIdentifierorg.agentseal.codeburn-menubar +CFBundlePackageTypeAPPL +\n`) + await execFileAsync('/usr/bin/codesign', ['--force', '--sign', '-', '--timestamp=none', appPath]) + }) + + afterEach(async () => { + await rm(sandbox, { recursive: true, force: true }) + }) + + it('changes only to a supported recovery id and leaves a valid signed bundle', async () => { + const recoveryID = 'org.agentseal.codeburn-menubar.recovery.0123456789abcdef' + await reidentifyMenubarBundleForPlacementRecovery(appPath, recoveryID) + + const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Print :CFBundleIdentifier', join(appPath, 'Contents', 'Info.plist'), + ]) + expect(stdout.trim()).toBe(recoveryID) + await expect(execFileAsync('/usr/bin/codesign', [ + '--verify', '--deep', '--strict', appPath, + ])).resolves.toBeDefined() + await expect(reidentifyMenubarBundleForPlacementRecovery( + appPath, + 'org.attacker.injected', + )).rejects.toThrow(/unsupported recovery bundle id/) + }) + + it('runs bounded Login Item maintenance only for a capable exact installed identity', async () => { + const executableDir = join(appPath, 'Contents', 'MacOS') + const executablePath = join(executableDir, 'CodeBurnMenubar') + await mkdir(executableDir, { recursive: true }) + await writeFile(executablePath, `#!/bin/sh +case "$1" in + --codeburn-login-item-status) echo disabled ;; + --codeburn-unregister-login-item) echo disabled ;; + --codeburn-register-login-item) echo registered ;; + *) exit 2 ;; +esac +`) + await chmod(executablePath, 0o755) + + expect(await installedMenubarSupportsLoginItemMaintenance(appPath)).toBe(false) + await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Add :CodeBurnLoginItemMaintenanceVersion integer 1', + join(appPath, 'Contents', 'Info.plist'), + ]) + expect(await installedMenubarSupportsLoginItemMaintenance(appPath)).toBe(true) + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'status', + )).resolves.toBe('disabled') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'unregister', + )).resolves.toBe('disabled') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'register', + )).resolves.toBe('registered') + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + createMenubarPlacementRecoveryBundleId('9999999999999999'), + 'status', + )).rejects.toThrow(/identity is org\.agentseal\.codeburn-menubar/) + }) + + it('terminates a non-responsive Login Item maintenance command', async () => { + const executableDir = join(appPath, 'Contents', 'MacOS') + const executablePath = join(executableDir, 'CodeBurnMenubar') + await mkdir(executableDir, { recursive: true }) + await writeFile(executablePath, '#!/bin/sh\nsleep 2\n') + await chmod(executablePath, 0o755) + await execFileAsync('/usr/libexec/PlistBuddy', [ + '-c', 'Add :CodeBurnLoginItemMaintenanceVersion integer 1', + join(appPath, 'Contents', 'Info.plist'), + ]) + + await expect(runInstalledMenubarLoginItemMaintenance( + appPath, + 'org.agentseal.codeburn-menubar', + 'status', + { timeoutMs: 25 }, + )).rejects.toThrow(/timed out after 25ms/) + }) + + it('migrates preferences between recovery identities without touching other domains', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('1111111111111111') + const targetID = createMenubarPlacementRecoveryBundleId('2222222222222222') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'NSStatusItem Legacy Ghost Position', '-string', 'poisoned', + ]) + await migrateMenubarPreferencesForPlacementRecovery(sourceID, targetID, sandbox) + + const { stdout } = await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ]) + expect(stdout.trim()).toBe('tokens') + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).rejects.toBeDefined() + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'NSStatusItem Legacy Ghost Position', + ])).rejects.toBeDefined() + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('restores the target preference domain when a prepared migration rolls back', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('3333333333333333') + const targetID = createMenubarPlacementRecoveryBundleId('4444444444444444') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + await execFileAsync('/usr/bin/defaults', [ + 'write', targetID, 'CodeBurnDisplayMetric', '-string', 'cost', + ]) + + const migration = await prepareMenubarPreferenceMigration(sourceID, targetID, sandbox) + await migration.apply() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('tokens') + + await migration.rollback() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('cost') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('carries an explicit Login Items disable choice to a replacement identity', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('7777777777777777') + const targetID = createMenubarPlacementRecoveryBundleId('8888888888888888') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'codeburn.loginItemRegistered', '-bool', 'true', + ]) + const migration = await prepareMenubarPreferenceMigration( + sourceID, + targetID, + sandbox, + { preserveLoginDisable: true }, + ) + await migration.apply() + + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).stdout.trim()).toBe('1') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('sanitizes target placement and preserves disable without a source domain', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('aaaaaaaaaaaaaaaa') + const targetID = createMenubarPlacementRecoveryBundleId('bbbbbbbbbbbbbbbb') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', targetID, 'NSStatusItem Legacy Ghost Position', '-string', 'poisoned', + ]) + const migration = await prepareMenubarPreferenceMigration( + sourceID, + targetID, + sandbox, + { preserveLoginDisable: true }, + ) + await migration.apply() + + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'codeburn.loginItemRegistered', + ])).stdout.trim()).toBe('1') + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'NSStatusItem Legacy Ghost Position', + ])).rejects.toBeDefined() + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('removes a superseded recovery preference domain only after commit', async () => { + const sourceID = createMenubarPlacementRecoveryBundleId('5555555555555555') + const targetID = createMenubarPlacementRecoveryBundleId('6666666666666666') + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + try { + await execFileAsync('/usr/bin/defaults', [ + 'write', sourceID, 'CodeBurnDisplayMetric', '-string', 'tokens', + ]) + + const migration = await prepareMenubarPreferenceMigration(sourceID, targetID, sandbox) + await migration.apply() + await migration.commit() + + await expect(execFileAsync('/usr/bin/defaults', [ + 'read', sourceID, 'CodeBurnDisplayMetric', + ])).rejects.toBeDefined() + expect((await execFileAsync('/usr/bin/defaults', [ + 'read', targetID, 'CodeBurnDisplayMetric', + ])).stdout.trim()).toBe('tokens') + } finally { + await execFileAsync('/usr/bin/defaults', ['delete', sourceID]).catch(() => {}) + await execFileAsync('/usr/bin/defaults', ['delete', targetID]).catch(() => {}) + } + }) + + it('restores the previous app and state when replacement cannot commit', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => { throw new Error('disk full') }, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('must not launch') }, + })).rejects.toThrow(/disk full/) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('old') + expect(restoredState).toBe(true) + expect((await readdir(sandbox)).filter(name => name.includes('backup'))).toEqual([]) + }) + + it('commits and launches the new app without leaving a backup bundle', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let launched = false + + await replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => {}, + restoreState: async () => {}, + launch: async () => { launched = true }, + }) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('new') + expect(launched).toBe(true) + expect((await readdir(sandbox)).filter(name => name.includes('backup'))).toEqual([]) + }) + + it('keeps the committed new app when only the launch request fails', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + await mkdir(targetPath) + await mkdir(stagedPath) + await writeFile(join(targetPath, 'marker'), 'old') + await writeFile(join(stagedPath, 'marker'), 'new') + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => {}, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('launch request failed') }, + })).rejects.toThrow(/launch request failed/) + + expect(await readFile(join(targetPath, 'marker'), 'utf8')).toBe('new') + expect(restoredState).toBe(false) + }) + + it('restores identity state even when the previous bundle cannot be restored', async () => { + const targetPath = join(sandbox, 'Installed.app') + const stagedPath = join(sandbox, 'Staged.app') + const backupPath = `${targetPath}.codeburn-backup-${process.pid}` + await mkdir(targetPath) + await mkdir(stagedPath) + let restoredState = false + + await expect(replaceMenubarBundleWithRollback({ + stagedPath, + targetPath, + commitState: async () => { + await rm(backupPath, { recursive: true, force: true }) + throw new Error('identity commit failed') + }, + restoreState: async () => { restoredState = true }, + launch: async () => { throw new Error('must not launch') }, + })).rejects.toThrow(/could not be fully restored/) + + expect(restoredState).toBe(true) + }) +})