diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 6c8cd7011..58a7ba394 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -72,8 +72,12 @@ ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id ade help ios-sim preview-render ade ios-sim devices --text +ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text ade --socket ios-sim preview-render --source apps/ios/ADE/Views/Home.swift --index 0 --text +ade --socket update status --text +ade --socket update check --text +ade --socket update install --text ade actions list ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade cursor cloud agents list --text diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index fb45c6fcf..4760084c9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -291,6 +291,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory $ ade settings action Call project config actions + $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status Escape hatch for every ADE service action $ ade cursor cloud agents | runs | artifacts | repos | models | me Drive Cursor Cloud agents via @cursor/sdk @@ -366,9 +367,9 @@ const IOS_SIMULATOR_SUBCOMMAND_HELP: Record = { apps: `${ADE_BANNER} iOS Simulator: apps - Lists launchable app targets from Xcode projects, DerivedData, and apps - already installed on the selected simulator. Aliases: targets, launchable, - launchables. + Lists launchable app targets from root-level .xcodeproj bundles, + apps/*/*.xcodeproj projects, DerivedData, and apps already installed on the + selected simulator. Aliases: targets, launchable, launchables. $ ade --socket ios-sim apps --device --text @@ -852,6 +853,10 @@ const HELP_BY_COMMAND: Record = { $ ade --socket ios-sim shutdown --force Force-release a session owned by another chat $ ade ios-sim actions --text List every callable ios_simulator action + Project discovery scans root-level .xcodeproj bundles and apps/*/*.xcodeproj + projects; do not create symlink shims or fake schemes before checking + "ios-sim apps --text" and the build output. + Capture and inspection: $ ade ios-sim screenshot --text One-shot PNG via simctl (screenshot) $ ade ios-sim snapshot --text Screenshot + selectable elements (getScreenSnapshot) @@ -1072,6 +1077,26 @@ const HELP_BY_COMMAND: Record = { `, cursor: `${ADE_BANNER} ${CURSOR_CLOUD_HELP.cloud}`, + update: `${ADE_BANNER} + Auto-update + + Auto-update commands query and drive ADE desktop's auto-updater. The desktop + app owns the updater process; CLI parity exists so agents can read state and, + when explicitly requested by the user, trigger a check, install, or dismiss + the post-install notice. quitAndInstall relaunches the desktop app and only + succeeds when status is "ready". + + $ ade --socket update status --text Read AutoUpdateSnapshot (status, version, progress) + $ ade --socket update check --text Trigger a background update check + $ ade --socket update install --text Refresh latest, then quit and install when ready + $ ade --socket update dismiss --text Clear the recently-installed banner + $ ade --socket update actions --text List callable update actions + + Snapshot status values: idle, checking, downloading, ready, installing, error. + "installing" appears between quitAndInstall and the desktop relaunch; if the + install fails, status falls back to error and the pending-install record is + cleared automatically. +`, }; function isRecord(value: unknown): value is JsonObject { @@ -3219,6 +3244,24 @@ function buildCoordinatorPlan(args: string[]): CliPlan { return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; } +function buildUpdatePlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "status"; + if (sub === "actions") return { kind: "execute", label: "update actions", steps: [listActionsStep("actions", "update")] }; + if (sub === "status" || sub === "state" || sub === "snapshot" || sub === "show") { + return { kind: "execute", label: "update status", steps: [actionStep("result", "update", "getSnapshot", collectGenericObjectArgs(args))] }; + } + if (sub === "check" || sub === "check-for-updates" || sub === "check-now") { + return { kind: "execute", label: "update check", steps: [actionStep("result", "update", "checkForUpdates", collectGenericObjectArgs(args))] }; + } + if (sub === "install" || sub === "quit-and-install" || sub === "apply") { + return { kind: "execute", label: "update install", steps: [actionStep("result", "update", "quitAndInstall", collectGenericObjectArgs(args))] }; + } + if (sub === "dismiss" || sub === "dismiss-installed" || sub === "dismiss-installed-notice") { + return { kind: "execute", label: "update dismiss", steps: [actionStep("result", "update", "dismissInstalledNotice", collectGenericObjectArgs(args))] }; + } + return { kind: "execute", label: `update ${sub}`, steps: [actionStep("result", "update", sub, collectGenericObjectArgs(args))] }; +} + const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ // Only flags that actually take a following value (readValue / readIntOption // callers) belong here. Boolean-only flags consumed via readFlag must be @@ -3312,6 +3355,8 @@ function buildCliPlan(command: string[]): CliPlan { action: "actions", coord: "coordinator", automation: "automations", + "auto-update": "update", + updates: "update", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { @@ -3398,6 +3443,7 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "memory") return buildMemoryPlan(args); if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); if (primary === "actions" || primary === "action") return buildActionsPlan(args); + if (primary === "update" || primary === "auto-update" || primary === "updates") return buildUpdatePlan(args); if (primary === "cursor") return buildCursorPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); } diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index 4933fa96f..bb639a651 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -54,6 +54,62 @@ const simulatorDevicesJson = JSON.stringify({ }, }); +function writeMinimalXcodeProject( + projectRoot: string, + projectName: string, + options: { targetName?: string; productName?: string; schemeName?: string } = {}, +): string { + const targetName = options.targetName ?? projectName; + const productName = options.productName ?? targetName; + const targetId = "PROXTARGET00000000000001"; + const projectPath = path.join(projectRoot, `${projectName}.xcodeproj`); + fs.mkdirSync(projectPath, { recursive: true }); + fs.writeFileSync(path.join(projectPath, "project.pbxproj"), ` +/* Begin PBXGroup section */ + PROXGROUP0000000000000001 /* Products */ = { + isa = PBXGroup; + name = Products; + }; +/* End PBXGroup section */ +/* Begin PBXNativeTarget section */ + ${targetId} /* ${targetName} */ = { + isa = PBXNativeTarget; + buildConfigurationList = PROXCONFIG00000000000001 /* Build configuration list for PBXNativeTarget "${targetName}" */; + buildPhases = (); + buildRules = (); + dependencies = (); + name = ${targetName}; + productName = ${productName}; + productReference = PROXPRODUCT0000000000001 /* ${productName}.app */; + productType = "com.apple.product-type.application"; + }; + PROXTESTS000000000000001 /* ${targetName}Tests */ = { + isa = PBXNativeTarget; + name = ${targetName}Tests; + productName = ${targetName}Tests; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ +`); + if (options.schemeName) { + const schemeDir = path.join(projectPath, "xcshareddata", "xcschemes"); + fs.mkdirSync(schemeDir, { recursive: true }); + fs.writeFileSync(path.join(schemeDir, `${options.schemeName}.xcscheme`), ` + + + + + + + + + + +`); + } + return projectPath; +} + afterEach(() => { __testResetIosurfaceCapabilityCache(); vi.restoreAllMocks(); @@ -188,6 +244,59 @@ describe("iosSimulatorService cross-platform safety", () => { }); }); +describe("iosSimulatorService launch target discovery", () => { + it("discovers root-level Xcode projects and ignores Products groups when parsing app targets", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ios-root-project-")); + writeMinimalXcodeProject(projectRoot, "Prox"); + const service = createIosSimulatorService({ + projectRoot, + logger: noopLogger, + }); + + try { + const targets = await service.listLaunchTargets({ projectRoot }); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + kind: "project", + projectPath: "Prox.xcodeproj", + scheme: "Prox", + name: "Prox", + }); + expect(targets.map((target) => target.scheme)).not.toContain("Products"); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("uses shared scheme names when the app target and scheme differ", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-ios-root-scheme-")); + writeMinimalXcodeProject(projectRoot, "Prox", { + targetName: "AppTarget", + productName: "Prox", + schemeName: "Prox", + }); + const service = createIosSimulatorService({ + projectRoot, + logger: noopLogger, + }); + + try { + const targets = await service.listLaunchTargets({ projectRoot }); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + projectPath: "Prox.xcodeproj", + scheme: "Prox", + name: "Prox", + }); + expect(targets[0]?.detail).toContain("target AppTarget"); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); +}); + describe("iosSimulatorService single-owner lock contract", () => { it("IosSimulatorOwnedBySessionError carries a stable code and currentChatSessionId", () => { const previousSession = { @@ -238,6 +347,110 @@ describe("iosSimulatorService shutdown contract", () => { }); describe("iosSimulatorService Simulator.app launch visibility", () => { + it("recovers a stale drawer target id when the project now has one valid app scheme", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const projectRoot = fs.mkdtempSync(`${os.tmpdir()}/ade-ios-stale-target-`); + writeMinimalXcodeProject(projectRoot, "Prox"); + const buildArgs: string[][] = []; + const runMock = vi.fn(async (command: string, commandArgs: string[]) => { + if (command === "ps") return { stdout: "", stderr: "" }; + if (command === "/usr/bin/xcode-select") return { stdout: "", stderr: "" }; + if (command === "xcodebuild" && commandArgs[0] === "-version") { + return { stdout: "Xcode 26.3\nBuild version 17C52\n", stderr: "" }; + } + if (command === "xcodebuild") { + buildArgs.push(commandArgs); + const derivedDataIndex = commandArgs.indexOf("-derivedDataPath"); + const derivedDataPath = commandArgs[derivedDataIndex + 1]; + const appPath = path.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", "Prox.app"); + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync(path.join(appPath, "Info.plist"), ""); + return { stdout: "", stderr: "" }; + } + if (command === "xcrun" && commandArgs.join(" ") === "simctl list devices available --json") { + return { stdout: simulatorDevicesJson, stderr: "" }; + } + if (command === "xcrun" && commandArgs[1] === "bootstatus") return { stdout: "", stderr: "" }; + if (command === "xcrun" && commandArgs[1] === "listapps") return { stdout: "", stderr: "" }; + if (command === "xcrun" && commandArgs[1] === "install") return { stdout: "", stderr: "" }; + if (command === "xcrun" && commandArgs[1] === "launch") return { stdout: "com.prox.app: 123\n", stderr: "" }; + if (command === "/usr/libexec/PlistBuddy" && commandArgs[1]?.includes("CFBundleIdentifier")) { + return { stdout: "com.prox.app\n", stderr: "" }; + } + if (command === "/usr/libexec/PlistBuddy" && commandArgs[1]?.includes("CFBundleDisplayName")) { + return { stdout: "Prox\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + const restoreHooks = __testSetIosSimulatorProcessHooks({ + run: runMock, + commandExists: () => true, + }); + const service = createIosSimulatorService({ projectRoot, logger: noopLogger }); + + try { + const staleTargetId = Buffer.from("project|apps/Prox/Prox.xcodeproj|Products").toString("base64url"); + const session = await service.launch({ + projectRoot, + targetId: staleTargetId, + build: true, + }); + + const build = buildArgs.find((args) => args.includes("-scheme")); + expect(build?.[build.indexOf("-scheme") + 1]).toBe("Prox"); + expect(session.targetId).not.toBe(staleTargetId); + expect(session.bundleId).toBe("com.prox.app"); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + restoreHooks(); + platformSpy.mockRestore(); + } + }); + + it("turns xcodebuild failures into actionable drawer errors", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const projectRoot = fs.mkdtempSync(`${os.tmpdir()}/ade-ios-build-failure-`); + writeMinimalXcodeProject(projectRoot, "Prox"); + const runMock = vi.fn(async (command: string, commandArgs: string[]) => { + if (command === "ps") return { stdout: "", stderr: "" }; + if (command === "/usr/bin/xcode-select") return { stdout: "", stderr: "" }; + if (command === "xcodebuild" && commandArgs[0] === "-version") { + return { stdout: "Xcode 26.3\nBuild version 17C52\n", stderr: "" }; + } + if (command === "xcodebuild") { + const failure = new Error("Command failed: xcodebuild build") as Error & { stderr?: string }; + failure.stderr = [ + "2026-05-06 00:15:59.080 xcodebuild[10266:93709] Writing error result bundle to /tmp/ResultBundle.xcresult", + "** BUILD FAILED **", + "error: No such module 'MissingKit'", + ].join("\n"); + throw failure; + } + if (command === "xcrun" && commandArgs.join(" ") === "simctl list devices available --json") { + return { stdout: simulatorDevicesJson, stderr: "" }; + } + if (command === "xcrun" && commandArgs[1] === "bootstatus") return { stdout: "", stderr: "" }; + if (command === "xcrun" && commandArgs[1] === "listapps") return { stdout: "", stderr: "" }; + return { stdout: "", stderr: "" }; + }); + const restoreHooks = __testSetIosSimulatorProcessHooks({ + run: runMock, + commandExists: () => true, + }); + const service = createIosSimulatorService({ projectRoot, logger: noopLogger }); + + try { + await expect(service.launch({ projectRoot, build: true })).rejects.toThrow(/Could not build Prox/); + await expect(service.launch({ projectRoot, build: true })).rejects.toThrow(/No such module 'MissingKit'/); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + restoreHooks(); + platformSpy.mockRestore(); + } + }); + it("does not open Simulator.app by default during headless launch", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const runMock = vi.fn(async (command: string, commandArgs: string[]) => { diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index db53bc051..f085544b7 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -168,6 +168,19 @@ type ResolvedLaunchTarget = { shouldInstall: boolean; }; +type PbxApplicationTarget = { + id: string; + targetName: string; + productName: string; +}; + +type XcodeSchemeDefinition = { + name: string; + blueprintIdentifiers: string[]; + blueprintNames: string[]; + buildableNames: string[]; +}; + type IosSourceMatch = { sourceFile: string; sourceLine: number; @@ -266,6 +279,52 @@ function isTerminatedByTimeout(error: unknown): boolean { return record.killed === true && record.signal === "SIGTERM"; } +function outputToString(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString(); + return ""; +} + +function commandFailureOutput(error: unknown): string { + if (!error || typeof error !== "object") return error instanceof Error ? error.message : String(error); + const record = error as { message?: unknown; stdout?: unknown; stderr?: unknown }; + return [ + outputToString(record.stderr), + outputToString(record.stdout), + typeof record.message === "string" ? record.message : null, + ].filter((part): part is string => Boolean(part?.trim())).join("\n"); +} + +function buildFailureSummary(output: string): string { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const important = lines.filter((line) => ( + /\berror:|fatal error:|BUILD FAILED|The following build commands failed|Provisioning profile|No such module|CodeSign|Signing for|Command SwiftCompile failed|Command CompileSwiftSources failed/i.test(line) + )); + const selected = important.length ? important : lines.slice(-30); + return selected.slice(-40).join("\n").slice(0, 4000); +} + +function formatXcodeBuildFailure(error: unknown, context: { projectPath: string; scheme: string; deviceName: string }): Error { + const output = commandFailureOutput(error); + const summary = buildFailureSummary(output); + const resultBundle = /Writing error result bundle to\s+([^\n]+\.xcresult)/i.exec(output)?.[1]?.trim() ?? null; + const schemeMissing = /does not contain a scheme named/i.test(output); + const nextAction = schemeMissing + ? "ADE could not find that scheme in the project. Refresh the iOS simulator drawer and choose one of the listed schemes; if Xcode only has a user-local scheme, share it from Xcode so command-line builds can see it." + : "Open the project in Xcode or rerun the shown xcodebuild command to see the full compiler output, then fix the build error and launch again."; + return new Error([ + `Could not build ${context.scheme} for ${context.deviceName}.`, + `Project: ${context.projectPath}`, + `Scheme: ${context.scheme}`, + resultBundle ? `Result bundle: ${resultBundle}` : null, + summary ? `Build output:\n${summary}` : null, + `Next action: ${nextAction}`, + ].filter(Boolean).join("\n")); +} + const defaultRunCommand: RunCommand = async (command, args, options = {}) => { const result = await execFile(command, args, { cwd: options.cwd, @@ -855,6 +914,15 @@ function targetId(parts: Array): string { return Buffer.from(parts.filter(Boolean).join("|")).toString("base64url"); } +function decodeTargetId(id: string | null | undefined): string[] { + if (!id) return []; + try { + return Buffer.from(id, "base64url").toString("utf8").split("|").filter(Boolean); + } catch { + return []; + } +} + function normalizeProjectPath(root: string, rawProjectPath?: string | null): string | null { if (!rawProjectPath) return null; const resolved = path.isAbsolute(rawProjectPath) ? rawProjectPath : path.join(root, rawProjectPath); @@ -1760,10 +1828,26 @@ async function findAppBundles(root: string): Promise { return found; } -async function findAppBundle(root: string, criteria: { bundleId?: string | null; scheme?: string | null; appBundlePath?: string | null }): Promise { +async function findAppBundle( + root: string, + criteria: { + bundleId?: string | null; + scheme?: string | null; + /** + * Xcode product name produced by the app target ("MyApp" → "MyApp.app"). + * Preferred over `scheme` because schemes can build multiple `.app` + * bundles or use a name that differs from the produced bundle. + */ + productName?: string | null; + appBundlePath?: string | null; + }, +): Promise { if (criteria.appBundlePath && fs.existsSync(criteria.appBundlePath)) return criteria.appBundlePath; const appBundles = await findAppBundles(root); + // productName is the most accurate hint (scheme name can drift from the + // produced .app); fall back to scheme, then the well-known ADE.app name. const expectedNames = [ + criteria.productName ? `${criteria.productName}.app` : null, criteria.scheme ? `${criteria.scheme}.app` : null, "ADE.app", ].filter((value): value is string => Boolean(value)); @@ -1780,7 +1864,22 @@ async function findAppBundle(root: string, criteria: { bundleId?: string | null; return appBundles[0] ?? null; } -function parseApplicationTargetsFromPbxproj(projectPath: string): Array<{ scheme: string; productName: string }> { +function unquotePbxValue(raw: string | null | undefined): string | null { + const value = raw?.trim(); + if (!value) return null; + return value.replace(/^"|"$/g, "").trim(); +} + +function readPbxAssignment(block: string, key: string): string | null { + return unquotePbxValue(new RegExp(`\\n\\s*${escapeRegExp(key)} = ([^;]+);`).exec(block)?.[1]); +} + +function normalizePbxName(value: string | null, fallback: string): string { + if (!value || value.includes("$(")) return fallback; + return value; +} + +function parseApplicationTargetsFromPbxproj(projectPath: string): PbxApplicationTarget[] { const pbxPath = path.join(projectPath, "project.pbxproj"); let text = ""; try { @@ -1788,18 +1887,159 @@ function parseApplicationTargetsFromPbxproj(projectPath: string): Array<{ scheme } catch { return []; } - const targets: Array<{ scheme: string; productName: string }> = []; - const targetBlocks = text.matchAll(/\/\* ([^*]+) \*\/ = \{[\s\S]*?isa = PBXNativeTarget;[\s\S]*?\n\t\t\};/g); + const nativeTargetSection = /\/\* Begin PBXNativeTarget section \*\/([\s\S]*?)\/\* End PBXNativeTarget section \*\//.exec(text)?.[1] ?? ""; + const targets: PbxApplicationTarget[] = []; + const targetBlocks = nativeTargetSection.matchAll(/^\s*([A-Za-z0-9]+) \/\* ([^*]+) \*\/ = \{([\s\S]*?)^\s*\};/gm); for (const match of targetBlocks) { - const block = match[0]; + const id = match[1]?.trim(); + const block = match[3] ?? ""; + if (!id) continue; if (!block.includes(`productType = "${IOS_APPLICATION_PRODUCT_TYPE}"`)) continue; - const name = /\n\s*name = ([^;]+);/.exec(block)?.[1]?.replace(/^"|"$/g, "").trim() || match[1]?.trim(); - const productName = /\n\s*productName = ([^;]+);/.exec(block)?.[1]?.replace(/^"|"$/g, "").trim() || name; - if (name) targets.push({ scheme: name, productName }); + const fallbackName = match[2]?.trim() ?? id; + const targetName = normalizePbxName(readPbxAssignment(block, "name"), fallbackName); + const productName = normalizePbxName(readPbxAssignment(block, "productName"), targetName); + if (targetName) targets.push({ id, targetName, productName }); } return targets; } +function parseXmlAttributes(raw: string): Record { + const attributes: Record = {}; + for (const match of raw.matchAll(/([A-Za-z_:][A-Za-z0-9_:.-]*)="([^"]*)"/g)) { + attributes[match[1]] = match[2]; + } + return attributes; +} + +function readXcodeSchemeDefinitions(projectPath: string): XcodeSchemeDefinition[] { + const schemeDirs = [ + path.join(projectPath, "xcshareddata", "xcschemes"), + ]; + const xcuserdataDir = path.join(projectPath, "xcuserdata"); + try { + for (const entry of fs.readdirSync(xcuserdataDir, { withFileTypes: true })) { + if (entry.isDirectory()) schemeDirs.push(path.join(xcuserdataDir, entry.name, "xcschemes")); + } + } catch { + // Shared schemes and target-name fallback cover normal projects. + } + + const byName = new Map(); + for (const schemeDir of schemeDirs) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(schemeDir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".xcscheme")) continue; + const name = path.basename(entry.name, ".xcscheme"); + if (byName.has(name)) continue; + const schemePath = path.join(schemeDir, entry.name); + let text = ""; + try { + text = fs.readFileSync(schemePath, "utf8"); + } catch { + text = ""; + } + const blueprintIdentifiers = new Set(); + const blueprintNames = new Set(); + const buildableNames = new Set(); + for (const refMatch of text.matchAll(/]*)\/?>/g)) { + const attrs = parseXmlAttributes(refMatch[1] ?? ""); + if (attrs.BlueprintIdentifier) blueprintIdentifiers.add(attrs.BlueprintIdentifier); + if (attrs.BlueprintName) blueprintNames.add(attrs.BlueprintName); + if (attrs.BuildableName) buildableNames.add(attrs.BuildableName); + } + byName.set(name, { + name, + blueprintIdentifiers: Array.from(blueprintIdentifiers), + blueprintNames: Array.from(blueprintNames), + buildableNames: Array.from(buildableNames), + }); + } + } + return Array.from(byName.values()); +} + +function schemeMatchesApplicationTarget(scheme: XcodeSchemeDefinition, target: PbxApplicationTarget): boolean { + if (scheme.blueprintIdentifiers.includes(target.id)) return true; + if (scheme.blueprintNames.includes(target.targetName) || scheme.blueprintNames.includes(target.productName)) return true; + if (scheme.buildableNames.includes(`${target.targetName}.app`) || scheme.buildableNames.includes(`${target.productName}.app`)) return true; + return scheme.name === target.targetName || scheme.name === target.productName; +} + +function schemesForApplicationTarget(projectPath: string, target: PbxApplicationTarget): string[] { + const matchedSchemes = readXcodeSchemeDefinitions(projectPath) + .filter((scheme) => schemeMatchesApplicationTarget(scheme, target)) + .map((scheme) => scheme.name); + return matchedSchemes.length ? Array.from(new Set(matchedSchemes)) : [target.targetName]; +} + +async function discoverXcodeProjectPaths(projectRoot: string): Promise { + const projectPaths = new Set(); + const addProjectPath = async (candidate: string) => { + if (!candidate.endsWith(".xcodeproj")) return; + try { + const stat = await fs.promises.stat(candidate); + if (!stat.isDirectory()) return; + projectPaths.add(path.resolve(candidate)); + } catch { + // Missing or unreadable projects are ignored during best-effort discovery. + } + }; + const scanDirectory = async (dir: string) => { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + await Promise.all(entries.map((entry) => addProjectPath(path.join(dir, entry.name)))); + }; + + await addProjectPath(path.join(projectRoot, ADE_IOS_PROJECT)); + await scanDirectory(projectRoot); + + const appsRoot = path.join(projectRoot, "apps"); + await scanDirectory(appsRoot); + let appEntries: fs.Dirent[]; + try { + appEntries = await fs.promises.readdir(appsRoot, { withFileTypes: true }); + } catch { + appEntries = []; + } + await Promise.all(appEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => scanDirectory(path.join(appsRoot, entry.name)))); + + return Array.from(projectPaths); +} + +function recoverStaleLaunchTarget(targetIdValue: string | null | undefined, targets: IosSimulatorLaunchTarget[]): IosSimulatorLaunchTarget | null { + const parts = decodeTargetId(targetIdValue); + if (parts[0] === "project" && parts[1]) { + const matches = targets.filter((candidate) => candidate.kind === "project" && candidate.projectPath === parts[1]); + if (matches.length === 1) return matches[0]; + const projectBasename = path.basename(parts[1]); + const basenameMatches = targets.filter((candidate) => ( + candidate.kind === "project" + && candidate.projectPath + && path.basename(candidate.projectPath) === projectBasename + )); + return basenameMatches.length === 1 ? basenameMatches[0] : null; + } + if (parts[0] === "built" && parts[1]) { + const matches = targets.filter((candidate) => candidate.kind === "built" && candidate.appBundlePath === parts[1]); + return matches.length === 1 ? matches[0] : null; + } + if (parts[0] === "installed" && parts[2]) { + return targets.find((candidate) => candidate.kind === "installed" && candidate.bundleId === parts[2]) ?? null; + } + return null; +} + export type IosSimulatorService = ReturnType; export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { @@ -2497,49 +2737,40 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { }; const listProjectLaunchTargets = async (projectRoot: string): Promise => { - const appsRoot = path.join(projectRoot, "apps"); - const projectPaths: string[] = []; - const directIosProject = path.join(projectRoot, ADE_IOS_PROJECT); - if (fs.existsSync(directIosProject)) projectPaths.push(directIosProject); - try { - const appEntries = await fs.promises.readdir(appsRoot, { withFileTypes: true }); - for (const entry of appEntries) { - if (!entry.isDirectory()) continue; - const appDir = path.join(appsRoot, entry.name); - const nested = await fs.promises.readdir(appDir, { withFileTypes: true }).catch(() => []); - for (const child of nested) { - if (child.isDirectory() && child.name.endsWith(".xcodeproj")) { - const projectPath = path.join(appDir, child.name); - if (!projectPaths.includes(projectPath)) projectPaths.push(projectPath); - } - } - } - } catch { - // The ADE repo only needs apps/ios, but keep discovery best-effort. - } + const projectPaths = await discoverXcodeProjectPaths(projectRoot); const targets: IosSimulatorLaunchTarget[] = []; for (const projectPath of projectPaths) { const appTargets = parseApplicationTargetsFromPbxproj(projectPath); for (const appTarget of appTargets) { const relativeProject = relativeToRoot(projectRoot, projectPath); - const bundleId = appTarget.scheme === ADE_IOS_SCHEME && relativeProject === ADE_IOS_PROJECT - ? ADE_IOS_BUNDLE_ID - : null; - targets.push({ - id: targetId(["project", relativeProject, appTarget.scheme]), - kind: "project", - name: appTarget.productName || appTarget.scheme, - bundleId, - detail: `${appTarget.scheme} · ${relativeProject}`, - projectPath: relativeProject, - scheme: appTarget.scheme, - appBundlePath: null, - installed: false, - canBuild: true, - canLaunch: true, - source: "xcode-project", - }); + for (const scheme of schemesForApplicationTarget(projectPath, appTarget)) { + const bundleId = scheme === ADE_IOS_SCHEME && relativeProject === ADE_IOS_PROJECT + ? ADE_IOS_BUNDLE_ID + : null; + targets.push({ + // appTarget.id is the PBXNativeTarget id — including it in the + // target id keeps two app targets that share a scheme (or a scheme + // whose name differs from the produced `.app`) from collapsing + // onto the same id and confusing findAppBundle(). + id: targetId(["project", relativeProject, scheme, appTarget.id]), + kind: "project", + name: appTarget.productName || appTarget.targetName || scheme, + bundleId, + detail: scheme === appTarget.targetName + ? `${scheme} · ${relativeProject}` + : `${scheme} · ${relativeProject} · target ${appTarget.targetName}`, + projectPath: relativeProject, + scheme, + productName: appTarget.productName || appTarget.targetName || null, + appTargetId: appTarget.id, + appBundlePath: null, + installed: false, + canBuild: true, + canLaunch: true, + source: "xcode-project", + }); + } } } return targets; @@ -2565,6 +2796,8 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { detail: `${bundleId} · ${relativeToRoot(projectRoot, appBundle)}`, projectPath: null, scheme: null, + productName: path.basename(appBundle, ".app") || null, + appTargetId: null, appBundlePath: appBundle, installed: false, canBuild: false, @@ -2601,6 +2834,8 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { detail: `${bundleId} · installed on selected simulator`, projectPath: null, scheme: null, + productName: null, + appTargetId: null, appBundlePath: null, installed: true, canBuild: false, @@ -3035,6 +3270,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { if (launchArgs.targetId) { target = targets.find((candidate) => candidate.id === launchArgs.targetId) ?? null; + target ??= recoverStaleLaunchTarget(launchArgs.targetId, targets); if (!target) throw new Error(`Launch target ${launchArgs.targetId} was not found. Refresh launchable iOS apps and try again.`); } if (!target && launchArgs.bundleId) { @@ -3053,6 +3289,8 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { detail: `${bundleId ?? "unknown bundle"} · ${relativeToRoot(projectRoot, explicitAppBundle)}`, projectPath: null, scheme: null, + productName: path.basename(explicitAppBundle, ".app") || null, + appTargetId: null, appBundlePath: explicitAppBundle, installed: false, canBuild: false, @@ -3075,6 +3313,11 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { detail: `${scheme} · ${relativeProject}`, projectPath: relativeProject, scheme, + // Synthesized fallback target — caller didn't reference a discovered + // PBX target, so we have no productName/appTargetId. findAppBundle + // falls back to scheme/ADE.app, which is safe for the synthesized path. + productName: null, + appTargetId: null, appBundlePath: null, installed: false, canBuild: true, @@ -3087,7 +3330,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { ?? targets[0] ?? null; if (!target) { - throw new Error("No launchable iOS apps were found. Add an application target under apps/ios or provide --app-bundle/--bundle-id."); + throw new Error("No launchable iOS apps were found. Add a buildable application target to a root-level .xcodeproj or apps/*/*.xcodeproj project, or provide --app-bundle/--bundle-id."); } const projectPath = normalizeProjectPath(projectRoot, target.projectPath); @@ -3122,26 +3365,46 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { deviceUdid: device.udid, derivedDataPath, }); - await run("xcodebuild", [ - "-project", - relativeProjectPath, - "-scheme", - target.scheme, - "-configuration", - "Debug", - "-sdk", - "iphonesimulator", - "-destination", - `platform=iOS Simulator,id=${device.udid}`, - "-derivedDataPath", - derivedDataPath, - "build", - ], { cwd: projectRoot, timeoutMs: 10 * 60_000 }); + try { + await run("xcodebuild", [ + "-project", + relativeProjectPath, + "-scheme", + target.scheme, + "-configuration", + "Debug", + "-sdk", + "iphonesimulator", + "-destination", + `platform=iOS Simulator,id=${device.udid}`, + "-derivedDataPath", + derivedDataPath, + "build", + ], { cwd: projectRoot, timeoutMs: 10 * 60_000 }); + } catch (error) { + const formatted = formatXcodeBuildFailure(error, { + projectPath: relativeProjectPath, + scheme: target.scheme, + deviceName: device.name, + }); + args.logger.warn("ios_simulator.build.failed", { + projectPath: relativeProjectPath, + scheme: target.scheme, + deviceUdid: device.udid, + error: formatted.message, + }); + throw formatted; + } args.logger.info("ios_simulator.build.complete", { projectPath: relativeProjectPath, scheme: target.scheme, derivedDataPath }); } return findAppBundle(derivedDataPath, { bundleId: target.bundleId, scheme: target.scheme, + // productName lives on the underlying launch target (ResolvedLaunchTarget + // wraps IosSimulatorLaunchTarget). Prefer it over scheme so a project + // whose scheme builds multiple .app bundles, or whose scheme name + // differs from the produced .app, still resolves to the right bundle. + productName: target.target.productName, appBundlePath: target.appBundlePath, }); }; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index ae51b91df..624974d3a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -9103,7 +9103,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.updateQuitAndInstall, () => { - getCtx().autoUpdateService?.quitAndInstall(); + return getCtx().autoUpdateService?.quitAndInstall() ?? false; }); ipcMain.handle(IPC.updateDismissInstalledNotice, () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index f782f3eb0..24a03fbf8 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3049,6 +3049,7 @@ export function createLaneService({ step.status = "running"; step.startedAt = new Date().toISOString(); broadcastDeleteEvent(progress); + await new Promise((resolve) => setImmediate(resolve)); const t0 = Date.now(); try { const result = await work(); @@ -3185,7 +3186,7 @@ export function createLaneService({ // the dir gone with stale metadata still registered. Either way: rm the // dir if any, then prune git's metadata. try { - fs.rmSync(row.worktree_path, { recursive: true, force: true }); + await fs.promises.rm(row.worktree_path, { recursive: true, force: true }); } catch (rmError) { const original = (removeRes.stderr || removeRes.stdout || "").trim(); throw new Error( @@ -3233,33 +3234,51 @@ export function createLaneService({ await runStep("pack_dir_remove", async () => { const lanePackDir = path.join(resolveAdeLayout(projectRoot).packsDir, "lanes", laneId); try { - fs.rmSync(lanePackDir, { recursive: true, force: true }); - } catch { - // ignore pack folder cleanup failures + await fs.promises.rm(lanePackDir, { recursive: true, force: true }); + return { detail: lanePackDir }; + } catch (err) { + // Best-effort cleanup — match the warn pattern used by the other + // best-effort steps (cleanup_env, stop_processes) so a failure + // here is at least surfaced in the lane's logs and progress UI + // rather than vanishing silently. + const message = err instanceof Error ? err.message : String(err); + logger.warn("lane.delete.pack_dir_remove_failed", { laneId, lanePackDir, error: message }); + return { detail: `warning: ${message}` }; } }); await runStep("database_cleanup", async () => { - db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("delete from pr_group_members where lane_id = ?", [laneId]); - // Explicit cascade — CRR conversion can strip checked FKs, leaving orphaned rows. - db.run("delete from pr_convergence_state where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from review_finding_feedback where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); - db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]); - db.run("delete from session_deltas where lane_id = ?", [laneId]); - db.run("delete from terminal_sessions where lane_id = ?", [laneId]); - db.run("delete from operations where lane_id = ?", [laneId]); - db.run("delete from packs_index where lane_id = ?", [laneId]); - db.run("delete from process_runtime where lane_id = ?", [laneId]); - db.run("delete from process_runs where lane_id = ?", [laneId]); - db.run("delete from test_runs where lane_id = ?", [laneId]); - db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); + db.run("begin immediate"); + try { + db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from pr_group_members where lane_id = ?", [laneId]); + // Explicit cascade — CRR conversion can strip checked FKs, leaving orphaned rows. + db.run("delete from pr_convergence_state where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from pr_pipeline_settings where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from pr_issue_inventory where pr_id in (select id from pull_requests where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_finding_feedback where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_findings where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_run_artifacts where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]); + db.run("delete from review_runs where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from session_deltas where lane_id = ?", [laneId]); + db.run("delete from terminal_sessions where lane_id = ?", [laneId]); + db.run("delete from operations where lane_id = ?", [laneId]); + db.run("delete from packs_index where lane_id = ?", [laneId]); + db.run("delete from process_runtime where lane_id = ?", [laneId]); + db.run("delete from process_runs where lane_id = ?", [laneId]); + db.run("delete from test_runs where lane_id = ?", [laneId]); + db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); + db.run("commit"); + } catch (error) { + try { + db.run("rollback"); + } catch { + // ignore rollback failures and surface the original cleanup error + } + throw error; + } }); invalidateLaneListCache(); diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts index 26bd7b5c9..557881828 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts @@ -3,14 +3,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createAutoUpdateService } from "./autoUpdateService"; +import { compareUpdateVersions, createAutoUpdateService } from "./autoUpdateService"; import type { Logger } from "../logging/logger"; class FakeAutoUpdater extends EventEmitter { logger: Logger | null = null; autoDownload = false; autoInstallOnAppQuit = true; - checkForUpdates = vi.fn(async () => null); + checkForUpdates = vi.fn<[], Promise>(async () => null); quitAndInstall = vi.fn(); } @@ -41,7 +41,11 @@ function makeUpdaterCacheDir(): string { } function readState(globalStatePath: string): Record { - return JSON.parse(fs.readFileSync(globalStatePath, "utf8")); + try { + return JSON.parse(fs.readFileSync(globalStatePath, "utf8")); + } catch { + return {}; + } } function expectCacheEmpty(updaterCacheDir: string): void { @@ -133,7 +137,7 @@ describe("createAutoUpdateService", () => { service.dispose(); }); - it("tracks download progress and persists the target version before quit-and-install", () => { + it("tracks download progress and persists the target version before quit-and-install", async () => { const globalStatePath = makeStatePath(); const updater = new FakeAutoUpdater(); const service = createAutoUpdateService({ @@ -177,7 +181,7 @@ describe("createAutoUpdateService", () => { releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", }); - expect(service.quitAndInstall()).toBe(true); + await expect(service.quitAndInstall()).resolves.toBe(true); expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true); expect(readState(globalStatePath)).toEqual({ @@ -192,6 +196,145 @@ describe("createAutoUpdateService", () => { service.dispose(); }); + it("refreshes a stale ready update before installing so the target is the newest version", async () => { + const globalStatePath = makeStatePath(); + const updaterCacheDir = makeUpdaterCacheDir(); + const logger = makeLogger(); + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger, + currentVersion: "1.2.2", + globalStatePath, + updaterCacheDir, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater, + }); + + updater.emit("update-downloaded", { + version: "1.2.3", + }); + expect(service.getSnapshot()).toMatchObject({ + status: "ready", + version: "1.2.3", + }); + + updater.checkForUpdates.mockImplementationOnce(async () => { + updater.emit("update-available", { + version: "1.2.4", + }); + return { + downloadPromise: Promise.resolve().then(() => { + updater.emit("update-downloaded", { + version: "1.2.4", + }); + }), + }; + }); + + await expect(service.quitAndInstall()).resolves.toBe(true); + + expect(updater.checkForUpdates).toHaveBeenCalledTimes(1); + expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true); + expect(readState(globalStatePath)).toEqual({ + pendingInstallUpdate: { + fromVersion: "1.2.2", + targetVersion: "1.2.4", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.4", + requestedAt: "2026-04-06T15:21:00.000Z", + }, + }); + expect(service.getSnapshot()).toMatchObject({ + status: "installing", + version: "1.2.4", + }); + expectCacheEmpty(updaterCacheDir); + expect(logger.info).toHaveBeenCalledWith( + "autoUpdate.cache_cleaned", + expect.objectContaining({ reason: "superseded_ready_update", entriesRemoved: 2 }), + ); + + service.dispose(); + }); + + it("does not install a ready update when latest-version verification fails", async () => { + const globalStatePath = makeStatePath(); + const updaterCacheDir = makeUpdaterCacheDir(); + const logger = makeLogger(); + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger, + currentVersion: "1.2.2", + globalStatePath, + updaterCacheDir, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + updater, + }); + + updater.emit("update-downloaded", { + version: "1.2.3", + }); + updater.checkForUpdates.mockRejectedValueOnce(new Error("network unavailable")); + + await expect(service.quitAndInstall()).resolves.toBe(false); + + expect(updater.quitAndInstall).not.toHaveBeenCalled(); + expect(readState(globalStatePath)).toEqual({}); + expect(service.getSnapshot()).toMatchObject({ + status: "error", + error: "Could not verify the latest update before installing: network unavailable", + }); + expectCacheEmpty(updaterCacheDir); + expect(logger.warn).toHaveBeenCalledWith( + "autoUpdate.refresh_ready_before_install_failed", + expect.objectContaining({ + version: "1.2.3", + message: "network unavailable", + }), + ); + + service.dispose(); + }); + + it("does not replace a ready update with an older downloaded event", () => { + const globalStatePath = makeStatePath(); + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger: makeLogger(), + currentVersion: "1.2.2", + globalStatePath, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + updater, + }); + + updater.emit("update-downloaded", { + version: "1.2.4", + }); + updater.emit("update-available", { + version: "1.2.3", + }); + updater.emit("download-progress", { + percent: 25, + bytesPerSecond: 128_000, + transferred: 2_500_000, + total: 10_000_000, + }); + updater.emit("update-downloaded", { + version: "1.2.3", + }); + + expect(service.getSnapshot()).toMatchObject({ + status: "ready", + version: "1.2.4", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.4", + }); + + service.dispose(); + }); + it("cleans stale updater cache when no update is available", () => { const globalStatePath = makeStatePath(); const updaterCacheDir = makeUpdaterCacheDir(); @@ -220,7 +363,7 @@ describe("createAutoUpdateService", () => { service.dispose(); }); - it("rolls back pending install state when quit-and-install fails synchronously", () => { + it("rolls back pending install state when quit-and-install fails synchronously", async () => { const globalStatePath = makeStatePath(); const updaterCacheDir = makeUpdaterCacheDir(); const logger = makeLogger(); @@ -243,7 +386,7 @@ describe("createAutoUpdateService", () => { version: "1.2.3", }); - expect(service.quitAndInstall()).toBe(false); + await expect(service.quitAndInstall()).resolves.toBe(false); expect(service.getSnapshot()).toMatchObject({ status: "error", error: "The command is disabled and cannot be executed", @@ -260,4 +403,86 @@ describe("createAutoUpdateService", () => { service.dispose(); }); + + it("rolls back pending install state when an async install error arrives", async () => { + const globalStatePath = makeStatePath(); + const updaterCacheDir = makeUpdaterCacheDir(); + const logger = makeLogger(); + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger, + currentVersion: "1.2.2", + globalStatePath, + updaterCacheDir, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater, + }); + + updater.emit("update-downloaded", { + version: "1.2.3", + }); + + await expect(service.quitAndInstall()).resolves.toBe(true); + expect(readState(globalStatePath)).toMatchObject({ + pendingInstallUpdate: { + targetVersion: "1.2.3", + }, + }); + + updater.emit("error", new Error("installer failed after launch")); + + expect(service.getSnapshot()).toMatchObject({ + status: "error", + error: "installer failed after launch", + }); + expect(readState(globalStatePath)).toEqual({}); + expectCacheEmpty(updaterCacheDir); + + service.dispose(); + }); + + it("treats a relaunch on a version newer than the pending target as installed", () => { + const globalStatePath = makeStatePath(); + const updaterCacheDir = makeUpdaterCacheDir(); + const logger = makeLogger(); + fs.writeFileSync(globalStatePath, JSON.stringify({ + pendingInstallUpdate: { + fromVersion: "1.2.2", + targetVersion: "1.2.3", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + requestedAt: "2026-04-06T15:20:00.000Z", + }, + }), "utf8"); + + const service = createAutoUpdateService({ + logger, + currentVersion: "1.2.4", + globalStatePath, + updaterCacheDir, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater: new FakeAutoUpdater(), + }); + + expect(service.getSnapshot().recentlyInstalled).toEqual({ + version: "1.2.4", + installedAt: "2026-04-06T15:21:00.000Z", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.4", + }); + expectCacheEmpty(updaterCacheDir); + + service.dispose(); + }); +}); + +describe("compareUpdateVersions", () => { + it("orders semver versions including prereleases", () => { + expect(compareUpdateVersions("1.2.4", "1.2.3")).toBeGreaterThan(0); + expect(compareUpdateVersions("v1.2.4-beta.2", "1.2.4-beta.1")).toBeGreaterThan(0); + expect(compareUpdateVersions("1.2.4", "1.2.4-beta.2")).toBeGreaterThan(0); + expect(compareUpdateVersions("1.2.4", "v1.2.4")).toBe(0); + }); }); diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.ts b/apps/desktop/src/main/services/updates/autoUpdateService.ts index 58dee2779..19160961e 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.ts @@ -30,6 +30,10 @@ type CreateAutoUpdateServiceArgs = { updaterCacheDir?: string; }; +type UpdateCheckResultLike = { + downloadPromise?: Promise | null; +}; + export function createEmptyAutoUpdateSnapshot(): AutoUpdateSnapshot { return { status: "idle", @@ -44,6 +48,69 @@ export function createEmptyAutoUpdateSnapshot(): AutoUpdateSnapshot { }; } +function parseVersion(version: string): { + core: number[]; + prerelease: string[]; +} { + const withoutBuild = version.trim().replace(/^v/i, "").split("+")[0] ?? ""; + const [coreText = "", prereleaseText = ""] = withoutBuild.split("-", 2); + return { + core: coreText.split(".").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }), + prerelease: prereleaseText ? prereleaseText.split(".") : [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) { + return Number(left) - Number(right); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return left.localeCompare(right); +} + +export function compareUpdateVersions(left: string, right: string): number { + const leftVersion = parseVersion(left); + const rightVersion = parseVersion(right); + const coreLength = Math.max(leftVersion.core.length, rightVersion.core.length, 3); + for (let index = 0; index < coreLength; index += 1) { + const delta = (leftVersion.core[index] ?? 0) - (rightVersion.core[index] ?? 0); + if (delta !== 0) return delta; + } + + if (leftVersion.prerelease.length === 0 && rightVersion.prerelease.length > 0) return 1; + if (leftVersion.prerelease.length > 0 && rightVersion.prerelease.length === 0) return -1; + const prereleaseLength = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length); + for (let index = 0; index < prereleaseLength; index += 1) { + const leftPart = leftVersion.prerelease[index]; + const rightPart = rightVersion.prerelease[index]; + if (leftPart == null && rightPart == null) return 0; + if (leftPart == null) return -1; + if (rightPart == null) return 1; + const delta = comparePrereleaseIdentifier(leftPart, rightPart); + if (delta !== 0) return delta; + } + return 0; +} + +function isUpdateCheckResultLike(result: unknown): result is UpdateCheckResultLike { + return Boolean(result && typeof result === "object" && "downloadPromise" in result); +} + +function extractDownloadPromise(result: unknown): Promise | null { + if (!isUpdateCheckResultLike(result)) return null; + const downloadPromise = result.downloadPromise; + return downloadPromise && typeof (downloadPromise as Promise).then === "function" + ? downloadPromise + : null; +} + export function buildReleaseNotesUrl( version: string, baseUrl = DEFAULT_RELEASE_NOTES_BASE_URL, @@ -67,6 +134,10 @@ function cloneSnapshot(snapshot: AutoUpdateSnapshot): AutoUpdateSnapshot { }; } +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? "unknown"); +} + function cleanupUpdaterCacheDir(args: { updaterCacheDir?: string; logger: Logger; @@ -129,13 +200,15 @@ function reconcilePersistedUpdateState(args: { const pendingInstall = nextState.pendingInstallUpdate; if (pendingInstall) { - if (pendingInstall.targetVersion === args.currentVersion) { + const installedTargetOrNewer = compareUpdateVersions(args.currentVersion, pendingInstall.targetVersion) >= 0; + if (installedTargetOrNewer) { nextState.recentlyInstalledUpdate = { - version: pendingInstall.targetVersion, + version: args.currentVersion, installedAt: args.now, releaseNotesUrl: - pendingInstall.releaseNotesUrl - ?? buildReleaseNotesUrl(pendingInstall.targetVersion, args.releaseNotesBaseUrl), + pendingInstall.targetVersion === args.currentVersion + ? pendingInstall.releaseNotesUrl + : buildReleaseNotesUrl(args.currentVersion, args.releaseNotesBaseUrl), }; cacheCleanupReason = "installed"; } else { @@ -201,6 +274,16 @@ export function createAutoUpdateService({ recentlyInstalled: initialState.recentlyInstalled, }; let checkPromise: Promise | null = null; + // In-flight guard for quitAndInstall. Two IPC callers (e.g. AutoUpdateControl + // double-click, or a renderer click that races a menu trigger) can both + // reach the await on refreshReadyUpdateBeforeInstall before either of them + // calls updater.quitAndInstall. Mirror the checkPromise pattern: the first + // call sets this; subsequent calls return the same promise so we never + // race the actual install. + let quitAndInstallPromise: Promise | null = null; + let ignoredDownloadVersion: string | null = null; + let readyRefreshInProgress = false; + let readyRefreshError: string | null = null; const listeners = new Set<(snapshot: AutoUpdateSnapshot) => void>(); function emit(): void { @@ -215,22 +298,69 @@ export function createAutoUpdateService({ emit(); } + function clearPendingInstallUpdate(): void { + const currentState = readGlobalState(globalStatePath); + if (!currentState.pendingInstallUpdate) return; + writeGlobalState(globalStatePath, { + ...currentState, + pendingInstallUpdate: undefined, + }); + } + + function isTerminalSnapshotStatus(): boolean { + return snapshot.status === "ready" || snapshot.status === "installing"; + } + + function preservedOrIdlePatch(idleStatus: AutoUpdateSnapshot["status"]): Partial { + if (isTerminalSnapshotStatus()) { + return { + status: snapshot.status, + version: snapshot.version, + progressPercent: snapshot.progressPercent, + bytesPerSecond: snapshot.bytesPerSecond, + transferredBytes: snapshot.transferredBytes, + totalBytes: snapshot.totalBytes, + releaseNotesUrl: snapshot.releaseNotesUrl, + error: null, + }; + } + return { + status: idleStatus, + version: null, + progressPercent: null, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + releaseNotesUrl: null, + error: null, + }; + } + const onCheckingForUpdate = () => { logger.info("autoUpdate.checking"); - patchSnapshot({ - status: snapshot.status === "ready" ? snapshot.status : "checking", - version: snapshot.status === "ready" ? snapshot.version : null, - progressPercent: snapshot.status === "ready" ? snapshot.progressPercent : null, - bytesPerSecond: snapshot.status === "ready" ? snapshot.bytesPerSecond : null, - transferredBytes: snapshot.status === "ready" ? snapshot.transferredBytes : null, - totalBytes: snapshot.status === "ready" ? snapshot.totalBytes : null, - releaseNotesUrl: snapshot.status === "ready" ? snapshot.releaseNotesUrl : null, - error: null, - }); + patchSnapshot(preservedOrIdlePatch("checking")); }; const onUpdateAvailable = (info: UpdateInfo) => { logger.info("autoUpdate.update_available", { version: info.version }); + if (snapshot.status === "ready" && snapshot.version) { + const comparison = compareUpdateVersions(info.version, snapshot.version); + if (comparison <= 0) { + ignoredDownloadVersion = info.version; + logger.info("autoUpdate.update_available_ignored", { + version: info.version, + readyVersion: snapshot.version, + reason: comparison === 0 ? "same_ready_version" : "older_than_ready_version", + }); + return; + } + cleanupUpdaterCacheDir({ + updaterCacheDir, + logger, + reason: "superseded_ready_update", + }); + } + ignoredDownloadVersion = null; patchSnapshot({ status: "downloading", progressPercent: 0, @@ -242,6 +372,7 @@ export function createAutoUpdateService({ }; const onDownloadProgress = (info: ProgressInfo) => { + if (ignoredDownloadVersion) return; patchSnapshot({ status: "downloading", progressPercent: info.percent, @@ -254,6 +385,23 @@ export function createAutoUpdateService({ const onUpdateDownloaded = (info: UpdateInfo) => { logger.info("autoUpdate.update_downloaded", { version: info.version }); + if (ignoredDownloadVersion === info.version) { + logger.info("autoUpdate.update_downloaded_ignored", { + version: info.version, + readyVersion: snapshot.version, + reason: "ignored_ready_version", + }); + ignoredDownloadVersion = null; + return; + } + if (snapshot.version && compareUpdateVersions(info.version, snapshot.version) < 0) { + logger.info("autoUpdate.update_downloaded_ignored", { + version: info.version, + readyVersion: snapshot.version, + reason: "older_than_ready_version", + }); + return; + } patchSnapshot({ status: "ready", progressPercent: 100, @@ -266,27 +414,19 @@ export function createAutoUpdateService({ const onUpdateNotAvailable = () => { logger.info("autoUpdate.update_not_available"); - if (snapshot.status !== "ready") { + if (!isTerminalSnapshotStatus()) { cleanupUpdaterCacheDir({ updaterCacheDir, logger, reason: "not_available", }); } - patchSnapshot({ - status: snapshot.status === "ready" ? "ready" : "idle", - version: snapshot.status === "ready" ? snapshot.version : null, - progressPercent: snapshot.status === "ready" ? snapshot.progressPercent : null, - bytesPerSecond: snapshot.status === "ready" ? snapshot.bytesPerSecond : null, - transferredBytes: snapshot.status === "ready" ? snapshot.transferredBytes : null, - totalBytes: snapshot.status === "ready" ? snapshot.totalBytes : null, - releaseNotesUrl: snapshot.status === "ready" ? snapshot.releaseNotesUrl : null, - error: null, - }); + patchSnapshot(preservedOrIdlePatch("idle")); }; const onUpdateCancelled = (info: UpdateInfo) => { logger.warn("autoUpdate.update_cancelled", { version: info.version }); + ignoredDownloadVersion = null; cleanupUpdaterCacheDir({ updaterCacheDir, logger, @@ -299,9 +439,17 @@ export function createAutoUpdateService({ }; const onError = (err: unknown) => { - const message = err instanceof Error ? err.message : String(err ?? "unknown"); + const message = formatErrorMessage(err); logger.warn("autoUpdate.error", { message }); + ignoredDownloadVersion = null; + if (readyRefreshInProgress) { + readyRefreshError = message; + return; + } if (snapshot.status === "ready") return; + if (snapshot.status === "installing") { + clearPendingInstallUpdate(); + } cleanupUpdaterCacheDir({ updaterCacheDir, logger, @@ -323,22 +471,79 @@ export function createAutoUpdateService({ updater.on("update-cancelled", onUpdateCancelled); updater.on("error", onError); - function checkForUpdates(): void { + async function runUpdateCheck(args: { allowReady?: boolean } = {}): Promise { + if (checkPromise) { + await checkPromise; + return; + } if ( - checkPromise - || snapshot.status === "checking" + snapshot.status === "checking" || snapshot.status === "downloading" - || snapshot.status === "ready" + || snapshot.status === "installing" + || (!args.allowReady && snapshot.status === "ready") ) { return; } checkPromise = updater.checkForUpdates() - .catch(() => { + .then(async (result) => { + const downloadPromise = extractDownloadPromise(result); + if (downloadPromise) { + await downloadPromise; + } + }) + .catch((error) => { + if (readyRefreshInProgress) { + readyRefreshError = formatErrorMessage(error); + } // `error` is emitted separately by electron-updater. }) .finally(() => { checkPromise = null; }); + await checkPromise; + } + + function checkForUpdates(): void { + void runUpdateCheck(); + } + + async function refreshReadyUpdateBeforeInstall(): Promise { + if (snapshot.status !== "ready" || !snapshot.version) return false; + const readyVersion = snapshot.version; + logger.info("autoUpdate.refresh_ready_before_install", { version: readyVersion }); + readyRefreshInProgress = true; + readyRefreshError = null; + try { + await runUpdateCheck({ allowReady: true }); + } finally { + readyRefreshInProgress = false; + } + if (readyRefreshError) { + const error = `Could not verify the latest update before installing: ${readyRefreshError}`; + logger.warn("autoUpdate.refresh_ready_before_install_failed", { + version: readyVersion, + message: readyRefreshError, + }); + cleanupUpdaterCacheDir({ + updaterCacheDir, + logger, + reason: "latest_refresh_failed", + }); + patchSnapshot({ + ...createEmptyAutoUpdateSnapshot(), + status: "error", + error, + recentlyInstalled: snapshot.recentlyInstalled, + }); + return false; + } + if (snapshot.status === "ready" && snapshot.version) { + logger.info("autoUpdate.refresh_ready_before_install_completed", { + previousVersion: readyVersion, + version: snapshot.version, + }); + } + return snapshot.status === "ready" && Boolean(snapshot.version); } function dismissInstalledNotice(): void { @@ -366,46 +571,63 @@ export function createAutoUpdateService({ return () => listeners.delete(cb); }, dismissInstalledNotice, - quitAndInstall(): boolean { + async quitAndInstall(): Promise { + // Mirrors checkPromise: collapse concurrent IPC calls onto one in-flight + // promise so two callers cannot race the refresh + quitAndInstall pair. + if (quitAndInstallPromise) return quitAndInstallPromise; if (snapshot.status !== "ready" || !snapshot.version) return false; - writeGlobalState(globalStatePath, { - ...readGlobalState(globalStatePath), - pendingInstallUpdate: { - fromVersion: currentVersion, - targetVersion: snapshot.version, - releaseNotesUrl: snapshot.releaseNotesUrl, - requestedAt: now(), - }, - recentlyInstalledUpdate: undefined, - }); - logger.info("autoUpdate.quit_and_install", { version: snapshot.version }); - try { - updater.quitAndInstall(false, true); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn("autoUpdate.quit_and_install_failed", { - version: snapshot.version, - message, - }); - const currentState = readGlobalState(globalStatePath); + const run = async (): Promise => { + const refreshSucceeded = await refreshReadyUpdateBeforeInstall(); + if (!refreshSucceeded) return false; + // After refresh, snapshot may have been replaced; re-check version + // (refreshReadyUpdateBeforeInstall returns false if snapshot is no + // longer "ready" with a version, but be explicit for the narrowing). + const installVersion = snapshot.version; + if (!installVersion) return false; writeGlobalState(globalStatePath, { - ...currentState, - pendingInstallUpdate: undefined, + ...readGlobalState(globalStatePath), + pendingInstallUpdate: { + fromVersion: currentVersion, + targetVersion: installVersion, + releaseNotesUrl: snapshot.releaseNotesUrl, + requestedAt: now(), + }, + recentlyInstalledUpdate: undefined, }); + logger.info("autoUpdate.quit_and_install", { version: installVersion }); patchSnapshot({ - ...createEmptyAutoUpdateSnapshot(), - status: "error", - error: message, - recentlyInstalled: snapshot.recentlyInstalled, - }); - cleanupUpdaterCacheDir({ - updaterCacheDir, - logger, - reason: "quit_and_install_failed", + status: "installing", + progressPercent: 100, + error: null, }); - return false; - } + try { + updater.quitAndInstall(false, true); + return true; + } catch (error) { + const message = formatErrorMessage(error); + logger.warn("autoUpdate.quit_and_install_failed", { + version: installVersion, + message, + }); + clearPendingInstallUpdate(); + patchSnapshot({ + ...createEmptyAutoUpdateSnapshot(), + status: "error", + error: message, + recentlyInstalled: snapshot.recentlyInstalled, + }); + cleanupUpdaterCacheDir({ + updaterCacheDir, + logger, + reason: "quit_and_install_failed", + }); + return false; + } + }; + quitAndInstallPromise = run().finally(() => { + quitAndInstallPromise = null; + }); + return quitAndInstallPromise; }, dispose() { clearTimeout(startupTimer); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index a2426c550..37ade017a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -2057,7 +2057,7 @@ declare global { }; updateCheckForUpdates: () => Promise; updateGetState: () => Promise; - updateQuitAndInstall: () => Promise; + updateQuitAndInstall: () => Promise; updateDismissInstalledNotice: () => Promise; onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => () => void; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ae118291c..7c8af55cb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3620,7 +3620,7 @@ contextBridge.exposeInMainWorld("ade", { updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), updateGetState: (): Promise => ipcRenderer.invoke(IPC.updateGetState), - updateQuitAndInstall: () => ipcRenderer.invoke(IPC.updateQuitAndInstall), + updateQuitAndInstall: (): Promise => ipcRenderer.invoke(IPC.updateQuitAndInstall), updateDismissInstalledNotice: () => ipcRenderer.invoke(IPC.updateDismissInstalledNotice), onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index fae7157f1..cbacc150d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4911,7 +4911,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { error: null, recentlyInstalled: null, }), - updateQuitAndInstall: resolved(undefined), + updateQuitAndInstall: resolved(true), updateDismissInstalledNotice: resolved(undefined), onUpdateEvent: noop, }; diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx index 8ea6f44ad..4db2e8c57 100644 --- a/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx +++ b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx @@ -29,6 +29,7 @@ function progressLabel(progressPercent: number | null): string | null { export function AutoUpdateControl() { const [snapshot, setSnapshot] = useState(EMPTY_UPDATE_SNAPSHOT); const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); + const [installRequested, setInstallRequested] = useState(false); useEffect(() => { let cancelled = false; @@ -46,6 +47,9 @@ export function AutoUpdateControl() { const unsubscribe = window.ade.onUpdateEvent((nextSnapshot) => { if (cancelled) return; setSnapshot(nextSnapshot); + if (nextSnapshot.status !== "ready") { + setInstallRequested(false); + } if (nextSnapshot.recentlyInstalled) { setReleaseNotesOpen(true); } @@ -70,21 +74,44 @@ export function AutoUpdateControl() { const handleRestartToInstall = useCallback(() => { const confirmed = window.confirm( - `ADE will quit and restart automatically to install ${versionLabel(snapshot.version)}.\n\nAny unsaved work may be lost. Continue?`, + `ADE will quit and reopen automatically to install ${versionLabel(snapshot.version)}.\n\nYou do not need to restart ADE yourself. Any unsaved work may be lost. Continue?`, ); if (!confirmed) return; - void window.ade.updateQuitAndInstall().catch(() => { - // The main process logs updater failures. - }); + setInstallRequested(true); + void window.ade.updateQuitAndInstall() + .then((started) => { + if (!started) setInstallRequested(false); + }) + .catch(() => { + setInstallRequested(false); + // The main process logs updater failures. + }); }, [snapshot.version]); + const effectiveStatus = installRequested && snapshot.status === "ready" + ? "installing" + : snapshot.status; + const isReadyOrInstalling = effectiveStatus === "ready" || effectiveStatus === "installing"; const shouldShowIndicator = - snapshot.status === "checking" - || snapshot.status === "downloading" - || snapshot.status === "ready"; + effectiveStatus === "checking" + || effectiveStatus === "downloading" + || isReadyOrInstalling; const downloadProgress = progressLabel(snapshot.progressPercent); const releaseNotesUrl = snapshot.recentlyInstalled?.releaseNotesUrl ?? null; + function indicatorTitle(): string { + switch (effectiveStatus) { + case "checking": + return "Checking for updates"; + case "downloading": + return `Downloading ${versionLabel(snapshot.version)}${downloadProgress ? ` (${downloadProgress})` : ""}`; + case "installing": + return "ADE is preparing to quit and reopen automatically"; + default: + return `Install ${versionLabel(snapshot.version)}. ADE will quit and reopen automatically.`; + } + } + return ( <> {shouldShowIndicator ? ( @@ -93,40 +120,37 @@ export function AutoUpdateControl() { className={cn( "ade-shell-control shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1", "text-[11px] font-medium transition-colors duration-150", - snapshot.status === "ready" - ? "border border-emerald-400/25 bg-emerald-500/12 text-emerald-100 hover:bg-emerald-500/20" + isReadyOrInstalling + ? "animate-pulse border border-fuchsia-200/70 bg-fuchsia-500/30 text-white shadow-[0_0_20px_rgba(217,70,239,0.38)] hover:bg-fuchsia-400/40 [animation-duration:2.8s]" : "border border-border/60 bg-card/90 text-muted-fg", - snapshot.status !== "ready" && "cursor-default", + effectiveStatus !== "ready" && "cursor-default", )} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} - disabled={snapshot.status !== "ready"} + disabled={effectiveStatus !== "ready"} onClick={() => { - if (snapshot.status === "ready") { + if (effectiveStatus === "ready") { handleRestartToInstall(); } }} - title={ - snapshot.status === "checking" - ? "Checking for updates" - : snapshot.status === "downloading" - ? `Downloading ${versionLabel(snapshot.version)}${downloadProgress ? ` (${downloadProgress})` : ""}` - : `Restart ADE to install ${versionLabel(snapshot.version)}` - } + title={indicatorTitle()} > - {snapshot.status === "checking" ? "Checking for updates" : null} - {snapshot.status === "downloading" ? ( + {effectiveStatus === "checking" ? "Checking for updates" : null} + {effectiveStatus === "downloading" ? ( <> Downloading {snapshot.version ? `v${snapshot.version}` : "update"} {downloadProgress ? {downloadProgress} : null} ) : null} - {snapshot.status === "ready" ? ( - Restart to install {snapshot.version ? `v${snapshot.version}` : "update"} + {effectiveStatus === "ready" ? ( + Install update {snapshot.version ? `v${snapshot.version}` : ""} + ) : null} + {effectiveStatus === "installing" ? ( + ADE will quit and reopen ) : null} ) : null} @@ -172,7 +196,7 @@ export function AutoUpdateControl() {
- The update is installed. You can reopen the Mintlify release notes to see what changed in this build. + The update is installed. You can reopen the release notes to see what changed in this build.
diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx index a59891c82..314e1bce2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx @@ -59,6 +59,8 @@ const launchTarget: IosSimulatorLaunchTarget = { detail: "Example", projectPath: "apps/ios/Example.xcodeproj", scheme: "Example", + productName: "Example", + appTargetId: "target-1", appBundlePath: null, installed: false, canBuild: true, diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index 45c759c68..5647192b1 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -3872,7 +3872,7 @@ export function ChatIosSimulatorPanel({
) : null} {footerStatus ? ( -
+
{footerStatus}
) : null} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index cc048dc44..f5052b8ce 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { lanePrMatchesCurrentBranch, + resolveLaneDeleteStartSelection, resolveCreateLaneRequest, resolveLaneIdsDeepLinkSelection, selectLanePrTag, @@ -132,6 +133,55 @@ describe("resolveLaneIdsDeepLinkSelection", () => { }); }); +describe("resolveLaneDeleteStartSelection", () => { + it("moves selection and active panes away from lanes that just started deleting", () => { + const result = resolveLaneDeleteStartSelection({ + deletingLaneIds: ["lane-b"], + selectedLaneId: "lane-b", + activeLaneIds: ["lane-b", "lane-c"], + pinnedLaneIds: ["lane-b", "lane-d"], + filteredLaneIds: ["lane-b", "lane-c", "lane-d"], + sortedLaneIds: ["lane-main", "lane-b", "lane-c", "lane-d"], + }); + + expect(result.selectedLaneId).toBe("lane-c"); + expect(result.activeLaneIds).toEqual(["lane-c", "lane-d"]); + expect(Array.from(result.pinnedLaneIds)).toEqual(["lane-d"]); + }); + + it("keeps the current selected lane when a different split starts deleting", () => { + const result = resolveLaneDeleteStartSelection({ + deletingLaneIds: ["lane-c"], + selectedLaneId: "lane-b", + activeLaneIds: ["lane-b", "lane-c", "lane-d"], + pinnedLaneIds: [], + filteredLaneIds: ["lane-b", "lane-c", "lane-d"], + sortedLaneIds: ["lane-main", "lane-b", "lane-c", "lane-d"], + }); + + expect(result.selectedLaneId).toBe("lane-b"); + expect(result.activeLaneIds).toEqual(["lane-b", "lane-d"]); + }); + + it("falls back through sortedLaneIds when every filtered lane is being deleted", () => { + const result = resolveLaneDeleteStartSelection({ + deletingLaneIds: ["lane-b", "lane-c", "lane-d"], + selectedLaneId: "lane-b", + activeLaneIds: ["lane-b", "lane-c"], + pinnedLaneIds: ["lane-b", "lane-d"], + // Every filtered lane is being deleted, so the function must fall + // through to sortedLaneIds and pick the first non-deleting entry there + // (lane-main) rather than re-selecting one of the deleting lanes. + filteredLaneIds: ["lane-b", "lane-c", "lane-d"], + sortedLaneIds: ["lane-main", "lane-b", "lane-c", "lane-d"], + }); + + expect(result.selectedLaneId).toBe("lane-main"); + expect(result.activeLaneIds).toEqual(["lane-main"]); + expect(Array.from(result.pinnedLaneIds)).toEqual([]); + }); +}); + describe("selectLanePrTag", () => { it("surfaces a merged PR when it still matches the lane branch", () => { const mergedPr = makePr({ state: "merged" }); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index afcf40572..ddcd1ee3b 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea import { useClickOutside } from "../../hooks/useClickOutside"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; -import { Check, CaretDown, FileCode, GitBranch, GitPullRequest, House, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info, ArrowCounterClockwise, UsersThree } from "@phosphor-icons/react"; +import { Check, CaretDown, FileCode, GitBranch, GitPullRequest, House, Stack, Link, ArrowsOutSimple, ArrowsInSimple, PushPin, Plus, MagnifyingGlass, Terminal, X, ArrowSquareOut, Info, ArrowCounterClockwise, UsersThree, CircleNotch } from "@phosphor-icons/react"; import { useAppStore, type LaneInspectorTab } from "../../state/appStore"; import { buildIntegrationSourcesByLaneId } from "../../lib/integrationLanes"; import { EmptyState } from "../ui/EmptyState"; @@ -64,6 +64,7 @@ import type { RebaseRun, RebaseScope, IntegrationProposal, + LaneDeleteProgress, LaneTemplate } from "../../../shared/types"; import { eventMatchesBinding, getEffectiveBinding } from "../../lib/keybindings"; @@ -237,6 +238,53 @@ export function resolveLaneIdsDeepLinkSelection(args: { return { laneIds, signature }; } +export function isLaneDeleteProgressActive(progress: LaneDeleteProgress | null | undefined): boolean { + return progress?.overallStatus === "running" || progress?.overallStatus === "completed"; +} + +function createPendingDeleteProgress(laneId: string): LaneDeleteProgress { + return { + laneId, + steps: [], + startedAt: new Date().toISOString(), + overallStatus: "running", + cancellable: true, + }; +} + +function getLaneDeleteStatusLabel(progress: LaneDeleteProgress | null | undefined): string { + return progress?.overallStatus === "completed" ? "Deleted" : "Deleting"; +} + +export function resolveLaneDeleteStartSelection(args: { + deletingLaneIds: Iterable; + selectedLaneId: string | null; + activeLaneIds: string[]; + pinnedLaneIds: Iterable; + filteredLaneIds: string[]; + sortedLaneIds: string[]; +}): { selectedLaneId: string | null; activeLaneIds: string[]; pinnedLaneIds: Set } { + const deleting = new Set(args.deletingLaneIds); + const isAvailable = (laneId: string | null | undefined): laneId is string => + Boolean(laneId && !deleting.has(laneId)); + const pinnedLaneIds = new Set(Array.from(args.pinnedLaneIds).filter((laneId) => !deleting.has(laneId))); + const nextSelectedLaneId = isAvailable(args.selectedLaneId) + ? args.selectedLaneId + : args.filteredLaneIds.find((laneId) => !deleting.has(laneId)) + ?? args.sortedLaneIds.find((laneId) => !deleting.has(laneId)) + ?? null; + const preservedActiveLaneIds = args.activeLaneIds.filter((laneId) => !deleting.has(laneId) && laneId !== nextSelectedLaneId); + return { + selectedLaneId: nextSelectedLaneId, + activeLaneIds: mergeUnique( + nextSelectedLaneId ? [nextSelectedLaneId] : [], + preservedActiveLaneIds, + Array.from(pinnedLaneIds), + ), + pinnedLaneIds, + }; +} + function laneTilingLayoutIds(laneId: string): string[] { return [ `lanes:tiling:${LANES_TILING_LAYOUT_VERSION}:${laneId}`, @@ -334,6 +382,7 @@ export function LanesPage() { const [laneActionStatus, setLaneActionStatus] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [laneActionKind, setLaneActionKind] = useState<"delete" | "archive" | "adopt" | null>(null); + const [deleteProgressByLaneId, setDeleteProgressByLaneId] = useState>({}); const [managedLaneIds, setManagedLaneIds] = useState([]); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); const chipTimersRef = useRef>(new Map()); @@ -365,6 +414,14 @@ export function LanesPage() { const branchDropdownRef = useRef(null); const completedLaneDeleteRefreshesRef = useRef>(new Set()); const activeLanePresenceSignatureRef = useRef(null); + // Refs for the onDeleteEvent IPC handler. Capturing high-churn values + // (selectedLaneId, lanesById, managedLaneIds, manageOpen) in refs lets the + // subscription useEffect keep its dep array minimal so it doesn't tear down + // and re-subscribe to the IPC bridge on every render. + const selectedLaneIdRef = useRef(null); + const lanesByIdRef = useRef | null>(null); + const managedLaneIdsRef = useRef([]); + const manageOpenRef = useRef(false); const [addLaneDropdownOpen, setAddLaneDropdownOpen] = useState(false); const addLaneDropdownRef = useRef(null); @@ -391,6 +448,7 @@ export function LanesPage() { useEffect(() => { completedLaneDeleteRefreshesRef.current.clear(); + setDeleteProgressByLaneId({}); }, [project?.rootPath]); const laneSnapshotByLaneId = useMemo( @@ -399,12 +457,23 @@ export function LanesPage() { ); const sortedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); const lanesById = useMemo(() => new Map(sortedLanes.map((lane) => [lane.id, lane])), [sortedLanes]); + const deletingLaneIds = useMemo(() => { + const ids = new Set(); + for (const progress of Object.values(deleteProgressByLaneId)) { + if (isLaneDeleteProgressActive(progress)) ids.add(progress.laneId); + } + return ids; + }, [deleteProgressByLaneId]); + const sortedSelectableLaneIds = useMemo( + () => sortedLanes.map((lane) => lane.id).filter((laneId) => !deletingLaneIds.has(laneId)), + [sortedLanes, deletingLaneIds], + ); // `availableLaneIdsKey` is the content-stable dep trigger (string changes only // when the id set changes). `availableLaneIds` recomputes from the key so its // identity is also content-stable, letting effects depend on either safely. const availableLaneIdsKey = useMemo( - () => sortedLanes.map((lane) => lane.id).sort().join("\0"), - [sortedLanes], + () => sortedSelectableLaneIds.slice().sort().join("\0"), + [sortedSelectableLaneIds], ); const availableLaneIds = useMemo( () => (availableLaneIdsKey ? availableLaneIdsKey.split("\0") : []), @@ -503,10 +572,14 @@ export function LanesPage() { const stackGraphLanes = useMemo(() => sortLanesForStackGraph(filteredLanes), [filteredLanes]); const filteredLaneIds = useMemo(() => filteredLanes.map((lane) => lane.id), [filteredLanes]); - const filteredSet = useMemo(() => new Set(filteredLaneIds), [filteredLaneIds]); + const selectableFilteredLaneIds = useMemo( + () => filteredLaneIds.filter((laneId) => !deletingLaneIds.has(laneId)), + [filteredLaneIds, deletingLaneIds], + ); + const selectableFilteredSet = useMemo(() => new Set(selectableFilteredLaneIds), [selectableFilteredLaneIds]); const visibleRebaseSuggestions = useMemo(() => { if (suppressTourDistractions) return []; - const laneIdSet = new Set(filteredLaneIds); + const laneIdSet = new Set(selectableFilteredLaneIds); return laneSnapshots .map((snapshot) => snapshot.rebaseSuggestion) .filter( @@ -515,10 +588,10 @@ export function LanesPage() { return laneIdSet.has(suggestion.laneId); }, ); - }, [laneSnapshots, filteredLaneIds, suppressTourDistractions]); + }, [laneSnapshots, selectableFilteredLaneIds, suppressTourDistractions]); const visibleAutoRebaseNeedsAttention = useMemo(() => { if (suppressTourDistractions) return []; - const laneIdSet = new Set(filteredLaneIds); + const laneIdSet = new Set(selectableFilteredLaneIds); return laneSnapshots .map((snapshot) => snapshot.autoRebaseStatus) .filter( @@ -527,15 +600,18 @@ export function LanesPage() { return laneIdSet.has(status.laneId) && status.state !== "autoRebased"; }, ); - }, [laneSnapshots, filteredLaneIds, suppressTourDistractions]); + }, [laneSnapshots, selectableFilteredLaneIds, suppressTourDistractions]); const activeWithPins = useMemo( - () => mergeUnique(activeLaneIds, Array.from(pinnedLaneIds).filter((id) => lanesById.has(id))), - [activeLaneIds, pinnedLaneIds, lanesById] + () => mergeUnique( + activeLaneIds.filter((id) => !deletingLaneIds.has(id)), + Array.from(pinnedLaneIds).filter((id) => lanesById.has(id) && !deletingLaneIds.has(id)), + ), + [activeLaneIds, pinnedLaneIds, lanesById, deletingLaneIds] ); const visibleLaneIds = useMemo( - () => activeWithPins.filter((id) => lanesById.has(id) && filteredSet.has(id)), - [activeWithPins, lanesById, filteredSet] + () => activeWithPins.filter((id) => lanesById.has(id) && selectableFilteredSet.has(id)), + [activeWithPins, lanesById, selectableFilteredSet] ); useEffect(() => { @@ -715,14 +791,43 @@ export function LanesPage() { /* ---- Effects ---- */ + // Mirror high-churn values into refs so the IPC subscription below doesn't + // re-subscribe every render (lanesById is rebuilt whenever any lane field + // changes; selectedLaneId / managedLaneIds / manageOpen flip on every nav). + useEffect(() => { selectedLaneIdRef.current = selectedLaneId; }, [selectedLaneId]); + useEffect(() => { lanesByIdRef.current = lanesById; }, [lanesById]); + useEffect(() => { managedLaneIdsRef.current = managedLaneIds; }, [managedLaneIds]); + useEffect(() => { manageOpenRef.current = manageOpen; }, [manageOpen]); + useEffect(() => { const unsubscribe = window.ade.lanes.onDeleteEvent((event) => { const { laneId, overallStatus } = event.progress; + setDeleteProgressByLaneId((prev) => { + if (isLaneDeleteProgressActive(event.progress)) { + return { ...prev, [laneId]: event.progress }; + } + if (!prev[laneId]) return prev; + const next = { ...prev }; + delete next[laneId]; + return next; + }); + if (overallStatus === "failed" || overallStatus === "cancelled") { + completedLaneDeleteRefreshesRef.current.delete(laneId); + const failedStep = event.progress.steps.find((step) => step.status === "failed"); + const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; + const detail = failedStep?.errorMessage ? `: ${failedStep.errorMessage}` : ""; + setLaneActionError( + overallStatus === "cancelled" + ? `${laneName} delete was cancelled.` + : `${laneName} delete failed${detail}`, + ); + return; + } if (overallStatus !== "completed") return; if (completedLaneDeleteRefreshesRef.current.has(laneId)) return; completedLaneDeleteRefreshesRef.current.add(laneId); - if (selectedLaneId === laneId) selectLane(null); + if (selectedLaneIdRef.current === laneId) selectLane(null); setActiveLaneIds((prev) => prev.filter((id) => id !== laneId)); setPinnedLaneIds((prev) => { if (!prev.has(laneId)) return prev; @@ -736,7 +841,10 @@ export function LanesPage() { void refreshLanes() .then(() => { - if (manageOpen && (selectedLaneId === laneId || managedLaneIds.includes(laneId))) { + if ( + manageOpenRef.current + && (selectedLaneIdRef.current === laneId || managedLaneIdsRef.current.includes(laneId)) + ) { setManageOpen(false); } }) @@ -745,7 +853,7 @@ export function LanesPage() { }); }); return unsubscribe; - }, [clearLaneInspectorTab, managedLaneIds, manageOpen, refreshLanes, selectLane, selectedLaneId]); + }, [clearLaneInspectorTab, refreshLanes, selectLane]); useEffect(() => { const unsubscribe = window.ade.conflicts.onEvent((event) => { @@ -940,20 +1048,30 @@ export function LanesPage() { }, [lanesById]); useEffect(() => { - const pinned = Array.from(pinnedLaneIds).filter((laneId) => lanesById.has(laneId)); + setDeleteProgressByLaneId((prev) => { + const next: Record = {}; + for (const [laneId, progress] of Object.entries(prev)) { + if (lanesById.has(laneId) && isLaneDeleteProgressActive(progress)) next[laneId] = progress; + } + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + }, [lanesById]); + + useEffect(() => { + const pinned = Array.from(pinnedLaneIds).filter((laneId) => lanesById.has(laneId) && !deletingLaneIds.has(laneId)); setActiveLaneIds((prev) => { - const validPrev = prev.filter((laneId) => lanesById.has(laneId)); - const selected = selectedLaneId && lanesById.has(selectedLaneId) ? [selectedLaneId] : []; + const validPrev = prev.filter((laneId) => lanesById.has(laneId) && !deletingLaneIds.has(laneId)); + const selected = selectedLaneId && lanesById.has(selectedLaneId) && !deletingLaneIds.has(selectedLaneId) ? [selectedLaneId] : []; const fallback = selected.length ? [] : validPrev.length ? [validPrev[0]!] - : sortedLanes[0]?.id - ? [sortedLanes[0]!.id] + : sortedSelectableLaneIds[0] + ? [sortedSelectableLaneIds[0]] : []; return mergeUnique(selected, fallback, validPrev, pinned); }); - }, [selectedLaneId, lanesById, sortedLanes, pinnedLaneIds]); + }, [selectedLaneId, lanesById, sortedSelectableLaneIds, pinnedLaneIds, deletingLaneIds]); useEffect(() => { setLanePaneDetails((prev) => { @@ -968,16 +1086,16 @@ export function LanesPage() { /* ---- Keyboard navigation ---- */ const stepLaneSelection = useCallback((direction: -1 | 1) => { - if (filteredLaneIds.length === 0) return; - const currentId = selectedLaneId && filteredSet.has(selectedLaneId) ? selectedLaneId : filteredLaneIds[0]!; - const currentIdx = filteredLaneIds.indexOf(currentId); - const nextIdx = (currentIdx + direction + filteredLaneIds.length) % filteredLaneIds.length; - const nextId = filteredLaneIds[nextIdx]; + if (selectableFilteredLaneIds.length === 0) return; + const currentId = selectedLaneId && selectableFilteredSet.has(selectedLaneId) ? selectedLaneId : selectableFilteredLaneIds[0]!; + const currentIdx = selectableFilteredLaneIds.indexOf(currentId); + const nextIdx = (currentIdx + direction + selectableFilteredLaneIds.length) % selectableFilteredLaneIds.length; + const nextId = selectableFilteredLaneIds[nextIdx]; if (!nextId) return; - const pinned = Array.from(pinnedLaneIds).filter((laneId) => laneId !== nextId && lanesById.has(laneId)); + const pinned = Array.from(pinnedLaneIds).filter((laneId) => laneId !== nextId && lanesById.has(laneId) && !deletingLaneIds.has(laneId)); setActiveLaneIds(mergeUnique([nextId], pinned)); selectLane(nextId); - }, [filteredLaneIds, selectedLaneId, filteredSet, pinnedLaneIds, lanesById, selectLane]); + }, [selectableFilteredLaneIds, selectedLaneId, selectableFilteredSet, pinnedLaneIds, lanesById, deletingLaneIds, selectLane]); const kbFilterFocus = useMemo(() => getEffectiveBinding(keybindings, "lanes.filter.focus", "/,Mod+F"), [keybindings]); const kbNext = useMemo(() => getEffectiveBinding(keybindings, "lanes.select.next", "J,ArrowDown"), [keybindings]); @@ -990,7 +1108,21 @@ export function LanesPage() { if (expandedGitActionsLaneId && !lanesById.has(expandedGitActionsLaneId)) { setExpandedGitActionsLaneId(null); } - }, [expandedGitActionsLaneId, lanesById]); + if (expandedGitActionsLaneId && deletingLaneIds.has(expandedGitActionsLaneId)) { + setExpandedGitActionsLaneId(null); + } + }, [expandedGitActionsLaneId, lanesById, deletingLaneIds]); + + useEffect(() => { + if (expandedLaneId && (!lanesById.has(expandedLaneId) || deletingLaneIds.has(expandedLaneId))) { + setExpandedLaneId(null); + } + }, [expandedLaneId, lanesById, deletingLaneIds]); + + useEffect(() => { + if (!selectedLaneId || !deletingLaneIds.has(selectedLaneId)) return; + selectLane(selectableFilteredLaneIds[0] ?? sortedSelectableLaneIds[0] ?? null); + }, [selectedLaneId, deletingLaneIds, selectableFilteredLaneIds, sortedSelectableLaneIds, selectLane]); useEffect(() => { const isTypingTarget = (target: EventTarget | null): boolean => { @@ -1033,17 +1165,17 @@ export function LanesPage() { } if (eventMatchesBinding(event, kbNext)) { event.preventDefault(); stepLaneSelection(1); return; } if (eventMatchesBinding(event, kbPrev)) { event.preventDefault(); stepLaneSelection(-1); return; } - if (eventMatchesBinding(event, kbConfirm) && filteredLaneIds.length > 0) { + if (eventMatchesBinding(event, kbConfirm) && selectableFilteredLaneIds.length > 0) { event.preventDefault(); - const laneId = selectedLaneId && filteredSet.has(selectedLaneId) ? selectedLaneId : filteredLaneIds[0]!; - const pinned = Array.from(pinnedLaneIds).filter((lane) => lane !== laneId && lanesById.has(lane)); + const laneId = selectedLaneId && selectableFilteredSet.has(selectedLaneId) ? selectedLaneId : selectableFilteredLaneIds[0]!; + const pinned = Array.from(pinnedLaneIds).filter((lane) => lane !== laneId && lanesById.has(lane) && !deletingLaneIds.has(lane)); setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [filteredLaneIds, filteredSet, selectedLaneId, pinnedLaneIds, lanesById, selectLane, laneFilter, stepLaneSelection, kbFilterFocus, kbNext, kbPrev, kbNextTab, kbPrevTab, kbConfirm, expandedLaneId, expandedGitActionsLaneId]); + }, [selectableFilteredLaneIds, selectableFilteredSet, selectedLaneId, pinnedLaneIds, lanesById, deletingLaneIds, selectLane, laneFilter, stepLaneSelection, kbFilterFocus, kbNext, kbPrev, kbNextTab, kbPrevTab, kbConfirm, expandedLaneId, expandedGitActionsLaneId]); /* ---- Lane management actions ---- */ @@ -1232,75 +1364,104 @@ export function LanesPage() { }, actionable.length > 1 ? `Archiving ${actionable.length} lanes...` : "Archiving lane...", "archive"); }; + const moveAwayFromDeletingLanes = useCallback((laneIds: string[]) => { + const allDeletingLaneIds = new Set([...deletingLaneIds, ...laneIds]); + const selection = resolveLaneDeleteStartSelection({ + deletingLaneIds: allDeletingLaneIds, + selectedLaneId, + activeLaneIds, + pinnedLaneIds, + filteredLaneIds, + sortedLaneIds: sortedSelectableLaneIds, + }); + setPinnedLaneIds(selection.pinnedLaneIds); + setActiveLaneIds(selection.activeLaneIds); + selectLane(selection.selectedLaneId); + for (const laneId of laneIds) { + clearLaneInspectorTab(laneId); + } + const nextSearch = selection.selectedLaneId + ? `?laneId=${encodeURIComponent(selection.selectedLaneId)}` + : ""; + navigate(`/lanes${nextSearch}`, { replace: true }); + }, [ + activeLaneIds, + clearLaneInspectorTab, + deletingLaneIds, + filteredLaneIds, + navigate, + pinnedLaneIds, + selectLane, + selectedLaneId, + sortedSelectableLaneIds, + ]); + const deleteManagedLanes = async () => { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; const actionable = targets.filter((l) => l.laneType !== "primary"); if (actionable.length === 0) return; if (deleteConfirmText.trim().toLowerCase() !== deletePhrase.toLowerCase()) return; - const attachedCount = actionable.filter((l) => l.laneType === "attached").length; - const managedCount = actionable.length - attachedCount; - const deleteStatus = (() => { - if (managedCount === 0 && attachedCount > 0) { - return attachedCount > 1 - ? `Unlinking ${attachedCount} attached lanes...` - : "Unlinking attached lane..."; + + const deleteArgsByLaneId = new Map(); + for (const lane of actionable) { + const args: DeleteLaneArgs = { laneId: lane.id, force: deleteForce }; + if (deleteMode === "worktree") { + args.deleteBranch = false; + } else { + args.deleteBranch = true; + if (deleteMode === "remote_branch") { + args.deleteRemoteBranch = true; + args.remoteName = deleteRemoteName.trim() || "origin"; + } } - const managedPhrase = - deleteMode === "remote_branch" - ? managedCount > 1 - ? `${managedCount} lane worktrees, local branches, and remote branches` - : "lane worktree, local branch, and remote branch" - : deleteMode === "local_branch" - ? managedCount > 1 - ? `${managedCount} lane worktrees and local branches` - : "lane worktree and local branch" - : managedCount > 1 - ? `${managedCount} lane worktrees` - : "lane worktree"; - if (attachedCount === 0) { - return `Deleting ${managedPhrase}...`; + deleteArgsByLaneId.set(lane.id, args); + } + + const laneIds = actionable.map((lane) => lane.id); + completedLaneDeleteRefreshesRef.current = new Set( + Array.from(completedLaneDeleteRefreshesRef.current).filter((laneId) => !laneIds.includes(laneId)), + ); + setDeleteProgressByLaneId((prev) => { + const next = { ...prev }; + for (const laneId of laneIds) { + next[laneId] = createPendingDeleteProgress(laneId); } - const attachedPhrase = attachedCount > 1 - ? `${attachedCount} attached lanes` - : "attached lane"; - return `Unlinking ${attachedPhrase} and deleting ${managedPhrase}...`; - })(); - await runLaneAction(async () => { + return next; + }); + setManageOpen(false); + setLaneActionBusy(false); + setLaneActionStatus(null); + setLaneActionKind(null); + setLaneActionError(null); + setDeleteConfirmText(""); + moveAwayFromDeletingLanes(laneIds); + + void (async () => { const errors: string[] = []; for (const lane of actionable) { try { - const args: DeleteLaneArgs = { laneId: lane.id, force: deleteForce }; - if (deleteMode === "worktree") { - args.deleteBranch = false; - } else { - args.deleteBranch = true; - if (deleteMode === "remote_branch") { - args.deleteRemoteBranch = true; - args.remoteName = deleteRemoteName.trim() || "origin"; - } - } + const args = deleteArgsByLaneId.get(lane.id); + if (!args) continue; await window.ade.lanes.delete(args); } catch (err) { errors.push(`${lane.name}: ${err instanceof Error ? err.message : String(err)}`); + setDeleteProgressByLaneId((prev) => { + const next = { ...prev }; + delete next[lane.id]; + return next; + }); } } if (errors.length > 0) { - throw new Error(errors.join("\n")); - } - const deletedIds = new Set(actionable.map((l) => l.id)); - if (deletedIds.has(selectedLaneId ?? "")) selectLane(null); - setActiveLaneIds((prev) => prev.filter((id) => !deletedIds.has(id))); - // Clean up per-lane inspector tab preferences - for (const id of deletedIds) { - clearLaneInspectorTab(id); + setLaneActionError(errors.join("\n")); } - }, deleteStatus); + })(); }; const openBatchManage = useCallback((laneIds: string[]) => { const manageable = laneIds.filter((id) => { const lane = lanesById.get(id); - return lane && lane.laneType !== "primary"; + return lane && lane.laneType !== "primary" && !deletingLaneIds.has(id); }); if (manageable.length === 0) return; setManagedLaneIds(manageable); @@ -1310,14 +1471,15 @@ export function LanesPage() { setDeleteRemoteName("origin"); setDeleteConfirmText(""); setManageOpen(true); - }, [lanesById]); + }, [lanesById, deletingLaneIds]); const handleLaneSelect = useCallback((laneId: string, args: { extend: boolean }) => { + if (deletingLaneIds.has(laneId)) return; const lane = lanesById.get(laneId); if (!lane) return; if (!args.extend) { - const pinned = Array.from(pinnedLaneIds).filter((id) => id !== laneId && lanesById.has(id)); + const pinned = Array.from(pinnedLaneIds).filter((id) => id !== laneId && lanesById.has(id) && !deletingLaneIds.has(id)); setActiveLaneIds(mergeUnique([laneId], pinned)); selectLane(laneId); return; @@ -1331,25 +1493,25 @@ export function LanesPage() { } const next = isActive ? activeWithPins.filter((id) => id !== laneId) : [...activeWithPins, laneId]; - const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id)); + const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id) && !deletingLaneIds.has(id)); setActiveLaneIds(mergeUnique(next.length ? next : [laneId], pinned)); selectLane(laneId); - }, [lanesById, pinnedLaneIds, activeWithPins, selectLane]); + }, [deletingLaneIds, lanesById, pinnedLaneIds, activeWithPins, selectLane]); const removeSplitLane = useCallback((laneId: string) => { if (pinnedLaneIds.has(laneId)) return; - const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id)); + const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id) && !deletingLaneIds.has(id)); const next = activeWithPins.filter((id) => id !== laneId); const normalized = mergeUnique(next, pinned); setActiveLaneIds(normalized); if (!normalized.includes(selectedLaneId ?? "")) { selectLane(normalized[0] ?? null); } - }, [pinnedLaneIds, lanesById, activeWithPins, selectedLaneId, selectLane]); + }, [pinnedLaneIds, lanesById, deletingLaneIds, activeWithPins, selectedLaneId, selectLane]); const togglePinnedLane = useCallback((laneId: string) => { const lane = lanesById.get(laneId); - if (!lane || lane.laneType === "primary") return; + if (!lane || lane.laneType === "primary" || deletingLaneIds.has(laneId)) return; const isPinned = pinnedLaneIds.has(laneId); setPinnedLaneIds((prev) => { const next = new Set(prev); @@ -1360,15 +1522,15 @@ export function LanesPage() { if (!isPinned) { setActiveLaneIds((prev) => mergeUnique(prev, [laneId])); } - }, [lanesById, pinnedLaneIds]); + }, [lanesById, deletingLaneIds, pinnedLaneIds]); const resetGridLayout = useCallback(async (preferredLaneId?: string | null) => { const selectedVisibleLane = - preferredLaneId && lanesById.has(preferredLaneId) + preferredLaneId && lanesById.has(preferredLaneId) && !deletingLaneIds.has(preferredLaneId) ? preferredLaneId - : selectedLaneId && lanesById.has(selectedLaneId) + : selectedLaneId && lanesById.has(selectedLaneId) && !deletingLaneIds.has(selectedLaneId) ? selectedLaneId - : visibleLaneIds[0] ?? filteredLaneIds[0] ?? sortedLanes[0]?.id ?? null; + : visibleLaneIds[0] ?? selectableFilteredLaneIds[0] ?? sortedSelectableLaneIds[0] ?? null; if (selectedVisibleLane) { setPinnedLaneIds(new Set()); setActiveLaneIds([selectedVisibleLane]); @@ -1376,7 +1538,7 @@ export function LanesPage() { } const promises: Promise[] = []; const laneIdsToReset = new Set([ - ...filteredLaneIds, + ...selectableFilteredLaneIds, ...visibleLaneIds, ...activeLaneIds, ]); @@ -1394,7 +1556,7 @@ export function LanesPage() { await Promise.all(promises); /* Force full remount so default sizes/trees take effect */ setGridResetKey((k) => k + 1); - }, [activeLaneIds, filteredLaneIds, lanesById, selectLane, selectedLaneId, sortedLanes, visibleLaneIds]); + }, [activeLaneIds, selectableFilteredLaneIds, lanesById, deletingLaneIds, selectLane, selectedLaneId, sortedSelectableLaneIds, visibleLaneIds]); useEffect(() => { const onTourEnded = (event: Event) => { @@ -1404,7 +1566,7 @@ export function LanesPage() { }; window.addEventListener("ade:tour-ended", onTourEnded); return () => window.removeEventListener("ade:tour-ended", onTourEnded); - }, [resetGridLayout]); + }, [resetGridLayout, selectedLaneId]); useEffect(() => { const onTourFocusLane = (event: Event) => { @@ -1709,7 +1871,7 @@ export function LanesPage() { const targetId = urlLaneDeeplinks.laneId; if (!targetId) return; const lane = lanesById.get(targetId); - if (!lane || lane.laneType === "primary") return; + if (!lane || lane.laneType === "primary" || deletingLaneIds.has(targetId)) return; setManagedLaneIds([targetId]); setLaneActionError(null); setDeleteForce(false); @@ -1722,7 +1884,7 @@ export function LanesPage() { next.delete("action"); navigate(`${location.pathname}?${next.toString()}`, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlLaneDeeplinks.action, urlLaneDeeplinks.laneId, lanesById]); + }, [urlLaneDeeplinks.action, urlLaneDeeplinks.laneId, lanesById, deletingLaneIds]); useEffect(() => { if (!urlLaneDeeplinks.laneIdsRaw) return; @@ -1749,6 +1911,7 @@ export function LanesPage() { consumedLaneIdsDeepLinkSignatureRef.current = null; const laneId = urlLaneDeeplinks.laneId; if (!laneId) return; + if (deletingLaneIds.has(laneId)) return; selectLane(laneId); if (urlLaneDeeplinks.focus === "single") { setActiveLaneIds([laneId]); @@ -1761,6 +1924,7 @@ export function LanesPage() { urlLaneDeeplinks.laneId, urlLaneDeeplinks.focus, urlLaneDeeplinks.inspectorTab, + deletingLaneIds, selectLane, setLaneInspectorTab, ]); @@ -1906,6 +2070,7 @@ export function LanesPage() { }, [attachName, attachPath, attachDescription, attachBusy, refreshLanes, navigate]); const openManageDialog = useCallback((laneId: string) => { + if (deletingLaneIds.has(laneId)) return; selectLane(laneId); setManagedLaneIds([laneId]); setLaneActionError(null); @@ -1915,7 +2080,7 @@ export function LanesPage() { setDeleteRemoteName("origin"); setDeleteConfirmText(""); setManageOpen(true); - }, [selectLane]); + }, [deletingLaneIds, selectLane]); const handleCreateDialogBusOpen = useCallback((props?: Record) => { setAddLaneDropdownOpen(false); @@ -2410,6 +2575,30 @@ export function LanesPage() { ); })}
+ {laneActionError ? ( +
+ Lane action failed + +
+ ) : null} {/* NEW LANE button + dropdown */}
@@ -2653,15 +2842,19 @@ export function LanesPage() { const devicesOpen = lane.devicesOpen ?? []; const tabNumber = String(index + 1).padStart(2, "0"); const lanePr = lanePrByLaneId.get(lane.id) ?? null; + const deleteProgress = deleteProgressByLaneId[lane.id] ?? null; + const isDeleting = isLaneDeleteProgressActive(deleteProgress); return (
{ + if (isDeleting) return; handleLaneSelect(lane.id, { extend: Boolean(event.shiftKey || event.metaKey || event.ctrlKey) }); }} onContextMenu={(event) => { event.preventDefault(); + if (isDeleting) return; setLaneContextMenu({ laneId: lane.id, x: event.clientX, y: event.clientY }); }} onMouseEnter={(e) => { - if (!isSelected && !isInSplit) e.currentTarget.style.background = COLORS.hoverBg; + if (!isDeleting && !isSelected && !isInSplit) e.currentTarget.style.background = COLORS.hoverBg; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = isInSplit ? "rgba(167,139,250,0.06)" : "transparent"; @@ -2706,7 +2903,7 @@ export function LanesPage() { )} {/* Terminal attention state */} - {laneRuntime.bucket === "running" || laneRuntime.bucket === "awaiting-input" ? ( + {!isDeleting && (laneRuntime.bucket === "running" || laneRuntime.bucket === "awaiting-input") ? ( - ) : laneRuntime.bucket === "ended" ? ( + ) : !isDeleting && laneRuntime.bucket === "ended" ? ( ) : null} + {!isDeleting ? ( event.stopPropagation()} @@ -2733,6 +2931,7 @@ export function LanesPage() { > + ) : null} {/* Lane name */} {lane.name} - {lanePr ? ( + {!isDeleting && lanePr ? ( ) : null} + {isDeleting ? ( +
+ + {getLaneDeleteStatusLabel(deleteProgress)} +
+ ) : null}
); })} diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index fe547c8e6..fbae2fed6 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -22,6 +22,7 @@ export const ADE_CLI_AGENT_GUIDANCE = [ "", "### iOS Simulator and Preview Lab", "- For iOS work inside an ADE chat, start with `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim devices --text`, and `ade --socket ios-sim apps --device --text`, then launch with `ade --socket ios-sim launch --target --text`.", + "- For iOS launch target problems, do not create symlink projects, fake schemes, or repo-layout shims as the first fix. Re-list `ade --socket ios-sim apps --text`; ADE should discover root-level `.xcodeproj` bundles and `apps/*/*.xcodeproj` projects. If no app appears or the build fails, report the selected project, scheme, and exact build output instead of guessing.", "- After launch, use `ade --socket ios-sim snapshot --text` or `ade --socket ios-sim elements --text` for screenshot + accessibility/ADEInspector grounding. Use `ade --socket ios-sim select --x --y ` to attach context to the drawer chat, and `ade --socket ios-sim tap`, `ade --socket ios-sim drag` / `ade --socket ios-sim swipe`, or `ade --socket ios-sim type` against the active app.", "- Stream commands are `ade --socket ios-sim window-start`, `ade --socket ios-sim live-start`, `ade --socket ios-sim preview-start`, `ade --socket ios-sim stream-status`, and `ade --socket ios-sim stream-stop`. Use `ade --socket ios-sim stream-status --text` to explain the active backend/input path, fallback reason, helper pid, latency, and any blocker. Low idle fps is normal on `iosurface-indigo` because frames are event-driven while the simulator is still.", "- For ADE Preview Lab, check `ade --socket ios-sim preview-status --text`, discover existing previews with `ade --socket ios-sim previews --source --text`, and finish with `ade --socket ios-sim preview-render --source --index --text`. Add a preview only when no matching preview already exists.", @@ -47,7 +48,7 @@ export const ADE_CLI_INLINE_GUIDANCE = [ "- First checks: `ade doctor --text`, `ade help `, typed `ade ... --text` commands, and `ade actions list --text`. Common starts: `ade lanes list --text`, `ade chats list --text`, `ade proof status --text`.", "- If `command -v ade` fails, try `${ADE_CLI_PATH:-}`, then `${ADE_CLI_BIN_DIR:-}/ade`, then `node apps/ade-cli/dist/cli.cjs ...` in an ADE source checkout after confirming it exists. The only normal reason to skip ADE CLI for an ADE action is that it is truly unreachable.", "- Use `--socket` when the CLI and ADE desktop drawer need the same live state: App Control, iOS Simulator, Preview Lab, terminal logs, selections/context, and proof drawer updates.", - "- iOS Simulator and Preview Lab control is only supported from ADE desktop chats. For iOS work use `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim snapshot --text`, `ade --socket ios-sim select --x --y `, `ade --socket ios-sim stream-status --text`, and Preview Lab commands such as `ade --socket ios-sim preview-status --text`, `ade --socket ios-sim previews --source --text`, `ade --socket ios-sim preview-render --source --index --text`.", + "- iOS Simulator and Preview Lab control is only supported from ADE desktop chats. For iOS work use `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim devices --text`, `ade --socket ios-sim apps --text`, `ade --socket ios-sim snapshot --text`, `ade --socket ios-sim select --x --y `, `ade --socket ios-sim stream-status --text`, and Preview Lab commands such as `ade --socket ios-sim preview-status --text`, `ade --socket ios-sim previews --source --text`, `ade --socket ios-sim preview-render --source --index --text`. Do not create symlink projects or fake schemes as a first fix for app detection; re-list ADE's launch targets and report exact build output.", "- For App Control on Electron apps use `ade help app-control`, `ade --socket app-control status --text`, `ade --socket app-control launch --command ... --text`, `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control snapshot --text`, `ade --socket app-control select --x --y `, `ade --socket app-control click`, and `ade --socket app-control type`.", "- For ADE browser use `ade help browser`, `ade --socket browser panel --text`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text`. The ADE browser is global, not lane-scoped.", "- If a live app/simulator/session is missing, report the exact blocker instead of guessing. When asked for proof, register artifacts with `ade proof ...` so they appear in the ADE proof drawer, and clean up old, stale, or finished processes before leaving the task.", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 5a6133abb..160a2acc4 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -25,7 +25,7 @@ export type RecentlyInstalledUpdate = { releaseNotesUrl: string | null; }; -export type AutoUpdateStatus = "idle" | "checking" | "downloading" | "ready" | "error"; +export type AutoUpdateStatus = "idle" | "checking" | "downloading" | "ready" | "installing" | "error"; export type AutoUpdateSnapshot = { status: AutoUpdateStatus; diff --git a/apps/desktop/src/shared/types/iosSimulator.ts b/apps/desktop/src/shared/types/iosSimulator.ts index e638b8a4b..6f627ae10 100644 --- a/apps/desktop/src/shared/types/iosSimulator.ts +++ b/apps/desktop/src/shared/types/iosSimulator.ts @@ -44,6 +44,20 @@ export type IosSimulatorLaunchTarget = { detail: string; projectPath: string | null; scheme: string | null; + /** + * Xcode product name produced by the application target this launch target + * resolves to. Distinct from `scheme` because a scheme can build multiple + * `.app` bundles or a scheme name can differ from the produced `.app`. + * Used to disambiguate target ids and to resolve the right `.app` bundle + * after a build (`findAppBundle` prefers `${productName}.app`). + */ + productName: string | null; + /** + * Internal Xcode target identifier (PBXNativeTarget id). Only populated + * for `kind === "project"`. Carries the discriminator into the target id + * so two app targets that share a scheme don't collapse onto the same id. + */ + appTargetId: string | null; appBundlePath: string | null; installed: boolean; canBuild: boolean; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 63c49a079..5f72cc3ce 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -142,10 +142,19 @@ private struct ProjectHomeView: View { } private var attachedComputerTint: Color { - switch syncService.connectionState { - case .connected: return ADEColor.success - case .syncing, .connecting: return ADEColor.warning - case .error, .disconnected: return ADEColor.textMuted + let health = syncService.connectionHealth + switch health.transport { + case .connected: + return health.load == .strained ? ADEColor.warning : ADEColor.success + case .connecting: + return ADEColor.warning + case .unreachable: + // Surface the connection-error affordance — collapsing this with + // .disconnected loses the "something is wrong" tint when a host that + // was reachable goes silent. + return ADEColor.danger + case .disconnected: + return ADEColor.textMuted } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ed1cab985..398efd2ef 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -29,13 +29,64 @@ enum RemoteConnectionState: String { /// True when the host is not reachable — either we never connected /// (or gave up) or the last socket turned over into an error state. /// UI uses this to suppress per-screen "failed to load" banners whose - /// underlying cause is simply "not connected"; the top-right gear dot - /// (ADEConnectionDot) is the single source of truth for this state. + /// underlying cause is simply "not connected"; visible connection affordances + /// should use `SyncConnectionHealth` so transport, sync work, and load strain + /// stay distinct. var isHostUnreachable: Bool { self == .disconnected || self == .error } } +enum SyncTransportHealth: String, Equatable { + case disconnected + case connecting + case connected + case unreachable + + var isConnected: Bool { + self == .connected + } + + var isHostUnreachable: Bool { + self == .disconnected || self == .unreachable + } +} + +enum SyncLoadHealth: String, Equatable { + case normal + case strained +} + +struct SyncConnectionHealth: Equatable { + let transport: SyncTransportHealth + let load: SyncLoadHealth + let lastFailureMessage: String? +} + +func syncConnectionHealth( + connectionState: RemoteConnectionState, + prefersReducedSyncLoad: Bool, + lastError: String? +) -> SyncConnectionHealth { + let transport: SyncTransportHealth = { + switch connectionState { + case .disconnected: + return .disconnected + case .connecting: + return .connecting + case .connected, .syncing: + return .connected + case .error: + return .unreachable + } + }() + return SyncConnectionHealth( + transport: transport, + load: transport.isConnected && prefersReducedSyncLoad ? .strained : .normal, + lastFailureMessage: transport == .unreachable ? lastError : nil + ) +} + enum SyncChatMessageDelivery: Equatable { case sent case queued @@ -193,6 +244,7 @@ enum SyncBonjourTiming { enum SyncSocketTiming { static let openTimeoutNanoseconds: UInt64 = 5_000_000_000 static let lanePresenceHeartbeatNanoseconds: UInt64 = 30_000_000_000 + static let requestTimeoutReconnectSilenceSeconds: TimeInterval = 12 } enum SyncTailnetDiscoveryTiming { @@ -451,6 +503,15 @@ func syncClientHeartbeatIntervalNanoseconds(serverIntervalMs rawValue: Any?) -> return UInt64(clientMs) * 1_000_000 } +func syncShouldReconnectAfterRequestTimeout( + now: TimeInterval, + lastInboundMessageAt: TimeInterval?, + silenceThreshold: TimeInterval = SyncSocketTiming.requestTimeoutReconnectSilenceSeconds +) -> Bool { + guard let lastInboundMessageAt else { return true } + return now - lastInboundMessageAt >= silenceThreshold +} + func syncShouldRoamToTailnet( currentAddress: String?, hasTailnetRoute: Bool, @@ -693,6 +754,14 @@ final class SyncService: ObservableObject { @Published var requestedLaneNavigation: LaneNavigationRequest? @Published var requestedPrNavigation: PrNavigationRequest? + var connectionHealth: SyncConnectionHealth { + syncConnectionHealth( + connectionState: connectionState, + prefersReducedSyncLoad: prefersReducedSyncLoad, + lastError: lastError + ) + } + private(set) var terminalBuffers: [String: String] = [:] private(set) var chatEventEnvelopesBySession: [String: [AgentChatEventEnvelope]] = [:] private(set) var chatEventRevisionsBySession: [String: Int] = [:] @@ -796,6 +865,7 @@ final class SyncService: ObservableObject { private var reconnectState = SyncReconnectState() private var connectionGeneration: UInt64 = 0 private var connectAttemptGeneration: UInt64 = 0 + private var lastInboundMessageAt: TimeInterval? private var allowAutoReconnect = true /// User-initiated disconnects should stay disconnected until the user explicitly reconnects or pairs again. private var autoReconnectPausedByUser = false @@ -5543,6 +5613,7 @@ final class SyncService: ObservableObject { let type = envelope["type"] as? String ?? "" let requestId = envelope["requestId"] as? String let payload = try decodeEnvelopePayload(envelope) + lastInboundMessageAt = ProcessInfo.processInfo.systemUptime switch type { case "hello_ok": @@ -5980,6 +6051,7 @@ final class SyncService: ObservableObject { socket?.cancel(with: closeCode, reason: reason?.data(using: .utf8)) socket = nil currentAddress = nil + lastInboundMessageAt = nil refreshReducedSyncLoad() pendingProjectCatalogChunks.removeAll() connectionGeneration &+= 1 @@ -6019,15 +6091,34 @@ final class SyncService: ObservableObject { timeoutError: NSError = SyncRequestTimeout.error() ) { guard pending[requestId] != nil else { return } - if disconnectOnTimeout { + if disconnectOnTimeout, + syncShouldReconnectAfterRequestTimeout( + now: ProcessInfo.processInfo.systemUptime, + lastInboundMessageAt: lastInboundMessageAt + ) { // handleTransportFailure tears the socket down before flipping the // reduced-load preference, so we must NOT call markConnectionLoadStrained // here — calling it pre-teardown re-subscribes chat events on the // doomed socket. The transport-failure path strains the load itself. handleTransportFailure(timeoutError) } else { + // The default `timeoutError` is `SyncRequestTimeout.error()`, whose + // message says "Reconnecting now." That copy is only honest in the + // reconnect branch above. When we explicitly do *not* tear the socket + // down (either disconnectOnTimeout was false, or the socket has been + // hearing inbound traffic recently and we don't want to reconnect), + // surfacing that message lies to the user. Fall back to a non-reconnect + // timeout message in that branch. + let resolvedError: NSError + if disconnectOnTimeout { + resolvedError = SyncRequestTimeout.error( + message: "The host took too long to respond. Try again." + ) + } else { + resolvedError = timeoutError + } markConnectionLoadStrained() - resolve(requestId: requestId, result: .failure(timeoutError)) + resolve(requestId: requestId, result: .failure(resolvedError)) } } diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 2c8dce14f..a3bc6aa68 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -447,24 +447,49 @@ struct ADEStatusPill: View { } } -struct ADEConnectionDot: View { - @EnvironmentObject private var syncService: SyncService +/// Single source of truth for the "computer connection" presentation +/// (status-dot tint, glow, accessibility label, truncated host name). +/// +/// The view-model is computed from the same inputs the underlying views read +/// directly from `SyncService` — `connectionHealth`, `connectionState`, and +/// `hostName` — so its branching matches `switch health.transport` exactly +/// and the existing semantics are preserved. Two callsites used to duplicate +/// these computed-vars; both now instantiate this struct and read from it. +struct ConnectionHealthPresentation { + let tint: Color + let showsConnectedGlow: Bool + let truncatedHostName: String? + let accessibilityLabel: String - private var tint: Color { - switch syncService.connectionState { - case .connected: return ADEColor.success - case .syncing: return ADEColor.warning - case .connecting: return ADEColor.warning - case .error, .disconnected: return ADEColor.danger - } + init( + health: SyncConnectionHealth, + connectionState: RemoteConnectionState, + hostName: String? + ) { + let truncated = Self.truncate(hostName: hostName) + self.tint = Self.computeTint(health: health) + self.showsConnectedGlow = health.transport.isConnected + self.truncatedHostName = truncated + self.accessibilityLabel = Self.computeAccessibilityLabel( + health: health, + connectionState: connectionState, + truncatedHostName: truncated + ) } - private var showsConnectedGlow: Bool { - syncService.connectionState == .connected + private static func computeTint(health: SyncConnectionHealth) -> Color { + switch health.transport { + case .connected: + return health.load == .strained ? ADEColor.warning : ADEColor.success + case .connecting: + return ADEColor.warning + case .unreachable, .disconnected: + return ADEColor.danger + } } - private var truncatedHostName: String? { - guard let rawName = syncService.hostName else { return nil } + private static func truncate(hostName: String?) -> String? { + guard let rawName = hostName else { return nil } let cleaned = rawName .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: ".…")) @@ -474,10 +499,14 @@ struct ADEConnectionDot: View { return String(cleaned.prefix(9)) + "…" } - private var accessibilityLabel: String { + private static func computeAccessibilityLabel( + health: SyncConnectionHealth, + connectionState: RemoteConnectionState, + truncatedHostName: String? + ) -> String { let errorSuffix: String = { - guard syncService.connectionState == .error, - let raw = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), + guard health.transport == .unreachable, + let raw = health.lastFailureMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return "" @@ -487,22 +516,48 @@ struct ADEConnectionDot: View { return ". \(clipped)" }() - switch syncService.connectionState { + switch health.transport { case .connected: if let name = truncatedHostName { + if health.load == .strained { + return "Connected to \(name). Host is responding slowly" + } + if connectionState == .syncing { + return "Connected to \(name). Syncing changes" + } return "Connected to \(name)" } + if health.load == .strained { + return "Connected. Host is responding slowly" + } + if connectionState == .syncing { + return "Connected. Syncing changes" + } return "Connected" - case .syncing: - return "Syncing with host" case .connecting: return "Connecting to host" - case .error: + case .unreachable: return "Connection error\(errorSuffix)" case .disconnected: return "Disconnected from host" } } +} + +struct ADEConnectionDot: View { + @EnvironmentObject private var syncService: SyncService + + private var presentation: ConnectionHealthPresentation { + ConnectionHealthPresentation( + health: syncService.connectionHealth, + connectionState: syncService.connectionState, + hostName: syncService.hostName + ) + } + + private var tint: Color { presentation.tint } + private var showsConnectedGlow: Bool { presentation.showsConnectedGlow } + private var accessibilityLabel: String { presentation.accessibilityLabel } /// Standalone disc — retained for detail screens that still need the chip /// form. The root top-bar uses `ADERootToolbarControls` which draws the @@ -603,52 +658,18 @@ struct ADERootToolbarControls: View { self.scopeKey = scopeKey } - private var connectionTint: Color { - switch syncService.connectionState { - case .connected: return ADEColor.success - case .syncing, .connecting: return ADEColor.warning - case .error, .disconnected: return ADEColor.danger - } - } - - private var connectionIsAlive: Bool { - syncService.connectionState == .connected + private var presentation: ConnectionHealthPresentation { + ConnectionHealthPresentation( + health: syncService.connectionHealth, + connectionState: syncService.connectionState, + hostName: syncService.hostName + ) } + private var connectionTint: Color { presentation.tint } + private var connectionIsAlive: Bool { presentation.showsConnectedGlow } private var connectionAccessibilityLabel: String { - let base: String - switch syncService.connectionState { - case .connected: - if let rawName = syncService.hostName { - let cleaned = rawName - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: ".…")) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !cleaned.isEmpty { - let truncated = cleaned.count <= 10 ? cleaned : String(cleaned.prefix(9)) + "…" - base = "Connected to \(truncated)" - } else { - base = "Connected" - } - } else { - base = "Connected" - } - case .syncing: - base = "Syncing with host" - case .connecting: - base = "Connecting to host" - case .error: - var suffix = "" - if let raw = syncService.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { - let normalized = raw.split(whereSeparator: \.isWhitespace).joined(separator: " ") - let clipped = normalized.count > 120 ? String(normalized.prefix(117)) + "…" : normalized - suffix = ". \(clipped)" - } - base = "Connection error\(suffix)" - case .disconnected: - base = "Disconnected from host" - } - return "Computer connection · \(base)" + "Computer connection · \(presentation.accessibilityLabel)" } private var hasUnread: Bool { drawer.unreadCount > 0 } diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 328079fde..535b26037 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -6,16 +6,20 @@ struct SettingsConnectionHeader: View { @State private var pulsing = false + private var health: SyncConnectionHealth { + syncService.connectionHealth + } + var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .center, spacing: 12) { SettingsStatusDot( - state: syncService.connectionState, + health: health, pulsing: pulsing, reduceMotion: reduceMotion ) VStack(alignment: .leading, spacing: 1) { - Text(SettingsConnectionPresentation.statusLabel(for: syncService.connectionState)) + Text(SettingsConnectionPresentation.statusLabel(for: health)) .font(.system(.body, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) if let detail = stateDetailLine { @@ -29,7 +33,7 @@ struct SettingsConnectionHeader: View { .environmentObject(syncService) } - if syncService.connectionState == .connected { + if health.transport.isConnected { SettingsConnectedHostDetails() .environmentObject(syncService) } else if let hostName = pendingHostName { @@ -45,8 +49,7 @@ struct SettingsConnectionHeader: View { } if let errorMessage, - syncService.connectionState != .connected, - syncService.connectionState != .syncing { + !health.transport.isConnected { SettingsInlineErrorBanner(message: errorMessage) } } @@ -71,8 +74,8 @@ struct SettingsConnectionHeader: View { .strokeBorder( LinearGradient( colors: [ - SettingsConnectionPresentation.statusTint(for: syncService.connectionState).opacity(0.55), - SettingsConnectionPresentation.statusTint(for: syncService.connectionState).opacity(0.10), + SettingsConnectionPresentation.statusTint(for: health).opacity(0.55), + SettingsConnectionPresentation.statusTint(for: health).opacity(0.10), ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -81,7 +84,7 @@ struct SettingsConnectionHeader: View { ) ) .shadow( - color: SettingsConnectionPresentation.glowTint(for: syncService.connectionState).opacity(0.35), + color: SettingsConnectionPresentation.glowTint(for: health).opacity(0.35), radius: 22, y: 8 ) @@ -91,7 +94,7 @@ struct SettingsConnectionHeader: View { } private var isActiveState: Bool { - syncService.connectionState == .connecting || syncService.connectionState == .syncing + health.transport == .connecting } private var pulseTaskKey: Bool { @@ -110,12 +113,12 @@ struct SettingsConnectionHeader: View { } private var errorMessage: String? { - syncService.lastError + health.lastFailureMessage } private var pendingHostName: String? { - switch syncService.connectionState { - case .connecting, .syncing, .error: + switch health.transport { + case .connecting, .unreachable: return displayHostName default: return nil @@ -130,12 +133,18 @@ struct SettingsConnectionHeader: View { } private var stateDetailLine: String? { - switch syncService.connectionState { - case .connected, .syncing: + switch health.transport { + case .connected: + if health.load == .strained { + return "Live · host responding slowly" + } + if syncService.connectionState == .syncing { + return "Live · syncing changes" + } return "Live · ready to sync" case .connecting: return "Connecting to saved host" - case .error: + case .unreachable: return "Unable to reach your Mac" case .disconnected: if syncService.savedReconnectHost?.tailscaleAddress != nil { @@ -149,12 +158,10 @@ struct SettingsConnectionHeader: View { } private func pendingDescription(hostName: String) -> String { - switch syncService.connectionState { + switch health.transport { case .connecting: return "Reaching \(hostName)..." - case .syncing: - return "Syncing data from \(hostName)..." - case .error: + case .unreachable: return "Tap reconnect to try \(hostName) again, or pair a different host below." default: return "Reaching \(hostName)..." @@ -250,7 +257,7 @@ private struct SettingsConnectionQuickAction: View { } private struct SettingsStatusDot: View { - let state: RemoteConnectionState + let health: SyncConnectionHealth let pulsing: Bool let reduceMotion: Bool @@ -283,7 +290,7 @@ private struct SettingsStatusDot: View { } private var shouldPulse: Bool { - (state == .connecting || state == .syncing) && pulsing && !reduceMotion + health.transport == .connecting && pulsing && !reduceMotion } private var pulseAnimation: Animation? { @@ -292,15 +299,18 @@ private struct SettingsStatusDot: View { } private var dotColor: Color { - switch state { + switch health.transport { case .connected: - return ADEColor.purpleAccent - case .connecting, .syncing: + return health.load == .strained ? ADEColor.warning : ADEColor.purpleAccent + case .connecting: return ADEColor.warning - case .error: + case .unreachable: return ADEColor.danger case .disconnected: - return Color(red: 156.0 / 255.0, green: 145.0 / 255.0, blue: 200.0 / 255.0) + // Disconnected is an inactive state in the new header presentation — + // a saturated purple here reads as "active" and contradicts the rest + // of the inactive affordances. + return ADEColor.textMuted } } } diff --git a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift index 5d5d7c68c..a3e755d6d 100644 --- a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift +++ b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift @@ -43,31 +43,42 @@ enum PinPreset: Identifiable { } enum SettingsConnectionPresentation { - static func statusLabel(for state: RemoteConnectionState) -> String { - switch state { - case .connected: return "Connected" - case .connecting: return "Connecting" - case .syncing: return "Syncing" - case .error: return "Connection error" - case .disconnected: return "Not connected" + static func statusLabel(for health: SyncConnectionHealth) -> String { + switch health.transport { + case .connected: + return health.load == .strained ? "Connected, slow" : "Connected" + case .connecting: + return "Connecting" + case .unreachable: + return "Connection error" + case .disconnected: + return "Not connected" } } - static func statusTint(for state: RemoteConnectionState) -> Color { - switch state { - case .connected: return ADEColor.success - case .connecting, .syncing: return ADEColor.warning - case .error: return ADEColor.danger - case .disconnected: return ADEColor.textMuted + static func statusTint(for health: SyncConnectionHealth) -> Color { + switch health.transport { + case .connected: + return health.load == .strained ? ADEColor.warning : ADEColor.success + case .connecting: + return ADEColor.warning + case .unreachable: + return ADEColor.danger + case .disconnected: + return ADEColor.textMuted } } - static func glowTint(for state: RemoteConnectionState) -> Color { - switch state { - case .connected: return ADEColor.purpleGlow - case .connecting, .syncing: return ADEColor.warning.opacity(0.25) - case .error: return ADEColor.danger.opacity(0.22) - case .disconnected: return .clear + static func glowTint(for health: SyncConnectionHealth) -> Color { + switch health.transport { + case .connected: + return health.load == .strained ? ADEColor.warning.opacity(0.22) : ADEColor.purpleGlow + case .connecting: + return ADEColor.warning.opacity(0.25) + case .unreachable: + return ADEColor.danger.opacity(0.22) + case .disconnected: + return .clear } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 326103b22..8d2221485 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -160,6 +160,78 @@ final class ADETests: XCTestCase { XCTAssertEqual(SyncRequestTimeout.error().localizedDescription, "The host took too long to respond. Reconnecting now.") } + func testSyncRequestTimeoutOnlyReconnectsAfterSocketSilence() { + XCTAssertFalse( + syncShouldReconnectAfterRequestTimeout( + now: 100, + lastInboundMessageAt: 94, + silenceThreshold: 12 + ) + ) + XCTAssertTrue( + syncShouldReconnectAfterRequestTimeout( + now: 100, + lastInboundMessageAt: 80, + silenceThreshold: 12 + ) + ) + XCTAssertTrue( + syncShouldReconnectAfterRequestTimeout( + now: 100, + lastInboundMessageAt: nil, + silenceThreshold: 12 + ) + ) + } + + func testSyncConnectionHealthTreatsSyncingAsConnectedTransport() { + let health = syncConnectionHealth( + connectionState: .syncing, + prefersReducedSyncLoad: false, + lastError: "Transient sync work" + ) + + XCTAssertEqual(health.transport, .connected) + XCTAssertEqual(health.load, .normal) + XCTAssertNil(health.lastFailureMessage) + } + + func testSyncConnectionHealthSeparatesLoadStrainFromTransportFailure() { + let health = syncConnectionHealth( + connectionState: .connected, + prefersReducedSyncLoad: true, + lastError: "The host took too long to respond." + ) + + XCTAssertEqual(health.transport, .connected) + XCTAssertEqual(health.load, .strained) + XCTAssertNil(health.lastFailureMessage) + } + + func testSyncConnectionHealthSurfacesFailureOnlyWhenUnreachable() { + let health = syncConnectionHealth( + connectionState: .error, + prefersReducedSyncLoad: true, + lastError: "Heartbeat timed out." + ) + + XCTAssertEqual(health.transport, .unreachable) + XCTAssertEqual(health.load, .normal) + XCTAssertEqual(health.lastFailureMessage, "Heartbeat timed out.") + } + + func testSyncConnectionHealthHidesStaleFailureWhenDisconnected() { + let health = syncConnectionHealth( + connectionState: .disconnected, + prefersReducedSyncLoad: true, + lastError: "Previous socket failed." + ) + + XCTAssertEqual(health.transport, .disconnected) + XCTAssertEqual(health.load, .normal) + XCTAssertNil(health.lastFailureMessage) + } + func testSyncReconnectStateUsesBackoffAndResetsAfterSuccess() { var state = SyncReconnectState() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17889bc96..39ebef129 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -478,7 +478,7 @@ Every service lives under `apps/desktop/src/main/services//`. Summary: | `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | WebSocket host, peer client, remote command routing, protocol framing, device registry, pairing secrets. | | `notifications/` | `apnsService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, encrypted `.p8`), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. | | `tests/` | `testService.ts` | Test-suite execution + run history. | -| `updates/` | `autoUpdateService.ts` | Electron auto-update. | +| `updates/` | `autoUpdateService.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle | checking | downloading | ready | installing | error`), uses `compareUpdateVersions` (SemVer-aware) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. `quitAndInstall()` is async: it re-runs `checkForUpdates({ allowReady: true })` to confirm the staged build is still latest, and only then flips to `installing` and calls `updater.quitAndInstall(false, true)`. | | `usage/` | `usageTrackingService.ts`, `budgetCapService.ts` | Token/cost accounting, budget enforcement. | Startup sequencing: every background service goes through `scheduleBackgroundProjectTask()` in `main.ts`, which provides explicit labels, `ADE_ENABLE_*` env gates, `project.startup_task_begin`/`_done`/`_enabled`/`_skipped` telemetry, and per-task delays. Integrations stay **dormant-until-configured**. diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index 8b91e1a96..f973bf69e 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -57,15 +57,34 @@ called from a non-darwin host. 2. **Device + target discovery.** `listDevices()` parses `xcrun simctl list -j devices` and keeps `isAvailable` rows; `listLaunchTargets({ deviceUdid?, projectRoot? })` unions three - sources tagged on each target: `xcode-project` (running - `xcodebuild -list -json` against `apps/ios/ADE.xcodeproj` or any - `*.xcodeproj` discovered under the project root, filtered by - `IOS_APPLICATION_PRODUCT_TYPE`), `derived-data` (built `.app` + sources tagged on each target: `xcode-project` (parsing every + `*.xcodeproj` discovered by `discoverXcodeProjectPaths` — + root-level `.xcodeproj` bundles, the canonical `apps/ios/ADE.xcodeproj`, + and any `apps/*/*.xcodeproj` project), `derived-data` (built `.app` bundles already present under `~/Library/Developer/Xcode/DerivedData`), and `simctl-listapps` (apps already installed on the chosen simulator). `canBuild` is true for project sources; `canLaunch` depends on whether a usable bundle id is known. + The `xcode-project` parser reads `project.pbxproj` directly + (scoped to the `PBXNativeTarget` section so foreign matches like + widget extensions don't bleed in), filters by + `IOS_APPLICATION_PRODUCT_TYPE`, and then expands each target into + one launch target per matching shared/user `.xcscheme`. Schemes + are correlated to native targets by `BuildableReference`'s + `BlueprintIdentifier` first, then `BlueprintName` / + `BuildableName`, then a name fallback — so a project with a custom + "App Debug" scheme and a "App" target produces two distinct + launch targets, each with the right scheme name and a detail + string that calls out the target name when it differs from the + scheme. Targets without a matching `.xcscheme` fall back to the + target name so a stripped-down project still launches. Stale + target ids (e.g. saved by a previous session before the project + was renamed) are recovered through `recoverStaleLaunchTarget`, + which decodes the base64url id and matches the path or basename + against the freshly listed targets so a stale `--target` value + resolves silently when there is exactly one obvious replacement. + 3. **Launch.** `launch(args)` walks an eight-step pipeline and emits one `launch-progress` event per step transition: `resolve-device → boot-simulator → open-simulator → resolve-target @@ -80,7 +99,16 @@ called from a non-darwin host. -configuration Debug build`; install uses `xcrun simctl install` with a 180 s timeout (same stuck-CoreSimulator error mapping); launch uses `xcrun simctl launch --terminate-running-process - `. The default path does not open Simulator.app; + `. When `xcodebuild` itself fails, + `formatXcodeBuildFailure` extracts the important compiler / + provisioning / scheme-missing lines from the merged stdout/stderr, + captures the `.xcresult` bundle path when xcodebuild printed one, + and rejects with a multi-line message that names the project, + scheme, device, and a "Next action" hint pointing the user at + Xcode (or, when the scheme isn't visible, at sharing the + user-local scheme). The same formatted text is logged as + `ios_simulator.build.failed` for diagnostics. The default path + does not open Simulator.app; the live drawer stream is headless when IOSurface/idb/simctl backends are active. Pass `keepSimulatorInBackground: false` (or `--foreground` from the CLI) @@ -387,6 +415,17 @@ chip attached. `idb_companion` remains lazy fallback infrastructure for accessibility, text, and degraded input, and is stopped after `COMPANION_IDLE_STOP_MS` when idle. +- **Don't fake project layouts to fix "no apps" results.** Project + discovery already scans root-level `.xcodeproj` bundles and + `apps/*/*.xcodeproj` projects, expands each native iOS target across + every matching `.xcscheme`, and recovers stale target ids when the + project is renamed. Symlink shims, fake schemes, or layout hacks + added on top usually mask a real failure (missing shared scheme, + build error, wrong project root) instead of fixing it. Re-list with + `ade --socket ios-sim apps --text` and read the formatted xcodebuild + failure first — `ADE_CLI_AGENT_GUIDANCE` and the `ade ios-sim` + troubleshooting block both call this out so agents don't reach for + the wrong fix. - **Screenshot pairing window.** `AgentChatPane` tracks the most recent attachment via `latestAttachmentRef` and only stamps an `attachmentPath` onto the iOS element if the attachment was added diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 3a916d4f7..598d8ef67 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -32,7 +32,7 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR; the row uses `selectLanePrTag` (open/draft → merged → closed, then most recent) and falls back through the same branch-equality rules as `prService.getDisplayRowForCurrentLaneBranch`, so the badge stays attached to the lane even after the PR merges. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR; the row uses `selectLanePrTag` (open/draft → merged → closed, then most recent) and falls back through the same branch-equality rules as `prService.getDisplayRowForCurrentLaneBranch`, so the badge stays attached to the lane even after the PR merges. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated 12-swatch lane color palette (`LANE_COLOR_PALETTE`) plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. | @@ -232,6 +232,13 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in (`processService`, `ptyService`, `autoRebaseService`, `rebaseSuggestionService`, `fileWatcherService`); when one is not wired, the corresponding step is `skipped` rather than `failed`. + The pipeline yields cooperatively (`setImmediate`) at the start of + each step so a long-running step never blocks the IPC event loop, + filesystem cleanup uses `fs.promises.rm` instead of synchronous + `rmSync`, and the `database_cleanup` step now wraps every cascade + delete inside a single `begin immediate` / `commit` transaction so + a partial failure rolls back to a consistent DB state instead of + leaving lane rows half-deleted. ## Lane color diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 3e00c3608..a2984b342 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -187,6 +187,48 @@ Renderer — settings: - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` — runtime diagnostics. +Auto-update (top-bar control, not a settings tab): + +- `apps/desktop/src/main/services/updates/autoUpdateService.ts` — + electron-updater wrapper that owns the renderer-visible + `AutoUpdateSnapshot` (`status: "idle" | "checking" | "downloading" + | "ready" | "installing" | "error"`, version, progress, recently + installed notice). Tracks superseded downloads against the current + ready version via `compareUpdateVersions` (a SemVer-aware + comparator that handles `v` prefixes, missing patch, and + prerelease ordering) so a same-or-older `update-available` while a + newer build is already staged is logged and ignored instead of + clobbering the staged installer; if the new build is strictly + newer, the cached installer dir is wiped and the snapshot + transitions back through `downloading`. `quitAndInstall()` is + asynchronous: it gates on the current snapshot being `ready`, + re-runs `updater.checkForUpdates()` with `allowReady: true` to + confirm the staged installer is still the latest, and only then + flips the snapshot to `installing`, persists the + `pendingInstallUpdate` global-state row, and calls + `updater.quitAndInstall(false, true)`. If the refresh check fails, + it surfaces the error, drops the cache, and clears the pending + install. On the next launch, `reconcilePersistedUpdateState` + matches the running version against `pendingInstallUpdate` using + the same SemVer comparator (so `>=` target counts as installed, + even if the running build is one ahead), populates + `recentlyInstalledUpdate` with the actual running version, and + cleans up the updater cache directory. +- `apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx` — + the small badge in the app shell top bar. Shows "Checking for + updates" / "Downloading vX.Y.Z (NN%)" / "Install update vX.Y.Z" / + "ADE will quit and reopen" depending on the snapshot. Clicking the + install affordance prompts the user, sets a local + `installRequested` flag, and calls + `window.ade.updateQuitAndInstall()`; if the IPC returns `false` + (refresh check failed, no longer ready, etc.) the flag is cleared + so the badge falls back to the underlying snapshot. While + `installing` (or after the user clicks install but before the main + process flips status), the badge animates in fuchsia and is + disabled. The "Update installed" dialog reads + `recentlyInstalled.releaseNotesUrl` and opens the public release + notes link for the running version. + ## Detail docs - [configuration-schema.md](./configuration-schema.md) — shape of @@ -315,6 +357,21 @@ Onboarding and settings follow a simple rule: (most route to **General**; provider/GitHub/Linear/computer-use route to **Integrations**). The dedicated Onboarding tab no longer exists — its settings moved into General + the top-bar Help menu. +- **Auto-update install must refresh before quitting.** + `quitAndInstall()` deliberately re-runs `updater.checkForUpdates()` + with `allowReady: true` before flipping to `installing`. Skipping + that step (e.g. a synchronous quitAndInstall) reintroduces the bug + where ADE quits to install a stale download while a strictly newer + build is available. Comparison goes through `compareUpdateVersions` + — never `===` on the version string — because `v1.2.3` / + `1.2.3-rc.1` / `1.2.3` all need consistent ordering on both the + pending-install reconcile path and the supersede check. +- **`installing` is a sticky status.** While the snapshot is + `installing` the service ignores `update-not-available`, + `checking-for-update`, and `error`, because the main process is in + the middle of quitAndInstall. New status checks should treat + `ready` and `installing` symmetrically when deciding whether to + cancel or override the staged update. ## Cross-links diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index ad257314b..df29a14d6 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -110,17 +110,40 @@ Phase 7). Host connection status is surfaced through a single shared component, `ADEConnectionDot` (in `Views/Components/ADEDesignSystem.swift`). It -renders a colored dot, a state label (Connected / Syncing / Connecting -/ Disconnected / Error), and the truncated host name when connected, -and acts as a 44pt button that opens Settings. Tint mapping: - -| Connection state | Color | -|---|---| -| `connected` | success (green) | -| `syncing` | warning (amber) | -| `connecting` | warning (amber) | -| `disconnected` | danger (red) | -| `error` | danger (red) | +renders a colored dot, a state label, and the truncated host name when +connected, and acts as a 44pt button that opens Settings. + +All visible connection affordances read from `SyncConnectionHealth` +(produced by the pure helper `syncConnectionHealth(connectionState: +prefersReducedSyncLoad: lastError:)` and re-exposed through +`SyncService.connectionHealth`) instead of branching on the raw +`RemoteConnectionState`. The health value separates three concerns +that used to be tangled together: + +- `transport: SyncTransportHealth` — `disconnected` / `connecting` / + `connected` / `unreachable`. `RemoteConnectionState.syncing` collapses + into `connected` because the socket is alive while the host streams a + catchup batch; only `RemoteConnectionState.error` maps to + `unreachable`. +- `load: SyncLoadHealth` — `normal` / `strained`. `strained` is set + when the transport is connected but `prefersReducedSyncLoad` is on, + i.e. recent request timeouts have caused the phone to back off + background work. +- `lastFailureMessage` — surfaced only when transport is `unreachable`, + so a stale error from a previous socket does not bleed into the UI + while the phone is happily disconnected or reconnecting. + +Tint mapping (resolved by `SettingsConnectionPresentation.statusTint`, +`ADEConnectionDot`, `ProjectHomeView.attachedComputerTint`, +`ADERootToolbarControls`, and `SettingsStatusDot`): + +| Transport | Load | Color | +|---|---|---| +| `connected` | `normal` | success (green) / purple accent on the Settings dot | +| `connected` | `strained` | warning (amber) | +| `connecting` | (n/a) | warning (amber) | +| `unreachable` | (n/a) | danger (red) | +| `disconnected` | (n/a) | danger (red) on the toolbar dot, muted on the Settings dot | The dot is placed in the top-leading `ToolbarItem` of every top-level tab (Lanes, Files, Work, PRs) and every deep screen @@ -134,16 +157,37 @@ hydrating cards inside each screen body. `ProjectHomeView` is the exception: with the navigation bar hidden (brand-mark hero only) it surfaces the same connection state through an inline "Attached to " banner above the open-project button. -The banner uses the same tint mapping as `ADEConnectionDot` (success -when connected/syncing, warning when connecting/error, muted when -disconnected) and routes taps through `syncService.settingsPresented` -to the same Settings sheet the dot opens. - -Accessibility: the dot exposes `accessibilityLabel` that includes the -host name when connected and trims the last error message for the -error state, an `accessibilityHint` of "Opens settings to pair or -reconnect", and `accessibilityShowsLargeContentViewer()` so VoiceOver -and Large Content can reach it. +The banner uses the same `SyncConnectionHealth` mapping as +`ADEConnectionDot` (success when connected and not strained, warning +when connecting or strained, muted when disconnected/unreachable) and +routes taps through `syncService.settingsPresented` to the same +Settings sheet the dot opens. + +`SettingsConnectionHeader` distinguishes the four states explicitly: + +- Connected, normal load → "Live · ready to sync". +- Connected, strained load → "Live · host responding slowly". +- Connected with `connectionState == .syncing` → "Live · syncing + changes". +- `connecting` → "Connecting to saved host". +- `unreachable` → "Unable to reach your Mac" plus the + `lastFailureMessage` banner. +- `disconnected` → reconnect / pair-different-host CTA depending on + whether a saved Tailscale address candidate is present. + +`SettingsConnectionPresentation.statusLabel` returns "Connected, slow" +when transport is connected and load is strained, and "Connected" +otherwise. The legacy "Syncing" label was removed — syncing is just +a connected transport doing work. + +Accessibility: the dot's `accessibilityLabel` describes load strain +("Connected to . Host is responding slowly"), explicit syncing +work ("Connected to . Syncing changes"), or plain "Connected to +" when neither applies; for transport `unreachable` it appends +the trimmed `lastFailureMessage`. `accessibilityHint` is "Opens +settings to pair or reconnect", and +`accessibilityShowsLargeContentViewer()` keeps it reachable from +VoiceOver and Large Content. The one remaining inline banner per tab is the hydration-failure notice built from `SyncDomainStatus.inlineHydrationFailureNotice(for:)` @@ -269,7 +313,22 @@ turns a raw response dict into either the `result` value or throws an `SyncRequestTimeout.defaultTimeoutNanoseconds = 30_000_000_000` (30s). Timed-out requests throw with the message *"The host took too long to -respond. Reconnecting now."* The reconnect is automatic. +respond. Reconnecting now."* + +A request timeout no longer unconditionally drops the socket. Inbound +traffic on the WebSocket is timestamped via `lastInboundMessageAt` +(set whenever any envelope arrives — heartbeats, change batches, +results, anything), and the timeout path consults +`syncShouldReconnectAfterRequestTimeout(now:lastInboundMessageAt: +silenceThreshold:)` before tearing down. The default silence +threshold is `SyncSocketTiming.requestTimeoutReconnectSilenceSeconds += 12 s`. If any envelope arrived within the last 12 seconds, the +phone keeps the socket and lets the user retry; only when the socket +has actually been silent for the full window does the timeout escalate +to a reconnect. This avoids cycling a healthy connection while one +slow command is in flight, and falls back to "reconnect" when +`lastInboundMessageAt` is `nil` (fresh socket / never received +anything). `InitialHydrationGate` polls for the project row at 200ms intervals up to a 15s total budget. This covers the first sync-after-pairing gap @@ -709,6 +768,23 @@ reflected in the phone's UI on the next descriptor read. command handlers on the host responsive; bulk operations should be batched into a single command with a single reply rather than rapid-fire command storms. +- **A request timeout is not the same as a dead socket.** The 30 s + `SyncRequestTimeout` only forces a reconnect when + `syncShouldReconnectAfterRequestTimeout` agrees — that helper + consults `lastInboundMessageAt` and the 12 s + `requestTimeoutReconnectSilenceSeconds` window. If anything has + arrived on the WebSocket recently (heartbeats, change batches, a + result), the timeout surfaces to the caller without dropping the + socket. New transport-affecting code should bump + `lastInboundMessageAt` on inbound traffic and treat that timestamp + as the source of truth for "is this socket actually alive". +- **Connection UI must use `SyncConnectionHealth`, not the raw state.** + `RemoteConnectionState.syncing` is just transport `connected` doing + catchup work, and `RemoteConnectionState.error` carries failure text + that should not bleed into a `disconnected` UI. New connection + affordances should render off `syncService.connectionHealth` so + load-strain and transport failure stay distinct from each other and + from background sync work. - **Chat streaming is push, with polling as fallback.** Once a phone sends `chat_subscribe`, the host fans out `chat_event` envelopes in real time from `agentChatService.subscribeToEvents`. The host still