Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 49 additions & 3 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <method> 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
Expand Down Expand Up @@ -366,9 +367,9 @@ const IOS_SIMULATOR_SUBCOMMAND_HELP: Record<string, string> = {
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 <udid> --text

Expand Down Expand Up @@ -852,6 +853,10 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ 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)
Expand Down Expand Up @@ -1072,6 +1077,26 @@ const HELP_BY_COMMAND: Record<string, string> = {
`,
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 {
Expand Down Expand Up @@ -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<string> = new Set([
// Only flags that actually take a following value (readValue / readIntOption
// callers) belong here. Boolean-only flags consumed via readFlag must be
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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'.`);
}
Expand Down
213 changes: 213 additions & 0 deletions apps/desktop/src/main/services/ios/iosSimulatorService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`), `<?xml version="1.0" encoding="UTF-8"?>
<Scheme version="1.7">
<BuildAction>
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="${targetId}" BuildableName="${productName}.app" BlueprintName="${targetName}" ReferencedContainer="container:${projectName}.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
</Scheme>
`);
}
return projectPath;
}

afterEach(() => {
__testResetIosurfaceCapabilityCache();
vi.restoreAllMocks();
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"), "<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[]) => {
Expand Down
Loading
Loading