From 9ad339ddf929e24533034a0a8d7215ef83275e4e Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Fri, 23 Oct 2026 14:48:37 +0400 Subject: [PATCH 01/22] feat(claude): add desktop manifest and packaging workflow --- claude-desktop/manifest.json | 22 ++++++++++++++++++++++ package.json | 3 +++ scripts/claude-config.mjs | 19 +++++++++++++++++++ scripts/package-claude.mjs | 26 ++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 claude-desktop/manifest.json create mode 100644 scripts/claude-config.mjs create mode 100644 scripts/package-claude.mjs diff --git a/claude-desktop/manifest.json b/claude-desktop/manifest.json new file mode 100644 index 0000000..1e905de --- /dev/null +++ b/claude-desktop/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": "0.3", + "name": "mobile-dev", + "display_name": "Mobile Dev", + "version": "0.1.164", + "description": "Inspect local iOS and Android devices, logs and performance in Claude Desktop. Initial Apple Silicon development preview.", + "author": { "name": "Callstack" }, + "server": { + "type": "node", + "entry_point": "dist/server.mjs", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/dist/server.mjs"], + "env": { "MOBILE_DEV_HOST": "claude-desktop" } + } + }, + "compatibility": { + "platforms": ["darwin"], + "runtimes": { "node": ">=22.19.0" } + }, + "tools_generated": true +} diff --git a/package.json b/package.json index 221c458..351c1db 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "test:ios-cpu": "node scripts/smoke-ios-cpu.mjs", "package": "node scripts/package.mjs", "package:release": "node scripts/package.mjs --release", + "claude:config": "node scripts/claude-config.mjs", + "package:claude": "node scripts/package-claude.mjs", + "test:claude": "node scripts/smoke-claude-package.mjs", "vendor:serve-emu": "node scripts/vendor-serve-emu.mjs", "dev": "node scripts/dev-ui.mjs", "dev:off": "node scripts/dev-ui.mjs --off", diff --git a/scripts/claude-config.mjs b/scripts/claude-config.mjs new file mode 100644 index 0000000..128ee18 --- /dev/null +++ b/scripts/claude-config.mjs @@ -0,0 +1,19 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +// Generate a snippet only; never replace the user's existing Claude configuration. +await access("dist/server.mjs"); +const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8")); +const config = { + mcpServers: { + "mobile-dev": { + command: process.execPath, + args: [resolve(manifest.server.entry_point)], + env: manifest.server.mcp_config.env, + }, + }, +}; +await mkdir(".local-dev", { recursive: true }); +const path = resolve(".local-dev/claude-desktop-config.json"); +await writeFile(path, JSON.stringify(config, null, 2) + "\n"); +console.log(`Merge the mcpServers entry from ${path} into Claude Desktop Settings > Developer > Edit Config, then fully restart Claude.`); diff --git a/scripts/package-claude.mjs b/scripts/package-claude.mjs new file mode 100644 index 0000000..e9b0651 --- /dev/null +++ b/scripts/package-claude.mjs @@ -0,0 +1,26 @@ +import { execFileSync } from "node:child_process"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "./telemetry-build.mjs"; + +// Reuse the existing artifact inventory, native assets and environment checks. +telemetryBuildEnvironment(); +execFileSync(process.execPath, ["scripts/package.mjs", ...process.argv.slice(2)], { stdio: "inherit" }); +const metadata = JSON.parse(await readFile("package.json", "utf8")); +const manifest = JSON.parse(await readFile("claude-desktop/manifest.json", "utf8")); +manifest.version = metadata.version; +const source = resolve("release/marketplace/plugins/mobile-dev"); +const directory = resolve("release/claude-desktop/mobile-dev"); +await rm(directory, { recursive: true, force: true }); +await mkdir(directory, { recursive: true }); +for (const path of ["dist", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]) { + await cp(`${source}/${path}`, `${directory}/${path}`, { recursive: true, verbatimSymlinks: true }); +} +await cp("docs/claude-desktop.md", `${directory}/README.md`); +await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n"); +const archive = resolve(`release/mobile-dev-${manifest.version}-claude-desktop-darwin-arm64.mcpb`); +await rm(archive, { force: true }); +// MCPB is a ZIP with manifest.json at its root. Preserve executable modes. +execFileSync("zip", ["-qr", archive, "manifest.json", "dist", "README.md", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"], { cwd: directory }); +execFileSync("unzip", ["-tq", archive], { stdio: "inherit" }); +console.log(`Claude Desktop bundle: ${archive}`); From c0058c8920ff3e8a6c6f182da71e495365f5c3ee Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Fri, 23 Oct 2026 14:49:06 +0400 Subject: [PATCH 02/22] fix(claude): ask for ambiguous devices in chat --- src/server/plugin.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/server/plugin.ts b/src/server/plugin.ts index d592bdc..3efd20a 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -90,22 +90,27 @@ function guarded(handler: (input: T, context: ServerRequestContext) => Promis } export async function createPlugin(html: string | (() => Promise), baguette = new Baguette(), simulatorInput: SimulatorInput = new SimulatorInputService(udid => baguette.repairInput(udid)), logs = new LogSessions(), android = new ServeEmu(), copyScreenshot = copyPNGToClipboard, providedCpu?: CpuSessions) { + const claudeDesktop = process.env.MOBILE_DEV_HOST === "claude-desktop"; const streams = new StreamSessions(baguette); let selectedCpu = providedCpu; if (selectedCpu === undefined) selectedCpu = await createCpuSessions(); const cpu = selectedCpu; const fps = new DisplayFpsSessions(); const server = new McpServer({ name: "mobile-dev", version: PLUGIN_VERSION }, { - instructions: "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.", + instructions: claudeDesktop + ? "For mobile app development, call mobile_open_workspace to show Mobile Dev in the conversation. The user can expand it to fullscreen. Use selected device IDs. If device targets are ambiguous, discover candidates and ask the user in chat; wait for their answer before starting the task. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate." + : "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.", }); instrumentMcpServer(server); const updates = new PluginUpdates(); addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS }); registerPluginUpdateTools(server, updates); - const extensions = new OpenAIExtensions(server); - registerDeviceChoiceTools(server, extensions, { - simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices, - }); + if (!claudeDesktop) { + const extensions = new OpenAIExtensions(server); + registerDeviceChoiceTools(server, extensions, { + simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices, + }); + } registerIosDeviceTools(server); const closeIosMirror = registerIosMirrorTools(server, APP_URI); registerLogTools(server, logs, baguette); From fb9f25979d690cff37853c0847f3dbc4fca89605 Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Fri, 23 Oct 2026 14:49:26 +0400 Subject: [PATCH 03/22] fix(claude): disable codex updater and polling --- src/server/plugin.ts | 3 ++- src/ui/workspace-app.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/plugin.ts b/src/server/plugin.ts index 3efd20a..125ea75 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -102,7 +102,7 @@ export async function createPlugin(html: string | (() => Promise), b : "For mobile app development, open mobile_open_simulator beside the chat before the first device launch, or reuse the panel. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask with mobile_choose_devices; proceed only after action accept. For a local CPU or React profile path, use mobile_analyze_profile to show TraceSift findings, then wait for the user to choose Investigate. See the Mobile Dev skill for device control, logs, profiles, and performance workflows.", }); instrumentMcpServer(server); - const updates = new PluginUpdates(); + const updates = new PluginUpdates(claudeDesktop ? { enabled: false } : {}); addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS }); registerPluginUpdateTools(server, updates); if (!claudeDesktop) { @@ -164,6 +164,7 @@ export async function createPlugin(html: string | (() => Promise), b } function configureUI(content: string): string { + if (claudeDesktop) content = content.replace(" { performancePanel.setAvailable(available); storagePanel.setAvailable(available); deviceApps.setAvailable(available); - updates.setAvailable(available); + updates.setAvailable(available && !claudeDesktop); for (const panel of panels) if (!available) panel.notice("This host cannot call the plugin's simulator tools."); if (available) { stopLiveReload = startLiveReload(app, disposeUI); From 45535fe12e562890425599abe08effe49002a3bf Mon Sep 17 00:00:00 2001 From: Mike Grabowski Date: Fri, 23 Oct 2026 14:49:27 +0400 Subject: [PATCH 04/22] feat(ui): support fullscreen in claude desktop --- src/server/plugin.ts | 8 ++++++-- src/ui/components/display-mode-button.tsx | 20 ++++++++++++++++++++ src/ui/components/workspace.tsx | 4 +++- src/ui/workspace-app.tsx | 9 ++++++--- 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 src/ui/components/display-mode-button.tsx diff --git a/src/server/plugin.ts b/src/server/plugin.ts index 125ea75..ffdd54e 100644 --- a/src/server/plugin.ts +++ b/src/server/plugin.ts @@ -255,7 +255,9 @@ export async function createPlugin(html: string | (() => Promise), b registerAppTool(server, "mobile_open_workspace", { title: "Mobile Dev", - description: "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.", + description: claudeDesktop + ? "Open Mobile Dev in the conversation, with an Expand button for fullscreen devices, logs and performance tools. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting a device." + : "Open the fullscreen Mobile Dev workspace when the user asks for fullscreen, with logs and performance tools beside the devices. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development beside a chat, prefer mobile_open_simulator. Starts the bundled backend without booting a device.", inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write, _meta: { ui: { resourceUri: WORKSPACE_URI, visibility: ["app", "model"] }, @@ -265,7 +267,9 @@ export async function createPlugin(html: string | (() => Promise), b registerAppTool(server, "mobile_open_simulator", { title: "Mobile Dev", - description: "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.", + description: claudeDesktop + ? "Open a compact Mobile Dev view in the conversation. Prefer mobile_open_workspace for devices with logs and performance tools. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices." + : "Open Mobile Dev beside the chat for local mobile app development, before the first device launch unless a panel is open or the workflow is tool-only. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. Starts the bundled backend without booting devices.", inputSchema: panelTargetSchema, outputSchema: { ...statusOutput, panelTarget: panelTargetSchema }, annotations: write, _meta: { ui: { resourceUri: APP_URI, visibility: ["app", "model"] }, diff --git a/src/ui/components/display-mode-button.tsx b/src/ui/components/display-mode-button.tsx new file mode 100644 index 0000000..ff9b9ad --- /dev/null +++ b/src/ui/components/display-mode-button.tsx @@ -0,0 +1,20 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { useState } from "react"; +import { Button } from "./ui/button"; +import { captureUiError, countUiEvent } from "../telemetry.ts"; + +export function DisplayModeButton({ app }: { app: App }) { + const host = app.getHostContext(); + const [busy, setBusy] = useState(false); + const mode = host?.displayMode === "fullscreen" ? "inline" : "fullscreen"; + if (!host?.availableDisplayModes?.includes(mode)) return null; + return ; +} diff --git a/src/ui/components/workspace.tsx b/src/ui/components/workspace.tsx index a5c6262..f74cca4 100644 --- a/src/ui/components/workspace.tsx +++ b/src/ui/components/workspace.tsx @@ -1,5 +1,6 @@ import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon } from "lucide-react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; import type { LogsPanel } from "../logs-panel.ts"; import type { PerformancePanel } from "../performance-panel.ts"; import type { StoragePanel } from "../storage-panel.ts"; @@ -31,7 +32,7 @@ const tools = { type Tool = keyof typeof tools; const isTool = (value: string): value is Tool => value in tools; -export function Workspace({ logs, performance, storage, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { +export function Workspace({ logs, performance, storage, recordingController, updates, displayModeControl, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; recordingController: RecordingController; updates?: PluginUpdateController; displayModeControl?: ReactNode; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { const [tool, setTool] = useState("logs"); const [savedVisible, setSavedVisible] = useState(false); const [simulatorsCollapsed, setSimulatorsCollapsed] = useState(false); @@ -106,6 +107,7 @@ export function Workspace({ logs, performance, storage, recordingController, upd const menuSide = bottomToolbar ? "top" : "bottom"; const ToolIcon = tools[tool].icon; const toolbar =