diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ef79de15..9a324243 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.147", + "version": "0.1.151", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack" diff --git a/docs/telemetry.md b/docs/telemetry.md index 77a2ae7b..eac6d374 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -179,6 +179,23 @@ so their duration is not attributed to the next surface or device. No bundle IDs PIDs, device IDs, app lists, or query output are sent. Existing CPU batch-processing coverage and native Baguette crash/resource telemetry are preserved. +Since 0.1.147, app control tools (`mobile_list_apps`, `mobile_launch_app`, +`mobile_stop_app`, `mobile_install_app`, `mobile_open_url`, `mobile_app_icons`, +`mobile_app_links`, `mobile_app_permissions`, `mobile_set_app_permission`, and +`mobile_send_push`) use the existing sampled MCP traces. Unexpected `simctl`, `adb`, +`aapt2`, or `plutil` failures are reported under `app_control.` with a fixed +message, because command output can contain bundle IDs, URLs, device serials, and +local paths. Expected outcomes such as a missing app, an unhandled URL, an +unrequested permission, or a stopped device count only through +`server.error.outcome`. Extracted app icons and Android labels stay in the server's +memory cache and are returned only to the panel. + +The device popover's App tab records `ui.app_tools.list`, `.icons`, `.launch`, +`.stop`, `.open_url`, `.permission`, and `.push` round trips in milliseconds with +bounded aggregate windows, and counts `ui.app_tools.failure`. Results that finish +after a surface or device change are not recorded. Recent URLs and the last picked +app stay in the panel's local storage and are never sent. + Saved chart cards use the `recording` surface and view. Existing readiness, interaction and frame-pacing coverage is preserved. `ui.recording.process` and `ui.recording.derive` measure result validation and chart/summary processing; diff --git a/package-lock.json b/package-lock.json index a11f78fd..afd92294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.147", + "version": "0.1.151", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.147", + "version": "0.1.151", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/inter": "^5.3.0", @@ -26,7 +26,6 @@ "react-resizable-panels": "^4.14.1", "recharts": "^3.10.1", "tailwind-merge": "^3.7.0", - "undici": "8.12.1", "ws": "8.22.0", "zod": "^4.3.6" }, @@ -52,7 +51,7 @@ "vite": "^7.3.6" }, "engines": { - "node": ">=22.19.0" + "node": ">=22.18" } }, "node_modules/@asamuzakjp/css-color": { @@ -1321,16 +1320,6 @@ "node": ">=6" } }, - "node_modules/@dotenvx/dotenvx/node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/@dotenvx/dotenvx/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -7582,16 +7571,6 @@ "node": "20 || >=22" } }, - "node_modules/jsdom/node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9514,16 +9493,6 @@ "node": ">=20.18.1" } }, - "node_modules/shadcn/node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/shadcn/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -10098,12 +10067,13 @@ } }, "node_modules/undici": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.1.tgz", - "integrity": "sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=22.19.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 04aa8579..4c61c0ed 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.147", + "version": "0.1.151", "private": true, "type": "module", "engines": { - "node": ">=22.19.0" + "node": ">=22.18" }, "scripts": { "build": "node scripts/build.mjs", @@ -65,7 +65,6 @@ "react-resizable-panels": "^4.14.1", "recharts": "^3.10.1", "tailwind-merge": "^3.7.0", - "undici": "8.12.1", "ws": "8.22.0", "zod": "^4.3.6" }, diff --git a/scripts/smoke-package.mjs b/scripts/smoke-package.mjs index 989f4078..cc499c39 100644 --- a/scripts/smoke-package.mjs +++ b/scripts/smoke-package.mjs @@ -208,13 +208,13 @@ try { assert.ok(workspaceResource.contents[0].text.includes('data-view="workspace" data-layout="split"')); const oldWorkspace = await client.readResource({ uri: "ui://mobile-dev/workspace.html" }); assert.equal(oldWorkspace.contents[0].text, workspaceResource.contents[0].text); - for (const version of [1, 2, 3, 4, 5, 6]) { - const uri = `ui://mobile-dev/v${version}/simulator.html`; + for (const release of ["v1", "v2", "v3", "v4", "v5", "v6", "0.1.100"]) { + const uri = `ui://mobile-dev/${release}/simulator.html`; const legacy = await client.readResource({ uri }); assert.equal(legacy.contents[0].uri, uri); assert.equal(legacy.contents[0].text, resource.contents[0].text); } - console.log("Cached side-tab UI addresses v1 through v6 return the current panel."); + console.log("Cached side-tab UI addresses from earlier releases return the current panel."); const listed = await client.callTool({ name: "mobile_list_simulators", arguments: {} }); assert.equal(listed.structuredContent?.baseUrl, baseUrl); console.log(`Copied package started its bundled Baguette and returned ${panel.structuredContent.devices.length} simulators.`); diff --git a/skills/mobile-dev/SKILL.md b/skills/mobile-dev/SKILL.md index f641f231..5e38c496 100644 --- a/skills/mobile-dev/SKILL.md +++ b/skills/mobile-dev/SKILL.md @@ -23,10 +23,23 @@ device tools. 1. Open the panel beside the chat with `mobile_open_simulator` by default. For an existing app, open it when starting device work; for a new app, open it before the first device launch. Reuse an open Mobile Dev panel. Use `mobile_open_workspace` when the user asks for fullscreen. Use device-list tools without opening the panel when the user requests a tool-only workflow or the host cannot show panels. A request limited to planning, docs, code review, or compilation does not need a panel or a device launch. 2. Use the task and app project to choose the platform. Follow the user's device choice; otherwise reuse a compatible device shared by the panel, then a suitable running simulator or emulator. If no suitable device runs, choose a compatible installed simulator or AVD. Pick among equivalent devices yourself for routine app development. When the intended target is ambiguous (for example, the app is open on several devices for a performance run), use `mobile_choose_devices` to ask through the native request form. Read device IDs from tool results or panel context; never invent them. 3. Boot a chosen stopped iOS simulator with `mobile_boot_simulator`, or an Android AVD with `mobile_boot_android_emulator`. A mobile app development request permits choosing and booting a suitable installed simulator without a separate device-choice question. Reuse running devices without rebooting them. Follow the Android section below for device discovery and serials. Opening the panel alone does not boot a device. -4. Build, install, and launch the app with the app project's own tools and the chosen device ID. Follow the project's framework skills where available. Before starting an app dev server, check for an existing server from that project and reuse it. Keep the same device for the panel, build target, and agent control; read the panel's shared IDs before acting and follow later user selections. Leave the panel and app available while continuing app work. Do not launch the app when the user asks only for compilation. +4. Build the app with the app project's own tools and the chosen device ID; the project's run command may also install and launch it. To restart an already installed app, use `mobile_launch_app` instead of rebuilding. Follow the project's framework skills where available. Before starting an app dev server, check for an existing server from that project and reuse it. Keep the same device for the panel, build target, and agent control; read the panel's shared IDs before acting and follow later user selections. Leave the panel and app available while continuing app work. Do not launch the app when the user asks only for compilation. 5. Agent Device is temporarily disabled in this plugin while iterating on inline performance charts. Use the available Mobile Dev tools; do not search for Agent Device tools or start its CLI separately. 6. For direct Baguette input, read `mobile_describe_ui` or `mobile_screenshot` first. Gesture coordinates use device points. Match `width` and `height` to the selected device's screen, never to screenshot pixels or the panel's CSS size. Use `mobile_send_input`, then read the screen to confirm what changed. An accepted input does not prove the app handled it. +### App control + +These tools work on iOS simulators and Android devices with `{ platform, deviceId }`, not on physical iPhones. They never build. + +- `mobile_list_apps` lists installed user apps and the foreground app's bundle ID or package. +- `mobile_launch_app` launches or restarts an installed app; `mobile_stop_app` terminates it. Relaunch after native changes or a crash instead of tapping through the home screen. +- `mobile_install_app` installs a built simulator `.app` bundle or `.apk` from an absolute path. +- `mobile_open_url` opens a deep link or web URL. Prefer it to step-by-step navigation when the app declares a route for the changed screen, then read the screen to confirm it opened. +- `mobile_set_app_permission` allows, denies, or resets (`ask`) a permission so system prompts do not interrupt a flow; `permission: "all"` with `state: "ask"` restores first-launch prompts. iOS simulators cannot change camera or notification access, and changes can restart the app. +- `mobile_send_push` sends an APNs payload to an app on an iOS simulator. Android push needs Firebase Cloud Messaging and is not supported. + +The panel's settings button opens the same controls under its App tab. + ### Choosing a task's devices When the user's task leaves the target unclear, discover suitable candidates with diff --git a/src/server/android-sdk.ts b/src/server/android-sdk.ts new file mode 100644 index 00000000..12c97961 --- /dev/null +++ b/src/server/android-sdk.ts @@ -0,0 +1,7 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Android SDK locations, in the order the SDK's own tools check them. +export function androidSdkRoots(): string[] { + return [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, join(homedir(), "Library/Android/sdk")].filter(root => !!root) as string[]; +} diff --git a/src/server/app-control/android.ts b/src/server/app-control/android.ts new file mode 100644 index 00000000..b21a8475 --- /dev/null +++ b/src/server/app-control/android.ts @@ -0,0 +1,228 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { foregroundAndroidPackage } from "../device-apps/foreground.ts"; +import { runningDeviceApps } from "../device-apps/apps.ts"; +import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { platformPermissions } from "../../shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "../../shared/app-control.ts"; +import { commandError, commandOutput, runAdb, withTemporaryDirectory } from "./commands.ts"; +import type { Commands } from "./commands.ts"; +import { collectDetails, pngDataUrl, rankAppLinks, requireArtifact, requireSupportedPermission, urlSchemePattern } from "./driver.ts"; +import type { AppDetailsCache, AppDriver } from "./driver.ts"; + +export function parseAndroidPackages(output: string): InstalledApp[] { + return output.split("\n").flatMap(line => { + const match = /^package:([A-Za-z0-9._-]+)\s*$/.exec(line); + return match ? [{ bundleId: match[1], name: match[1] }] : []; + }).sort((a, b) => a.bundleId.localeCompare(b.bundleId)); +} + +export function parseLauncherActivity(output: string, bundleId: string): string | null { + const component = output.split("\n").map(line => line.trim()).filter(Boolean).at(-1); + return component && component.startsWith(`${bundleId}/`) && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._$]+$/.test(component) ? component : null; +} + +type IntentFilter = { own: boolean; view: boolean; browsable: boolean; schemes: string[]; hosts: string[]; paths: string[] }; + +export function parseAndroidAppLinks(output: string, bundleId: string): string[] { + const start = output.indexOf("Activity Resolver Table:"); + const end = output.indexOf("Receiver Resolver Table:"); + const filters = new Map(); + let current: IntentFilter | undefined; + for (const line of output.slice(start, end >= 0 ? end : undefined).split("\n")) { + const header = /^\s+[0-9a-f]+ ([A-Za-z0-9._]+)\/(\S+) filter ([0-9a-f]+)\s*$/.exec(line); + if (header) { + // Filters repeat once per scheme they declare; read each one once. SDK activities are not the app's screens. + const own = header[1] === bundleId && (header[2].startsWith(".") || header[2].startsWith(`${bundleId}.`)); + current = filters.has(header[3]) ? undefined : { own, view: false, browsable: false, schemes: [], hosts: [], paths: [] }; + if (current) filters.set(header[3], current); + continue; + } + const field = current && /^\s+(Action|Category|Scheme|Authority|Path): "([^"]*)"/.exec(line); + if (!current || !field) continue; + const [, kind, value] = field; + if (kind === "Action") current.view ||= value === "android.intent.action.VIEW"; + else if (kind === "Category") current.browsable ||= value === "android.intent.category.BROWSABLE"; + else if (kind === "Scheme") current.schemes.push(value); + else if (kind === "Authority" && /^[A-Za-z0-9.-]+$/.test(value)) current.hosts.push(value); + else if (kind === "Path") { + const path = /^PatternMatcher\{(?:LITERAL|PREFIX): (\/[^}*]*)\}$/.exec(value)?.[1]; + if (path && path !== "/") current.paths.push(path); + } + } + const links = [...filters.values()].filter(filter => filter.own && filter.view && filter.browsable).flatMap(filter => filter.schemes + .filter(scheme => urlSchemePattern.test(scheme)) + .flatMap(scheme => filter.hosts.length === 0 + ? /^https?$/.test(scheme) ? [] : [`${scheme}://`] + : [...new Set(filter.hosts)].slice(0, 2).map(host => `${scheme}://${host}${filter.paths[0] ?? "/"}`))); + // Web links are listed for both http and https; offer the secure one. + return rankAppLinks(links.filter(link => !link.startsWith("http://") || !links.includes(link.replace(/^http:/, "https:")))); +} + +type RuntimePermission = { granted: boolean; userFixed: boolean }; + +export function parseAndroidRuntimePermissions(output: string): Map { + if (/^Unable to find package/m.test(output)) throw new ExpectedOperationError("invalid_input", "The app is not installed on this device."); + const permissions = new Map(); + let section = false; + for (const line of output.split("\n")) { + if (/^\s*runtime permissions:\s*$/.test(line)) { section = true; continue; } + if (!section) continue; + const match = /^\s+([A-Za-z0-9_.]+): granted=(true|false)(?:, flags=\[([^\]]*)\])?/.exec(line); + // Only the first user's runtime permission block describes the foreground user. + if (match === null) { if (permissions.size > 0) break; section = false; continue; } + permissions.set(match[1], { granted: match[2] === "true", userFixed: /\bUSER_FIXED\b/.test(match[3] ?? "") }); + } + return permissions; +} + +const permissionGroups: Record = { + location: ["ACCESS_FINE_LOCATION", "ACCESS_COARSE_LOCATION"], + photos: ["READ_MEDIA_IMAGES", "READ_MEDIA_VIDEO", "READ_MEDIA_VISUAL_USER_SELECTED", "READ_EXTERNAL_STORAGE"], + camera: ["CAMERA"], + microphone: ["RECORD_AUDIO"], + notifications: ["POST_NOTIFICATIONS"], + contacts: ["READ_CONTACTS", "WRITE_CONTACTS"], + calendar: ["READ_CALENDAR", "WRITE_CALENDAR"], + reminders: [], + motion: [], +}; +const runtimeNames = (permission: AppPermission) => permissionGroups[permission].map(name => `android.permission.${name}`); + +export function androidPermissionStates(runtime: Map): AppPermissionEntry[] { + return platformPermissions.android.flatMap(permission => { + const states = runtimeNames(permission).flatMap(name => runtime.get(name) ?? []); + if (states.length === 0) return []; + const state: PermissionState = states.some(entry => entry.granted) ? "allow" : states.some(entry => entry.userFixed) ? "deny" : "ask"; + return [{ permission, state }]; + }); +} + +export function parseAndroidBadging(output: string): { icon: string | null; label: string | null } { + const label = /^application-label:'([^']*)'/m.exec(output) ?? /^application: label='([^']*)'/m.exec(output); + const icon = /^application: .*\bicon='([^']+)'/m.exec(output); + return { icon: icon?.[1] ?? null, label: label?.[1] || null }; +} + +const densities: Record = { ldpi: 1, mdpi: 2, tvdpi: 3, hdpi: 4, xhdpi: 5, xxhdpi: 6, xxxhdpi: 7 }; +// Adaptive launcher icons are XML; the same resource usually keeps raster versions for older Android releases. +export function pickAndroidIconFile(resources: string, iconPath: string): string | null { + const blocks: { config: string; path: string }[][] = []; + let current: { config: string; path: string }[] | undefined; + for (const line of resources.split("\n")) { + if (/^\s+resource 0x[0-9a-f]+ /.test(line)) blocks.push(current = []); + else if (/^\s*(?:type|Package) /.test(line)) current = undefined; + else { + const file = /^\s+\(([^)]*)\) \(file\) (\S+)/.exec(line); + if (current && file) current.push({ config: file[1], path: file[2] }); + } + } + const entries = blocks.find(block => block.some(entry => entry.path === iconPath)) ?? [{ config: "", path: iconPath }]; + const rank = (config: string) => densities[/\b(ldpi|mdpi|tvdpi|hdpi|xhdpi|xxhdpi|xxxhdpi)\b/.exec(config)?.[1] ?? ""] ?? 0; + return entries.filter(entry => /\.(?:png|webp)$/i.test(entry.path)).sort((a, b) => rank(b.config) - rank(a.config))[0]?.path ?? null; +} + +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +export class AndroidDriver implements AppDriver { + private readonly deviceId: string; + private readonly commands: Commands; + private readonly cache: AppDetailsCache; + + constructor(deviceId: string, commands: Commands, cache: AppDetailsCache) { + this.deviceId = deviceId; + this.commands = commands; + this.cache = cache; + } + + // am and pm report rejected intents and permissions on stdout, sometimes with exit status 0. + private async shell(action: string, command: string, timeout = 15000) { + const rejected = (output: string) => { + const failure = /^(?:Error: |Exception occurred while executing)[^\n]*/m.exec(output) ?? /^Error[^\n]*/m.exec(output); + return failure ? new ExpectedOperationError("invalid_input", `${action} failed: ${failure[0]}`) : undefined; + }; + let output: string; + try { output = await this.commands.run(await this.commands.adb(), ["-s", this.deviceId, "shell", command], { timeout }); } + catch (error) { throw rejected(commandOutput(error)) ?? commandError(error, action); } + const failure = rejected(output); + if (failure) throw failure; + return output; + } + + private async runtime(bundleId: string) { + return parseAndroidRuntimePermissions(await this.shell("Reading app permissions", `dumpsys package ${bundleId}`)); + } + + async installed() { return parseAndroidPackages(await this.shell("Listing apps", "pm list packages -3")); } + async activity() { + const [running, foreground] = await Promise.all([runningDeviceApps(this.deviceId, undefined, "android").catch(() => []), foregroundAndroidPackage(this.deviceId).catch(() => null)]); + return { foreground, running }; + } + + async launch(bundleId: string) { + // Some emulators only resolve launcher activities when direct-boot matching is explicit. + const resolved = await this.shell("Finding the launch activity", `cmd package resolve-activity --brief --query-flags 0x000C0000 -a android.intent.action.MAIN -c android.intent.category.LAUNCHER ${bundleId}`); + const component = parseLauncherActivity(resolved, bundleId); + if (component === null) throw new ExpectedOperationError("invalid_input", `${bundleId} is not installed or has no launcher activity.`); + await this.shell("Launching the app", `am force-stop ${bundleId} && am start -W -n ${shellQuote(component)}`, 30000); + } + + async stop(bundleId: string) { await this.shell("Stopping the app", `am force-stop ${bundleId}`); } + + async install(path: string) { + const apk = await requireArtifact(path, ".apk", "a built .apk file"); + await runAdb(this.commands, this.deviceId, "Installing the app", ["install", "-r", apk], 300000); + return {}; + } + + async openUrl(url: string) { await this.shell("Opening the URL", `am start -W -a android.intent.action.VIEW -d ${shellQuote(url)}`, 30000); } + + async links(bundleId: string) { return parseAndroidAppLinks(await this.shell("Reading app links", `dumpsys package ${bundleId}`), bundleId); } + + async details(bundleIds?: string[]) { + const aapt2 = await this.commands.aapt2(); + if (!aapt2) return {}; + return collectDetails(bundleIds ?? (await this.installed()).map(app => app.bundleId), bundleId => this.appDetails(bundleId, aapt2)); + } + + // Reads only the manifest, resource table, and icon from the device instead of pulling the whole APK. + private async appDetails(bundleId: string, aapt2: string): Promise { + const paths = (await this.shell("Finding the app package", `pm path ${bundleId}`)).split("\n").map(line => line.trim().replace(/^package:/, "")).filter(Boolean); + const apk = paths.find(path => path.endsWith("base.apk")) ?? paths[0]; + if (!apk || !/^\/[\w./~=+-]+\.apk$/.test(apk)) return {}; + const adb = await this.commands.adb(); + const entry = (name: string) => this.commands.readBinary(adb, ["-s", this.deviceId, "exec-out", "unzip", "-p", apk, name], { timeout: 20000 }); + return this.cache.get(`android:${this.deviceId}:${apk}`, () => withTemporaryDirectory(async directory => { + const parts = ["AndroidManifest.xml", "resources.arsc"]; + await Promise.all(parts.map(async part => writeFile(join(directory, part), await entry(part)))); + const partial = join(directory, "partial.apk"); + await this.commands.run("zip", ["-q", "-0", "-j", partial, ...parts.map(part => join(directory, part))], { timeout: 10000 }); + const badging = parseAndroidBadging(await this.commands.run(aapt2, ["dump", "badging", partial], { timeout: 15000 })); + const name = badging.label ?? undefined; + const file = badging.icon && pickAndroidIconFile(await this.commands.run(aapt2, ["dump", "resources", partial], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }), badging.icon); + if (!file) return { name }; + const source = join(directory, `icon${file.slice(file.lastIndexOf("."))}`); + await writeFile(source, await entry(file)); + return { name, icon: await pngDataUrl(this.commands.run, source, directory) }; + })); + } + + async permissions(bundleId: string) { return androidPermissionStates(await this.runtime(bundleId)); } + + async setPermission(bundleId: string, permission: AppPermission | "all", state: PermissionState) { + requireSupportedPermission("android", permission); + const runtime = await this.runtime(bundleId); + const names = (permission === "all" ? platformPermissions.android : [permission]).flatMap(runtimeNames).filter(name => runtime.has(name)); + if (names.length === 0) throw new ExpectedOperationError("invalid_input", permission === "all" ? "The app does not request any supported runtime permissions." : `The app does not request the ${permission} permission.`); + // USER_FIXED makes a denial final, matching "Don't allow"; clearing it makes Android ask again. + const commands = names.flatMap(name => state === "allow" + ? [`pm clear-permission-flags ${bundleId} ${name} user-set user-fixed`, `pm grant ${bundleId} ${name}`] + : [`pm revoke ${bundleId} ${name}`, `pm ${state === "deny" ? "set" : "clear"}-permission-flags ${bundleId} ${name} user-set user-fixed`]); + await this.shell("Changing the permission", commands.join(" && ")); + return this.permissions(bundleId); + } + + async push(): Promise { + throw new ExpectedOperationError("unsupported_platform", "Test push notifications are available on iOS simulators. Android needs Firebase Cloud Messaging."); + } +} diff --git a/src/server/app-control/commands.ts b/src/server/app-control/commands.ts new file mode 100644 index 00000000..28f98230 --- /dev/null +++ b/src/server/app-control/commands.ts @@ -0,0 +1,77 @@ +import { execFile } from "node:child_process"; +import { access, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; +import { adbPath } from "../native-logs.ts"; +import { androidSdkRoots } from "../android-sdk.ts"; + +export type CommandOptions = { timeout: number; input?: string; maxBuffer?: number }; +export type CommandRunner = (file: string, args: string[], options: CommandOptions) => Promise; +export type BinaryRunner = (file: string, args: string[], options: { timeout: number }) => Promise; +export type Commands = { run: CommandRunner; readBinary: BinaryRunner; adb: () => Promise; aapt2: () => Promise }; + +const run: CommandRunner = (file, args, { timeout, input, maxBuffer = 8 * 1024 * 1024 }) => new Promise((resolve, reject) => { + const child = execFile(file, args, { encoding: "utf8", timeout, maxBuffer }, (error, stdout, stderr) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve(stdout); + }); + if (input !== undefined) child.stdin?.end(input); +}); + +const readBinary: BinaryRunner = (file, args, { timeout }) => new Promise((resolve, reject) => { + execFile(file, args, { encoding: "buffer", timeout, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); +}); + +let aapt2: string | undefined; +// aapt2 ships with the Android SDK build tools; use the newest installed version. Only a found path is +// remembered, so build tools installed while the plugin runs are picked up. +async function findAapt2(): Promise { + if (aapt2) return aapt2; + for (const root of androidSdkRoots()) { + const versions = await readdir(join(root, "build-tools")).catch(() => []); + versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const version of versions) { + const path = join(root, "build-tools", version, "aapt2"); + if (await access(path).then(() => true, () => false)) return aapt2 = path; + } + } + return null; +} + +export const systemCommands: Commands = { run, readBinary, adb: adbPath, aapt2: findAapt2 }; + +export function commandOutput(error: unknown): string { + if (error === null || typeof error !== "object") return String(error); + const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr : ""; + const stdout = "stdout" in error && typeof error.stdout === "string" ? error.stdout : ""; + return `${stderr}\n${stdout}`.trim(); +} + +const unavailable = /current state: Shutdown|not booted|Invalid device|device offline|device unauthorized|device '.*' not found|no devices\/emulators found/i; + +// Keep only the readable simctl or adb lines; "Command failed: ..." repeats every argument. +export function commandError(error: unknown, action: string): Error { + if (expectedOutcome(error) !== undefined) return error as Error; + const output = commandOutput(error); + const message = output.split("\n").map(line => line.trim()).filter(line => line && !/^Underlying error|^An error was encountered/.test(line)).slice(0, 3).join(" "); + const text = `${action} failed${message ? `: ${message}` : "."}`; + if (unavailable.test(output)) return new ExpectedOperationError("device_unavailable", text); + if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return new ExpectedOperationError("unsupported_platform", `${action} needs ${process.platform === "darwin" ? "Xcode or the Android SDK" : "macOS and Xcode"}.`); + return new Error(text, { cause: error }); +} + +// Runs adb against one device, turning failures into readable errors. +export async function runAdb(commands: Commands, serial: string, action: string, args: string[], timeout = 15000): Promise { + try { return await commands.run(await commands.adb(), ["-s", serial, ...args], { timeout }); } + catch (error) { throw commandError(error, action); } +} + +export async function withTemporaryDirectory(work: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), "mobile-dev-app-")); + try { return await work(directory); } + finally { await rm(directory, { recursive: true, force: true }); } +} diff --git a/src/server/app-control/driver.ts b/src/server/app-control/driver.ts new file mode 100644 index 00000000..12d6cd4e --- /dev/null +++ b/src/server/app-control/driver.ts @@ -0,0 +1,95 @@ +import { readFile, stat } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { platformPermissions } from "../../shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, InstalledApps, PermissionState } from "../../shared/app-control.ts"; +import type { CommandRunner } from "./commands.ts"; + +export type RunningApp = { bundleId: string; pid: number }; +// Either part is empty when the device cannot report it. +export type AppActivity = { foreground: string | null; running: RunningApp[] }; + +// One device's apps, reached through simctl or adb. Tools pick the driver; drivers never branch on platform. +export interface AppDriver { + installed(): Promise; + activity(): Promise; + launch(bundleId: string): Promise; + stop(bundleId: string): Promise; + install(path: string): Promise<{ bundleId?: string }>; + openUrl(url: string): Promise; + links(bundleId: string): Promise; + details(bundleIds?: string[]): Promise>; + permissions(bundleId: string): Promise; + setPermission(bundleId: string, permission: AppPermission | "all", state: PermissionState): Promise; + push(bundleId: string, payload: Record): Promise; +} + +export async function listApps(driver: AppDriver): Promise { + const [apps, { foreground, running }] = await Promise.all([driver.installed(), driver.activity()]); + const installed = new Set(apps.map(app => app.bundleId)); + const foregroundBundleId = foreground !== null && installed.has(foreground) ? foreground : null; + // The foreground app leads, then higher PIDs, which started later. + const newest = [...running].sort((a, b) => b.pid - a.pid).map(app => app.bundleId); + const runningBundleIds = [...new Set([...(foregroundBundleId ? [foregroundBundleId] : []), ...newest])].filter(bundleId => installed.has(bundleId)); + return { apps, foregroundBundleId, runningBundleIds }; +} + +export function requireSupportedPermission(platform: "ios" | "android", permission: AppPermission | "all") { + if (permission === "all" || platformPermissions[platform].includes(permission)) return; + throw new ExpectedOperationError("invalid_input", `${platform === "ios" ? "iOS simulators do" : "Android does"} not support the ${permission} permission here.`); +} + +// Simulator builds are .app bundle directories; Android builds are .apk files. +export async function requireArtifact(path: string, extension: ".app" | ".apk", description: string): Promise { + const trimmed = path.replace(/(.)\/+$/, "$1"); + const info = isAbsolute(trimmed) && trimmed.endsWith(extension) ? await stat(trimmed).catch(() => undefined) : undefined; + const directory = extension === ".app"; + if (info === undefined || info.isDirectory() !== directory) throw new ExpectedOperationError("invalid_input", `Pass the absolute path of ${description}.`); + return trimmed; +} + +// Sign-in, payment, and social SDKs register redirect schemes that are not app screens. +const sdkScheme = /^fb\d+$|^com\.googleusercontent\.apps\.|auth|^stripe|^link-popup$|^msal|^db-|^twitterkit-|^pdk\d|^vk\d|^tiktok/i; +export const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*$/; + +// Own short schemes first, then web links, then bundle-ID-style schemes that SDKs and dev clients add. +export function rankAppLinks(links: string[]): string[] { + const rank = (link: string) => /^https?:/.test(link) ? 1 : /^[^:]*[.+][^:]*:/.test(link) ? 2 : 0; + return [...new Set(links)].filter(link => !sdkScheme.test(link.slice(0, link.indexOf(":")))) + .map((link, index) => ({ link, index })).sort((a, b) => rank(a.link) - rank(b.link) || a.index - b.index) + .slice(0, 5).map(entry => entry.link); +} + +// Extracted icons are cached by a key that changes on reinstall: the bundle path and Info.plist time, or the APK path. +export class AppDetailsCache { + private readonly entries = new Map(); + + async get(key: string, read: () => Promise): Promise { + const hit = this.entries.get(key); + if (hit) return hit; + const details = await read(); + if (this.entries.size >= 200) this.entries.delete(this.entries.keys().next().value!); + this.entries.set(key, details); + return details; + } +} + +// Details are best effort: an app without a readable icon keeps its initial in the panel. +export async function collectDetails(bundleIds: readonly string[], read: (bundleId: string) => Promise): Promise> { + const results: Record = {}; + let next = 0; + await Promise.all(Array.from({ length: Math.min(3, bundleIds.length) }, async () => { + while (next < bundleIds.length) { + const bundleId = bundleIds[next++]; + const details = await read(bundleId).catch((): AppDetails => ({})); + if (details.icon || details.name) results[bundleId] = details; + } + })); + return results; +} + +export async function pngDataUrl(run: CommandRunner, source: string, directory: string): Promise { + const output = join(directory, "icon-64.png"); + await run("sips", ["-s", "format", "png", "-Z", "64", source, "--out", output], { timeout: 10000 }); + return `data:image/png;base64,${(await readFile(output)).toString("base64")}`; +} diff --git a/src/server/app-control/simulator.ts b/src/server/app-control/simulator.ts new file mode 100644 index 00000000..f57a6a5c --- /dev/null +++ b/src/server/app-control/simulator.ts @@ -0,0 +1,141 @@ +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { foregroundSimulatorPid } from "../device-apps/foreground.ts"; +import { runningSimulatorApps } from "../device-apps/apps.ts"; +import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; +import { bundleIdSchema, platformPermissions } from "../../shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "../../shared/app-control.ts"; +import { commandError, commandOutput, withTemporaryDirectory } from "./commands.ts"; +import type { Commands } from "./commands.ts"; +import { collectDetails, pngDataUrl, rankAppLinks, requireArtifact, requireSupportedPermission, urlSchemePattern } from "./driver.ts"; +import type { AppDetailsCache, AppDriver } from "./driver.ts"; + +type SimulatorApp = InstalledApp & { path: string }; +// Failures simctl reports for a fixable request, mapped to the message the user sees. +type SimctlOptions = { timeout?: number; input?: string; known?: readonly (readonly [RegExp, string])[]; tolerate?: RegExp }; + +const listingSchema = z.record(z.string(), z.object({ + ApplicationType: z.string().optional(), CFBundleIdentifier: z.string().optional(), CFBundleDisplayName: z.string().optional(), CFBundleName: z.string().optional(), Path: z.string().optional(), +}).loose()); + +export function parseSimulatorApps(json: string): SimulatorApp[] { + const apps: SimulatorApp[] = []; + for (const [key, app] of Object.entries(listingSchema.parse(JSON.parse(json)))) { + const bundleId = bundleIdSchema.safeParse(app.CFBundleIdentifier ?? key); + if (app.ApplicationType !== "User" || !bundleId.success) continue; + apps.push({ bundleId: bundleId.data, name: app.CFBundleDisplayName || app.CFBundleName || bundleId.data, path: app.Path ?? "" }); + } + return apps.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function parseIosUrlSchemes(json: string): string[] { + const types = z.array(z.object({ CFBundleURLSchemes: z.array(z.string()).optional() }).loose()).safeParse(JSON.parse(json)); + return rankAppLinks((types.data ?? []).flatMap(type => type.CFBundleURLSchemes ?? []).filter(scheme => urlSchemePattern.test(scheme)).map(scheme => `${scheme}://`)); +} + +const iconFilesSchema = z.object({ CFBundlePrimaryIcon: z.object({ CFBundleIconFiles: z.array(z.string()).optional() }).loose().optional() }).loose(); + +export class SimulatorDriver implements AppDriver { + private readonly deviceId: string; + private readonly commands: Commands; + private readonly cache: AppDetailsCache; + + constructor(deviceId: string, commands: Commands, cache: AppDetailsCache) { + this.deviceId = deviceId; + this.commands = commands; + this.cache = cache; + } + + private async simctl(action: string, args: string[], { timeout = 30000, input, known = [], tolerate }: SimctlOptions = {}) { + try { return await this.commands.run("xcrun", ["simctl", ...args], { timeout, input }); } + catch (error) { + const output = commandOutput(error); + if (tolerate?.test(output)) return ""; + const failure = commandError(error, action); + const match = expectedOutcome(failure) === undefined ? known.find(([pattern]) => pattern.test(output)) : undefined; + throw match ? new ExpectedOperationError("invalid_input", match[1]) : failure; + } + } + + private plutil(args: string[], input?: string) { return this.commands.run("plutil", args, { timeout: 10000, input }); } + + private async listing(): Promise { + const listing = await this.simctl("Listing apps", ["listapps", this.deviceId]); + try { return parseSimulatorApps(await this.plutil(["-convert", "json", "-o", "-", "-"], listing)); } + catch (error) { throw commandError(error, "Reading the app list"); } + } + + async installed() { return (await this.listing()).map(({ bundleId, name }) => ({ bundleId, name })); } + // One launchctl listing names both the running apps and the foreground app's process. + async activity() { + const [running, pid] = await Promise.all([runningSimulatorApps(this.deviceId).catch(() => []), foregroundSimulatorPid(this.deviceId).catch(() => null)]); + return { foreground: running.find(app => app.pid === pid)?.bundleId ?? null, running }; + } + + async launch(bundleId: string) { + await this.simctl("Launching the app", ["launch", "--terminate-running-process", this.deviceId, bundleId], + { known: [[/FBSOpenApplicationServiceErrorDomain/, `${bundleId} is not installed or could not open.`]] }); + } + + async stop(bundleId: string) { + await this.simctl("Stopping the app", ["terminate", this.deviceId, bundleId], { tolerate: /found nothing to terminate/ }); + } + + async install(path: string) { + const bundle = await requireArtifact(path, ".app", "a built .app bundle for the simulator"); + await this.simctl("Installing the app", ["install", this.deviceId, bundle], { timeout: 300000 }); + const bundleId = bundleIdSchema.safeParse(await this.plutil(["-extract", "CFBundleIdentifier", "raw", "-o", "-", join(bundle, "Info.plist")]).then(id => id.trim(), () => undefined)); + return bundleId.success ? { bundleId: bundleId.data } : {}; + } + + async openUrl(url: string) { + await this.simctl("Opening the URL", ["openurl", this.deviceId, url], { known: [[/LSApplicationWorkspaceErrorDomain/, "No installed app handles this URL."]] }); + } + + async links(bundleId: string) { + const container = await this.simctl("Reading app links", ["get_app_container", this.deviceId, bundleId, "app"], + { known: [[/No such file or directory/, `${bundleId} is not installed on this simulator.`]] }); + // Apps without URL types make plutil exit with "No value at that key path". + const types = await this.plutil(["-extract", "CFBundleURLTypes", "json", "-o", "-", join(container.trim(), "Info.plist")]).catch(() => undefined); + return types ? parseIosUrlSchemes(types) : []; + } + + async details(bundleIds?: string[]) { + const apps = await this.listing(); + const paths = new Map(apps.map(app => [app.bundleId, app.path])); + return collectDetails(bundleIds ?? apps.map(app => app.bundleId), async bundleId => { + const path = paths.get(bundleId); + return path ? this.icon(path) : {}; + }); + } + + private async icon(bundlePath: string): Promise { + const plist = join(bundlePath, "Info.plist"); + return this.cache.get(`ios:${bundlePath}:${(await stat(plist)).mtimeMs}`, async () => { + const icons = await this.plutil(["-extract", "CFBundleIcons", "json", "-o", "-", plist]).catch(() => "{}"); + const names = iconFilesSchema.safeParse(JSON.parse(icons)).data?.CFBundlePrimaryIcon?.CFBundleIconFiles ?? []; + const files = (await readdir(bundlePath)).filter(file => file.endsWith(".png") && names.some(name => file.startsWith(name))); + const sized = await Promise.all(files.map(async file => ({ file, size: (await stat(join(bundlePath, file))).size }))); + const largest = sized.sort((a, b) => b.size - a.size)[0]; + return largest ? { icon: await withTemporaryDirectory(directory => pngDataUrl(this.commands.run, join(bundlePath, largest.file), directory)) } : {}; + }); + } + + // simctl cannot read privacy decisions, so states are unknown until the panel sets them. + async permissions(): Promise { return platformPermissions.ios.map(permission => ({ permission, state: null })); } + + async setPermission(bundleId: string, permission: AppPermission | "all", state: PermissionState) { + requireSupportedPermission("ios", permission); + const action = { allow: "grant", deny: "revoke", ask: "reset" }[state]; + await this.simctl("Changing the permission", ["privacy", this.deviceId, action, permission, bundleId]); + return platformPermissions.ios.map(entry => ({ permission: entry, state: permission === "all" || permission === entry ? state : null })); + } + + async push(bundleId: string, payload: Record) { + const json = JSON.stringify(payload); + if (Buffer.byteLength(json) > 4096) throw new ExpectedOperationError("invalid_input", "Push payloads must be 4 KB or smaller."); + await this.simctl("Sending the notification", ["push", this.deviceId, bundleId, "-"], + { input: json, known: [[/UNErrorDomain/, `${bundleId} is not installed or cannot receive notifications.`]] }); + } +} diff --git a/src/server/app-control/tools.ts b/src/server/app-control/tools.ts new file mode 100644 index 00000000..43aa51c7 --- /dev/null +++ b/src/server/app-control/tools.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { androidIdSchema } from "../serve-emu.ts"; +import { captureServerError } from "../telemetry.ts"; +import { errorMessage, udidSchema } from "../../shared/protocol.ts"; +import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; +import { appDetailsBatch, appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, permissionStates } from "../../shared/app-control.ts"; +import { systemCommands } from "./commands.ts"; +import type { Commands } from "./commands.ts"; +import { AppDetailsCache, listApps } from "./driver.ts"; +import type { AppDriver } from "./driver.ts"; +import { SimulatorDriver } from "./simulator.ts"; +import { AndroidDriver } from "./android.ts"; + +type Target = { platform: "ios" | "android"; deviceId: string }; +export type DriverFactory = (target: Target) => AppDriver; + +// Validates the target once; everything after this works with a single platform's driver. +export function createDrivers(commands: Commands = systemCommands): DriverFactory { + const cache = new AppDetailsCache(); + return ({ platform, deviceId }) => { + if (platform === "android") { + if (!androidIdSchema.safeParse(deviceId).success || deviceId.startsWith("avd:")) throw new ExpectedOperationError("invalid_input", "Choose a running Android device serial."); + return new AndroidDriver(deviceId, commands, cache); + } + if (!udidSchema.safeParse(deviceId).success) throw new ExpectedOperationError("invalid_input", "App tools support iOS simulators and Android devices. Pass an iOS simulator UDID."); + if (process.platform !== "darwin") throw new ExpectedOperationError("unsupported_platform", "iOS simulators require macOS and Xcode."); + return new SimulatorDriver(deviceId, commands, cache); + }; +} + +type ToolConfig = { title: string; description: string; inputSchema: Shape; readOnly?: boolean; destructive?: boolean; panelOnly?: boolean }; +type ToolInput = Target & z.infer>; + +const bundleId = bundleIdSchema.describe("App bundle ID or Android package name."); +const payloadSchema = z.record(z.string(), z.unknown()).refine(value => typeof value.aps === "object" && value.aps !== null, "Payload needs an aps object."); + +export function registerAppControlTools(server: McpServer, driverFor: DriverFactory = createDrivers()) { + function tool(name: string, action: string, config: ToolConfig, handler: (driver: AppDriver, input: ToolInput) => Promise>) { + const inputSchema: z.ZodRawShape = { ...appTargetShape, ...config.inputSchema }; + registerAppTool(server, name, { + title: config.title, description: config.description, inputSchema, + annotations: { readOnlyHint: config.readOnly ?? false, destructiveHint: config.destructive ?? false, openWorldHint: false }, + _meta: { ui: { visibility: config.panelOnly ? ["app"] : ["app", "model"] } }, + }, async input => { + // registerAppTool validated input against this exact shape; its generic callback type cannot express that. + const parsed = input as ToolInput; + try { + const result = await handler(driverFor(parsed), parsed); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result }; + } catch (error) { + // Command output can contain app IDs, URLs, and device serials, so Sentry receives only the action. + // Expected outcomes carry fixed reasons and are counted without a report. + captureServerError(expectedOutcome(error) === undefined ? new Error(`App ${action} command failed.`) : error, `app_control.${action}`); + return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] }; + } + }); + } + + tool("mobile_list_apps", "list", { title: "List installed apps", readOnly: true, inputSchema: {}, + description: "List user-installed apps on the selected iOS simulator or Android device. Returns bundle IDs, names, the foreground app, and running apps, foreground then most recent first." }, + listApps); + tool("mobile_launch_app", "launch", { title: "Launch app", inputSchema: { bundleId }, + description: "Launch an installed app, restarting it if it is already running. Does not build; use the project's build tools first." }, + async (driver, input) => { await driver.launch(input.bundleId); return { launched: input.bundleId }; }); + tool("mobile_stop_app", "stop", { title: "Stop app", inputSchema: { bundleId }, description: "Terminate a running app on the device." }, + async (driver, input) => { await driver.stop(input.bundleId); return { stopped: input.bundleId }; }); + tool("mobile_install_app", "install", { title: "Install app", destructive: true, + inputSchema: { path: z.string().min(1).max(4096).describe("Absolute path to a simulator .app bundle or an .apk.") }, + description: "Install or replace a built app on the device. Build it with the project's tools first." }, + async (driver, input) => ({ installed: true, ...await driver.install(input.path) })); + tool("mobile_open_url", "open_url", { title: "Open URL", inputSchema: { url: appUrlSchema.describe("Deep link or web URL, such as myapp://checkout.") }, + description: "Open a deep link or URL on the device to jump straight to a screen." }, + async (driver, input) => { await driver.openUrl(input.url); return { opened: input.url }; }); + tool("mobile_app_icons", "icons", { title: "Read app icons", readOnly: true, panelOnly: true, + inputSchema: { bundleIds: z.array(bundleIdSchema).max(appDetailsBatch).optional() }, + description: "Read small PNG icons for installed apps, plus Android app labels." }, + async (driver, input) => ({ apps: await driver.details(input.bundleIds) })); + tool("mobile_app_links", "links", { title: "Read app URL schemes", readOnly: true, panelOnly: true, inputSchema: { bundleId }, + description: "Read the URL schemes and Android link hosts an app declares, without SDK redirect schemes." }, + async (driver, input) => ({ links: await driver.links(input.bundleId) })); + tool("mobile_app_permissions", "permissions", { title: "Read app permissions", readOnly: true, panelOnly: true, inputSchema: { bundleId }, + description: "Read supported app permissions. Android reports current states; iOS simulators report null." }, + async (driver, input) => ({ permissions: await driver.permissions(input.bundleId) })); + tool("mobile_set_app_permission", "permission", { title: "Set app permission", + inputSchema: { bundleId, permission: z.enum([...appPermissions, "all"]), state: z.enum(permissionStates).describe("allow grants, deny blocks, ask resets so the app prompts again.") }, + description: "Allow, deny, or reset an app permission without the system prompt. iOS simulators lack camera and notifications; Android lacks reminders and motion. Changes may restart the app." }, + async (driver, input) => ({ permissions: await driver.setPermission(input.bundleId, input.permission, input.state) })); + tool("mobile_send_push", "push", { title: "Send test push", + inputSchema: { bundleId, payload: payloadSchema.describe("APNs payload with an aps object, such as {\"aps\":{\"alert\":\"Hi\"}}.") }, + description: "Send a test push notification to an app on an iOS simulator." }, + async (driver, input) => { await driver.push(input.bundleId, input.payload); return { sent: true }; }); +} diff --git a/src/server/native-logs.ts b/src/server/native-logs.ts index 73ea0d69..685bccfd 100644 --- a/src/server/native-logs.ts +++ b/src/server/native-logs.ts @@ -4,9 +4,9 @@ import { StringDecoder } from "node:string_decoder"; import { setTimeout as delay } from "node:timers/promises"; import { access } from "node:fs/promises"; import { join } from "node:path"; -import { homedir } from "node:os"; import type { LogRecord, LogSourceStatus, NativeLogTarget } from "../shared/logs.ts"; import { errorMessage } from "../shared/protocol.ts"; +import { androidSdkRoots } from "./android-sdk.ts"; import { parseIOSLog, parseLogcat } from "./log-parsers.ts"; import { physicalIosLogCommand } from "./physical-ios-logs.ts"; import { IOSLogProcessingTelemetry } from "./telemetry.ts"; @@ -16,8 +16,7 @@ export type LogSink = { log: (log: LogRecord) => void; status: (status: LogSourc export type StopLogSource = () => Promise; export async function adbPath(): Promise { - for (const root of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, join(homedir(), "Library/Android/sdk")]) { - if (!root) continue; + for (const root of androidSdkRoots()) { const path = join(root, "platform-tools/adb"); try { await access(path); return path; } catch { /* Try the next SDK, then PATH. */ } } diff --git a/src/server/plugin.ts b/src/server/plugin.ts index 4bf0fd3b..382654a4 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -15,6 +15,7 @@ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@model import { OpenAIExtensions } from "@openai/mcp-extensions/server"; import { z } from "zod"; import { registerDeviceSettingsTools } from "./device-settings.ts"; +import { registerAppControlTools } from "./app-control/tools.ts"; import { readBezel } from "./bezel.ts"; import { ServeEmu } from "./serve-emu.ts"; import { registerAndroidTools } from "./android-tools.ts"; @@ -40,53 +41,16 @@ import type { InputStatus, SimulatorInput } from "./simulator-input.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; import { copyPNGToClipboard } from "./clipboard.ts"; import { errorMessage, inputSchema, streamMessageSchema, udidSchema } from "../shared/protocol.ts"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; export const APP_URI = `ui://mobile-dev/${PLUGIN_VERSION}/simulator.html`; export const WORKSPACE_URI = `ui://mobile-dev/${PLUGIN_VERSION}/workspace.html`; -// Codex can retain entrypoint metadata after updating the installed plugin. -const legacyAppUris = [ - "ui://mobile-dev/0.1.57/simulator.html", - "ui://mobile-dev/0.1.56/simulator.html", - "ui://mobile-dev/0.1.52/simulator.html", - "ui://mobile-dev/0.1.44/simulator.html", - "ui://mobile-dev/0.1.43/simulator.html", - "ui://mobile-dev/0.1.42/simulator.html", - "ui://mobile-dev/0.1.41/simulator.html", - "ui://mobile-dev/0.1.40/simulator.html", - "ui://mobile-dev/0.1.39/simulator.html", - "ui://mobile-dev/0.1.38/simulator.html", - "ui://mobile-dev/0.1.37/simulator.html", - "ui://mobile-dev/0.1.36/simulator.html", - "ui://mobile-dev/0.1.35/simulator.html", - "ui://mobile-dev/0.1.34/simulator.html", - "ui://mobile-dev/0.1.33/simulator.html", - "ui://mobile-dev/0.1.32/simulator.html", - "ui://mobile-dev/0.1.24/mcp-stream/simulator.html", - "ui://mobile-dev/0.1.31/simulator.html", - "ui://mobile-dev/0.1.30/simulator.html", - "ui://mobile-dev/0.1.29/simulator.html", "ui://mobile-dev/0.1.28/simulator.html", "ui://mobile-dev/0.1.27/simulator.html", "ui://mobile-dev/0.1.26/simulator.html", "ui://mobile-dev/0.1.25/simulator.html", "ui://mobile-dev/0.1.24/simulator.html", "ui://mobile-dev/0.1.23/simulator.html", "ui://mobile-dev/0.1.22/simulator.html", "ui://mobile-dev/0.1.21/simulator.html", "ui://mobile-dev/0.1.20/simulator.html", "ui://mobile-dev/0.1.19/simulator.html", "ui://mobile-dev/0.1.18/simulator.html", "ui://mobile-dev/0.1.17/simulator.html", "ui://mobile-dev/0.1.16/simulator.html", "ui://mobile-dev/0.1.15/simulator.html", "ui://mobile-dev/0.1.14/simulator.html", "ui://mobile-dev/0.1.13/simulator.html", "ui://mobile-dev/0.1.12/simulator.html", "ui://mobile-dev/0.1.11/simulator.html", "ui://mobile-dev/simulator.html", ...Array.from({ length: 6 }, (_, index) => `ui://mobile-dev/v${index + 1}/simulator.html`)]; -const legacyWorkspaceUris = [ - "ui://mobile-dev/0.1.57/workspace.html", - "ui://mobile-dev/0.1.56/workspace.html", - "ui://mobile-dev/0.1.52/workspace.html", - "ui://mobile-dev/0.1.44/workspace.html", - "ui://mobile-dev/0.1.43/workspace.html", - "ui://mobile-dev/0.1.42/workspace.html", - "ui://mobile-dev/0.1.41/workspace.html", - "ui://mobile-dev/0.1.40/workspace.html", - "ui://mobile-dev/0.1.39/workspace.html", - "ui://mobile-dev/0.1.38/workspace.html", - "ui://mobile-dev/0.1.37/workspace.html", - "ui://mobile-dev/0.1.36/workspace.html", - "ui://mobile-dev/0.1.35/workspace.html", - "ui://mobile-dev/0.1.34/workspace.html", - "ui://mobile-dev/0.1.33/workspace.html", - "ui://mobile-dev/0.1.32/workspace.html", - "ui://mobile-dev/0.1.24/mcp-stream/workspace.html", - "ui://mobile-dev/0.1.31/workspace.html", - "ui://mobile-dev/0.1.30/workspace.html", - "ui://mobile-dev/0.1.29/workspace.html", "ui://mobile-dev/0.1.28/workspace.html", "ui://mobile-dev/0.1.27/workspace.html", "ui://mobile-dev/0.1.26/workspace.html", "ui://mobile-dev/0.1.25/workspace.html", "ui://mobile-dev/0.1.24/workspace.html", "ui://mobile-dev/0.1.23/workspace.html", "ui://mobile-dev/0.1.22/workspace.html", "ui://mobile-dev/0.1.21/workspace.html", "ui://mobile-dev/0.1.20/workspace.html", "ui://mobile-dev/0.1.19/workspace.html", "ui://mobile-dev/0.1.18/workspace.html", "ui://mobile-dev/0.1.17/workspace.html", "ui://mobile-dev/0.1.16/workspace.html", "ui://mobile-dev/0.1.15/workspace.html", "ui://mobile-dev/0.1.14/workspace.html", "ui://mobile-dev/0.1.13/workspace.html", "ui://mobile-dev/0.1.12/workspace.html", "ui://mobile-dev/0.1.11/workspace.html", "ui://mobile-dev/workspace.html"]; +// Codex keeps the address of side tabs and saved cards opened before a plugin update, so earlier +// releases' addresses serve the current UI. These older addresses do not fit that pattern. +const unversionedAppUris = ["ui://mobile-dev/simulator.html", "ui://mobile-dev/0.1.24/mcp-stream/simulator.html"]; +const unversionedWorkspaceUris = ["ui://mobile-dev/workspace.html", "ui://mobile-dev/0.1.24/mcp-stream/workspace.html"]; +const releaseSegment = /^(?:\d+\.\d+\.\d+|v[1-6])$/; const sessionIdSchema = z.string().regex(/^[a-f0-9]{64}$/); const read = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; const write = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }; @@ -97,6 +61,14 @@ const statusOutput = { devices: z.array(deviceSchema), error: z.string().optional(), }; +function registerEarlierReleases(server: McpServer, name: string, page: string, read: (uri: URL) => Promise) { + const template = new ResourceTemplate(`ui://mobile-dev/{release}/${page}.html`, { list: undefined }); + server.registerResource(name, template, { mimeType: RESOURCE_MIME_TYPE }, (uri, { release }) => { + if (typeof release !== "string" || !releaseSegment.test(release)) throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`); + return read(uri); + }); +} + function result(data: Record, message: string): CallToolResult { return { content: [{ type: "text", text: message }], structuredContent: data }; } @@ -143,6 +115,7 @@ export async function createPlugin(html: string | (() => Promise), b await Promise.all([closeCpu, closeFps]); }); registerDeviceSettingsTools(server, baguette, android); + registerAppControlTools(server); const automaticRepairs = new Map }>(); async function panelInputStatus(udid: string) { @@ -191,7 +164,7 @@ export async function createPlugin(html: string | (() => Promise), b const content = configureUI(resource.html); return ({ contents: [{ - uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: (uri.href === WORKSPACE_URI || legacyWorkspaceUris.includes(uri.href)) + uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: uri.href.endsWith("/workspace.html") ? content.replace('data-view="panel"', 'data-view="workspace"').replace('data-layout="stacked"', 'data-layout="split"') : content, _meta: { @@ -217,21 +190,24 @@ export async function createPlugin(html: string | (() => Promise), b for (const [name, resourceUri, view] of [ ["mobile-dev-recording", RECORDING_URI, "recording"], ["mobile-dev-comparison", COMPARISON_URI, "comparison"], - ]) registerAppResource(server, name, resourceUri, {}, async uri => { - const resource = typeof html === "string" ? { html } : await html(); - const configured = configureUI(resource.html); - const content = configured.replace('data-view="panel"', `data-view="${view}"`); - return { contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: content, _meta: { - ui: { prefersBorder: true, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, - "openai/ui": { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] }, - } }] }; - }); - for (const [index, uri] of legacyWorkspaceUris.entries()) { - registerAppResource(server, `mobile-dev-workspace-legacy-${index}`, uri, {}, readApp); + ]) { + const readCard = async (uri: URL) => { + const resource = typeof html === "string" ? { html } : await html(); + const configured = configureUI(resource.html); + const content = configured.replace('data-view="panel"', `data-view="${view}"`); + return { contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: content, _meta: { + ui: { prefersBorder: true, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, + "openai/ui": { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] }, + } }] }; + }; + registerAppResource(server, name, resourceUri, {}, readCard); + registerEarlierReleases(server, `${name}-earlier`, view, readCard); } - for (const [index, uri] of legacyAppUris.entries()) { - registerAppResource(server, `mobile-dev-simulator-v${index + 1}`, uri, {}, readApp); + for (const [index, uri] of [...unversionedAppUris, ...unversionedWorkspaceUris].entries()) { + registerAppResource(server, `mobile-dev-unversioned-${index}`, uri, {}, readApp); } + registerEarlierReleases(server, "mobile-dev-simulator-earlier", "simulator", readApp); + registerEarlierReleases(server, "mobile-dev-workspace-earlier", "workspace", readApp); const frameTemplate = new ResourceTemplate("mobile-frame://{sessionId}/latest{?after}", { list: undefined }); server.registerResource("simulator-latest-frame", frameTemplate, { mimeType: "image/jpeg" }, async (uri, variables, extra) => { diff --git a/src/server/serve-emu.ts b/src/server/serve-emu.ts index b1c8c39f..988604ff 100644 --- a/src/server/serve-emu.ts +++ b/src/server/serve-emu.ts @@ -3,7 +3,6 @@ import type { ChildProcess } from "node:child_process"; import { promisify } from "node:util"; import { access } from "node:fs/promises"; import { constants } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createServer } from "node:net"; @@ -17,6 +16,7 @@ import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; import { recordAndroidEmulatorReconnect, recordAndroidBackendStartup, recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } from "./telemetry.ts"; import { androidStartupMessageSchema, androidDeviceState, setAndroidStartupDiagnostic } from "../shared/android-startup-diagnostics.ts"; import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummary } from "../shared/android-startup-diagnostics.ts"; +import { androidSdkRoots } from "./android-sdk.ts"; const execute = promisify(execFile); export const androidIdSchema = z.string().min(1).max(256).regex(/^[A-Za-z0-9_.:\[\]%-]+$/); @@ -50,8 +50,7 @@ export class ServeEmu { } private async emulatorPath() { - for (const root of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, join(homedir(), "Library/Android/sdk")]) { - if (!root) continue; + for (const root of androidSdkRoots()) { const path = join(root, "emulator/emulator"); try { await access(path, constants.X_OK); return path; } catch { /* Try PATH next. */ } } diff --git a/src/shared/app-control.ts b/src/shared/app-control.ts new file mode 100644 index 00000000..2152540e --- /dev/null +++ b/src/shared/app-control.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const appPermissions = ["location", "photos", "camera", "microphone", "notifications", "contacts", "calendar", "reminders", "motion"] as const; +export type AppPermission = typeof appPermissions[number]; +// simctl privacy has no camera or notification services; Android has no reminders or motion runtime permissions. +export const platformPermissions: Record<"ios" | "android", readonly AppPermission[]> = { + ios: ["location", "photos", "microphone", "contacts", "calendar", "reminders", "motion"], + android: ["location", "photos", "camera", "microphone", "notifications", "contacts", "calendar"], +}; +export const permissionStates = ["allow", "deny", "ask"] as const; +export type PermissionState = typeof permissionStates[number]; + +export const bundleIdSchema = z.string().min(1).max(255).regex(/^[A-Za-z0-9_][A-Za-z0-9._-]*$/); +export const appUrlSchema = z.string().min(3).max(2048).regex(/^[A-Za-z][A-Za-z0-9+.-]*:\S+$/, "Expected a URL with a scheme, such as myapp://checkout."); +export const appTargetShape = { + platform: z.enum(["ios", "android"]), + deviceId: z.string().min(1).max(256), +}; + +const installedApp = z.object({ bundleId: bundleIdSchema, name: z.string() }); +// runningBundleIds starts with the foreground app when it is known, then the most recently launched. +export const installedAppsSchema = z.object({ apps: z.array(installedApp), foregroundBundleId: bundleIdSchema.nullable(), runningBundleIds: z.array(bundleIdSchema) }); +export type InstalledApp = z.infer; +export type InstalledApps = z.infer; + +const permissionEntry = z.object({ permission: z.enum(appPermissions), state: z.enum(permissionStates).nullable() }); +export const appPermissionsSchema = z.object({ permissions: z.array(permissionEntry) }); +export type AppPermissionEntry = z.infer; + +export const appLinksSchema = z.object({ links: z.array(z.string().max(2048)) }); + +// The most apps one icon request may name; the panel asks in batches of this size. +export const appDetailsBatch = 200; +// A 64px PNG icon and, on Android, the app label that replaces the package name. +const appDetails = z.object({ icon: z.string().startsWith("data:image/png;base64,").max(256 * 1024).optional(), name: z.string().max(256).optional() }); +export const appDetailsSchema = z.object({ apps: z.record(z.string(), appDetails) }); +export type AppDetails = z.infer; diff --git a/src/shared/version.ts b/src/shared/version.ts index e657b577..a7321c5d 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.147"; +export const PLUGIN_VERSION = "0.1.151"; diff --git a/src/ui/app-tools.ts b/src/ui/app-tools.ts new file mode 100644 index 00000000..4726b7f6 --- /dev/null +++ b/src/ui/app-tools.ts @@ -0,0 +1,241 @@ +import { z } from "zod"; +import { appDetailsBatch, appDetailsSchema, appLinksSchema, appPermissionsSchema, installedAppsSchema, platformPermissions } from "../shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "../shared/app-control.ts"; +import { measureUiOperation } from "./telemetry.ts"; +import { elementStores } from "./element-store.ts"; + +export type AppToolAction = "list" | "icons" | "launch" | "stop" | "open_url" | "permission" | "push"; +export type AppEntry = InstalledApp & { icon?: string }; +export type AppSection = "permissions" | "push"; +export type AppToolsState = { + platform: "ios" | "android"; + deviceId: string; + disabled: boolean; + loading: boolean; + apps: AppEntry[]; + runningBundleIds: string[]; + bundleId: string | null; + // Null until the first app's permissions load; later switches keep the previous rows until the new ones arrive. + permissions: AppPermissionEntry[] | null; + links: string[]; + busy: AppToolAction | null; + error: string; + status: string; + recentUrls: string[]; + // The collapsible section left open, kept across panels so the one used most stays open. + section: AppSection | null; +}; + +// localStorage can be missing or blocked in sandboxed hosts, so stored values are best effort. +function storedValue(key: string, schema: z.ZodType, fallback: T) { + return { + read(): T { + try { return schema.safeParse(JSON.parse(globalThis.localStorage?.getItem(key) ?? "null")).data ?? fallback; } + catch { return fallback; } + }, + write(value: T) { + try { globalThis.localStorage?.setItem(key, JSON.stringify(value)); } + catch { /* Keep the in-memory value. */ } + }, + }; +} + +const recentUrls = storedValue("mobile-dev:recent-urls:v1", z.array(z.string()).max(5), []); +const openSection = storedValue("mobile-dev:app-section:v1", z.enum(["permissions", "push"]).nullable(), null); +const lastPicked = storedValue("mobile-dev:last-app:v1", z.record(z.string(), z.string()), {}); +const deviceDefaults = { loading: false, apps: [], runningBundleIds: [], bundleId: null, permissions: null, links: [], busy: null, error: "", status: "" } satisfies Partial; + +export class AppToolsStore { + private state: AppToolsState = { platform: "ios", deviceId: "", disabled: true, ...deviceDefaults, recentUrls: recentUrls.read(), section: openSection.read() }; + private listeners = new Set<() => void>(); + private revision = 0; + // simctl cannot read privacy decisions, so iOS rows show what this panel last applied, keyed by app and permission. + private applied = new Map(); + // An entry exists once requested; the server caches extracted icons across panels. + private details = new Map(); + call?: (name: string, args: Record) => Promise; + subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + getSnapshot = () => this.state; + private update(patch: Partial) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); } + + configure(platform: "ios" | "android", deviceId: string, disabled: boolean) { + if (deviceId === this.state.deviceId && platform === this.state.platform) { + if (disabled !== this.state.disabled) this.update({ disabled }); + return; + } + this.revision++; + this.applied.clear(); + this.details.clear(); + this.update({ platform, deviceId, disabled, ...deviceDefaults }); + } + + get app(): AppEntry | undefined { return this.state.apps.find(app => app.bundleId === this.state.bundleId); } + + // Async results apply only while the device, and the app when given, are still the ones that started the work. + private current(bundleId?: string) { + const revision = this.revision; + return () => revision === this.revision && (bundleId === undefined || bundleId === this.state.bundleId); + } + + private tool(name: string, args: Record = {}) { + if (!this.call) return Promise.reject(new Error("This host cannot call the plugin's app tools.")); + return this.call(name, { platform: this.state.platform, deviceId: this.state.deviceId, ...args }); + } + + private measure(action: AppToolAction, work: () => Promise) { return measureUiOperation(`ui.app_tools.${action}`, "ui.app_tools.failure", work); } + + async load() { + if (!this.call || this.state.disabled || this.state.loading) return; + const current = this.current(); + this.update({ loading: true, error: "" }); + try { + const data = installedAppsSchema.parse(await this.measure("list", () => this.tool("mobile_list_apps"))); + if (!current()) return; + const installed = new Set(data.apps.map(app => app.bundleId)); + // The foreground or newest running app, then the current selection, then the app last picked on this device. + const preferred = [data.runningBundleIds[0], this.state.bundleId, lastPicked.read()[this.state.deviceId]].find(bundleId => bundleId && installed.has(bundleId)); + this.update({ apps: this.withDetails(data.apps), runningBundleIds: data.runningBundleIds, ...this.switchTo(preferred ?? data.apps[0]?.bundleId ?? null) }); + void this.loadSelected(); + void this.loadDetails(); + } catch (error) { + if (current()) this.update({ error: message(error) }); + } finally { + if (current()) this.update({ loading: false }); + } + } + + select(bundleId: string) { + if (bundleId === this.state.bundleId) return; + lastPicked.write({ ...lastPicked.read(), [this.state.deviceId]: bundleId }); + this.update({ ...this.switchTo(bundleId), error: "", status: "" }); + void this.loadSelected(); + } + + // Picking an app in the panel means "show me this app", so it launches right away. + open(bundleId: string) { + this.select(bundleId); + return this.launch(); + } + + // Keep the previous rows and suggestions until the new app's arrive, so the popover keeps its height. + private switchTo(bundleId: string | null): Partial { + if (bundleId === this.state.bundleId) return {}; + return { bundleId, permissions: this.state.permissions && this.withApplied(this.state.permissions.map(entry => ({ ...entry, state: null })), bundleId) }; + } + + private withApplied(entries: AppPermissionEntry[], bundleId = this.state.bundleId): AppPermissionEntry[] { + return entries.map(entry => ({ ...entry, state: entry.state ?? this.applied.get(`${bundleId}:${entry.permission}`) ?? null })); + } + + private withDetails(apps: InstalledApp[]): AppEntry[] { return apps.map(app => ({ ...app, ...this.details.get(app.bundleId) })); } + + private async loadSelected() { + const bundleId = this.state.bundleId; + if (!bundleId) return; + const current = this.current(bundleId); + const [permissions, links] = await Promise.allSettled([ + this.tool("mobile_app_permissions", { bundleId }).then(data => appPermissionsSchema.parse(data).permissions), + this.tool("mobile_app_links", { bundleId }).then(data => appLinksSchema.parse(data).links), + ]); + if (!current()) return; + // Suggestions are optional, so only a permission failure is shown. + this.update({ + links: links.status === "fulfilled" ? links.value : [], + ...(permissions.status === "fulfilled" ? { permissions: this.withApplied(permissions.value) } : { error: message(permissions.reason) }), + }); + } + + private async loadDetails() { + const bundleIds = this.state.apps.map(app => app.bundleId).filter(bundleId => !this.details.has(bundleId)); + if (bundleIds.length === 0) return; + for (const bundleId of bundleIds) this.details.set(bundleId, {}); + const current = this.current(); + // Icons arrive batch by batch, so devices with many apps show the first ones sooner. + for (let start = 0; start < bundleIds.length; start += appDetailsBatch) { + const batch = bundleIds.slice(start, start + appDetailsBatch); + try { + const data = appDetailsSchema.parse(await this.measure("icons", () => this.tool("mobile_app_icons", { bundleIds: batch }))); + if (!current()) return; + for (const [bundleId, details] of Object.entries(data.apps)) this.details.set(bundleId, details); + this.update({ apps: this.withDetails(this.state.apps) }); + } catch { + // Initials remain; the next panel open tries again. + if (current()) for (const bundleId of batch) this.details.delete(bundleId); + } + } + } + + private async run(action: AppToolAction, describe: (app: AppEntry) => string, work: (app: AppEntry, current: () => boolean) => Promise) { + const app = this.app; + if (!app || !this.call || this.state.disabled || this.state.busy) return; + const current = this.current(); + this.update({ busy: action, error: "", status: "" }); + try { + await this.measure(action, () => work(app, current)); + if (current()) this.update({ status: describe(app) }); + } catch (error) { + if (current()) this.update({ error: message(error) }); + } finally { + if (current()) this.update({ busy: null }); + } + } + + private markRunning(bundleId: string, running: boolean) { + const others = this.state.runningBundleIds.filter(id => id !== bundleId); + this.update({ runningBundleIds: running ? [bundleId, ...others] : others }); + } + + launch() { + return this.run("launch", app => `Relaunched ${app.name}.`, async (app, current) => { + await this.tool("mobile_launch_app", { bundleId: app.bundleId }); + if (current()) this.markRunning(app.bundleId, true); + }); + } + + stop() { + return this.run("stop", app => `Stopped ${app.name}.`, async (app, current) => { + await this.tool("mobile_stop_app", { bundleId: app.bundleId }); + if (current()) this.markRunning(app.bundleId, false); + }); + } + + openUrl(input: string) { + const url = input.trim(); + if (!url) return Promise.resolve(); + return this.run("open_url", () => "Opened URL.", async () => { + await this.tool("mobile_open_url", { url }); + const urls = [url, ...this.state.recentUrls.filter(entry => entry !== url)].slice(0, 5); + recentUrls.write(urls); + this.update({ recentUrls: urls }); + }); + } + + setPermission(permission: AppPermission | "all", state: PermissionState) { + return this.run("permission", () => permission === "all" ? "Reset all permissions." : `Updated ${permission} permission.`, async (app, current) => { + const { permissions } = appPermissionsSchema.parse(await this.tool("mobile_set_app_permission", { bundleId: app.bundleId, permission, state })); + // A result that lands after a device switch belongs to the previous device. + if (!current()) return; + for (const entry of permission === "all" ? platformPermissions[this.state.platform] : [permission]) this.applied.set(`${app.bundleId}:${entry}`, state); + if (app.bundleId === this.state.bundleId) this.update({ permissions: this.withApplied(permissions) }); + }); + } + + setSection(section: AppSection | null) { + openSection.write(section); + this.update({ section }); + } + + push(payload: Record) { + return this.run("push", app => `Sent notification to ${app.name}.`, async app => { + await this.tool("mobile_send_push", { bundleId: app.bundleId, payload }); + }); + } + + dispose() { this.call = undefined; this.configure(this.state.platform, "", true); } +} + +function message(error: unknown) { return error instanceof Error ? error.message : String(error); } + +const stores = elementStores(() => new AppToolsStore()); +export const bindAppTools = stores.bind; +export const getAppTools = stores.get; diff --git a/src/ui/components/animated-height.tsx b/src/ui/components/animated-height.tsx new file mode 100644 index 00000000..89cb74a2 --- /dev/null +++ b/src/ui/components/animated-height.tsx @@ -0,0 +1,17 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +// Animates height changes of its content. The clip margin keeps focus rings at the edges visible. +export function AnimatedHeight({ children }: { children: React.ReactNode }) { + const inner = useRef(null); + const [height, setHeight] = useState(); + useLayoutEffect(() => { + const element = inner.current; + if (!element || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setHeight(element.offsetHeight)); + observer.observe(element); + return () => observer.disconnect(); + }, []); + return
+
{children}
+
; +} diff --git a/src/ui/components/app-tools.tsx b/src/ui/components/app-tools.tsx new file mode 100644 index 00000000..4daaea8a --- /dev/null +++ b/src/ui/components/app-tools.tsx @@ -0,0 +1,202 @@ +import { useId, useRef, useState, useSyncExternalStore } from "react"; +import type { LucideIcon } from "lucide-react"; +import { ActivityIcon, ArrowRightIcon, BellIcon, BracesIcon, CalendarIcon, CameraIcon, ChevronDownIcon, ClockIcon, ContactIcon, ImageIcon, InfoIcon, ListChecksIcon, MapPinIcon, MicIcon, RotateCcwIcon, RotateCwIcon, SendIcon, ShieldIcon, SquareIcon } from "lucide-react"; +import { z } from "zod"; +import type { AppPermission, PermissionState } from "../../shared/app-control"; +import type { AppEntry, AppSection, AppToolsState, AppToolsStore } from "../app-tools"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "./ui/input-group"; +import { Textarea } from "./ui/textarea"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "./ui/base-select"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; + +type SectionProps = { store: AppToolsStore; state: AppToolsState; app: AppEntry; blocked: boolean }; + +const permissionRows: Record = { + location: { label: "Location", icon: MapPinIcon }, photos: { label: "Photos", icon: ImageIcon }, camera: { label: "Camera", icon: CameraIcon }, + microphone: { label: "Microphone", icon: MicIcon }, notifications: { label: "Notifications", icon: BellIcon }, contacts: { label: "Contacts", icon: ContactIcon }, + calendar: { label: "Calendar", icon: CalendarIcon }, reminders: { label: "Reminders", icon: ListChecksIcon }, motion: { label: "Motion & fitness", icon: ActivityIcon }, +}; +// Only a chosen state is colored, so rows nobody has set stay neutral. +const permissionOptions = [ + { value: "allow", label: "Allow", on: "data-[state=on]:bg-green-500/15 data-[state=on]:text-green-700 dark:data-[state=on]:text-green-400" }, + { value: "deny", label: "Deny", on: "data-[state=on]:bg-destructive/10 data-[state=on]:text-destructive" }, + { value: "ask", label: "Ask", on: "data-[state=on]:text-foreground" }, +] as const satisfies readonly { value: PermissionState; label: string; on: string }[]; +const payloadSchema = z.record(z.string(), z.unknown()); + +// A collapsible row; the App tab keeps at most one open so the popover stays short. +function Disclosure({ section, state, store, icon: Icon, title, summary, children }: { section: AppSection; state: AppToolsState; store: AppToolsStore; icon: LucideIcon; title: string; summary?: React.ReactNode; children: React.ReactNode }) { + const open = state.section === section; + return store.setSection(value ? section : null)} className="border-t py-1"> + + + {title} + {!open && summary && {summary}} + + + {children} + ; +} + +// Matches the device picker's running dot so both pickers read the same way. +function AppAvatar({ app, running, small = false }: { app: AppEntry; running: boolean; small?: boolean }) { + const shape = small ? "size-6 rounded-md text-xs" : "size-8 rounded-lg text-sm"; + return + {app.icon ? : app.name.trim().charAt(0).toUpperCase() || "?"} + {running && } + ; +} + +function AppPicker({ store, state, app, blocked }: SectionProps) { + const running = state.runningBundleIds.includes(app.bundleId); + const action = "h-auto w-9 shrink-0 rounded-none border-l border-l-border text-muted-foreground hover:text-foreground focus-visible:z-10"; + return
+ + + +
; +} + +function OpenUrl({ store, state, blocked }: SectionProps) { + const [url, setUrl] = useState(""); + const input = useRef(null); + const suggestions = state.links.filter(link => !state.recentUrls.includes(link)); + const chip = "max-w-44 shrink-0 font-mono font-normal"; + return
+
{ event.preventDefault(); if (url.trim()) void store.openUrl(url); else input.current?.focus(); }}> + + setUrl(event.target.value)} /> + Open + +
+ {/* One fixed-height row, so switching apps never changes the popover height. */} +
+ {state.recentUrls.map(recent => )} + {/* Declared schemes are usually incomplete, so they start a URL instead of opening one. */} + {suggestions.map(link => )} + {suggestions.length === 0 && state.recentUrls.length === 0 && This app declares no URL schemes.} +
+
; +} + +function permissionSummary(permissions: AppToolsState["permissions"]) { + if (permissions === null) return "Loading…"; + if (permissions.length === 0) return "None requested"; + const allowed = permissions.filter(entry => entry.state === "allow").length; + const denied = permissions.filter(entry => entry.state === "deny").length; + if (!allowed && !denied) return `${permissions.length} available`; + return <> + {allowed > 0 && {allowed} allowed} + {allowed > 0 && denied > 0 && " · "} + {denied > 0 && {denied} denied} + ; +} + +function Permissions({ store, state, blocked }: SectionProps) { + const id = useId(); + const permissions = state.permissions; + return + {permissions === null ?

Loading permissions…

+ : permissions.length === 0 ?

This app does not request supported runtime permissions.

+ :
+ {permissions.map(entry => { + const { label, icon: Icon } = permissionRows[entry.permission]; + return
+ + {label} + { const option = permissionOptions.find(candidate => candidate.value === value); if (option) void store.setPermission(entry.permission, option.value); }}> + {permissionOptions.map(option => {option.label})} + +
; + })} + + {state.platform === "ios" &&

The simulator doesn't report current choices. Highlights show what you set here.

} +
} +
; +} + +function PushNotification({ store, state, app, blocked }: SectionProps) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + // Undefined while composing with title and body; the raw JSON text while editing the payload. + const [json, setJson] = useState(); + const [jsonError, setJsonError] = useState(""); + const formPayload = () => ({ aps: { alert: { title: title.trim() || app.name, ...(body.trim() ? { body: body.trim() } : {}) }, sound: "default" } }); + const send = () => { + if (json === undefined) { void store.push(formPayload()); return; } + try { + const payload = payloadSchema.safeParse(JSON.parse(json)); + if (!payload.success) throw new Error("Payload must be a JSON object."); + setJsonError(""); + void store.push(payload.data); + } catch (error) { setJsonError(error instanceof Error ? error.message : String(error)); } + }; + return +
{ event.preventDefault(); send(); }}> + {json === undefined ? <> + setTitle(event.target.value)} /> +