diff --git a/skills/build-codex-native-plugins/SKILL.md b/.agents/skills/build-codex-native-plugins/SKILL.md similarity index 100% rename from skills/build-codex-native-plugins/SKILL.md rename to .agents/skills/build-codex-native-plugins/SKILL.md diff --git a/skills/build-codex-native-plugins/agents/openai.yaml b/.agents/skills/build-codex-native-plugins/agents/openai.yaml similarity index 100% rename from skills/build-codex-native-plugins/agents/openai.yaml rename to .agents/skills/build-codex-native-plugins/agents/openai.yaml diff --git a/skills/build-codex-native-plugins/references/events.md b/.agents/skills/build-codex-native-plugins/references/events.md similarity index 100% rename from skills/build-codex-native-plugins/references/events.md rename to .agents/skills/build-codex-native-plugins/references/events.md diff --git a/skills/build-codex-native-plugins/references/files.md b/.agents/skills/build-codex-native-plugins/references/files.md similarity index 100% rename from skills/build-codex-native-plugins/references/files.md rename to .agents/skills/build-codex-native-plugins/references/files.md diff --git a/skills/build-codex-native-plugins/references/mcp-server.md b/.agents/skills/build-codex-native-plugins/references/mcp-server.md similarity index 100% rename from skills/build-codex-native-plugins/references/mcp-server.md rename to .agents/skills/build-codex-native-plugins/references/mcp-server.md diff --git a/skills/build-codex-native-plugins/references/native-ui.md b/.agents/skills/build-codex-native-plugins/references/native-ui.md similarity index 100% rename from skills/build-codex-native-plugins/references/native-ui.md rename to .agents/skills/build-codex-native-plugins/references/native-ui.md diff --git a/skills/build-codex-native-plugins/references/packaging.md b/.agents/skills/build-codex-native-plugins/references/packaging.md similarity index 100% rename from skills/build-codex-native-plugins/references/packaging.md rename to .agents/skills/build-codex-native-plugins/references/packaging.md diff --git a/skills/build-codex-native-plugins/references/settings-and-forms.md b/.agents/skills/build-codex-native-plugins/references/settings-and-forms.md similarity index 100% rename from skills/build-codex-native-plugins/references/settings-and-forms.md rename to .agents/skills/build-codex-native-plugins/references/settings-and-forms.md diff --git a/skills/build-codex-native-plugins/references/sources.md b/.agents/skills/build-codex-native-plugins/references/sources.md similarity index 100% rename from skills/build-codex-native-plugins/references/sources.md rename to .agents/skills/build-codex-native-plugins/references/sources.md diff --git a/skills/build-codex-native-plugins/references/testing-and-release.md b/.agents/skills/build-codex-native-plugins/references/testing-and-release.md similarity index 100% rename from skills/build-codex-native-plugins/references/testing-and-release.md rename to .agents/skills/build-codex-native-plugins/references/testing-and-release.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f3a80cc..3b1d2576 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,8 +43,8 @@ jobs: cache: npm cache-dependency-path: | package-lock.json - runtimes/agent-device/package-lock.json - runtimes/serve-emu/package-lock.json + packages/runtimes/agent-device/package-lock.json + packages/runtimes/serve-emu/package-lock.json - name: Validate release version and tag id: release @@ -52,7 +52,7 @@ jobs: RELEASE_TAG: ${{ inputs.tag || github.ref_name }} run: | git show-ref --verify "refs/tags/$RELEASE_TAG" - node scripts/release-metadata.mjs + node tooling/release-metadata.mjs - name: Check Sentry configuration env: @@ -88,7 +88,7 @@ jobs: id: native env: GH_TOKEN: ${{ github.token }} - run: node scripts/release-native.mjs + run: node tooling/release-native.mjs - name: Run tests run: | @@ -209,7 +209,7 @@ jobs: run: | gh api "repos/$GH_REPO/releases" --paginate \ --jq '.[] | select(.draft == false) | .tag_name' > "$RUNNER_TEMP/published-release-tags.txt" - node scripts/release-notes.mjs \ + node tooling/release-notes.mjs \ "$RUNNER_TEMP/published-release-tags.txt" "$RUNNER_TEMP/release-notes.md" gh release create "$RELEASE_TAG" \ "release/mobile-dev-$RELEASE_VERSION-darwin-arm64.zip" \ @@ -248,4 +248,4 @@ jobs: run: | mkdir "$RUNNER_TEMP/mobile-dev-marketplace" unzip -q "release/mobile-dev-$RELEASE_VERSION-darwin-arm64.zip" -d "$RUNNER_TEMP/mobile-dev-marketplace" - node scripts/publish-release.mjs "$RUNNER_TEMP/mobile-dev-marketplace" + node apps/codex-plugin/tooling/publish-release.mjs "$RUNNER_TEMP/mobile-dev-marketplace" diff --git a/.gitignore b/.gitignore index 5fc308c2..51a45bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,11 @@ node_modules/ .husky/_/ dist/ -vendor/baguette/ +packages/native/vendor/baguette/ release/ .DS_Store *.log .local-dev/ .sentry/ .env.sentry-build-plugin -native/ios-mirror/target/ +packages/native/ios-mirror/target/ diff --git a/AGENTS.md b/AGENTS.md index 1fc74348..2960170a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,14 @@ # Project instructions +## Repository layout + +This is an npm workspace: shared code lives in `packages/` (`shared`, `core`, `ui`, +`mcp-app`, `skills`, `native`, `runtimes`) and each shipped product in `apps/` (`codex-plugin`, +`claude-desktop`, `mac`). Keep host chrome out of `packages/ui`: chrome every MCP Apps +host shares goes in `packages/mcp-app`, chrome for one host in its app. Drive host +differences from advertised capabilities rather than host names. See +[CONTRIBUTING.md](CONTRIBUTING.md#repository-layout). + ## Commit messages Use Conventional Commits for new source commits and pull request titles, for @@ -26,16 +35,17 @@ new telemetry when existing coverage already answers the relevant questions. monitored device/app's data. Browser frame intervals are callback pacing, and device input round trips end at acknowledgement; neither is device rendered FPS or touch-to-photon latency. -- Reuse `src/ui/telemetry.ts`: `recordUiTiming`, `setUiGauge`, `countUiEvent`, +- Reuse `packages/ui/src/telemetry.ts`: `recordUiTiming`, `setUiGauge`, `countUiEvent`, `setUiSurface`, `setUiTelemetryContext`, and `markUiSurfaceReady`. Use - `captureUiError` and `src/server/telemetry.ts`'s `captureServerError` for relevant + `captureUiError` and `packages/core/src/telemetry.ts`'s `captureServerError` for relevant handled failures. Keep SDK initialization centralized in the existing UI and server telemetry modules, with shared sampling and scrubbing in - `src/shared/telemetry.ts`. -- Native helpers share `native/telemetry/telemetry.c` and its Rust wrapper. Reuse + `packages/shared/src/telemetry.ts`. +- Native helpers share `packages/native/telemetry/telemetry.c` and its Rust wrapper. Reuse their bounded timing windows and resource sampler. Rebuild affected helpers after changing shared telemetry, retain matching symbols in `.sentry/native`, - and upload them to `codex-mobile-dev-native` with the same plugin release. + and upload them to the native project in `tooling/sentry-projects.mjs` with the same + plugin release. - Use sampled traces for meaningful operations spanning UI and server work. The existing MCP wrapper propagates trace context; preserve it when changing the bridge. Use bounded aggregate metrics for frequent frame, polling, processing, @@ -62,7 +72,7 @@ new telemetry when existing coverage already answers the relevant questions. Node feature change into native Sentry setup incidentally. - Verify instrumentation on the changed execution path, including surface attribution and observer/timer cleanup where relevant. Use the existing - `tests/telemetry.test.ts` and `tests/ui-telemetry.test.ts` for meaningful telemetry + `packages/core/test/telemetry.test.ts` and `packages/ui/test/ui-telemetry.test.ts` for meaningful telemetry behavior checks. In the completion summary, state what coverage was added or preserved, or why existing coverage is sufficient. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f5755e9..fb942ff2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,74 @@ This guide covers building the plugin, installing it locally, and working on its UI and native helpers. To use the prebuilt plugin, see the [README](README.md#install). +## Repository layout + +The repository is an npm workspace. Host-independent code lives in `packages/`; each +product that ships it lives in `apps/`. Adding a host means adding an app; the shared +packages know no host by name. + +| Path | Contents | +| --- | --- | +| `packages/shared` (`@mobile-dev/shared`) | Protocol types and pure logic used by the server and every UI host. | +| `packages/core` (`@mobile-dev/core`) | The MCP server, device runtimes and tool handlers. A host adds to it with `ServerFeature`s passed to `runStdioServer`. | +| `packages/ui` (`@mobile-dev/ui`) | Host-independent React views, panel controllers, theme and UI telemetry. Views reach the host only through `HostBridge`. | +| `packages/mcp-app` (`@mobile-dev/mcp-app`) | The MCP Apps panel every MCP Apps host loads. Host differences follow advertised capabilities; a host adds chrome through `startMcpApp`. | +| `packages/native` | Native helper sources; `packages/native/vendor` holds their verified prebuilt binaries. | +| `packages/runtimes` | Pinned Node runtimes bundled into the server (agent-device, serve-emu). | +| `packages/skills` | Agent skills for the shared tools; each host's package step includes the ones its host supports. | +| `apps/codex-plugin` | Codex: manifest, launcher, assets, and its own server (`server/`, the self-updater) and panel (`ui/`, the update banner). | +| `apps/claude-desktop` | Claude Desktop: the MCPB manifest. It runs the shared server and panel unchanged. | +| `apps/mac` | The Mac app: a SwiftUI shell (`Sources/`), the page it hosts (`web/`) and its server (`server/`). | +| `packages/test-support` | Fixtures shared by tests across packages and apps. | +| `tooling` | Shared build, native, release and device smoke-test scripts. Run them from the repository root. | + +Each app's `tooling/` holds that host's packaging and smoke tests. + +Tests live beside the code they cover, in each package's or app's `test/` folder; +`tooling/test` covers the release and build scripts. Fixtures several packages share +are in `packages/test-support`. `npm test` runs them all from the repository root, +which test paths such as `packages/ui/src/...` are relative to. + +Import across packages with the workspace name and an explicit extension, for example +`@mobile-dev/shared/logs.ts`. `packages/ui` never imports from `packages/mcp-app` or an +app. Chrome every MCP Apps host shares belongs in `packages/mcp-app`; chrome for one +host, such as the Mac sidebar or Codex's update banner, belongs in that host's app. + +`npm run build` writes the host-neutral runtime to `dist/`: the shared server, its +services and natives, and the MCP Apps panel. Each host's package step copies that +runtime and builds the host's own pieces into the copy: Codex its server and panel, +the Mac app its server and page. No host packages through another, and the shared +build knows no host. The release version comes +from the root `package.json`; each package step writes it into its host's manifest. +The published `release/latest` layout is unchanged. + +## Mac app + +The Mac app runs the same server and shared UI as the Codex plugin. A SwiftUI +window provides the native sidebar (tools and visible device platforms); a web +view renders `apps/mac/web`, the shared views without the Codex toolbar. + +The app starts the bundled server over stdio exactly as Codex does and relays +JSON-RPC lines between it and the page. Before the page connects, the app reads +`ui://mobile-dev/mac.html` from the server, so the page gets the server's telemetry +metadata. The page implements `HostBridge` (`packages/ui/src/host-bridge.ts`) with +an MCP client and offers no chat capabilities, so chat-only actions stay hidden. + +```sh +npm run build # shared runtime in dist/ +npm run build:mac # release/mac/Mobile Dev.app, signed ad hoc +npm run test:mac # launches the app, waits for the page to connect and draw, quits +open "release/mac/Mobile Dev.app" +``` + +`build:mac` copies the runtime from `dist/`, builds the Mac server and page into it, and +adds the Node executable running the build +(pass `--node ` for another self-contained build) into the app. Distribution +still needs a Developer ID signature, hardened-runtime entitlements for Node, and +notarization. `test:mac` uses the app's `MOBILE_DEV_SMOKE_SNAPSHOT` hook to save a +page snapshot because it cannot rely on Screen Recording permission; pass a path to +keep it. Restart the server from **View › Restart Server** (⇧⌘R). + ## Development requirements - An Apple Silicon Mac with Xcode 27 and Swift 6.4 or later selected as the active @@ -63,13 +131,13 @@ changing native code or shared native telemetry; the build validates source and binary hashes. For UI or server edits with unchanged native sources, run the build, relevant checks, and packaging steps again. -`vendor:baguette` downloads the pinned official release and checks its SHA-256. It keeps the full resource bundle and rebuilds the same v0.2.1 source with Swift 6.4 or later because the official binary crashes in Swift task allocation on macOS 27. Preparing Baguette requires Xcode 27, Git, and network access to fetch its pinned source and Swift dependencies. `rebuild:baguette` repeats the source build. `vendor/baguette/release.json` records the source commit, compiler version, and rebuilt binary SHA-256. +`vendor:baguette` downloads the pinned official release and checks its SHA-256. It keeps the full resource bundle and rebuilds the same v0.2.1 source with Swift 6.4 or later because the official binary crashes in Swift task allocation on macOS 27. Preparing Baguette requires Xcode 27, Git, and network access to fetch its pinned source and Swift dependencies. `rebuild:baguette` repeats the source build. `packages/native/vendor/baguette/release.json` records the source commit, compiler version, and rebuilt binary SHA-256. -`vendor:agent-device` installs the exact npm packages in `runtimes/agent-device/package-lock.json` with package scripts disabled. `build` copies this full runtime into `dist/agent-device`, records its integrity and lockfile hash in `release.json`, and saves its version-matched help in the control skill. The installed plugin needs no npm install or runtime download. Xcode compiles the shipped Apple runner source on its first use. +`vendor:agent-device` installs the exact npm packages in `packages/runtimes/agent-device/package-lock.json` with package scripts disabled. `build` copies this full runtime into `dist/agent-device`, records its integrity and lockfile hash in `release.json`, and saves its version-matched help in the control skill. The installed plugin needs no npm install or runtime download. Xcode compiles the shipped Apple runner source on its first use. `build` also bundles JavaScript and CSS with esbuild and copies Baguette into `dist/baguette`. It does not run TypeScript, lint, Biome, visual checks, or React Doctor. -`package` copies built files into `release/marketplace/plugins/mobile-dev` and creates the ZIP. It includes agent-device's runtime `node_modules`, licenses, and Apple runner source. The Codex compatibility manifest lives at `.codex-plugin/plugin.json` and declares `.mcp.json`. The MCP entry invokes `scripts/launch-mcp.sh` through `/bin/sh` and explicitly forwards `HOME` and the telemetry settings. The launcher directly executes Codex's bundled Node at `$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node`. `npm start` uses the same launcher. The source repo's existing build-codex-native-plugins skill stays outside that release package. +`package` copies built files into `release/marketplace/plugins/mobile-dev` and creates the ZIP. It includes agent-device's runtime `node_modules`, licenses, and Apple runner source. The Codex compatibility manifest lives at `apps/codex-plugin/.codex-plugin/plugin.json` and declares `.mcp.json`. The MCP entry invokes `apps/codex-plugin/scripts/launch-mcp.sh` (`scripts/launch-mcp.sh` inside the package) through `/bin/sh` and explicitly forwards `HOME` and the telemetry settings. The launcher directly executes Codex's bundled Node at `$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node`. `npm start` uses the same launcher. The `build-codex-native-plugins` skill in `.agents/skills` helps agents working on this repository and is not shipped. `test` checks MCP contracts, frame reads, input validation, and capture cleanup against a local fixture, plus runtime selection and argument forwarding with Node absent from PATH or another Node present. `test:package` copies the release package into a temporary directory and uses its actual MCP launch configuration with a system-only PATH. The smoke-test caller supplies an isolated home with the Codex runtime cache layout and its test Node executable; the MCP process has a system-only PATH. Run these checks with the Codex bundled Node executable to verify the packaged runtime. It checks native addon loading, sidebar/chat entrypoints, UI resources, and telemetry attribution, starts bundled Baguette, reads the real device list, and checks shutdown. It does not boot or change a simulator. Run it on an Apple Silicon Mac with Xcode. @@ -93,6 +161,8 @@ not included in the plugin package. ## Install the local build +### Codex + From this project directory, after packaging: ```sh @@ -104,9 +174,48 @@ Open a new chat after installing, then open Mobile Dev from the sidebar. `npm run package` writes the local ZIP to `release/mobile-dev--darwin-arm64.zip`. Install through the local marketplace above. The New Plugin archive dialog uploads to the workspace plugin service; it is a separate install route. This package has not gone through public directory review or publication. +### Claude Desktop local development + +Complete the first-time runtime and native-helper setup under [Build and package](#build-and-package), +then run from this checkout with Node.js 22.19 or later: + +```sh +npm run build +npm run claude:config +``` + +1. Open Claude Desktop **Settings > Developer > Edit Config**. On macOS, this is + `~/Library/Application Support/Claude/claude_desktop_config.json`. +2. Merge the `mobile-dev` entry from `.local-dev/claude-desktop-config.json` into + the existing `mcpServers` object, preserving other servers and settings. The + snippet uses absolute paths to this checkout and the Node executable running + the generator. +3. Fully quit and reopen Claude, start a new conversation, and ask **“Open Mobile Dev.”** + Approve the connector when prompted; use **Expand** when available. + +After UI or server edits, run `npm run build` and fully restart Claude. This +workflow uses the checkout directly and needs no repackaging. Regenerate the +config if the checkout or Node path changes. For connection failures, check +**Settings > Developer** for the server status and logs. + +To create and check a bundle for manual installation instead: + +```sh +npm run package:claude +npm run test:claude +``` + +This writes `release/mobile-dev--claude-desktop-darwin-arm64.mcpb`. +Follow the [manual installation steps](README.md#claude-desktop-manual-installation). +Remove the `mobile-dev` development entry before installing the bundle to avoid +duplicate servers. Rebuild, repackage, and reinstall to update a bundled copy. + +Local builds retain the `development` telemetry environment. Public releases use +`npm run build:release` and `npm run package:claude -- --release`. + ## Live React UI development -Run `npm run dev` after installing the plugin. It watches the UI sources and +In Codex, run `npm run dev` after installing the plugin. It watches the UI sources and rebuilds a self-contained HTML bundle. Open panels read updates through MCP, then reload and reconnect their streams. Edits reset UI state. @@ -124,7 +233,7 @@ last working UI. Set `MOBILE_DEV_PLUGIN_ROOT` for a nonstandard plugin install. ## UI conventions -The panel uses React and shadcn/ui with preset `b0`, Nova controls, neutral colors, Inter, and Lucide icons. The build bundles the SVGs and font files into the HTML; the panel needs no external asset requests. Use the preset components in `src/ui/components/ui` for controls, forms, notices, and empty states. Compose the views with Tailwind utilities. `src/ui/style.css` only covers device frames, Codex layout, and base rules; preset tokens live in `src/ui/theme.css`. The log list uses the [Legend List React DOM entrypoint](https://www.legendapp.com/open-source/list/v3/react/getting-started/). +The panel uses React and shadcn/ui with preset `b0`, Nova controls, neutral colors, Inter, and Lucide icons. The build bundles the SVGs and font files into the HTML; the panel needs no external asset requests. Use the preset components in `packages/ui/src/components/ui` for controls, forms, notices, and empty states. Compose the views with Tailwind utilities. `packages/ui/src/style.css` only covers device frames, Codex layout, and base rules; preset tokens live in `packages/ui/src/theme.css`. The log list uses the [Legend List React DOM entrypoint](https://www.legendapp.com/open-source/list/v3/react/getting-started/). Panels, toolbars, settings areas, and footers use transparent backgrounds so they blend with the host. Add a panel background only when the user asks to highlight that area. @@ -136,8 +245,9 @@ helpers. Local builds use the `development` environment; public packages require `npm run build:release` and `npm run package:release`. Keep credentials, source maps, and native symbols outside the package. -Bump the plugin version for plugin changes and keep `.codex-plugin/plugin.json`, `package.json`, -both root versions in `package-lock.json`, and `src/shared/version.ts` in sync. +Bump the version in the root `package.json` for product changes and keep both root +versions in `package-lock.json` and `packages/shared/src/version.ts` in sync. Host +manifests carry no version; each package step writes it. See [GitHub releases](docs/releases.md) for tagging, CI packaging, symbol uploads, and marketplace publishing. diff --git a/README.md b/README.md index c69bacfb..288e6375 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,34 @@ -# Mobile Dev for Codex +# Mobile Dev -Keep iOS and Android devices beside your Codex desktop chat while building, -running, and debugging mobile apps. Mobile Dev works with Expo, React Native, -SwiftUI, and other native mobile projects. +Keep iOS and Android devices beside your chat while building, running, and +debugging mobile apps. Mobile Dev works with Expo, React Native, SwiftUI, and +other native mobile projects. + +Mobile Dev runs in three places, all on the same server and device panel: + +| Host | How it shows | Status | +| --- | --- | --- | +| [Codex](#codex) | A plugin panel beside the chat, or fullscreen | Released | +| [Claude Desktop](#claude-desktop-manual-installation) | An MCP App in the conversation, with an expanded view when available | Development preview | +| [Mac app](#mac-app) | A standalone window with a native sidebar, no chat | Development preview, build from source | + +- [Live devices beside your chat](#live-devices-beside-your-chat): stream simulators, emulators, and physical devices. +- [Device control, inspection and annotations](#device-control-inspection-and-annotations): tap, type, inspect elements, and leave notes for the agent. +- [App control](#app-control): install, launch, and stop apps, open deep links, send pushes, and set permissions and languages. +- [Device settings and network conditions](#device-settings-and-network-conditions): appearance, text size, rotation, location, status bar, and throttled networks. +- [Native and JavaScript logs](#native-and-javascript-logs): iOS, Android, and Metro logs in one stream. +- [Live performance monitoring and improvements](#live-performance-monitoring-and-improvements): CPU, memory, Display FPS, recordings, and comparisons. +- [Profile analysis with TraceSift](#profile-analysis-with-tracesift): measured findings from CPU and React profiles, with an explorer. +- [App storage](#app-storage): inspect and watch what an app saves, without adding code. ## Install +### Codex + Install the prebuilt plugin from the release marketplace: ```sh -codex plugin marketplace add https://github.com/callstackincubator/codex-mobile-dev-plugin.git --ref release/latest +codex plugin marketplace add https://github.com/callstackincubator/mobile-dev.git --ref release/latest codex plugin add mobile-dev@mobile-dev ``` @@ -27,6 +46,42 @@ codex plugin marketplace upgrade mobile-dev Fully quit and reopen Codex after updating. +### Claude Desktop: manual installation + +On an Apple Silicon Mac, obtain a `mobile-dev--claude-desktop-darwin-arm64.mcpb` +bundle from the person sharing the preview, or [build one locally](CONTRIBUTING.md#claude-desktop-local-development). +The Claude bundle is not yet published through this repository's release workflow. + +1. Open Claude Desktop **Settings > Extensions > Advanced settings**. +2. Under **Extension Developer**, click **Install Extension…**, select the `.mcpb` + file, and confirm installation. You can also open the file with Claude Desktop. +3. Fully quit and reopen Claude, then start a new conversation and ask **"Open Mobile Dev."** +4. Approve the connector when prompted. Click **Expand** to use the larger view + when available; Mobile Dev does not add a permanent Claude sidebar entry. + +The bundle includes the plugin and native helpers; Claude supplies Node, so no +`npm install` is needed. Install a newer `.mcpb` manually to update. Use either +the bundle or the [local development connection](CONTRIBUTING.md#claude-desktop-local-development) +to avoid duplicate Mobile Dev servers. See [Claude's installation guide](https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop). + +### Mac app + +The Mac app shows the devices and tools in their own window, without a chat. Pick +a tool and the visible platforms in the sidebar. It runs the same server as the +plugins, so the device panels, logs, performance, storage, and TraceSift work the +same way. Chat actions such as Send to chat and annotations are hidden because +there is no chat to send to. + +There is no download yet. Build it from a checkout on an Apple Silicon Mac with +Xcode 27, following [the Mac app guide](CONTRIBUTING.md#mac-app): + +```sh +npm ci +npm run build +npm run build:mac +open "release/mac/Mobile Dev.app" +``` + ## Features ### Live devices beside your chat @@ -35,20 +90,29 @@ Stream iOS simulators, Android emulators, and connected iPhones, iPads, and Andr ![](/img/screenshot_side_by_side.png) ### Device control, inspection and annotations -Select and boot simulators or emulators, interact with taps and drags, type on supported devices, and let Codex inspect accessibility elements and control your app through MCP tools. Select an element or screen region and leave instructions for Codex, with React Native component and source details when available. +Select and boot simulators or emulators, interact with taps and drags, type on supported devices, and let the agent inspect accessibility elements and control your app through MCP tools. Select an element or screen region and leave instructions for the agent, with React Native component and source details when available. ![](/img/screenshot_annotations.png) +### App control +Install a built app, launch or stop it, and open a deep link or URL to jump to a screen. Send a test push notification to an iOS simulator app. Allow, deny, or reset an app permission without the system prompt, and run an app in another language than the device. The app tools in each device panel launch and stop apps, open URLs, send pushes, and set permissions and language; the agent can also call every action as an MCP tool. Mobile Dev does not build your app; your project's own tools do. + +### Device settings and network conditions +Change the running device's appearance, text size, rotation, location, and status bar without restarting its stream. Switch the network to LTE, 3G, Edge, or Offline to see how the app behaves on a slow or missing connection, then back to Normal. Available settings depend on the platform and device kind. + ### Native and JavaScript logs Read iOS unified logs, Android logcat, and Metro console messages together. Follow the foreground app, search and filter logs, and send an error and its stack trace to chat. ![](/img/screenshot_logs.png) ### Live performance monitoring and improvements -Track app CPU and memory, expand individual thread charts, and view device-wide Display FPS on supported devices. Ask Codex to record an interaction, inspect interactive charts in chat, compare runs, and analyze selected ranges. Android recordings include display frame pacing and jank statistics. +Track app CPU and memory, expand individual thread charts, and view device-wide Display FPS on supported devices. Ask the agent to record an interaction, inspect interactive charts in chat, compare runs, and analyze selected ranges. Android recordings include display frame pacing and jank statistics. ![](/img/screenshot_performance.png) +### Profile analysis with TraceSift +Analyze a local CPU profile or a React DevTools profiling export with TraceSift. The findings appear as cards in chat, each splitting a measured task between the features it entered. Explore opens the full call tree, timeline, and flame graph in the card. Select frames or components there and ask the agent to investigate them, with the measured evidence attached. The TraceSift tool in the device panel uploads a profile and keeps the source folder an investigation should land in. + ### App storage See what an app has saved without adding code to it, whether it is built with React Native or natively: UserDefaults and App Group containers, Core Data and SwiftData, SharedPreferences, DataStore, MMKV, AsyncStorage, SQLite databases, and the largest files in its data directory. Open SQLite tables as a paged grid, open any file to see JSON, property lists, and archives decoded, search across every store at once, and open the Changes tab to watch keys being added, changed, and removed while you use the app, with only the changed part of JSON values highlighted. SDK data from Firebase, Google Analytics and similar libraries is grouped separately. Clear one store or reset all app data. Works on iOS simulators and debuggable Android builds; MMKV value types are guessed from the stored bytes, and encrypted MMKV instances show as locked. @@ -61,9 +125,10 @@ See [storage privacy and review checks](docs/storage-privacy.md). ## Requirements -The prebuilt plugin targets Codex desktop on an Apple Silicon Mac and uses -Codex's bundled Node runtime from its workspace dependency cache. Install the -tools for the platforms you use: +Every host needs an Apple Silicon Mac. The Codex plugin uses Codex's bundled Node +runtime from its workspace dependency cache, the Claude Desktop bundle uses +Claude's Node runtime, and the Mac app bundles its own. Install the tools for the +platforms you use: | Platform or feature | Requirements | | --- | --- | @@ -73,6 +138,7 @@ tools for the platforms you use: | Metro logs and React Native inspection | An existing local Metro server with a compatible app inspector target. | | Physical iOS CPU and memory | iOS 17.4 or later, a running development-signed app with `get-task-allow`, a mounted developer disk image, and Xcode/LLDB detached. | | Display FPS | Android 12 or later with FrameTimeline support, or a physical iOS device running iOS 17.4 or later. iOS simulators do not support Display FPS. | +| Profile analysis | A local TraceSift installation, found on `PATH` or through `TRACE_SIFT_COMMAND`. | Simulator tools use the full Xcode installation selected by `xcode-select` (or `DEVELOPER_DIR`), including its Swift compatibility libraries. Custom Xcode names @@ -84,22 +150,26 @@ its device panel, inspection, logs, and performance tools. ## Get started -Open Mobile Dev from the Codex sidebar, or ask Codex to open the simulator beside -this chat. Pick a device from the toolbar; selecting a stopped simulator or AVD +In Codex, open Mobile Dev from the sidebar or ask to open the simulator beside +this chat. In Claude Desktop, ask **"Open Mobile Dev"** in a conversation with the +connector enabled, then use **Expand** when available. In the Mac app, pick a tool and +the platforms in the sidebar. + +Pick a device from the toolbar; selecting a stopped simulator or AVD boots it. Use the iOS and Android toggles to show either platform or both. Click an iOS simulator or Android screen to focus it, then press **Command-V** to paste text into the device. Paste accepts up to 4,096 printable US-ASCII characters; line breaks and other characters show a notice without sending partial text. -Ask Codex to work with your app, for example: +Ask your assistant to work with your app, for example: -- “Build and run my Expo app in the simulator beside this chat.” -- “Read the app logs and help me fix this error.” -- “Record CPU and memory for 30 seconds while I scroll checkout.” -- “Compare these two performance recordings.” +- "Build and run my Expo app in the simulator beside this chat." +- "Read the app logs and help me fix this error." +- "Record CPU and memory for 30 seconds while I scroll checkout." +- "Compare these two performance recordings." -Open Logs, Performance, or Storage to inspect the selected device. Use Screenshot to attach +Open Logs, Performance, Storage, or TraceSift to inspect the selected device. Use Screenshot to attach an image to your next message, or Select to annotate an element or region with an instruction. Physical iOS supports taps and drags; keyboard and hardware-button controls are unavailable. @@ -107,14 +177,30 @@ controls are unavailable. ## Documentation and contributing Detailed guides are available for -[devices](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/docs/devices.md), -[logs](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/docs/logs.md), -[performance](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/docs/performance.md), -and [MCP tools](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/docs/mcp-tools.md). +[devices](https://github.com/callstackincubator/mobile-dev/blob/main/docs/devices.md), +[logs](https://github.com/callstackincubator/mobile-dev/blob/main/docs/logs.md), +[performance](https://github.com/callstackincubator/mobile-dev/blob/main/docs/performance.md), +and [MCP tools](https://github.com/callstackincubator/mobile-dev/blob/main/docs/mcp-tools.md). +[Architecture](https://github.com/callstackincubator/mobile-dev/blob/main/docs/architecture.md) explains how the hosts share the server +and panel. -To build the plugin, install a local package, or contribute changes, see -[CONTRIBUTING.md](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/CONTRIBUTING.md). -Implementation and release details live in the linked contributor documentation. +The repository is an npm workspace. Shared code lives in `packages/`, and each +product in `apps/`: + +| Path | Contents | +| --- | --- | +| `packages/core` | The MCP server, device runtimes, and tool handlers | +| `packages/ui` | The device panels and tools every host shows | +| `packages/mcp-app` | The MCP Apps panel that Codex and Claude Desktop load | +| `packages/shared` | Types and logic the server and UI both use | +| `packages/skills` | Agent skills for the shared tools | +| `packages/native`, `packages/runtimes` | Native helpers and bundled Node runtimes | +| `apps/codex-plugin` | The Codex plugin: manifest, updater, and update banner | +| `apps/claude-desktop` | The Claude Desktop bundle manifest | +| `apps/mac` | The Mac app: SwiftUI window and its page | + +To build a host, install a local package, or contribute changes, see +[CONTRIBUTING.md](https://github.com/callstackincubator/mobile-dev/blob/main/CONTRIBUTING.md). ## Privacy and support @@ -124,10 +210,11 @@ screenshots, input content, tool payloads, app storage contents, profiles, and device performance recordings are excluded from that telemetry. Errors and crashes use a randomly generated persistent installation ID. Set `MOBILE_DEV_TELEMETRY=off` in the MCP launch environment and restart the plugin -processes to disable reporting. Tool results and content sent to chat are handled +processes to disable reporting. For the Mac app, start it with +`open --env MOBILE_DEV_TELEMETRY=off "Mobile Dev.app"`. Tool results and content sent to chat are handled by the host separately. Read the [Privacy Policy](PRIVACY.md) and [Terms of Service](TERMS.md), or see -[Sentry observability](https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/docs/telemetry.md) +[Sentry observability](https://github.com/callstackincubator/mobile-dev/blob/main/docs/telemetry.md) for implementation details. For help, email [hello@callstack.com](mailto:hello@callstack.com). diff --git a/SUPPORT.md b/SUPPORT.md index 9b77dd36..db298b0f 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -7,7 +7,7 @@ reports, or privacy requests. ## Bugs and setup questions Use "Mobile Dev support" in the email subject, or open a -[GitHub issue](https://github.com/callstackincubator/codex-mobile-dev-plugin/issues). +[GitHub issue](https://github.com/callstackincubator/mobile-dev/issues). Include the plugin and host versions, macOS/device OS versions, the feature involved, expected and actual behavior, and minimal reproduction steps. diff --git a/apps/claude-desktop/manifest.json b/apps/claude-desktop/manifest.json new file mode 100644 index 00000000..c439fd89 --- /dev/null +++ b/apps/claude-desktop/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": "0.3", + "name": "mobile-dev", + "display_name": "Mobile Dev", + "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" + ] + } + }, + "compatibility": { + "platforms": [ + "darwin" + ], + "runtimes": { + "node": ">=22.19.0" + } + }, + "tools_generated": true +} diff --git a/apps/claude-desktop/package.json b/apps/claude-desktop/package.json new file mode 100644 index 00000000..94a21a6d --- /dev/null +++ b/apps/claude-desktop/package.json @@ -0,0 +1,7 @@ +{ + "name": "@mobile-dev/claude-desktop", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Claude Desktop extension: the MCPB manifest for the shared server and MCP Apps panel." +} diff --git a/apps/claude-desktop/tooling/config.mjs b/apps/claude-desktop/tooling/config.mjs new file mode 100644 index 00000000..649e2b49 --- /dev/null +++ b/apps/claude-desktop/tooling/config.mjs @@ -0,0 +1,18 @@ +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("apps/claude-desktop/manifest.json", "utf8")); +const config = { + mcpServers: { + "mobile-dev": { + command: process.execPath, + args: [resolve(manifest.server.entry_point)], + }, + }, +}; +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/apps/claude-desktop/tooling/package.mjs b/apps/claude-desktop/tooling/package.mjs new file mode 100644 index 00000000..735603f1 --- /dev/null +++ b/apps/claude-desktop/tooling/package.mjs @@ -0,0 +1,25 @@ +import { execFileSync } from "node:child_process"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; +import { assertRuntime, releaseVersion, stageRuntime } from "../../../tooling/host-runtime.mjs"; + +// Claude Desktop runs the shared server and MCP Apps panel unchanged, so the bundle +// is the host-neutral runtime plus the MCPB manifest. +const telemetryEnvironment = telemetryBuildEnvironment(); +await assertRuntime(telemetryEnvironment); +const manifest = JSON.parse(await readFile("apps/claude-desktop/manifest.json", "utf8")); +manifest.version = await releaseVersion(); +const directory = resolve("release/claude-desktop/mobile-dev"); +const documents = ["README.md", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]; +await rm(directory, { recursive: true, force: true }); +await mkdir(directory, { recursive: true }); +await stageRuntime(`${directory}/dist`); +for (const path of documents) await copyFile(path, `${directory}/${path}`); +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", ...documents], { cwd: directory }); +execFileSync("unzip", ["-tq", archive], { stdio: "inherit" }); +console.log(`Claude Desktop bundle: ${archive}`); diff --git a/apps/claude-desktop/tooling/smoke.mjs b/apps/claude-desktop/tooling/smoke.mjs new file mode 100644 index 00000000..a06304e0 --- /dev/null +++ b/apps/claude-desktop/tooling/smoke.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { access, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const metadata = JSON.parse(await readFile("package.json", "utf8")); +const archive = resolve(process.argv[2] ?? `release/mobile-dev-${metadata.version}-claude-desktop-darwin-arm64.mcpb`); +const temporary = await mkdtemp(join(tmpdir(), "mobile-dev-claude-")); +try { + const directory = join(temporary, "bundle with spaces"); + await mkdir(directory); + execFileSync("unzip", ["-q", archive, "-d", directory]); + const manifest = JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")); + assert.equal(manifest.manifest_version, "0.3"); + assert.equal(manifest.version, metadata.version); + assert.equal(manifest.server.type, "node"); + assert.equal(manifest.server.mcp_config.command, "node"); + assert.equal(manifest.server.mcp_config.env, undefined); + assert.equal(manifest.compatibility.runtimes.node, metadata.engines.node); + for (const path of [manifest.server.entry_point, "dist/app.html", "dist/baguette/Baguette", "dist/ios-mirror/darwin-arm64.node", "dist/agent-device-server.mjs", "dist/serve-emu/src/cli.mjs"]) { + await access(join(directory, path)); + } + const args = manifest.server.mcp_config.args.map(arg => arg.replaceAll("${__dirname}", directory)); + // No Codex installation, no PATH Node lookup, and no dependency on package cwd. + const home = join(temporary, "home"); + await mkdir(home); + // Behavior follows client capabilities, not the host's name. + for (const [environment, forms] of [["development", false], ["release", false], ["development", true]]) { + const capabilities = forms ? { extensions: { "openai/elicitation": { form: {} } } } : {}; + const client = new Client({ name: "claude-package-smoke", version: "1" }, { capabilities }); + const transport = new StdioClientTransport({ + command: process.execPath, args, cwd: temporary, stderr: "pipe", + env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: home, + MOBILE_DEV_TELEMETRY: "off", MOBILE_DEV_ENVIRONMENT: environment }, + }); + let diagnostics = ""; + transport.stderr?.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-5000); }); + try { + await client.connect(transport); + assert.match(client.getInstructions(), /otherwise in chat/); + const { tools } = await client.listTools(); + assert.equal(tools.some(tool => tool.name === "mobile_choose_devices"), forms); + for (const name of ["mobile_open_workspace", "mobile_open_simulator", "mobile_list_simulators", "mobile_list_android_devices"]) { + assert.ok(tools.some(tool => tool.name === name), name); + } + const workspace = tools.find(tool => tool.name === "mobile_open_workspace"); + const resource = await client.readResource({ uri: workspace._meta.ui.resourceUri }); + const html = resource.contents[0].text; + assert.doesNotMatch(html, /data-host=/); + assert.deepEqual(resource.contents[0]._meta.ui.permissions, { clipboardWrite: {} }); + assert.match(html, /data-view="workspace"/); + assert.ok(html.includes(`name="mobile-dev-environment" content="${environment}"`)); + assert.match(html, /name="mobile-dev-telemetry" content="off"/); + assert.match(html, /ui.workspace.display_mode_changed/); + assert.doesNotMatch(html, /Retry update/, "The shared panel has no Codex update banner."); + // The Codex updater belongs to Codex's server; this bundle runs the shared one. + assert.equal(tools.some(tool => tool.name.includes("plugin_update")), false); + console.log(`${environment}${forms ? "/forms" : ""}: relocated MCP startup, tools, UI resource and no Codex updater passed.`); + } catch (error) { + if (diagnostics) console.error(diagnostics); + throw error; + } finally { await client.close(); await transport.close(); } + } +} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/.codex-plugin/plugin.json b/apps/codex-plugin/.codex-plugin/plugin.json similarity index 92% rename from .codex-plugin/plugin.json rename to apps/codex-plugin/.codex-plugin/plugin.json index 4bda188e..fcac16e3 100644 --- a/.codex-plugin/plugin.json +++ b/apps/codex-plugin/.codex-plugin/plugin.json @@ -1,12 +1,11 @@ { "name": "mobile-dev", - "version": "0.1.165", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack", "email": "hello@callstack.com" }, - "homepage": "https://github.com/callstackincubator/codex-mobile-dev-plugin", + "homepage": "https://github.com/callstackincubator/mobile-dev", "extensions": { "com.openai": { "onboardingSkill": "./skills/mobile-dev-setup/SKILL.md", @@ -66,7 +65,7 @@ ] }, "commerce": false, - "demo_recording_url": "https://raw.githubusercontent.com/callstackincubator/codex-mobile-dev-plugin/main/docs/review/media/mobile-dev-demo.mp4" + "demo_recording_url": "https://raw.githubusercontent.com/callstackincubator/mobile-dev/main/docs/review/media/mobile-dev-demo.mp4" }, "publication": { "release_notes": "Adds product-specific privacy, terms and support links, three starter prompts, and reproducible review cases. Corrects TraceSift write/service annotations. Filters storage secrets and binary values before host delivery, scopes reads before decoding, and adds exact key/column selection." @@ -78,10 +77,10 @@ "shortDescription": "Devices, logs, and performance", "longDescription": "Keep an iOS simulator or Android device beside your chat while building, running, or debugging Expo, React Native, SwiftUI, and other mobile apps. Monitor native CPU, memory, and device-wide Display FPS, analyze local CPU and React profiles with TraceSift cards, render interactive performance recordings in chat, read native and Metro logs, and inspect and control apps with Mobile Dev tools.", "developerName": "Callstack", - "websiteURL": "https://github.com/callstackincubator/codex-mobile-dev-plugin", - "supportURL": "https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/SUPPORT.md", - "privacyPolicyURL": "https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/PRIVACY.md", - "termsOfServiceURL": "https://github.com/callstackincubator/codex-mobile-dev-plugin/blob/main/TERMS.md", + "websiteURL": "https://github.com/callstackincubator/mobile-dev", + "supportURL": "https://github.com/callstackincubator/mobile-dev/blob/main/SUPPORT.md", + "privacyPolicyURL": "https://github.com/callstackincubator/mobile-dev/blob/main/PRIVACY.md", + "termsOfServiceURL": "https://github.com/callstackincubator/mobile-dev/blob/main/TERMS.md", "category": "Developer Tools", "capabilities": [ "Read", diff --git a/.mcp.json b/apps/codex-plugin/.mcp.json similarity index 100% rename from .mcp.json rename to apps/codex-plugin/.mcp.json diff --git a/assets/android/nine-patch/LICENSE b/apps/codex-plugin/assets/android/nine-patch/LICENSE similarity index 100% rename from assets/android/nine-patch/LICENSE rename to apps/codex-plugin/assets/android/nine-patch/LICENSE diff --git a/assets/android/nine-patch/NOTICE.md b/apps/codex-plugin/assets/android/nine-patch/NOTICE.md similarity index 100% rename from assets/android/nine-patch/NOTICE.md rename to apps/codex-plugin/assets/android/nine-patch/NOTICE.md diff --git a/assets/icon.svg b/apps/codex-plugin/assets/icon.svg similarity index 100% rename from assets/icon.svg rename to apps/codex-plugin/assets/icon.svg diff --git a/assets/store-logo.webp b/apps/codex-plugin/assets/store-logo.webp similarity index 100% rename from assets/store-logo.webp rename to apps/codex-plugin/assets/store-logo.webp diff --git a/apps/codex-plugin/package.json b/apps/codex-plugin/package.json new file mode 100644 index 00000000..5a2b8f18 --- /dev/null +++ b/apps/codex-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@mobile-dev/codex-plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Codex plugin: manifest, launcher, skills and assets for the shared server and MCP Apps panel." +} diff --git a/scripts/launch-mcp.sh b/apps/codex-plugin/scripts/launch-mcp.sh similarity index 100% rename from scripts/launch-mcp.sh rename to apps/codex-plugin/scripts/launch-mcp.sh diff --git a/apps/codex-plugin/server/codex-updates.ts b/apps/codex-plugin/server/codex-updates.ts new file mode 100644 index 00000000..2f9123cc --- /dev/null +++ b/apps/codex-plugin/server/codex-updates.ts @@ -0,0 +1,9 @@ +import type { ServerFeature } from "@mobile-dev/core/server-feature.ts"; +import { PluginUpdates, registerPluginUpdateTools } from "./plugin-updates.ts"; + +/** Checks for and installs releases through Codex's plugin marketplace; the panel's banner calls these tools. */ +export const codexUpdates: ServerFeature = server => { + const updates = new PluginUpdates(); + registerPluginUpdateTools(server, updates); + return () => updates.close(); +}; diff --git a/apps/codex-plugin/server/index.ts b/apps/codex-plugin/server/index.ts new file mode 100644 index 00000000..7a7b0761 --- /dev/null +++ b/apps/codex-plugin/server/index.ts @@ -0,0 +1,6 @@ +import "@mobile-dev/core/instrument.ts"; +import { runStdioServer } from "@mobile-dev/core/stdio-server.ts"; +import { codexUpdates } from "./codex-updates.ts"; + +// Codex's server: the shared server plus the updater for Codex plugin installs. +await runStdioServer([codexUpdates]); diff --git a/src/server/plugin-updates.ts b/apps/codex-plugin/server/plugin-updates.ts similarity index 92% rename from src/server/plugin-updates.ts rename to apps/codex-plugin/server/plugin-updates.ts index 1d1463b0..235a21fa 100644 --- a/src/server/plugin-updates.ts +++ b/apps/codex-plugin/server/plugin-updates.ts @@ -7,14 +7,24 @@ import { promisify } from "node:util"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import * as Sentry from "@sentry/node"; import { compareReleaseVersions, pluginUpdateSchema, releaseVersionSchema } from "../shared/plugin-updates.ts"; import type { PluginUpdate } from "../shared/plugin-updates.ts"; -import { PLUGIN_VERSION } from "../shared/version.ts"; -import { captureServerError, recordPluginUpdate } from "./telemetry.ts"; -import { resolveTelemetryEnvironment } from "./telemetry-environment.ts"; +import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; +import { captureServerError } from "@mobile-dev/core/telemetry.ts"; +import { resolveTelemetryEnvironment } from "@mobile-dev/core/telemetry-environment.ts"; const execute = promisify(execFile); -const repository = "callstackincubator/codex-mobile-dev-plugin"; + +export function recordPluginUpdate(operation: "check" | "install", outcome: "disabled" | "unavailable" | "current" | "available" | "updated" | "failed", duration: number) { + if (process.env.MOBILE_DEV_TELEMETRY === "off") return; + const attributes = { component: "server", operation, outcome }; + Sentry.metrics.count("plugin.update.operations", 1, { attributes }); + Sentry.metrics.gauge("plugin.update.duration", duration, { unit: "millisecond", attributes }); +} +const repository = "callstackincubator/mobile-dev"; +// Installs from before the rename keep this marketplace URL; GitHub redirects it. +const legacyRepository = "callstackincubator/codex-mobile-dev-plugin"; const releaseEndpoint = `https://api.github.com/repos/${repository}/releases/latest`; const marketplaceEndpoint = `https://raw.githubusercontent.com/${repository}/release/latest/plugins/mobile-dev/.codex-plugin/plugin.json`; const checkIntervalMs = 60 * 60 * 1000; @@ -73,8 +83,8 @@ export function createCodexRunner(cwd: string, options: { path?: string; env?: N } function isReleaseRepository(source: string): boolean { - return source === `https://github.com/${repository}` || source === `https://github.com/${repository}.git` - || source === `git@github.com:${repository}.git` || source === `ssh://git@github.com/${repository}.git`; + return [repository, legacyRepository].some(name => source === `https://github.com/${name}` || source === `https://github.com/${name}.git` + || source === `git@github.com:${name}.git` || source === `ssh://git@github.com/${name}.git`); } export class PluginUpdates { diff --git a/src/shared/plugin-updates.ts b/apps/codex-plugin/shared/plugin-updates.ts similarity index 91% rename from src/shared/plugin-updates.ts rename to apps/codex-plugin/shared/plugin-updates.ts index 9183ea1f..fd5c11a7 100644 --- a/src/shared/plugin-updates.ts +++ b/apps/codex-plugin/shared/plugin-updates.ts @@ -23,5 +23,5 @@ export function compareReleaseVersions(left: string, right: string): number { } export function pluginReleaseUrl(version: string): string { - return `https://github.com/callstackincubator/codex-mobile-dev-plugin/releases/tag/v${version}`; + return `https://github.com/callstackincubator/mobile-dev/releases/tag/v${version}`; } diff --git a/apps/codex-plugin/test/display-mode-control.test.ts b/apps/codex-plugin/test/display-mode-control.test.ts new file mode 100644 index 00000000..ef4e5238 --- /dev/null +++ b/apps/codex-plugin/test/display-mode-control.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setTimeout } from "node:timers/promises"; +import { build } from "esbuild"; +import { JSDOM } from "jsdom"; + +// Both hosts advertise inline and fullscreen. Claude has no control of its own to +// leave the expanded view, so the shared panel offers one; Codex has its own. +for (const [host, entry, shown] of [ + ["shared MCP Apps panel", "packages/mcp-app/src/main.tsx", true], + ["Codex panel", "apps/codex-plugin/ui/main.tsx", false], +] as const) test(`the ${host} ${shown ? "offers" : "leaves out"} Back to chat in fullscreen`, async t => { + const built = await build({ + entryPoints: [entry], bundle: true, write: false, format: "iife", platform: "browser", jsx: "automatic", + loader: { ".css": "empty", ".woff2": "empty", ".woff": "empty" }, define: { "process.env.NODE_ENV": '"production"' }, + }); + const dom = new JSDOM('
', { + pretendToBeVisual: true, runScripts: "outside-only", url: "https://mobile-dev.test/", + }); + const window = dom.window; + window.console.debug = () => {}; + window.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; + window.HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); + Object.defineProperty(window.HTMLElement.prototype, "clientWidth", { get: () => 400 }); + Object.defineProperty(window.HTMLElement.prototype, "clientHeight", { get: () => 800 }); + let initialized = false; + let tornDown = false; + function send(message: object) { + window.dispatchEvent(new window.MessageEvent("message", { data: { jsonrpc: "2.0", ...message }, source: window })); + } + window.postMessage = message => { + if (message.method === "ui/initialize") send({ id: message.id, result: { + protocolVersion: "2026-01-26", hostInfo: { name: "test", version: "1" }, hostCapabilities: { serverTools: {}, serverResources: {} }, + hostContext: { displayMode: "fullscreen", availableDisplayModes: ["inline", "fullscreen"] }, + } }); + else if (message.method === "ui/notifications/initialized") initialized = true; + else if (message.id === "teardown") tornDown = true; + else if (message.id !== undefined) send({ id: message.id, result: { content: [] } }); + }; + async function until(condition: () => boolean) { + for (let attempt = 0; attempt < 200 && !condition(); attempt++) await setTimeout(5); + assert.ok(condition()); + } + t.after(async () => { + send({ id: "teardown", method: "ui/resource-teardown", params: {} }); + await until(() => tornDown); + window.close(); + }); + window.eval(built.outputFiles[0].text); + await until(() => initialized); + await until(() => window.document.querySelector(".workspace-toolbar") !== null); + // Hosts follow the handshake with context changes; the control reacts to those. + send({ method: "ui/notifications/host-context-changed", params: { displayMode: "fullscreen", availableDisplayModes: ["inline", "fullscreen"] } }); + await setTimeout(20); + const buttons = [...window.document.querySelectorAll(".workspace-toolbar button")].map(button => button.textContent); + assert.equal(buttons.includes("Back to chat"), shown, buttons.join(", ")); +}); diff --git a/tests/mcp-launch.test.ts b/apps/codex-plugin/test/mcp-launch.test.ts similarity index 95% rename from tests/mcp-launch.test.ts rename to apps/codex-plugin/test/mcp-launch.test.ts index d4ee95a8..90976b7a 100644 --- a/tests/mcp-launch.test.ts +++ b/apps/codex-plugin/test/mcp-launch.test.ts @@ -4,11 +4,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test, type TestContext } from "node:test"; -import { createLaunchHome } from "../scripts/mcp-launch-fixture.mjs"; +import { createLaunchHome } from "../tooling/mcp-launch-fixture.mjs"; import mcp from "../.mcp.json" with { type: "json" }; const config = mcp.mcpServers["mobile-dev"]; -const launcher = resolve(config.args[0]); +const launcher = resolve("apps/codex-plugin", config.args[0]); const systemPath = "/usr/bin:/bin:/usr/sbin:/sbin"; async function fixture(t: TestContext) { diff --git a/tests/plugin-update-ui.test.ts b/apps/codex-plugin/test/plugin-update-ui.test.ts similarity index 95% rename from tests/plugin-update-ui.test.ts rename to apps/codex-plugin/test/plugin-update-ui.test.ts index 9a071868..d251e995 100644 --- a/tests/plugin-update-ui.test.ts +++ b/apps/codex-plugin/test/plugin-update-ui.test.ts @@ -12,8 +12,8 @@ async function banner(t: test.TestContext, app: unknown) { import { createElement } from "react"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; - import { PluginUpdateController } from "./src/ui/plugin-updates.ts"; - import { PluginUpdateBanner } from "./src/ui/components/plugin-update-banner.tsx"; + import { PluginUpdateController } from "./apps/codex-plugin/ui/plugin-updates.ts"; + import { PluginUpdateBanner } from "./apps/codex-plugin/ui/plugin-update-banner.tsx"; export function mount(app) { const controller = new PluginUpdateController(app); const element = document.getElementById("root"); @@ -57,7 +57,7 @@ test("the banner handles progress, failures, retry, release notes, and restart i const releaseNotes = view.button("Release notes"); releaseNotes.click(); await view.settle(); - assert.deepEqual(links, ["https://github.com/callstackincubator/codex-mobile-dev-plugin/releases/tag/v0.1.132"]); + assert.deepEqual(links, ["https://github.com/callstackincubator/mobile-dev/releases/tag/v0.1.132"]); const install = view.button("Update"); install.click(); install.click(); diff --git a/tests/plugin-updates.test.ts b/apps/codex-plugin/test/plugin-updates.test.ts similarity index 91% rename from tests/plugin-updates.test.ts rename to apps/codex-plugin/test/plugin-updates.test.ts index 70c42c3f..2435760f 100644 --- a/tests/plugin-updates.test.ts +++ b/apps/codex-plugin/test/plugin-updates.test.ts @@ -6,9 +6,9 @@ import { join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { PluginUpdates, registerPluginUpdateTools, resolveCodexCli } from "../src/server/plugin-updates.ts"; -import type { RunCodex } from "../src/server/plugin-updates.ts"; -import { compareReleaseVersions, pluginUpdateSchema } from "../src/shared/plugin-updates.ts"; +import { PluginUpdates, registerPluginUpdateTools, resolveCodexCli } from "../server/plugin-updates.ts"; +import type { RunCodex } from "../server/plugin-updates.ts"; +import { compareReleaseVersions, pluginUpdateSchema } from "../shared/plugin-updates.ts"; async function profile(t: test.TestContext) { const parent = tmpdir(); @@ -42,7 +42,7 @@ function cliFixture(directory: string, calls: string[][], options: { source?: st return async args => { calls.push(args); if (args[2] === "list") return { marketplaces: [{ name: "mobile-dev", root, marketplaceSource: { - sourceType: "git", source: options.source ?? "https://github.com/callstackincubator/codex-mobile-dev-plugin.git", + sourceType: "git", source: options.source ?? "https://github.com/callstackincubator/mobile-dev.git", } }] }; if (args[2] === "upgrade") { const version = options.version ?? "0.1.132"; @@ -110,7 +110,7 @@ test("offline checks remain quiet, invalid tags cannot advertise updates, and de const disabled = await dev.check(); assert.equal(disabled.status, "disabled"); const installation = dev.install(); - await assert.rejects(installation, /Development builds/); + await assert.rejects(installation, /Development builds do not install public plugin updates/); const otherDirectory = await profile(t); const invalid = new PluginUpdates({ enabled: true, profile: otherDirectory, fetch: releaseFetch("../bad", "0.1.132") }); const malformed = await invalid.check(); @@ -152,6 +152,16 @@ test("installation is deduplicated, verifies cache files, and retains the restar assert.equal(includesProfile, false); }); +test("installs added before the repository rename keep updating through the old marketplace URL", async t => { + for (const source of ["https://github.com/callstackincubator/codex-mobile-dev-plugin.git", "git@github.com:callstackincubator/codex-mobile-dev-plugin.git"]) { + const directory = await profile(t); + const run = cliFixture(directory, [], { source }); + const updates = new PluginUpdates({ enabled: true, profile: directory, currentVersion: "0.1.131", fetch: releaseFetch(), run }); + const installed = await updates.install(); + assert.equal(installed.status, "updated", source); + } +}); + test("an unchanged marketplace still verifies the installed version and reports a restart notice", async t => { const directory = await profile(t); const calls: string[][] = []; diff --git a/tests/publish-release.test.ts b/apps/codex-plugin/test/publish-release.test.ts similarity index 99% rename from tests/publish-release.test.ts rename to apps/codex-plugin/test/publish-release.test.ts index bbaebd1a..c7ebd438 100644 --- a/tests/publish-release.test.ts +++ b/apps/codex-plugin/test/publish-release.test.ts @@ -4,7 +4,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test, type TestContext } from "node:test"; -import { publishRelease } from "../scripts/publish-release.mjs"; +import { publishRelease } from "../tooling/publish-release.mjs"; function git(directory: string, args: string[]) { return execFileSync("git", args, { diff --git a/tests/workspace-opening.test.ts b/apps/codex-plugin/test/workspace-opening.test.ts similarity index 99% rename from tests/workspace-opening.test.ts rename to apps/codex-plugin/test/workspace-opening.test.ts index 359e77bd..abd3e179 100644 --- a/tests/workspace-opening.test.ts +++ b/apps/codex-plugin/test/workspace-opening.test.ts @@ -5,7 +5,7 @@ import { build } from "esbuild"; import { JSDOM } from "jsdom"; const built = await build({ - entryPoints: ["src/ui/workspace-app.tsx"], + entryPoints: ["apps/codex-plugin/ui/main.tsx"], bundle: true, write: false, format: "iife", platform: "browser", jsx: "automatic", loader: { ".css": "empty", ".woff2": "empty", ".woff": "empty" }, define: { "process.env.NODE_ENV": '"production"' }, diff --git a/apps/codex-plugin/tooling/build.mjs b/apps/codex-plugin/tooling/build.mjs new file mode 100644 index 00000000..0bd43f1d --- /dev/null +++ b/apps/codex-plugin/tooling/build.mjs @@ -0,0 +1,16 @@ +import { buildServerBundles } from "../../../tooling/server-bundle.mjs"; +import { buildUIPage } from "../../../tooling/ui-page.mjs"; + +/** + * Builds Codex's own server, with the updater for Codex plugin installs, and its + * panel, with the update banner, into a staged runtime. They replace the shared + * server.mjs and app.html there; their dependencies are the shared runtime's. + */ +export async function buildCodexHost(environment, outdir) { + await buildUIPage({ + name: "ui/codex", entry: "apps/codex-plugin/ui/main.tsx", template: "packages/mcp-app/src/index.html", + sources: ["packages/ui/src", "packages/mcp-app/src", "apps/codex-plugin/ui"], output: `${outdir}/app.html`, + environment, sourceURL: "app:///mobile-dev-ui.js", + }); + await buildServerBundles({ entryPoints: { server: "apps/codex-plugin/server/index.ts" }, outdir, sentryDirectory: ".sentry/server/codex" }); +} diff --git a/scripts/check-marketplace-review.mjs b/apps/codex-plugin/tooling/check-marketplace-review.mjs similarity index 100% rename from scripts/check-marketplace-review.mjs rename to apps/codex-plugin/tooling/check-marketplace-review.mjs diff --git a/scripts/dev-ui.mjs b/apps/codex-plugin/tooling/dev-ui.mjs similarity index 80% rename from scripts/dev-ui.mjs rename to apps/codex-plugin/tooling/dev-ui.mjs index 248ba76c..a50a5e3d 100644 --- a/scripts/dev-ui.mjs +++ b/apps/codex-plugin/tooling/dev-ui.mjs @@ -5,10 +5,10 @@ import { readFile, writeFile, rm, mkdir, rename, stat, utimes } from "node:fs/pr import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { exploreUILayerCSS } from "./explore-css.mjs"; +import { exploreUILayerCSS } from "../../../tooling/explore-css.mjs"; -const projectRoot = fileURLToPath(new URL("..", import.meta.url)); -const { version } = JSON.parse(await readFile(new URL("../.codex-plugin/plugin.json", import.meta.url), "utf8")); +const projectRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const { version } = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")); const pluginRoot = process.env.MOBILE_DEV_PLUGIN_ROOT ?? resolve(process.env.CODEX_HOME ?? resolve(homedir(), ".codex"), "plugins/cache/mobile-dev-local/mobile-dev", version); const configPath = resolve(pluginRoot, "ui-dev.json"); const output = resolve(projectRoot, ".local-dev"); @@ -30,7 +30,7 @@ try { if (!running) { const builder = await context({ absWorkingDir: projectRoot, - entryPoints: ["src/ui/app.tsx"], jsx: "automatic", + entryPoints: ["apps/codex-plugin/ui/main.tsx"], jsx: "automatic", define: { "process.env.NODE_ENV": '"production"' }, loader: { ".woff2": "dataurl", ".woff": "dataurl" }, bundle: true, write: false, format: "iife", platform: "browser", @@ -39,16 +39,16 @@ if (!running) { build.onLoad({ filter: /theme\.css$/ }, async ({ path }) => { const dependencies = []; const compiler = await compile(await readFile(path, "utf8"), { base: dirname(path), onDependency(file) { dependencies.push(file); } }); - const scanner = new Scanner({ sources: [{ base: resolve(projectRoot, "src/ui"), pattern: "**/*.{ts,tsx}", negated: false }] }); + const scanner = new Scanner({ sources: ["packages/ui/src", "packages/mcp-app/src", "apps/codex-plugin/ui"].map(base => ({ base: resolve(projectRoot, base), pattern: "**/*.{ts,tsx}", negated: false })) }); const contents = compiler.build(scanner.scan()); return { contents, loader: "css", resolveDir: dirname(path), watchFiles: [...dependencies, ...scanner.files] }; }); - build.onLoad({ filter: /\/app\.tsx$/ }, async ({ path }) => ({ contents: await readFile(path, "utf8"), loader: "tsx", resolveDir: dirname(path), watchFiles: [resolve(projectRoot, "src/ui/index.html")] })); + build.onLoad({ filter: /\/codex-plugin\/ui\/main\.tsx$/ }, async ({ path }) => ({ contents: await readFile(path, "utf8"), loader: "tsx", resolveDir: dirname(path), watchFiles: [resolve(projectRoot, "packages/mcp-app/src/index.html")] })); build.onEnd(async result => { if (result.errors.length) return; const js = result.outputFiles.find(file => file.path.endsWith(".js")).text; const css = `${result.outputFiles.find(file => file.path.endsWith(".css"))?.text ?? ""}\n${await exploreUILayerCSS()}`; - const template = await readFile(resolve(projectRoot, "src/ui/index.html"), "utf8"); + const template = await readFile(resolve(projectRoot, "packages/mcp-app/src/index.html"), "utf8"); const html = template.replace("", () => ``) .replace("", () => ``); await writeFile(resolve(output, "app.html.tmp"), html); diff --git a/scripts/mcp-launch-fixture.mjs b/apps/codex-plugin/tooling/mcp-launch-fixture.mjs similarity index 100% rename from scripts/mcp-launch-fixture.mjs rename to apps/codex-plugin/tooling/mcp-launch-fixture.mjs diff --git a/scripts/mcp-tool-budget.mjs b/apps/codex-plugin/tooling/mcp-tool-budget.mjs similarity index 100% rename from scripts/mcp-tool-budget.mjs rename to apps/codex-plugin/tooling/mcp-tool-budget.mjs diff --git a/apps/codex-plugin/tooling/package.mjs b/apps/codex-plugin/tooling/package.mjs new file mode 100644 index 00000000..aa614453 --- /dev/null +++ b/apps/codex-plugin/tooling/package.mjs @@ -0,0 +1,41 @@ +import { access, chmod, copyFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; +import { pluginMarketplace } from "./plugin-marketplace.mjs"; +import { buildCodexHost } from "./build.mjs"; +import { assertRuntime, releaseVersion, stageRuntime } from "../../../tooling/host-runtime.mjs"; + +const telemetryEnvironment = telemetryBuildEnvironment(); +await assertRuntime(telemetryEnvironment); +const source = "apps/codex-plugin"; +// The version comes from the workspace; the source manifest has none. +const { name, ...fields } = JSON.parse(await readFile(`${source}/.codex-plugin/plugin.json`, "utf8")); +const manifest = { name, version: await releaseVersion(), ...fields }; +const marketplace = resolve("release/marketplace"); +const plugin = `${marketplace}/plugins/${manifest.name}`; +await rm(plugin, { recursive: true, force: true }); +await mkdir(plugin, { recursive: true }); +await copyFile(`${source}/.mcp.json`, `${plugin}/.mcp.json`); +for (const path of ["README.md", "THIRD_PARTY_NOTICES.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md"]) await copyFile(path, `${plugin}/${path}`); +await stageRuntime(`${plugin}/dist`); +await buildCodexHost(telemetryEnvironment, `${plugin}/dist`); +for (const path of [".codex-plugin", "assets"]) await cp(`${source}/${path}`, `${plugin}/${path}`, { recursive: true, verbatimSymlinks: true }); +// Codex loads skills; the inactive agent-device skill stays out until its server returns. +for (const skill of ["mobile-dev", "mobile-dev-setup"]) await cp(`packages/skills/${skill}`, `${plugin}/skills/${skill}`, { recursive: true, verbatimSymlinks: true }); +await writeFile(`${plugin}/.codex-plugin/plugin.json`, JSON.stringify(manifest, null, 2) + "\n"); +await mkdir(`${plugin}/scripts`, { recursive: true }); +await copyFile(`${source}/scripts/launch-mcp.sh`, `${plugin}/scripts/launch-mcp.sh`); +await chmod(`${plugin}/scripts/launch-mcp.sh`, 0o755); +await chmod(`${plugin}/dist/baguette/Baguette`, 0o755); +await chmod(`${plugin}/dist/ios-logs/mobile-dev-ios-logs`, 0o755); +await mkdir(`${marketplace}/.agents/plugins`, { recursive: true }); +const catalog = pluginMarketplace(manifest.name); +const catalogText = JSON.stringify(catalog, null, 2); +await writeFile(`${marketplace}/.agents/plugins/marketplace.json`, catalogText + "\n"); +const archive = resolve(`release/${manifest.name}-${manifest.version}-darwin-arm64.zip`); +await rm(archive, { force: true }); +const files = await readdir(plugin); +execFileSync("zip", ["-qr", archive, ...files], { cwd: plugin }); +if ((await stat(archive)).size > 100_000_000) throw new Error("The plugin ZIP exceeds the 100 MB upload limit."); +console.log(`Plugin: ${plugin}\nMarketplace: ${marketplace}\nZIP: ${archive}`); diff --git a/scripts/plugin-marketplace.mjs b/apps/codex-plugin/tooling/plugin-marketplace.mjs similarity index 100% rename from scripts/plugin-marketplace.mjs rename to apps/codex-plugin/tooling/plugin-marketplace.mjs diff --git a/scripts/publish-release.mjs b/apps/codex-plugin/tooling/publish-release.mjs similarity index 98% rename from scripts/publish-release.mjs rename to apps/codex-plugin/tooling/publish-release.mjs index 0e190e38..29e7d858 100644 --- a/scripts/publish-release.mjs +++ b/apps/codex-plugin/tooling/publish-release.mjs @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { pluginMarketplace } from "./plugin-marketplace.mjs"; -import { assertBuildEnvironment } from "./telemetry-build.mjs"; +import { assertBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; const branch = "refs/heads/release/latest"; diff --git a/scripts/smoke-agent-device.mjs b/apps/codex-plugin/tooling/smoke-agent-device.mjs similarity index 100% rename from scripts/smoke-agent-device.mjs rename to apps/codex-plugin/tooling/smoke-agent-device.mjs diff --git a/scripts/smoke-codex-plugin.mjs b/apps/codex-plugin/tooling/smoke-codex-plugin.mjs similarity index 100% rename from scripts/smoke-codex-plugin.mjs rename to apps/codex-plugin/tooling/smoke-codex-plugin.mjs diff --git a/scripts/smoke-package.mjs b/apps/codex-plugin/tooling/smoke-package.mjs similarity index 97% rename from scripts/smoke-package.mjs rename to apps/codex-plugin/tooling/smoke-package.mjs index 39ae8df9..24195cf6 100644 --- a/scripts/smoke-package.mjs +++ b/apps/codex-plugin/tooling/smoke-package.mjs @@ -92,6 +92,8 @@ try { const client = new Client({ name: "mobile-dev-package-smoke", version: "1" }); await client.connect(transport); const tools = await client.listTools(); + // Codex's own server and panel: the updater tools and the banner that calls them. + assert.ok(tools.tools.some(tool => tool.name === "mobile_check_plugin_update"), "The Codex server must include its updater."); const recordingCard = tools.tools.find(tool => tool.name === "mobile_render_performance_recording"); assert.ok(recordingCard); assert.deepEqual(recordingCard._meta.ui.visibility, ["app", "model"]); @@ -181,6 +183,7 @@ try { assert.ok(resource.contents[0].text.includes('workspace-toolbar')); assert.ok(resource.contents[0].text.includes('workspace-panels')); assert.ok(resource.contents[0].text.includes('tool-logs')); + assert.ok(resource.contents[0].text.includes('Retry update'), "The Codex panel must include its update banner."); assert.ok(resource.contents[0].text.includes('Memory usage'), 'The packaged Performance view must include the live memory track.'); assert.equal(workspace._meta.ui.resourceUri, `ui://mobile-dev/${manifest.version}/workspace.html`); assert.deepEqual(resource.contents[0]._meta.ui.csp.connectDomains, ["https://o4512180958068736.ingest.de.sentry.io"]); diff --git a/scripts/smoke-plugin-updates.mjs b/apps/codex-plugin/tooling/smoke-plugin-updates.mjs similarity index 97% rename from scripts/smoke-plugin-updates.mjs rename to apps/codex-plugin/tooling/smoke-plugin-updates.mjs index acf7cd40..f3dd2f32 100644 --- a/scripts/smoke-plugin-updates.mjs +++ b/apps/codex-plugin/tooling/smoke-plugin-updates.mjs @@ -6,7 +6,7 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; -import { createCodexRunner, PluginUpdates, resolveCodexCli } from "../src/server/plugin-updates.ts"; +import { createCodexRunner, PluginUpdates, resolveCodexCli } from "../../../apps/codex-plugin/server/plugin-updates.ts"; const execute = promisify(execFile); const parent = tmpdir(); @@ -17,7 +17,7 @@ const profile = join(directory, "profile"); const fixtureHome = join(directory, "home"); const codexCli = process.env.CODEX_CLI_PATH ?? "codex"; const env = { ...process.env, HOME: fixtureHome, CODEX_HOME: profile, MOBILE_DEV_TELEMETRY: "off", GIT_TERMINAL_PROMPT: "0" }; -const repositoryUrl = "https://github.com/callstackincubator/codex-mobile-dev-plugin.git"; +const repositoryUrl = "https://github.com/callstackincubator/mobile-dev.git"; const originalCwd = process.cwd(); const codex = createCodexRunner(profile, { path: codexCli, env }); let http; diff --git a/apps/codex-plugin/ui/codex-update-banner.tsx b/apps/codex-plugin/ui/codex-update-banner.tsx new file mode 100644 index 00000000..41e5d940 --- /dev/null +++ b/apps/codex-plugin/ui/codex-update-banner.tsx @@ -0,0 +1,13 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { WorkspaceExtension } from "@mobile-dev/mcp-app/workspace-extension.ts"; +import { PluginUpdateBanner } from "./plugin-update-banner.tsx"; +import { PluginUpdateController } from "./plugin-updates.ts"; + +export function codexUpdateBanner(app: App): WorkspaceExtension { + const controller = new PluginUpdateController(app); + return { + banner: , + setAvailable: available => controller.setAvailable(available), + dispose: () => controller.dispose(), + }; +} diff --git a/apps/codex-plugin/ui/main.tsx b/apps/codex-plugin/ui/main.tsx new file mode 100644 index 00000000..f18f64ca --- /dev/null +++ b/apps/codex-plugin/ui/main.tsx @@ -0,0 +1,6 @@ +import { startMcpApp } from "@mobile-dev/mcp-app/app.tsx"; +import { codexUpdateBanner } from "./codex-update-banner.tsx"; + +// Codex's panel: the shared MCP Apps panel plus the update banner for Codex plugin +// installs. Codex has its own fullscreen and sidebar controls, so the panel adds none. +startMcpApp({ workspace: { extension: codexUpdateBanner, displayModeControl: false } }); diff --git a/src/ui/components/plugin-update-banner.tsx b/apps/codex-plugin/ui/plugin-update-banner.tsx similarity index 93% rename from src/ui/components/plugin-update-banner.tsx rename to apps/codex-plugin/ui/plugin-update-banner.tsx index 923b5463..20e27a01 100644 --- a/src/ui/components/plugin-update-banner.tsx +++ b/apps/codex-plugin/ui/plugin-update-banner.tsx @@ -1,7 +1,7 @@ import { ArrowUpCircleIcon, CheckCircleIcon, LoaderCircleIcon, XIcon } from "lucide-react"; import { useSyncExternalStore } from "react"; -import type { PluginUpdateController } from "../plugin-updates.ts"; -import { Button } from "./ui/button"; +import type { PluginUpdateController } from "./plugin-updates.ts"; +import { Button } from "@mobile-dev/ui/components/ui/button.tsx"; export function PluginUpdateBanner({ controller }: { controller: PluginUpdateController }) { const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); diff --git a/src/ui/plugin-updates.ts b/apps/codex-plugin/ui/plugin-updates.ts similarity index 98% rename from src/ui/plugin-updates.ts rename to apps/codex-plugin/ui/plugin-updates.ts index 9c11f4bc..3e1a5970 100644 --- a/src/ui/plugin-updates.ts +++ b/apps/codex-plugin/ui/plugin-updates.ts @@ -1,7 +1,7 @@ import type { App } from "@modelcontextprotocol/ext-apps"; import { pluginReleaseUrl, pluginUpdateSchema } from "../shared/plugin-updates.ts"; import type { PluginUpdate } from "../shared/plugin-updates.ts"; -import { captureUiError, countUiEvent } from "./telemetry.ts"; +import { captureUiError, countUiEvent } from "@mobile-dev/ui/telemetry.ts"; export type UpdateSnapshot = { update?: PluginUpdate; busy: boolean; error: string; dismissedVersion?: string }; diff --git a/apps/mac/.gitignore b/apps/mac/.gitignore new file mode 100644 index 00000000..2d9f16e2 --- /dev/null +++ b/apps/mac/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/apps/mac/Package.swift b/apps/mac/Package.swift new file mode 100644 index 00000000..07a3cc2a --- /dev/null +++ b/apps/mac/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MobileDev", + platforms: [.macOS(.v15)], + targets: [ + .executableTarget(name: "MobileDev", path: "Sources/MobileDev"), + ] +) diff --git a/apps/mac/Sources/MobileDev/AppDelegate.swift b/apps/mac/Sources/MobileDev/AppDelegate.swift new file mode 100644 index 00000000..a03a37c3 --- /dev/null +++ b/apps/mac/Sources/MobileDev/AppDelegate.swift @@ -0,0 +1,11 @@ +import AppKit + +/// Owns the workspace for the app's lifetime and stops the server when the app quits. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let model = WorkspaceModel() + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + func applicationWillTerminate(_ notification: Notification) { model.stop() } +} diff --git a/apps/mac/Sources/MobileDev/ContentView.swift b/apps/mac/Sources/MobileDev/ContentView.swift new file mode 100644 index 00000000..f051d8ce --- /dev/null +++ b/apps/mac/Sources/MobileDev/ContentView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct ContentView: View { + let model: WorkspaceModel + @State private var columns: NavigationSplitViewVisibility = SmokeSnapshot.collapsedSidebar ? .detailOnly : .all + + var body: some View { + NavigationSplitView(columnVisibility: $columns) { + SidebarView(model: model) + .navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 280) + } detail: { + // The page has its own toolbar row, so it runs up to the window's top edge. The + // leading safe area stays: it keeps the page clear of the floating sidebar. + PageView(page: model.page) + .ignoresSafeArea(.container, edges: .top) + } + .toolbar(removing: .title) + .onChange(of: columns, initial: true) { model.setSidebarVisible(columns != .detailOnly) } + } +} diff --git a/apps/mac/Sources/MobileDev/DeviceLayout.swift b/apps/mac/Sources/MobileDev/DeviceLayout.swift new file mode 100644 index 00000000..a2199610 --- /dev/null +++ b/apps/mac/Sources/MobileDev/DeviceLayout.swift @@ -0,0 +1,19 @@ +/// Which device platforms are visible. Raw values match the web page's layout names. +enum DeviceLayout: String { + case ios + case android + case both + case none + + init(showsIOS: Bool, showsAndroid: Bool) { + switch (showsIOS, showsAndroid) { + case (true, true): self = .both + case (true, false): self = .ios + case (false, true): self = .android + case (false, false): self = .none + } + } + + var showsIOS: Bool { self == .ios || self == .both } + var showsAndroid: Bool { self == .android || self == .both } +} diff --git a/apps/mac/Sources/MobileDev/LineBuffer.swift b/apps/mac/Sources/MobileDev/LineBuffer.swift new file mode 100644 index 00000000..c8b6aee1 --- /dev/null +++ b/apps/mac/Sources/MobileDev/LineBuffer.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Splits a byte stream into newline-terminated lines, as MCP's stdio transport frames messages. +struct LineBuffer { + private var pending = Data() + + /// Appends bytes and returns every line they complete, without the newline. + mutating func append(_ data: Data) -> [Data] { + pending.append(data) + var lines: [Data] = [] + while let newline = pending.firstIndex(of: 0x0A) { + lines.append(pending[pending.startIndex.. Void = { _ in } + var onShellMessage: (ShellMessage) -> Void = { _ in } + + private let log = Logger(subsystem: "com.callstack.mobile-dev", category: "page") + + /// A stable origin so the page keeps its local storage between launches. + private static let origin = URL(string: "https://mobile-dev.app.local/") + + override init() { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + webView = WKWebView(frame: .zero, configuration: configuration) + webView.isInspectable = true + super.init() + let proxy = WeakScriptMessageHandler(self) + webView.configuration.userContentController.add(proxy, name: "mcp") + webView.configuration.userContentController.add(proxy, name: "shell") + } + + func load(html: String) { webView.loadHTMLString(html, baseURL: Self.origin) } + + /// Hands one server line to the page. Each line is a JSON object, which is also a valid JavaScript expression. + func deliver(_ line: String) { + webView.evaluateJavaScript("window.__mobileDevReceive?.(\(line)); undefined") { [log] _, error in + if let error { log.error("The page rejected a server message: \(error.localizedDescription, privacy: .public)") } + } + } + + func select(tool: Tool?) { + let argument = tool.map { "'\($0.rawValue)'" } ?? "null" + webView.evaluateJavaScript("window.mobileDevShell?.selectTool(\(argument))", completionHandler: nil) + } + + func select(layout: DeviceLayout) { + webView.evaluateJavaScript("window.mobileDevShell?.selectLayout('\(layout.rawValue)')", completionHandler: nil) + } + + func setTitlebarInset(_ size: CGSize) { + webView.evaluateJavaScript("window.mobileDevShell?.setTitlebarInset(\(size.width), \(size.height))", completionHandler: nil) + } + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + switch message.name { + case "mcp": + if let line = message.body as? String { log.debug("Page sent \(line.prefix(120), privacy: .public)"); onServerMessage(line) } + case "shell": + if let shellMessage = ShellMessage(body: message.body) { onShellMessage(shellMessage) } + default: + break + } + } +} diff --git a/apps/mac/Sources/MobileDev/PageLoader.swift b/apps/mac/Sources/MobileDev/PageLoader.swift new file mode 100644 index 00000000..1e25e946 --- /dev/null +++ b/apps/mac/Sources/MobileDev/PageLoader.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Reads the Mac page from the server before the page itself connects. +/// +/// The server serves the page with its telemetry environment, opt-out and +/// anonymous identity, the same way it serves the Codex panel. Once the page +/// loads it initializes its own MCP client, which replaces this one's +/// capabilities on the same connection. String IDs keep these requests apart +/// from the page's numeric ones. +@MainActor +final class PageLoader { + private static let pageURI = "ui://mobile-dev/mac.html" + private let send: (String) -> Void + private let completion: (Result) -> Void + private var finished = false + + init(send: @escaping (String) -> Void, completion: @escaping (Result) -> Void) { + self.send = send + self.completion = completion + } + + func begin() { + request(id: "mac-initialize", method: "initialize", params: [ + "protocolVersion": "2025-06-18", + "capabilities": [String: Any](), + "clientInfo": ["name": "mobile-dev-mac-shell", "version": "1"], + ]) + } + + /// Handles a server line if it answers one of the loader's requests. + func receive(_ line: String) -> Bool { + guard !finished, line.contains("\"id\":\"mac-"), + let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + let id = object["id"] as? String else { return false } + if let error = object["error"] as? [String: Any] { + finish(.failure(.server(error["message"] as? String ?? "The server rejected the request."))) + return true + } + let result = object["result"] as? [String: Any] + switch id { + case "mac-initialize": + notify(method: "notifications/initialized") + request(id: "mac-page", method: "resources/read", params: ["uri": Self.pageURI]) + case "mac-page": + let contents = result?["contents"] as? [[String: Any]] + guard let html = contents?.first?["text"] as? String else { + finish(.failure(.server("The server returned no Mac page. Rebuild the app with npm run build:mac."))) + return true + } + finish(.success(html)) + default: + return false + } + return true + } + + private func finish(_ result: Result) { + finished = true + completion(result) + } + + private func request(id: String, method: String, params: [String: Any]) { + write(["jsonrpc": "2.0", "id": id, "method": method, "params": params]) + } + + private func notify(method: String) { write(["jsonrpc": "2.0", "method": method]) } + + private func write(_ message: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: message) else { return } + send(String(decoding: data, as: UTF8.self)) + } +} diff --git a/apps/mac/Sources/MobileDev/PageLoaderError.swift b/apps/mac/Sources/MobileDev/PageLoaderError.swift new file mode 100644 index 00000000..9d1f85e3 --- /dev/null +++ b/apps/mac/Sources/MobileDev/PageLoaderError.swift @@ -0,0 +1,11 @@ +import Foundation + +enum PageLoaderError: LocalizedError { + case server(String) + + var errorDescription: String? { + switch self { + case .server(let message): message + } + } +} diff --git a/apps/mac/Sources/MobileDev/PageView.swift b/apps/mac/Sources/MobileDev/PageView.swift new file mode 100644 index 00000000..2fc24fd3 --- /dev/null +++ b/apps/mac/Sources/MobileDev/PageView.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WebKit + +/// Embeds the bridge's long-lived web view, so the page survives SwiftUI view updates. +struct PageView: NSViewRepresentable { + let page: PageBridge + + func makeNSView(context: Context) -> WKWebView { page.webView } + func updateNSView(_ view: WKWebView, context: Context) {} +} diff --git a/apps/mac/Sources/MobileDev/ServerProcess.swift b/apps/mac/Sources/MobileDev/ServerProcess.swift new file mode 100644 index 00000000..de227e34 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ServerProcess.swift @@ -0,0 +1,68 @@ +import Foundation +import os + +/// Runs the Mobile Dev MCP server over stdio, the way Codex launches it. +/// +/// Reading, writing and buffering happen on one owned serial queue. Each complete +/// stdout line and the exit status cross to the main thread once, at delivery. +final class ServerProcess: @unchecked Sendable { + private let process = Process() + private let input = Pipe() + private let output = Pipe() + private let errors = Pipe() + // Confined to `queue`. + private let queue = DispatchQueue(label: "com.callstack.mobile-dev.server") + private var lines = LineBuffer() + private let log = Logger(subsystem: "com.callstack.mobile-dev", category: "server") + + init(runtime: ServerRuntime) { + process.executableURL = runtime.node + process.arguments = [runtime.server.path] + process.currentDirectoryURL = runtime.server.deletingLastPathComponent() + var environment = ProcessInfo.processInfo.environment + environment["MOBILE_DEV_HOST"] = "mac" + process.environment = environment + process.standardInput = input + process.standardOutput = output + process.standardError = errors + } + + /// Starts the server. `onLine` receives each JSON-RPC line and `onExit` the exit status, both on the main thread. + func start(onLine: @escaping @MainActor (String) -> Void, onExit: @escaping @MainActor (Int32) -> Void) throws { + output.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard let self, !data.isEmpty else { return } + self.queue.async { + let complete = self.lines.append(data).map { String(decoding: $0, as: UTF8.self) } + guard !complete.isEmpty else { return } + DispatchQueue.main.async { for line in complete { onLine(line) } } + } + } + errors.fileHandleForReading.readabilityHandler = { [log] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + log.info("\(String(decoding: data, as: UTF8.self), privacy: .public)") + } + process.terminationHandler = { [output, errors] process in + output.fileHandleForReading.readabilityHandler = nil + errors.fileHandleForReading.readabilityHandler = nil + let status = process.terminationStatus + DispatchQueue.main.async { onExit(status) } + } + try process.run() + } + + /// Writes one JSON-RPC message followed by the newline that frames it. + func send(_ line: String) { + queue.async { [input, log] in + do { try input.fileHandleForWriting.write(contentsOf: Data((line + "\n").utf8)) } + catch { log.error("Could not write to the server: \(error.localizedDescription, privacy: .public)") } + } + } + + /// Asks the server to shut down; it stops its Baguette child and device streams first. + func stop() { + guard process.isRunning else { return } + process.terminate() + } +} diff --git a/apps/mac/Sources/MobileDev/ServerRuntime.swift b/apps/mac/Sources/MobileDev/ServerRuntime.swift new file mode 100644 index 00000000..b0a7f3c4 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ServerRuntime.swift @@ -0,0 +1,19 @@ +import Foundation + +/// The Node executable and server script bundled in the app's resources. +struct ServerRuntime { + let node: URL + let server: URL + + /// Finds the runtime `tooling/build-mac.mjs` places under `Contents/Resources`. + static func bundled(in bundle: Bundle = .main) throws -> ServerRuntime { + guard let resources = bundle.resourceURL else { throw ServerRuntimeError.missing("the app's resources") } + let runtime = ServerRuntime( + node: resources.appending(path: "node/node"), + server: resources.appending(path: "runtime/server.mjs")) + let files = FileManager.default + guard files.isExecutableFile(atPath: runtime.node.path) else { throw ServerRuntimeError.missing("the bundled Node runtime") } + guard files.fileExists(atPath: runtime.server.path) else { throw ServerRuntimeError.missing("the bundled server") } + return runtime + } +} diff --git a/apps/mac/Sources/MobileDev/ServerRuntimeError.swift b/apps/mac/Sources/MobileDev/ServerRuntimeError.swift new file mode 100644 index 00000000..e9955609 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ServerRuntimeError.swift @@ -0,0 +1,11 @@ +import Foundation + +enum ServerRuntimeError: LocalizedError { + case missing(String) + + var errorDescription: String? { + switch self { + case .missing(let part): "Mobile Dev could not find \(part). Rebuild the app with npm run build:mac." + } + } +} diff --git a/apps/mac/Sources/MobileDev/ServerStatus.swift b/apps/mac/Sources/MobileDev/ServerStatus.swift new file mode 100644 index 00000000..519d7743 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ServerStatus.swift @@ -0,0 +1,14 @@ +/// The state of the Mobile Dev server and the page connected to it. +enum ServerStatus: Equatable { + case starting + case ready + case failed(String) + + var label: String { + switch self { + case .starting: "Starting server…" + case .ready: "Connected" + case .failed(let message): message + } + } +} diff --git a/apps/mac/Sources/MobileDev/ServerStatusView.swift b/apps/mac/Sources/MobileDev/ServerStatusView.swift new file mode 100644 index 00000000..1eafa2c9 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ServerStatusView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// The server's state at the bottom of the sidebar, with a restart action when it failed. +struct ServerStatusView: View { + let model: WorkspaceModel + + var body: some View { + HStack(spacing: 8) { + switch model.status { + case .starting: + ProgressView().controlSize(.small) + case .ready: + Image(systemName: "circle.fill").font(.system(size: 7)).foregroundStyle(.green) + case .failed: + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + } + Text(model.status.label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + Spacer(minLength: 0) + if case .failed = model.status { + Button("Restart") { model.restart() }.controlSize(.small) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } +} diff --git a/apps/mac/Sources/MobileDev/ShellMessage.swift b/apps/mac/Sources/MobileDev/ShellMessage.swift new file mode 100644 index 00000000..41aaffb1 --- /dev/null +++ b/apps/mac/Sources/MobileDev/ShellMessage.swift @@ -0,0 +1,23 @@ +/// A message the page posts to `window.webkit.messageHandlers.shell`. +enum ShellMessage { + /// The page connected to the server and its device panels are available. + case ready + /// The page chose a layout itself, for example after the server opened a device. + case layout(DeviceLayout) + case error(String) + + init?(body: Any) { + guard let fields = body as? [String: Any], let type = fields["type"] as? String else { return nil } + switch type { + case "ready": + self = .ready + case "layout": + guard let raw = fields["layout"] as? String, let layout = DeviceLayout(rawValue: raw) else { return nil } + self = .layout(layout) + case "error": + self = .error(fields["message"] as? String ?? "The page reported an error.") + default: + return nil + } + } +} diff --git a/apps/mac/Sources/MobileDev/SidebarView.swift b/apps/mac/Sources/MobileDev/SidebarView.swift new file mode 100644 index 00000000..0a296f90 --- /dev/null +++ b/apps/mac/Sources/MobileDev/SidebarView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// The native sidebar: tools to show beside the devices, the visible device platforms, and the server's state. +struct SidebarView: View { + let model: WorkspaceModel + + var body: some View { + List(selection: Binding(get: { model.tool }, set: { model.select(tool: $0) })) { + Section("Tools") { + ForEach(Tool.allCases) { tool in + Label(tool.title, systemImage: tool.systemImage).tag(tool) + } + } + Section("Devices") { + Toggle(isOn: Binding(get: { model.layout.showsIOS }, set: { model.setShowsIOS($0) })) { + Label("iOS", systemImage: "iphone") + } + Toggle(isOn: Binding(get: { model.layout.showsAndroid }, set: { model.setShowsAndroid($0) })) { + Label("Android", systemImage: "candybarphone") + } + } + } + .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { ServerStatusView(model: model) } + } +} diff --git a/apps/mac/Sources/MobileDev/SmokeSnapshot.swift b/apps/mac/Sources/MobileDev/SmokeSnapshot.swift new file mode 100644 index 00000000..9b8e107b --- /dev/null +++ b/apps/mac/Sources/MobileDev/SmokeSnapshot.swift @@ -0,0 +1,25 @@ +import AppKit +import WebKit + +/// Writes the page as a PNG for `tooling/smoke-mac.mjs`, which cannot rely on +/// Screen Recording permission. Enabled only by `MOBILE_DEV_SMOKE_SNAPSHOT=`. +@MainActor +enum SmokeSnapshot { + static let path = ProcessInfo.processInfo.environment["MOBILE_DEV_SMOKE_SNAPSHOT"] + /// `MOBILE_DEV_SMOKE_SIDEBAR=collapsed` starts the window with the sidebar collapsed. + static let collapsedSidebar = path != nil && ProcessInfo.processInfo.environment["MOBILE_DEV_SMOKE_SIDEBAR"] == "collapsed" + + /// Seconds to let the device panels draw first; `MOBILE_DEV_SMOKE_DELAY` overrides the default. + private static let delay = Double(ProcessInfo.processInfo.environment["MOBILE_DEV_SMOKE_DELAY"] ?? "") ?? 6 + + static func capture(_ webView: WKWebView) { + guard let path else { return } + Task { + try? await Task.sleep(for: .seconds(delay)) + guard let image = try? await webView.takeSnapshot(configuration: nil), + let tiff = image.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { return } + try? png.write(to: URL(fileURLWithPath: path)) + } + } +} diff --git a/apps/mac/Sources/MobileDev/Tool.swift b/apps/mac/Sources/MobileDev/Tool.swift new file mode 100644 index 00000000..8c3fc178 --- /dev/null +++ b/apps/mac/Sources/MobileDev/Tool.swift @@ -0,0 +1,27 @@ +/// A developer tool shown beside the devices. Raw values match the web page's tool names. +enum Tool: String, CaseIterable, Identifiable { + case logs + case performance + case storage + case tracesift + + var id: Self { self } + + var title: String { + switch self { + case .logs: "Logs" + case .performance: "Performance" + case .storage: "Storage" + case .tracesift: "TraceSift" + } + } + + var systemImage: String { + switch self { + case .logs: "terminal" + case .performance: "waveform.path.ecg" + case .storage: "cylinder.split.1x2" + case .tracesift: "chart.bar.xaxis" + } + } +} diff --git a/apps/mac/Sources/MobileDev/WeakScriptMessageHandler.swift b/apps/mac/Sources/MobileDev/WeakScriptMessageHandler.swift new file mode 100644 index 00000000..5393116a --- /dev/null +++ b/apps/mac/Sources/MobileDev/WeakScriptMessageHandler.swift @@ -0,0 +1,12 @@ +import WebKit + +/// Forwards script messages without letting `WKUserContentController` retain the receiver. +final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + private weak var target: (any WKScriptMessageHandler)? + + init(_ target: any WKScriptMessageHandler) { self.target = target } + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + target?.userContentController(controller, didReceive: message) + } +} diff --git a/apps/mac/Sources/MobileDev/WindowControls.swift b/apps/mac/Sources/MobileDev/WindowControls.swift new file mode 100644 index 00000000..d9a2fbc7 --- /dev/null +++ b/apps/mac/Sources/MobileDev/WindowControls.swift @@ -0,0 +1,8 @@ +import CoreGraphics + +/// The area the traffic lights and the system sidebar button cover at the top-left +/// of the detail column once the sidebar is collapsed, in points. +enum WindowControls { + static let width: CGFloat = 144 + static let height: CGFloat = 52 +} diff --git a/apps/mac/Sources/MobileDev/WorkspaceModel.swift b/apps/mac/Sources/MobileDev/WorkspaceModel.swift new file mode 100644 index 00000000..c5606f7c --- /dev/null +++ b/apps/mac/Sources/MobileDev/WorkspaceModel.swift @@ -0,0 +1,108 @@ +import Foundation +import Observation +import os + +/// The window's state: the sidebar selection, the server it runs, and the page that renders the tools. +@MainActor +@Observable +final class WorkspaceModel { + private(set) var tool: Tool? = .logs + /// The page chooses the first layout from the devices it finds and reports it. + private(set) var layout: DeviceLayout = .none + private(set) var status: ServerStatus = .starting { + didSet { log.info("Status: \(self.status.label, privacy: .public)") } + } + @ObservationIgnored private let log = Logger(subsystem: "com.callstack.mobile-dev", category: "workspace") + @ObservationIgnored private var sidebarVisible = true + @ObservationIgnored let page = PageBridge() + @ObservationIgnored private var server: ServerProcess? + @ObservationIgnored private var loader: PageLoader? + + init() { + page.onServerMessage = { [weak self] line in self?.server?.send(line) } + page.onShellMessage = { [weak self] message in self?.handle(message) } + start() + } + + func select(tool: Tool?) { + guard tool != self.tool else { return } + self.tool = tool + page.select(tool: tool) + } + + /// Moves the page's top-left controls clear of the window controls while the sidebar is collapsed. + func setSidebarVisible(_ visible: Bool) { + sidebarVisible = visible + page.setTitlebarInset(visible ? .zero : CGSize(width: WindowControls.width, height: WindowControls.height)) + } + + func setShowsIOS(_ shows: Bool) { select(layout: DeviceLayout(showsIOS: shows, showsAndroid: layout.showsAndroid)) } + func setShowsAndroid(_ shows: Bool) { select(layout: DeviceLayout(showsIOS: layout.showsIOS, showsAndroid: shows)) } + + /// Restarts the server and reloads the page, for example after the server exits. + func restart() { + server?.stop() + server = nil + start() + } + + func stop() { server?.stop() } + + private func select(layout: DeviceLayout) { + guard layout != self.layout else { return } + self.layout = layout + page.select(layout: layout) + } + + private func start() { + status = .starting + let process: ServerProcess + do { process = ServerProcess(runtime: try ServerRuntime.bundled()) } + catch { status = .failed(error.localizedDescription); return } + let loader = PageLoader(send: { process.send($0) }) { [weak self] result in + switch result { + case .success(let html): self?.page.load(html: html) + case .failure(let error): self?.status = .failed(error.localizedDescription) + } + } + do { + try process.start( + onLine: { [weak self] line in self?.receive(line, from: process) }, + onExit: { [weak self] code in self?.exited(process, code: code) }) + } catch { + status = .failed("Mobile Dev could not start its server: \(error.localizedDescription)") + return + } + server = process + self.loader = loader + loader.begin() + } + + private func receive(_ line: String, from process: ServerProcess) { + guard process === server else { return } + if loader?.receive(line) == true { return } + page.deliver(line) + } + + private func exited(_ process: ServerProcess, code: Int32) { + guard process === server else { return } + server = nil + status = .failed("The Mobile Dev server stopped (exit status \(code)).") + } + + private func handle(_ message: ShellMessage) { + switch message { + case .ready: + status = .ready + // The page starts with no tool; apply the sidebar's. It picks the devices itself. + page.select(tool: tool) + setSidebarVisible(sidebarVisible) + SmokeSnapshot.capture(page.webView) + case .layout(let layout): + // The page already applied this layout; reflect it without sending it back. + self.layout = layout + case .error(let message): + status = .failed(message) + } + } +} diff --git a/apps/mac/package.json b/apps/mac/package.json new file mode 100644 index 00000000..0347146e --- /dev/null +++ b/apps/mac/package.json @@ -0,0 +1,7 @@ +{ + "name": "@mobile-dev/mac", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Mac app: SwiftUI shell, the web content it hosts, and its server runtime packaging." +} diff --git a/apps/mac/server/index.ts b/apps/mac/server/index.ts new file mode 100644 index 00000000..c8120b20 --- /dev/null +++ b/apps/mac/server/index.ts @@ -0,0 +1,6 @@ +import "@mobile-dev/core/instrument.ts"; +import { runStdioServer } from "@mobile-dev/core/stdio-server.ts"; +import { macPage } from "./mac-page.ts"; + +// The Mac app's server: the shared server plus the page its window loads. +await runStdioServer([macPage]); diff --git a/apps/mac/server/mac-page.ts b/apps/mac/server/mac-page.ts new file mode 100644 index 00000000..31a64da5 --- /dev/null +++ b/apps/mac/server/mac-page.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import type { ServerFeature } from "@mobile-dev/core/server-feature.ts"; +import { configureUI } from "@mobile-dev/core/plugin.ts"; + +export const MAC_PAGE_URI = "ui://mobile-dev/mac.html"; + +/** + * Serves the Mac page bundled beside this server. The window reads it through MCP, + * so it receives the same server-owned telemetry metadata as the MCP Apps panel. + */ +export const macPage: ServerFeature = server => { + const html = readFileSync(new URL("./mac.html", import.meta.url), "utf8"); + server.registerResource("mobile-dev-mac", MAC_PAGE_URI, { mimeType: "text/html" }, uri => ({ + contents: [{ uri: uri.href, mimeType: "text/html", text: configureUI(html) }], + })); +}; diff --git a/apps/mac/tooling/build.mjs b/apps/mac/tooling/build.mjs new file mode 100644 index 00000000..955d473b --- /dev/null +++ b/apps/mac/tooling/build.mjs @@ -0,0 +1,78 @@ +import { chmod, copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { telemetryBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; +import { assertRuntime, releaseVersion, stageRuntime } from "../../../tooling/host-runtime.mjs"; +import { buildServerBundles } from "../../../tooling/server-bundle.mjs"; +import { buildUIPage } from "../../../tooling/ui-page.mjs"; + +// Assembles `release/mac/Mobile Dev.app` from the shared runtime in `dist/`, the +// Mac page, the SwiftUI shell and a Node runtime. Run `npm run build` first. +const option = name => { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; +const telemetryEnvironment = telemetryBuildEnvironment(process.argv.slice(2).filter(arg => arg === "--release")); +await assertRuntime(telemetryEnvironment); +const version = await releaseVersion(); +const node = resolve(option("--node") ?? process.execPath); +const nodeLibraries = execFileSync("otool", ["-L", node], { encoding: "utf8" }).split("\n").slice(1).map(line => line.trim().split(" ")[0]).filter(Boolean); +const external = nodeLibraries.filter(path => !path.startsWith("/usr/lib/") && !path.startsWith("/System/")); +if (external.length) throw new Error(`${node} links libraries outside macOS (${external.join(", ")}). Pass --node with a self-contained Node build.`); + +const app = resolve("release/mac/Mobile Dev.app"); +const contents = `${app}/Contents`; +await rm(app, { recursive: true, force: true }); +await mkdir(`${contents}/MacOS`, { recursive: true }); +await mkdir(`${contents}/Resources/node`, { recursive: true }); + +// The server is the one Codex runs; the Mac page sits beside it so the server +// registers and serves it with its telemetry metadata. +// The shared runtime with the Mac app's own server, which serves the page its window loads. +const runtime = `${contents}/Resources/runtime`; +await stageRuntime(runtime); +await buildServerBundles({ entryPoints: { server: "apps/mac/server/index.ts" }, outdir: runtime, sentryDirectory: ".sentry/server/mac" }); +await buildUIPage({ + name: "ui/mac", entry: "apps/mac/web/main.tsx", template: "apps/mac/web/index.html", + sources: ["packages/ui/src", "apps/mac/web"], output: `${runtime}/mac.html`, + environment: telemetryEnvironment, sourceURL: "app:///mobile-dev-mac-ui.js", +}); +await copyFile(node, `${contents}/Resources/node/node`); +await chmod(`${contents}/Resources/node/node`, 0o755); + +// SwiftPM links with the deployment target as the recorded SDK version, and AppKit +// uses that version to choose the window's appearance. Record the SDK it compiled +// against, so the app gets the current system design while still running on 15. +const minimumSystem = "15.0"; +const sdkVersion = execFileSync("xcrun", ["--sdk", "macosx", "--show-sdk-version"], { encoding: "utf8" }).trim(); +const swiftBuild = ["build", "-c", "release", "--package-path", "apps/mac", + "-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", minimumSystem, "-Xlinker", sdkVersion]; +execFileSync("swift", swiftBuild, { stdio: "inherit" }); +const binDirectory = execFileSync("swift", [...swiftBuild, "--show-bin-path"], { encoding: "utf8" }).trim(); +const linked = execFileSync("otool", ["-l", `${binDirectory}/MobileDev`], { encoding: "utf8" }); +if (!new RegExp(`\\bsdk ${sdkVersion.replace(".", "\\.")}\\b`).test(linked)) throw new Error(`MobileDev does not record the macOS ${sdkVersion} SDK it was built with.`); +await copyFile(`${binDirectory}/MobileDev`, `${contents}/MacOS/MobileDev`); +await copyFile("apps/mac/Resources/AppIcon.icns", `${contents}/Resources/AppIcon.icns`).catch(error => { if (error.code !== "ENOENT") throw error; }); +await writeFile(`${contents}/Info.plist`, ` + + + + CFBundleNameMobile Dev + CFBundleDisplayNameMobile Dev + CFBundleIdentifiercom.callstack.mobile-dev + CFBundleExecutableMobileDev + CFBundlePackageTypeAPPL + CFBundleShortVersionString${version} + CFBundleVersion${version} + CFBundleIconFileAppIcon + LSMinimumSystemVersion${minimumSystem} + LSApplicationCategoryTypepublic.app-category.developer-tools + NSHighResolutionCapable + MobileDevTelemetryEnvironment${telemetryEnvironment} + + +`); +// Local builds are signed ad hoc. Distribution needs a Developer ID identity, +// hardened runtime entitlements for Node, and notarization. +execFileSync("codesign", ["--force", "--sign", option("--sign") ?? "-", app], { stdio: "inherit" }); +console.log(`Mac app: ${app}`); diff --git a/apps/mac/tooling/smoke.mjs b/apps/mac/tooling/smoke.mjs new file mode 100644 index 00000000..a4a8c75c --- /dev/null +++ b/apps/mac/tooling/smoke.mjs @@ -0,0 +1,43 @@ +import { spawn, execFileSync } from "node:child_process"; +import { access, mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +// Launches the built Mac app, waits for its page to connect to the bundled +// server and draw, then quits it and checks the server stopped. Pass a path to +// keep the page snapshot. It never boots or changes a device. +const app = resolve("release/mac/Mobile Dev.app"); +await access(`${app}/Contents/MacOS/MobileDev`); +const directory = await mkdtemp(join(tmpdir(), "mobile-dev-mac-smoke-")); +const snapshot = resolve(process.argv[2] ?? join(directory, "page.png")); +// A snapshot left from an earlier run would look like this run's page drawing. +await rm(snapshot, { force: true }); +const child = spawn(`${app}/Contents/MacOS/MobileDev`, [], { + env: { ...process.env, MOBILE_DEV_SMOKE_SNAPSHOT: snapshot }, stdio: "ignore", +}); +const servers = () => { + try { return execFileSync("pgrep", ["-f", `${app}/Contents/Resources/runtime/server.mjs`], { encoding: "utf8" }).trim().split("\n").filter(Boolean); } + catch { return []; } +}; +try { + const deadline = Date.now() + 90_000; + let size = 0; + while (Date.now() < deadline) { + size = await stat(snapshot).then(file => file.size, () => 0); + if (size > 0) break; + if (child.exitCode !== null) throw new Error(`The app exited with status ${child.exitCode} before its page connected.`); + await delay(500); + } + if (size === 0) throw new Error("The page did not connect and draw within 90 seconds."); + if (servers().length !== 1) throw new Error(`Expected one bundled server process, found ${servers().length}.`); + console.log(`The page connected to the bundled server and drew (${Math.round(size / 1024)} KB snapshot: ${snapshot}).`); +} finally { + child.kill("SIGTERM"); + await new Promise(done => child.exitCode !== null ? done() : child.once("exit", done)); + const deadline = Date.now() + 10_000; + while (servers().length && Date.now() < deadline) await delay(250); + if (process.argv[2] === undefined) await rm(directory, { recursive: true, force: true }); +} +if (servers().length) throw new Error("The bundled server kept running after the app quit."); +console.log("Quitting the app stopped the bundled server."); diff --git a/apps/mac/web/index.html b/apps/mac/web/index.html new file mode 100644 index 00000000..23dc6f71 --- /dev/null +++ b/apps/mac/web/index.html @@ -0,0 +1,14 @@ + + + + + + + Mobile Dev + + + +
+ + + diff --git a/apps/mac/web/mac-bridge.ts b/apps/mac/web/mac-bridge.ts new file mode 100644 index 00000000..213647a8 --- /dev/null +++ b/apps/mac/web/mac-bridge.ts @@ -0,0 +1,69 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { HostBridge } from "@mobile-dev/ui/host-bridge.ts"; +import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; + +type ShellMessage = + | { type: "ready" } + | { type: "layout"; layout: string } + | { type: "tool"; tool: string } + | { type: "error"; message: string }; + +type WebKitHandlers = { mcp: { postMessage(message: string): void }; shell: { postMessage(message: ShellMessage): void } }; +declare global { + interface Window { + webkit?: { messageHandlers: WebKitHandlers }; + /** Swift delivers each line the server writes to stdout here. */ + __mobileDevReceive?: (message: JSONRPCMessage) => void; + } +} + +/** Posts to the native window. Outside the app (tests, a browser) it is a no-op. */ +export function postToShell(message: ShellMessage) { + window.webkit?.messageHandlers.shell.postMessage(message); +} + +/** + * Carries MCP JSON-RPC between this page and the server process the app runs. + * Swift writes outgoing messages to the server's stdin and evaluates each stdout + * line as `__mobileDevReceive(message)`. + */ +class WebKitRelayTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + async start() { + if (window.webkit === undefined) throw new Error("Mobile Dev's server connection is only available inside the Mac app."); + window.__mobileDevReceive = message => this.onmessage?.(message); + } + async send(message: JSONRPCMessage) { window.webkit!.messageHandlers.mcp.postMessage(JSON.stringify(message)); } + async close() { + window.__mobileDevReceive = undefined; + this.onclose?.(); + } +} + +const dark = window.matchMedia("(prefers-color-scheme: dark)"); + +/** + * The Mac app's host. It calls the server directly and has no chat, so it offers + * no message or model-context capability; views hide the actions that need one. + */ +export class MacHostBridge implements HostBridge { + private readonly client = new Client({ name: "mobile-dev-mac", version: PLUGIN_VERSION }); + + connect() { return this.client.connect(new WebKitRelayTransport()); } + close() { return this.client.close(); } + + callServerTool: HostBridge["callServerTool"] = async (params, options) => + await this.client.callTool(params, undefined, options) as CallToolResult; + readServerResource: HostBridge["readServerResource"] = (params, options) => this.client.readResource(params, options); + getHostCapabilities: HostBridge["getHostCapabilities"] = () => ({ serverTools: {}, serverResources: {} }); + getHostContext: HostBridge["getHostContext"] = () => ({ theme: dark.matches ? "dark" : "light", availableDisplayModes: ["inline"] }); + sendMessage: HostBridge["sendMessage"] = () => Promise.reject(new Error("The Mac app has no chat to send to.")); + updateModelContext: HostBridge["updateModelContext"] = () => Promise.reject(new Error("The Mac app has no chat context.")); + requestDisplayMode: HostBridge["requestDisplayMode"] = async () => ({ mode: "inline" }); +} diff --git a/apps/mac/web/mac-workspace.tsx b/apps/mac/web/mac-workspace.tsx new file mode 100644 index 00000000..d86f4b89 --- /dev/null +++ b/apps/mac/web/mac-workspace.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; +import { useGroupRef } from "react-resizable-panels"; +import type { ToolName, WorkspaceTools } from "@mobile-dev/ui/workspace-tools.ts"; +import type { DeviceLayout } from "@mobile-dev/ui/device-layout.ts"; +import { LogsView } from "@mobile-dev/ui/components/logs-view.tsx"; +import { PerformanceView } from "@mobile-dev/ui/components/performance-view.tsx"; +import { SimulatorView } from "@mobile-dev/ui/components/simulator-view.tsx"; +import { StorageView } from "@mobile-dev/ui/components/storage-view.tsx"; +import { TraceSiftView } from "@mobile-dev/ui/components/tracesift-view.tsx"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@mobile-dev/ui/components/ui/resizable.tsx"; + + +/** + * The Mac app's content area. The native sidebar chooses the tool and the visible + * platforms, so this view has no toolbar of its own: a tool on the left, the + * device panels on the right. Both device panels stay mounted, because their + * controllers attach to the DOM once. + */ +export function MacWorkspace({ panels, tool, layout }: { panels: WorkspaceTools; tool?: ToolName; layout: DeviceLayout }) { + const workspaceGroup = useGroupRef(); + const deviceGroup = useGroupRef(); + useEffect(() => { + const devices = layout === "none" ? 0 : tool === undefined ? 100 : 40; + workspaceGroup.current?.setLayout({ "mac-tools": 100 - devices, "mac-devices": devices }); + if (layout !== "none") deviceGroup.current?.setLayout({ "mac-ios": layout === "android" ? 0 : layout === "both" ? 50 : 100, "mac-android": layout === "ios" ? 0 : layout === "both" ? 50 : 100 }); + }, [tool, layout]); + // The leftmost visible panel sits under the window controls when the sidebar is collapsed. + const leading = tool !== undefined ? "tool" : layout === "android" ? "android" : "ios"; + const leadingClass = (panel: string) => panel === leading ? "mac-leading h-full" : "h-full"; + const toolView = tool === "logs" ? + : tool === "performance" ? + : tool === "storage" ? + : tool === "tracesift" ? + : ; + return
+ +
{toolView}
+
+
; +} + +function EmptyTool({ layout }: { layout: DeviceLayout }) { + return
+ {layout === "none" ? "Choose a device platform or a tool in the sidebar." : "Choose a tool in the sidebar."} +
; +} diff --git a/apps/mac/web/mac.css b/apps/mac/web/mac.css new file mode 100644 index 00000000..ab3747d3 --- /dev/null +++ b/apps/mac/web/mac.css @@ -0,0 +1,11 @@ +/* While the native sidebar is collapsed, the window's traffic lights and sidebar + button sit over the page's top-left corner. Swift sets --mac-titlebar-inset to + their width, and the leading panel's top row makes room for them. */ +.mac-leading .panel-toolbar { + padding-left: calc(var(--mac-titlebar-inset, 0px) + 0.5rem); + transition: padding-left 150ms ease; +} +/* Views without a toolbar row, such as TraceSift, move below the controls instead. */ +.mac-leading:not(:has(.panel-toolbar)) { + padding-top: var(--mac-titlebar-height, 0px); +} diff --git a/apps/mac/web/main.tsx b/apps/mac/web/main.tsx new file mode 100644 index 00000000..a1645fd9 --- /dev/null +++ b/apps/mac/web/main.tsx @@ -0,0 +1,107 @@ +import "@fontsource-variable/inter"; +import "@mobile-dev/ui/theme.css"; +import "@mobile-dev/ui/style.css"; +import "./mac.css"; +import { applyDocumentTheme } from "@modelcontextprotocol/ext-apps"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { DeviceLayout } from "@mobile-dev/ui/device-layout.ts"; +import { DeviceWorkspace } from "@mobile-dev/ui/device-workspace.ts"; +import { ErrorBoundary, captureUiError, startUiTelemetry, stopUiTelemetry } from "@mobile-dev/ui/telemetry.ts"; +import { isToolName, WorkspaceTools, type ToolName } from "@mobile-dev/ui/workspace-tools.ts"; +import { MacHostBridge, postToShell } from "./mac-bridge.ts"; +import { MacWorkspace } from "./mac-workspace.tsx"; + +const layouts = ["ios", "android", "both", "none"] as const; +const isLayout = (value: unknown): value is DeviceLayout => layouts.includes(value as DeviceLayout); + +const bridge = new MacHostBridge(); +startUiTelemetry(bridge); +// The Mac app has no chat, so the panels have no host attachments. +const panels = WorkspaceTools.create(bridge); +let tool: ToolName | undefined; +let layout: DeviceLayout = "none"; +const reactRoot = createRoot(document.getElementById("root")!); +function render() { + reactRoot.render(Mobile Dev could not render. Reload the window to try again.

}> + +
); +} +flushSync(render); + +const devices = new DeviceWorkspace(bridge, panels, { + onAutomaticLayout: next => { + layout = next; + render(); + postToShell({ type: "layout", layout: next }); + }, + connectionFailed, +}); +panels.logs.setLayout(true); +devices.changeLayout("none"); + +function connectionFailed(error: unknown) { + captureUiError(error, "host.connect"); + const message = error instanceof Error ? error.message : "Could not connect to the Mobile Dev server."; + devices.notice(message); + postToShell({ type: "error", message }); +} + +function selectTool(next: ToolName | undefined) { + if (next === tool) return; + tool = next; + if (next) panels.show(next); + else panels.close(); + render(); +} + +function selectLayout(next: DeviceLayout) { + if (next === layout) return; + layout = next; + devices.changeLayout(next, true); + render(); +} + +declare global { + interface Window { + /** The native sidebar drives the content area through these calls. */ + mobileDevShell?: { selectTool(tool: string | null): void; selectLayout(layout: string): void; setTitlebarInset(width: number, height: number): void }; + } +} +window.mobileDevShell = { + selectTool: value => selectTool(isToolName(value) ? value : undefined), + selectLayout: value => { if (isLayout(value)) selectLayout(value); }, + setTitlebarInset: (width, height) => { + document.documentElement.style.setProperty("--mac-titlebar-inset", `${Math.max(0, width)}px`); + document.documentElement.style.setProperty("--mac-titlebar-height", `${Math.max(0, height)}px`); + }, +}; + +const dark = window.matchMedia("(prefers-color-scheme: dark)"); +function hostChanged() { + applyDocumentTheme(dark.matches ? "dark" : "light"); + document.documentElement.style.setProperty("--font-sans", '"Inter Variable", sans-serif'); + panels.hostChanged(); + devices.refit(); +} +dark.addEventListener("change", hostChanged); +hostChanged(); + +window.addEventListener("pagehide", () => { + void Promise.allSettled([devices.dispose(), panels.dispose(), bridge.close(), stopUiTelemetry()]); +}); + +void (async () => { + try { + await bridge.connect(); + panels.setAvailable(true); + await devices.setAvailable(true); + postToShell({ type: "ready" }); + // Codex opens the panel through a tool call that names a target. The app has + // none, so the panels restore or pick devices and choose the layout themselves + // unless the sidebar changes it first. + await devices.open({}); + } catch (error) { + connectionFailed(error); + } +})(); diff --git a/components.json b/components.json index 57f59669..c81520b4 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/ui/theme.css", + "css": "packages/ui/src/theme.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/docs/architecture.md b/docs/architecture.md index cec8f364..6ef20f92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,9 +4,29 @@ The Agent Device MCP entry and skill are currently disabled; their implementation and bundled runtime are retained for later reactivation. +## Hosts + +The server (`packages/core`), the shared UI (`packages/ui`) and the MCP Apps panel +(`packages/mcp-app`) run in every host. Codex and Claude Desktop load the panel as an +MCP App: the `App` from ext-apps carries tool calls and resource reads through the +host, and the panel adapts to the capabilities each host advertises. The Mac app +(`apps/mac`) starts the server over stdio and relays its JSON-RPC lines to a web view, +whose page calls the server with an MCP client. + +A host adds to the shared pieces without changing them: + +- **Server:** each host's entry calls `runStdioServer` with its `ServerFeature`s. + Codex adds the self-updater, which installs through Codex's marketplace; the Mac + app adds its page resource; Claude Desktop runs the shared server unchanged. +- **Panel:** a host's page entry calls `startMcpApp` with its chrome. Codex adds the + update banner; Claude Desktop uses the shared entry. +- **Views:** shared views depend only on `HostBridge`, the subset of `App` they use, + plus optional host context attachments. `DeviceWorkspace` keeps the iOS and Android + panels, their selection and layout identical in every host. + ## Shared app discovery -`src/ui/device-apps.ts` owns one `DeviceAppsStore` per workspace. Device selection +`packages/ui/src/device-apps.ts` owns one `DeviceAppsStore` per workspace. Device selection feeds this store; it polls every three seconds while the panel is visible and the selected device is connected, including before Performance opens. Consumers share `subscribe` and `getSnapshot` (also usable with React's `useSyncExternalStore`). @@ -16,7 +36,7 @@ Device changes, hidden panels, disconnection, and disposal cancel pending work, clear foreground state, and ignore late results. A failed query is not a confirmed absence: consumers should check `ready` before interpreting `foregroundApp: null`. -`src/server/device-apps/` contains platform discovery, exposed through the existing +`packages/core/src/device-apps/` contains platform discovery, exposed through the existing `mobile_performance_sources` tool. Android reads the top resumed activity from ADB; iOS simulators read the frontmost accessibility translation's PID without walking the UI tree; paired iPhones retain their accessibility-audit PID query. Foreground @@ -77,7 +97,9 @@ Annotations send text only. Each note includes the user request, element label, ## Native device requests When a mobile task has ambiguous targets, `mobile_choose_devices` asks the user -through Codex's native inline request form. The agent discovers suitable devices +through Codex's native inline request form. The server lists this tool only for +clients that advertise the `openai/elicitation` form extension; other hosts ask in +chat. The agent discovers suitable devices first, then supplies their IDs, platform and kind, a task-specific question, and optional operation details. The form shows canonical device names, platform/type, runtime, optional verified app labels, and platform phone illustrations. Use @@ -167,7 +189,7 @@ frames** back off empties the set — the bar that explains picking goes with th mode, so the picks go with it, and a send never carries a set whose count the reader can no longer see. -**Investigate selected** writes its own hand-off, in `src/shared/tracesift-selection.ts` +**Investigate selected** writes its own hand-off, in `packages/shared/src/tracesift-selection.ts` rather than through `mobile_get_tracesift_handoff`. The picks exist only in this frame and the whole call tree is already in it, so a round trip could not have answered the question anyway — and an older installed service is never asked @@ -184,7 +206,7 @@ looking at. The same switch, over a recording. **Select components** turns the render tree and the component table from navigating to collecting, and **Investigate selected components** writes its own hand-off from -`src/shared/tracesift-react-selection.ts`. The state is the one field the task +`packages/shared/src/tracesift-react-selection.ts`. The state is the one field the task side uses, because it is the same shape; what differs is the id, the arithmetic and the words. diff --git a/docs/performance.md b/docs/performance.md index 0905e4e1..8f374061 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -68,14 +68,14 @@ The server verifies and retains all collector binaries in memory at startup. New CPU sessions deploy from that retained copy, so reconnecting still works if a plugin update removes the old installed cache. Host staging files are removed after each deployment. The app-side collector and sampling rate are unchanged. -`node scripts/smoke-cpu-cache.mjs` checks packaged CPU reconnection after deleting +`node tooling/smoke-cpu-cache.mjs` checks packaged CPU reconnection after deleting a temporary copy of the plugin cache, using fake ADB without touching a device. On an arm64 Android emulator with 23 app threads, 20 CPU and memory samples over 19.0 seconds used 0.046% of one core in the helper (0.460 ms of CPU per sample). This measures the collector itself, excluding ADB and video streaming; overhead varies with thread count and device. Reproduce with -`node scripts/smoke-android-cpu.mjs DEVICE_SERIAL RUNNING_PID`. +`node tooling/smoke-android-cpu.mjs DEVICE_SERIAL RUNNING_PID`. The Memory track shows the main process's current usage, sampled average, maximum and minimum in MiB. Android reads RSS from `/proc//statm` using the device's runtime page size; shared resident pages are counted in full. iOS requests `phys_footprint` in the same debugserver profiling stream, including compressed memory. These are different platform metrics, identified in the track tooltip; their values are not directly comparable across platforms. Memory is available from the first sample, shares CPU's timeline and session, and resets with the app's PID. diff --git a/docs/releases.md b/docs/releases.md index c256f1bc..36bb7c85 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -3,9 +3,9 @@ [Back to README](../README.md) · [Contributing](../CONTRIBUTING.md) The `Release plugin` workflow in `.github/workflows/release.yml` runs when you push -an existing commit with a `v` tag. The tag must match `.codex-plugin/plugin.json`, -`package.json`, both root versions in `package-lock.json`, and -`src/shared/version.ts`. After committing your changes, run from the repository root: +an existing commit with a `v` tag. The tag must match the root `package.json`, +both root versions in `package-lock.json`, and `packages/shared/src/version.ts`; host +manifests take the version at package time. After committing your changes, run from the repository root: ```sh npm run public-release @@ -27,7 +27,7 @@ fresh extraction of the actual ZIP. CI also validates the tagged commit message, including when the tag was pushed directly. Each native helper has a reusable Actions artifact containing its complete vendor -directory and matching Sentry symbols. `scripts/release-native.mjs` fingerprints +directory and matching Sentry symbols. `tooling/release-native.mjs` fingerprints the helper's tracked native sources, shared telemetry, build scripts, pinned dependencies, npm build command, and relevant installed toolchain versions. Plugin version bumps and UI/server edits do not change these fingerprints. @@ -101,8 +101,9 @@ Release builds check for updates when Mobile Dev becomes visible. The server checks GitHub's latest full release and the public `release/latest` manifest; their versions must agree before the banner offers an update. Successful checks are cached for one hour across chats in the active Codex profile; unavailable -checks retry after fifteen minutes. Development builds do not check or install -public updates. The embedded UI needs no GitHub network permissions. +checks retry after fifteen minutes. Development builds, and installs that are +not a Codex plugin package (such as the Claude MCPB bundle), do not check or +install public updates. The embedded UI needs no GitHub network permissions. The Update button calls an app-only MCP tool. It locates the CLI process that launched the plugin, verifies the `mobile-dev` Git marketplace belongs to this diff --git a/docs/review/README.md b/docs/review/README.md index d1f1e932..b2e53907 100644 --- a/docs/review/README.md +++ b/docs/review/README.md @@ -1,7 +1,7 @@ # Marketplace review package The canonical five positive and three negative cases, expected tools/results, and -release notes are in `.codex-plugin/plugin.json` under `extensions.com.openai`. +release notes are in `apps/codex-plugin/.codex-plugin/plugin.json` under `extensions.com.openai`. These are **test specifications**, not claims that the final submission passed. Run them against the exact ZIP to be submitted, in fresh chats where indicated. diff --git a/docs/storage-privacy.md b/docs/storage-privacy.md index b73ed9d5..7c7e9a07 100644 --- a/docs/storage-privacy.md +++ b/docs/storage-privacy.md @@ -66,7 +66,7 @@ before the optional key projection and display cap. ## Verification -Run `node --test tests/app-storage.test.ts tests/app-storage-redaction.test.ts`. +Run `node --test packages/core/test/app-storage.test.ts packages/core/test/app-storage-redaction.test.ts`. The tests exercise real MCP responses, both output channels, selected/unselected stores, nested and malformed JSON, redaction before truncation, credential column projection, actual SQLite blobs and key/value rows, static errors, pagination, diff --git a/docs/stream-profiling.md b/docs/stream-profiling.md index 3dbc8913..b5be0093 100644 --- a/docs/stream-profiling.md +++ b/docs/stream-profiling.md @@ -106,8 +106,8 @@ The ordinary input requests are fast: p95 was 1.18 ms in the instrumented run. The long requests represent only about 2% of samples, so p95 alone hides the periodic freezes. The panel's ordered input queue waits for each acknowledgment; pending moves are coalesced while a check is outstanding. Source locations: -`src/server/simulator-input.ts` (`status`/`check`), `src/server/plugin.ts` -(`blockedInput`/`mobile_stream_input`), and `src/ui/stream-input.ts` (`drain`). +`packages/core/src/simulator-input.ts` (`status`/`check`), `packages/core/src/plugin.ts` +(`blockedInput`/`mobile_stream_input`), and `packages/ui/src/stream-input.ts` (`drain`). This establishes a plugin-side cause of periodic interaction lag. It does not establish the cause of any additional frame-rate limit in the embedded panel. @@ -462,8 +462,8 @@ including new elements, time formatting, and event listeners; it then replaces the entire list. Reading `scrollHeight` immediately afterward to follow the latest logs forces synchronous style/layout. The embedded script's actual source positions confirm these functions and the scroll-height read, matching -`src/ui/log-list.ts:68`, `:83`, and `:87`; the call originates at -`src/ui/logs-panel.ts:136`. +`packages/ui/src/log-list.ts:68`, `:83`, and `:87`; the call originates at +`packages/ui/src/logs-panel.ts:136`. | Trace measurement | Value | | --- | ---: | diff --git a/docs/telemetry.md b/docs/telemetry.md index 61dd1d83..aee6e094 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -2,6 +2,31 @@ [Back to README](../README.md) · [Contributing](../CONTRIBUTING.md) +## Hosts + +Since 0.1.165, UI and server telemetry carry a `host` tag and attribute with one of +`codex`, `claude`, `mac` or `other`. Codex, Claude Desktop and other MCP Apps hosts +load the same panel and server; the Mac app runs the same server and shared UI. +Behavior never depends on this value; it only attributes measurements and errors. + +- The Mac page names itself (`data-host="mac"`); the Mac app also starts the server + with `MOBILE_DEV_HOST=mac`. +- MCP Apps panels are labelled once connected: `codex` when the host advertises + OpenAI extensions, otherwise the host's reported name mapped by `telemetryHost` + (`claude` for Claude, else `other`). Calls before connecting carry no host. +- The server labels itself from the connecting MCP client's name on initialize and + reports `other` until then. +- The server's metadata allowlist accepts only these four values; the raw host or + client name is never sent. + +Earlier releases have no `host` value. Surfaces, measurement names, units and +boundaries are unchanged in every host. + +The Mac app loads its page through the server's `ui://mobile-dev/mac.html` resource, +so the page receives the same server-owned environment, opt-out and anonymous +identity as the Codex panel. Mac builds are development-only for now; the SwiftUI +shell has no native Sentry SDK. + ## Volume policy Since 0.1.164, application metrics in the UI, Node servers and native helpers use @@ -322,6 +347,13 @@ Since 0.1.93, `ui.video.paint` and the platform frame counters also cover frames `ui.annotations.send` measures the host send round trip, including composer retries. Outcome counters distinguish success, a missing composer, timeout and other failures. Unexpected send failures use a fixed error message. No message content goes to Sentry. A timeout keeps notes for a manual retry; it never triggers an automatic resend, since delivery may have succeeded without acknowledgement. +The standard-host attachment bar reuses these annotation measurements. On standard hosts, +the measured operation includes publishing selection context before preparing the +chat draft. `ui.annotations.send_success` means the host accepted the request; +it does not confirm user submission or a model response. Notes remain until the +user clears them. Message/context construction timings, surface attribution, and +content scrubbing are preserved; no note text or selection evidence enters telemetry. + Since 0.1.99, `ui.logs.foreground_change` counts automatic app-filter changes applied while Logs is active, including selected-device and process-lifetime changes. From 0.1.103, these changes update the visible query and local process identity mapping instead of restarting a scoped collector. `ui.logs.app_identity` measures batch insertion with native PID-to-app joins and discovery updates in milliseconds, using bounded aggregate windows. Hidden or closed log views defer row processing until reopened. Shared `ui.device_apps.discovery` timing and handled discovery-error coverage remain in place, including Android's foreground PID lookup; native session operations retain MCP tracing, Node runtime coverage, and `logs.ios.parse` timings. No app IDs, PIDs, package names, device IDs, or query text are attached to these measurements. Native helper telemetry and symbols are unchanged. Since 0.1.99, `ui.logs.query_parse` measures query compilation in milliseconds once per edit, including automatic app-clause edits from 0.1.103. Existing `ui.logs.filter`, buffered/filtered row gauges, and search counts cover keyword filtering and visible age refreshes; filtering time still covers snapshot derivation and grouping. Query text, field values, regex patterns, and validation messages remain local. Age refresh timers stop when Logs closes, unmounts, or the document becomes hidden. @@ -332,7 +364,7 @@ Since 0.1.99, `ui.logs.query_parse` measures query compilation in milliseconds o `ui.logs.retention` measures bounded buffer updates in milliseconds. `ui.logs.evicted` counts rows removed locally to meet the shared row and text limits; `ui.logs.dropped` still counts only rows lost from the server buffer. Existing filter timing and row gauges cover the resulting list. These measurements contain no log text or source metadata. -The React UI reports to `codex-mobile-dev-ui` (project `4512181027471440`). The main Node MCP server and the agent-device launcher report to `codex-mobile-dev-server`, distinguished by the `component` attribute. Native helpers report to `codex-mobile-dev-native`: Baguette, physical iOS mirroring, iOS FPS and logs, and Android CPU and FPS collectors. All three projects use release `mobile-dev@`. +Since 0.1.165 the projects are named `mobile-dev-ui`, `mobile-dev-server` and `mobile-dev-native`; they were `codex-mobile-dev-*` before. Project IDs and DSNs are unchanged, so history and incoming events continue in the same projects. The React UI reports to `mobile-dev-ui` (project `4512181027471440`). The main Node MCP server and the agent-device launcher report to `mobile-dev-server`, distinguished by the `component` attribute. Native helpers report to `mobile-dev-native`: Baguette, physical iOS mirroring, iOS FPS and logs, and Android CPU and FPS collectors. All three projects use release `mobile-dev@`. TraceSift cards use the `tracesift` surface, which is also the workspace tool of the same name: the tool's upload pane, cards and explorer are the same surface the inline card reports, so its measurements stay comparable across the two entry points and the server's metadata validation accepts `tracesift` as both a surface and a view. `tracesift.operation.duration` measures local service readiness as part of analysis, profile upload and analysis, stable host-view registration when the host supplies it, persisted-analysis restoration from either a host view or replayed tool input, handoff generation, and invoking `tracesift explore` with a persisted compact analysis, in milliseconds; `tracesift.operation.samples` records the bounded operation and success/failure outcome. `ui.tracesift.pick` measures the app-to-server round trip that opens the host's file dialog and waits for a choice, so a slow dialog is told apart from a slow analysis; `ui.tracesift.pick_cancelled` counts dialogs closed without a file and `ui.tracesift.pick_failed` counts hosts with no dialog at all, and `tracesift.operation` adds a `pick` operation for the same call on the server. `ui.tracesift.analyze` measures the panel-initiated analysis round trip, counted by `ui.tracesift.analyze` and `ui.tracesift.analyze_failed`; the server's existing `analyze` and `ready` operations still measure the service side of that same work, whether the model or the panel started it. `ui.tracesift.project_pick` measures the folder dialog's round trip, with `ui.tracesift.project_chosen` and `project_cancelled` for its outcome and `project_failed` for a dialog or store that could not be used; `ui.tracesift.project_hit` and `project_miss` count whether a saved folder was still there when the panel opened, and `tracesift.operation` adds a `project` operation for both server calls. These say whether readers are choosing a project at all, which is what Investigate now waits for; the folder's path and name are never attributes of any of them. `ui.tracesift.reset` counts returning to the upload pane, which also sets `tracesift_view` to `upload` and clears the card gauge so a dropped reading's figures are not read against the next one. `ui.tracesift.process` measures result validation, `ui.tracesift.remember` measures the app-to-server registration round trip for a live card, `ui.tracesift.restore` measures the app-to-server restore round trip after a card view is recreated, `ui.tracesift.handoff` measures the app-to-server handoff round trip, and `ui.tracesift.message_ack` ends when the host acknowledges the investigation message. `ui.tracesift.explore` counts opening the embedded explorer, and `ui.tracesift.payload` measures that app-to-server round trip, which carries the finding's full call tree or commit timeline through MCP; `ui.tracesift.payload_bytes` records how large those delivered payloads are, and `tracesift.operation` adds a `payload` operation for the same fetch on the server. Since Explore renders in-panel, the older meaning of `ui.tracesift.explore` (a successful CLI browser launch) now belongs to the `ui.tracesift.explore_browser` counter and its `ui.tracesift.explore_browser` timing and `ui.tracesift.explore_browser_failed` counter. `ui.tracesift.explore_too_large` counts the findings the bridge refused on size or serialization depth, which Explore then opens in the browser window instead of reporting as a failure; those opens are counted and timed by the `ui.tracesift.explore_browser` measurements like any other browser launch, so a rise in panel refusals is read against them rather than against `ui.tracesift.explore_failed`. Explorer readiness reuses `ui.surface.ready`, which the tool restarts from the moment it is opened rather than from the workspace frame that holds it, and `tracesift_view` distinguishes `cards`, `explorer` and the tool's `upload` pane so explorer work is not attributed to the card list; navigating back resets that context. `tracesift_profile` distinguishes a `cpu` recording from a `react` one, taken from the analysis when it arrives, so the two explorers' payload and processing timings are not read as one figure; it is set with the analysis and never derived from the device, the profile's contents or its path. Picking inside the explorer counts `ui.tracesift.pick_on` and `pick_off` for the mode, `ui.tracesift.frame_add` and `frame_remove` per click, and `pick_clear` for emptying the set, which switching picking off with picks standing also counts; `ui.tracesift.selected_frames` gauges how many picks the open reading holds and is reset to zero whenever that set is dropped — emptying it, switching picking off, closing the explorer, or taking a new analysis — so one reading's picks are never read against another's. The same counters and gauge cover both explorers: since 0.1.157 a React recording's components are picked the same way a CPU task's frames are, told apart by `tracesift_profile` rather than by counter names, so the measurement boundaries stay comparable across releases. Sending them counts `ui.tracesift.pick_investigate`, with `pick_investigate_empty` when the reader sent nothing placeable and `pick_investigate_failed` when the description could not be built at all; delivery reuses the existing `ui.tracesift.message_ack` timing and the composer and delivery counters. A card-count gauge and bounded counters cover registration successes and failures, restore hits, misses and failures, Investigate actions, Explore openings and failures, hand-off copies and copy failures, handoff failures, composer unavailability, send failures, and unconfirmed delivery. Readiness, shared interaction, visibility, and cleanup measurements use the existing UI aggregation. The chosen file's name is shown in the pane while analysis runs, and the chosen project folder is shown above the findings and named in the hand-off sent to the chat; neither is ever sent as telemetry. Profile paths and contents, filenames, card text, frame, component and file names, selected frame and component names and their node, fiber and commit identifiers, task, root, and commit indices, analysis/card/host-view identifiers, handoffs, service URLs, process identifiers, tokens, and TraceSift diagnostics are never added to telemetry; replayed profile inputs are hashed only for local lookup. Expected setup, validation, expiry, cancellation, and version outcomes use existing expected-error classification; unexpected transport and malformed-response failures retain handled server/UI error coverage. These measurements describe plugin and local-service work, not the profiled app's performance. @@ -426,7 +458,7 @@ Error and crash events carry only a generated anonymous `user.id` and a `telemet Session Replay, minidumps, screenshots and profiling are disabled. Requests, account details, app log content, tool payloads, automatic console breadcrumbs and exception source context are excluded. JavaScript error text redacts common tokens, identifiers, URLs, email addresses and local home paths. Native reports retain source basenames and debug IDs, omit absolute module paths, and never send Rust panic payloads. Safe product attributes and source locations remain available for diagnosis. `MOBILE_DEV_TELEMETRY=off` disables reporting across JavaScript and native helpers. -Builds generate debug IDs and source maps under the ignored `.sentry/` directory. Native rebuilds retain macOS dSYMs and unstripped Android ELF files in `.sentry/native` before stripping the bundled binaries. Release runs can reuse each helper's exact build artifact, restoring the vendor files and matching symbols together after SHA-256 verification. Reused symbols are still uploaded by the current release's Sentry step. Native helpers receive the current release and environment at runtime, so reuse preserves release attribution, measurement boundaries and debug IDs. Symbols, source maps and the upload credential are excluded from the plugin package. Native builds require CMake and Ninja, with an Android NDK for Android collectors. The SDK source archive is pinned and checked by SHA-256. Store the organization build token in the ignored `.env.sentry-build-plugin` file at the repository root: +Builds generate debug IDs and source maps under the ignored `.sentry/` directory. Each page and server bundle keeps its maps in its own folder: `.sentry/ui/` (`mcp-app`, `codex`, `mac`) and `.sentry/server` with host servers under `.sentry/server/`. `npm run sentry:upload` uploads both trees, so every host's bundles share the release and its debug IDs. Host servers keep the shared server's component, metric names and boundaries; Codex's `plugin.update.*` metrics now come only from Codex's server. Native rebuilds retain macOS dSYMs and unstripped Android ELF files in `.sentry/native` before stripping the bundled binaries. Release runs can reuse each helper's exact build artifact, restoring the vendor files and matching symbols together after SHA-256 verification. Reused symbols are still uploaded by the current release's Sentry step. Native helpers receive the current release and environment at runtime, so reuse preserves release attribution, measurement boundaries and debug IDs. Symbols, source maps and the upload credential are excluded from the plugin package. Native builds require CMake and Ninja, with an Android NDK for Android collectors. The SDK source archive is pinned and checked by SHA-256. Store the organization build token in the ignored `.env.sentry-build-plugin` file at the repository root: ```dotenv SENTRY_AUTH_TOKEN=your_org_token diff --git a/package-lock.json b/package-lock.json index cf0ce41a..3b4f7724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,22 @@ { - "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "name": "mobile-dev", + "version": "0.1.166", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "name": "mobile-dev", + "version": "0.1.166", + "workspaces": [ + "packages/shared", + "packages/core", + "packages/ui", + "packages/mcp-app", + "packages/test-support", + "apps/codex-plugin", + "apps/claude-desktop", + "apps/mac" + ], "dependencies": { "@base-ui/react": "^1.8.0", "@callstack/tracesift-explore": "^0.5.0", @@ -57,6 +67,18 @@ "node": ">=22.19.0" } }, + "apps/claude-desktop": { + "name": "@mobile-dev/claude-desktop", + "version": "0.0.0" + }, + "apps/codex-plugin": { + "name": "@mobile-dev/codex-plugin", + "version": "0.0.0" + }, + "apps/mac": { + "name": "@mobile-dev/mac", + "version": "0.0.0" + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -1955,6 +1977,38 @@ } } }, + "node_modules/@mobile-dev/claude-desktop": { + "resolved": "apps/claude-desktop", + "link": true + }, + "node_modules/@mobile-dev/codex-plugin": { + "resolved": "apps/codex-plugin", + "link": true + }, + "node_modules/@mobile-dev/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@mobile-dev/mac": { + "resolved": "apps/mac", + "link": true + }, + "node_modules/@mobile-dev/mcp-app": { + "resolved": "packages/mcp-app", + "link": true + }, + "node_modules/@mobile-dev/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@mobile-dev/test-support": { + "resolved": "packages/test-support", + "link": true + }, + "node_modules/@mobile-dev/ui": { + "resolved": "packages/ui", + "link": true + }, "node_modules/@modelcontextprotocol/ext-apps": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", @@ -2031,7 +2085,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3968,7 +4021,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3982,7 +4034,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3996,7 +4047,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4010,7 +4060,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4024,7 +4073,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4038,7 +4086,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4052,7 +4099,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4066,7 +4112,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4080,7 +4125,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4094,7 +4138,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4108,7 +4151,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4122,7 +4164,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4136,7 +4177,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4150,7 +4190,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4164,7 +4203,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4178,7 +4216,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4192,7 +4229,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4206,7 +4242,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4220,7 +4255,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4234,7 +4268,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4248,7 +4281,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4262,7 +4294,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4276,7 +4307,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4290,7 +4320,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4304,7 +4333,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6959,7 +6987,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10761,6 +10788,26 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "packages/core": { + "name": "@mobile-dev/core", + "version": "0.0.0" + }, + "packages/mcp-app": { + "name": "@mobile-dev/mcp-app", + "version": "0.0.0" + }, + "packages/shared": { + "name": "@mobile-dev/shared", + "version": "0.0.0" + }, + "packages/test-support": { + "name": "@mobile-dev/test-support", + "version": "0.0.0" + }, + "packages/ui": { + "name": "@mobile-dev/ui", + "version": "0.0.0" } } } diff --git a/package.json b/package.json index fd49fd4a..e73fef77 100644 --- a/package.json +++ b/package.json @@ -1,51 +1,66 @@ { - "name": "codex-mobile-dev-plugin", - "version": "0.1.165", + "name": "mobile-dev", + "version": "0.1.166", "private": true, + "workspaces": [ + "packages/shared", + "packages/core", + "packages/ui", + "packages/mcp-app", + "packages/test-support", + "apps/codex-plugin", + "apps/claude-desktop", + "apps/mac" + ], "type": "module", "engines": { "node": ">=22.19.0" }, "scripts": { - "build": "node scripts/build.mjs", - "build:release": "node scripts/build.mjs --release", - "public-release": "node scripts/public-release.mjs", - "vendor:baguette": "node scripts/vendor-baguette.mjs", - "vendor:agent-device": "npm ci --prefix runtimes/agent-device --ignore-scripts --no-audit --no-fund", - "rebuild:baguette": "node scripts/rebuild-baguette.mjs", - "rebuild:ios-fps": "node scripts/build-fps.mjs ios", - "rebuild:android-fps": "node scripts/build-fps.mjs android", - "rebuild:android-cpu": "node scripts/build-android-cpu.mjs", - "rebuild:ios-logs": "node scripts/build-ios-logs.mjs", - "start": "/bin/sh scripts/launch-mcp.sh dist/server.mjs", - "test": "node --test tests/*.test.ts", - "test:package": "node scripts/smoke-package.mjs", - "test:discovery-diagnostics": "node scripts/smoke-discovery-diagnostics.mjs", - "test:cpu-cache": "node scripts/smoke-cpu-cache.mjs", - "test:agent-device": "node scripts/smoke-agent-device.mjs", - "check:mcp-budget": "node scripts/mcp-tool-budget.mjs", - "test:definition-diagnostics": "node scripts/smoke-definition-diagnostics.mjs", - "test:reconnect": "node scripts/smoke-reconnect.mjs", - "test:logs": "node scripts/smoke-logs.mjs", - "test:ios-logs": "node scripts/smoke-logs.mjs --physical", - "test:ios-cpu": "node scripts/smoke-ios-cpu.mjs", - "package": "node scripts/package.mjs", - "package:release": "node scripts/package.mjs --release", - "vendor:serve-emu": "node scripts/vendor-serve-emu.mjs", - "dev": "node scripts/dev-ui.mjs", - "dev:off": "node scripts/dev-ui.mjs --off", - "test:stream": "node scripts/smoke-stream.mjs", - "rebuild:ios-mirror": "node scripts/build-ios-mirror.mjs", - "test:ios-mirror": "node scripts/smoke-ios-mirror.mjs", - "test:ios-mirror-sharing": "node scripts/smoke-ios-mirror-sharing.mjs", - "test:native-telemetry": "node scripts/smoke-native-telemetry.mjs", - "sentry:upload": "node scripts/upload-sourcemaps.mjs", - "typecheck:telemetry": "tsc --noEmit --strict --skipLibCheck --target ES2022 --module ESNext --moduleResolution bundler --allowImportingTsExtensions src/shared/telemetry.ts src/server/instrument.ts src/server/telemetry.ts src/server/storage-metrics.ts src/ui/telemetry.ts", - "test:codex-plugin": "node scripts/smoke-codex-plugin.mjs", - "test:updates": "node scripts/smoke-plugin-updates.mjs", + "build": "node tooling/build.mjs", + "build:release": "node tooling/build.mjs --release", + "build:mac": "node apps/mac/tooling/build.mjs", + "public-release": "node tooling/public-release.mjs", + "vendor:baguette": "node tooling/vendor-baguette.mjs", + "vendor:agent-device": "npm ci --prefix packages/runtimes/agent-device --ignore-scripts --no-audit --no-fund", + "rebuild:baguette": "node tooling/rebuild-baguette.mjs", + "rebuild:ios-fps": "node tooling/build-fps.mjs ios", + "rebuild:android-fps": "node tooling/build-fps.mjs android", + "rebuild:android-cpu": "node tooling/build-android-cpu.mjs", + "rebuild:ios-logs": "node tooling/build-ios-logs.mjs", + "start": "/bin/sh apps/codex-plugin/scripts/launch-mcp.sh dist/server.mjs", + "test": "node --test \"packages/*/test/*.test.ts\" \"apps/*/test/*.test.ts\" \"tooling/test/*.test.ts\"", + "test:package": "node apps/codex-plugin/tooling/smoke-package.mjs", + "test:mac": "node apps/mac/tooling/smoke.mjs", + "test:discovery-diagnostics": "node tooling/smoke-discovery-diagnostics.mjs", + "test:cpu-cache": "node tooling/smoke-cpu-cache.mjs", + "test:agent-device": "node apps/codex-plugin/tooling/smoke-agent-device.mjs", + "check:mcp-budget": "node apps/codex-plugin/tooling/mcp-tool-budget.mjs", + "test:definition-diagnostics": "node tooling/smoke-definition-diagnostics.mjs", + "test:reconnect": "node tooling/smoke-reconnect.mjs", + "test:logs": "node tooling/smoke-logs.mjs", + "test:ios-logs": "node tooling/smoke-logs.mjs --physical", + "test:ios-cpu": "node tooling/smoke-ios-cpu.mjs", + "package": "node apps/codex-plugin/tooling/package.mjs", + "package:release": "node apps/codex-plugin/tooling/package.mjs --release", + "claude:config": "node apps/claude-desktop/tooling/config.mjs", + "package:claude": "node apps/claude-desktop/tooling/package.mjs", + "test:claude": "node apps/claude-desktop/tooling/smoke.mjs", + "vendor:serve-emu": "node tooling/vendor-serve-emu.mjs", + "dev": "node apps/codex-plugin/tooling/dev-ui.mjs", + "dev:off": "node apps/codex-plugin/tooling/dev-ui.mjs --off", + "test:stream": "node tooling/smoke-stream.mjs", + "rebuild:ios-mirror": "node tooling/build-ios-mirror.mjs", + "test:ios-mirror": "node tooling/smoke-ios-mirror.mjs", + "test:ios-mirror-sharing": "node tooling/smoke-ios-mirror-sharing.mjs", + "test:native-telemetry": "node tooling/smoke-native-telemetry.mjs", + "sentry:upload": "node tooling/upload-sourcemaps.mjs", + "typecheck:telemetry": "tsc --noEmit --strict --skipLibCheck --target ES2022 --module ESNext --moduleResolution bundler --allowImportingTsExtensions packages/shared/src/telemetry.ts packages/core/src/instrument.ts packages/core/src/telemetry.ts packages/core/src/storage-metrics.ts packages/ui/src/telemetry.ts", + "test:codex-plugin": "node apps/codex-plugin/tooling/smoke-codex-plugin.mjs", + "test:updates": "node apps/codex-plugin/tooling/smoke-plugin-updates.mjs", "prepare": "husky", "lint:commits": "commitlint", - "check:marketplace-review": "node scripts/check-marketplace-review.mjs" + "check:marketplace-review": "node apps/codex-plugin/tooling/check-marketplace-review.mjs" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..0504591b --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,8 @@ +{ + "name": "@mobile-dev/core", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "MCP server, device runtimes and tool handlers.", + "exports": { "./*": "./src/*" } +} diff --git a/src/server/agent-device-adapter.ts b/packages/core/src/agent-device-adapter.ts similarity index 98% rename from src/server/agent-device-adapter.ts rename to packages/core/src/agent-device-adapter.ts index dbef7e9d..b1a6ba91 100644 --- a/src/server/agent-device-adapter.ts +++ b/packages/core/src/agent-device-adapter.ts @@ -7,7 +7,7 @@ import { CallToolRequestSchema, CallToolResultSchema, ListToolsRequestSchema, type CallToolRequest, type CallToolResult, type Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { PLUGIN_VERSION } from "../shared/version.ts"; +import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; import { captureServerError } from "./telemetry.ts"; import { compactAgentDeviceTool, isObject } from "./agent-device-catalog.ts"; diff --git a/src/server/agent-device-catalog.ts b/packages/core/src/agent-device-catalog.ts similarity index 100% rename from src/server/agent-device-catalog.ts rename to packages/core/src/agent-device-catalog.ts diff --git a/src/server/agent-device-server.mjs b/packages/core/src/agent-device-server.mjs similarity index 100% rename from src/server/agent-device-server.mjs rename to packages/core/src/agent-device-server.mjs diff --git a/src/server/android-emulator.ts b/packages/core/src/android-emulator.ts similarity index 97% rename from src/server/android-emulator.ts rename to packages/core/src/android-emulator.ts index 17824205..6674b0f4 100644 --- a/src/server/android-emulator.ts +++ b/packages/core/src/android-emulator.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; -import type { Status } from "../shared/protocol.ts"; +import type { Status } from "@mobile-dev/shared/protocol.ts"; export function parseAvdName(stdout: string | undefined): string | undefined { const lines = stdout?.split(/\r?\n/).map(line => line.trim()).filter(Boolean) ?? []; diff --git a/src/server/android-sdk.ts b/packages/core/src/android-sdk.ts similarity index 100% rename from src/server/android-sdk.ts rename to packages/core/src/android-sdk.ts diff --git a/src/server/android-streams.ts b/packages/core/src/android-streams.ts similarity index 97% rename from src/server/android-streams.ts rename to packages/core/src/android-streams.ts index f81e1884..bc8673a2 100644 --- a/src/server/android-streams.ts +++ b/packages/core/src/android-streams.ts @@ -1,8 +1,8 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { randomBytes } from "node:crypto"; import { WebSocket } from "ws"; -import { videoPacket } from "../shared/android-video.ts"; -import { inputSchema } from "../shared/protocol.ts"; +import { videoPacket } from "@mobile-dev/shared/android-video.ts"; +import { inputSchema } from "@mobile-dev/shared/protocol.ts"; import type { ServeEmu } from "./serve-emu.ts"; type Packet = { sequence: number; data: string }; diff --git a/src/server/android-tools.ts b/packages/core/src/android-tools.ts similarity index 98% rename from src/server/android-tools.ts rename to packages/core/src/android-tools.ts index 85a770fc..a836f559 100644 --- a/src/server/android-tools.ts +++ b/packages/core/src/android-tools.ts @@ -1,7 +1,7 @@ import { parseResourceInput } from "./resource-input.ts"; import { openRequestSession } from "./request-session.ts"; import type { ServerRequestContext } from "./request-session.ts"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; @@ -10,7 +10,7 @@ import { z } from "zod"; import { ServeEmu, androidIdSchema } from "./serve-emu.ts"; import { AndroidStreams, androidInput } from "./android-streams.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; -import { errorMessage, inputSchema } from "../shared/protocol.ts"; +import { errorMessage, inputSchema } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "./telemetry.ts"; const sessionIdSchema = z.string().regex(/^[a-f0-9]{64}$/); diff --git a/src/server/app-control/android.ts b/packages/core/src/app-control/android.ts similarity index 98% rename from src/server/app-control/android.ts rename to packages/core/src/app-control/android.ts index fff63bb3..6fbb57d9 100644 --- a/src/server/app-control/android.ts +++ b/packages/core/src/app-control/android.ts @@ -2,9 +2,9 @@ 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 { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { platformPermissions } from "@mobile-dev/shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "@mobile-dev/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"; diff --git a/src/server/app-control/commands.ts b/packages/core/src/app-control/commands.ts similarity index 97% rename from src/server/app-control/commands.ts rename to packages/core/src/app-control/commands.ts index 28f98230..1205385f 100644 --- a/src/server/app-control/commands.ts +++ b/packages/core/src/app-control/commands.ts @@ -2,7 +2,7 @@ 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 { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; import { adbPath } from "../native-logs.ts"; import { androidSdkRoots } from "../android-sdk.ts"; diff --git a/src/server/app-control/driver.ts b/packages/core/src/app-control/driver.ts similarity index 95% rename from src/server/app-control/driver.ts rename to packages/core/src/app-control/driver.ts index f449fdc0..e9ce5fc1 100644 --- a/src/server/app-control/driver.ts +++ b/packages/core/src/app-control/driver.ts @@ -1,8 +1,8 @@ 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 { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { platformPermissions } from "@mobile-dev/shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, InstalledApps, PermissionState } from "@mobile-dev/shared/app-control.ts"; import type { CommandRunner } from "./commands.ts"; export type RunningApp = { bundleId: string; pid: number }; diff --git a/src/server/app-control/simulator.ts b/packages/core/src/app-control/simulator.ts similarity index 97% rename from src/server/app-control/simulator.ts rename to packages/core/src/app-control/simulator.ts index 6c3a9a52..7239a7d0 100644 --- a/src/server/app-control/simulator.ts +++ b/packages/core/src/app-control/simulator.ts @@ -3,9 +3,9 @@ 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 { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; +import { bundleIdSchema, platformPermissions } from "@mobile-dev/shared/app-control.ts"; +import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "@mobile-dev/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"; diff --git a/src/server/app-control/tools.ts b/packages/core/src/app-control/tools.ts similarity index 97% rename from src/server/app-control/tools.ts rename to packages/core/src/app-control/tools.ts index b638a0ca..57241376 100644 --- a/src/server/app-control/tools.ts +++ b/packages/core/src/app-control/tools.ts @@ -3,9 +3,9 @@ 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, languageTagSchema, permissionStates } from "../../shared/app-control.ts"; +import { errorMessage, udidSchema } from "@mobile-dev/shared/protocol.ts"; +import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; +import { appDetailsBatch, appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, languageTagSchema, permissionStates } from "@mobile-dev/shared/app-control.ts"; import { systemCommands } from "./commands.ts"; import type { Commands } from "./commands.ts"; import { AppDetailsCache, listApps } from "./driver.ts"; diff --git a/src/server/app-storage/container.ts b/packages/core/src/app-storage/container.ts similarity index 99% rename from src/server/app-storage/container.ts rename to packages/core/src/app-storage/container.ts index 4886ca85..cc584b8f 100644 --- a/src/server/app-storage/container.ts +++ b/packages/core/src/app-storage/container.ts @@ -1,6 +1,6 @@ import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; import { join, relative, sep } from "node:path"; -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { commandError, commandOutput } from "../app-control/commands.ts"; import type { Commands } from "../app-control/commands.ts"; diff --git a/src/server/app-storage/encoding.ts b/packages/core/src/app-storage/encoding.ts similarity index 100% rename from src/server/app-storage/encoding.ts rename to packages/core/src/app-storage/encoding.ts diff --git a/src/server/app-storage/formats.ts b/packages/core/src/app-storage/formats.ts similarity index 99% rename from src/server/app-storage/formats.ts rename to packages/core/src/app-storage/formats.ts index 6d36bef5..d2c1bb06 100644 --- a/src/server/app-storage/formats.ts +++ b/packages/core/src/app-storage/formats.ts @@ -1,4 +1,4 @@ -import type { EntryType, StoreKind } from "../../shared/app-storage.ts"; +import type { EntryType, StoreKind } from "@mobile-dev/shared/app-storage.ts"; import { jsonType, readVarint, utf8Text } from "./encoding.ts"; export type ParsedEntry = { key: string; type: EntryType; value: string; bytes: number; guessed?: boolean }; diff --git a/src/server/app-storage/mmkv.ts b/packages/core/src/app-storage/mmkv.ts similarity index 98% rename from src/server/app-storage/mmkv.ts rename to packages/core/src/app-storage/mmkv.ts index 9eed92f2..03b3e701 100644 --- a/src/server/app-storage/mmkv.ts +++ b/packages/core/src/app-storage/mmkv.ts @@ -1,5 +1,5 @@ import { crc32 } from "node:zlib"; -import type { EntryType } from "../../shared/app-storage.ts"; +import type { EntryType } from "@mobile-dev/shared/app-storage.ts"; import { jsonType, readVarint, utf8Text } from "./encoding.ts"; // MMKV's on-disk format, from Tencent/MMKV Core (MMKV_IO.cpp, MiniPBCoder.cpp, MMKVMetaInfo.hpp): diff --git a/src/server/app-storage/preview.ts b/packages/core/src/app-storage/preview.ts similarity index 95% rename from src/server/app-storage/preview.ts rename to packages/core/src/app-storage/preview.ts index 0ad6fcb9..d6518a40 100644 --- a/src/server/app-storage/preview.ts +++ b/packages/core/src/app-storage/preview.ts @@ -1,8 +1,8 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; -import { appleDate } from "../../shared/app-storage.ts"; -import type { StorageFilePreview } from "../../shared/app-storage.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { appleDate } from "@mobile-dev/shared/app-storage.ts"; +import type { StorageFilePreview } from "@mobile-dev/shared/app-storage.ts"; import { withTemporaryDirectory } from "../app-control/commands.ts"; import type { CommandRunner } from "../app-control/commands.ts"; import type { AppContainer } from "./container.ts"; diff --git a/src/server/app-storage/reader.ts b/packages/core/src/app-storage/reader.ts similarity index 98% rename from src/server/app-storage/reader.ts rename to packages/core/src/app-storage/reader.ts index 89749dbc..997198e4 100644 --- a/src/server/app-storage/reader.ts +++ b/packages/core/src/app-storage/reader.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; -import { storageLimits } from "../../shared/app-storage.ts"; -import type { StorageEntry, StorageFile, StorageSnapshot, StorageStore, StorageTable, StoreKind } from "../../shared/app-storage.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { storageLimits } from "@mobile-dev/shared/app-storage.ts"; +import type { StorageEntry, StorageFile, StorageSnapshot, StorageStore, StorageTable, StoreKind } from "@mobile-dev/shared/app-storage.ts"; import { withTemporaryDirectory } from "../app-control/commands.ts"; import type { CommandRunner } from "../app-control/commands.ts"; import type { AppContainer, ContainerFile } from "./container.ts"; diff --git a/src/server/app-storage/redaction.ts b/packages/core/src/app-storage/redaction.ts similarity index 98% rename from src/server/app-storage/redaction.ts rename to packages/core/src/app-storage/redaction.ts index c2845ee9..ac9f09ae 100644 --- a/src/server/app-storage/redaction.ts +++ b/packages/core/src/app-storage/redaction.ts @@ -1,4 +1,4 @@ -import type { StorageEntry } from "../../shared/app-storage.ts"; +import type { StorageEntry } from "@mobile-dev/shared/app-storage.ts"; import type { ParsedEntry } from "./formats.ts"; export const REDACTED = "[REDACTED]"; diff --git a/src/server/app-storage/tools.ts b/packages/core/src/app-storage/tools.ts similarity index 97% rename from src/server/app-storage/tools.ts rename to packages/core/src/app-storage/tools.ts index 3e5d51bf..c7dd1ede 100644 --- a/src/server/app-storage/tools.ts +++ b/packages/core/src/app-storage/tools.ts @@ -3,9 +3,9 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { captureServerError, recordAppStorageOperation } from "../telemetry.ts"; import type { AppStorageOperation } from "../telemetry.ts"; -import { ExpectedOperationError, expectedOutcome } from "../../shared/error-reporting.ts"; -import { appTargetShape, bundleIdSchema } from "../../shared/app-control.ts"; -import { storageLimits } from "../../shared/app-storage.ts"; +import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; +import { appTargetShape, bundleIdSchema } from "@mobile-dev/shared/app-control.ts"; +import { storageLimits } from "@mobile-dev/shared/app-storage.ts"; import { systemCommands } from "../app-control/commands.ts"; import type { Commands } from "../app-control/commands.ts"; import type { AppDriver } from "../app-control/driver.ts"; diff --git a/src/server/baguette-runtime.ts b/packages/core/src/baguette-runtime.ts similarity index 100% rename from src/server/baguette-runtime.ts rename to packages/core/src/baguette-runtime.ts diff --git a/src/server/baguette.ts b/packages/core/src/baguette.ts similarity index 97% rename from src/server/baguette.ts rename to packages/core/src/baguette.ts index ca475940..ee28c03a 100644 --- a/src/server/baguette.ts +++ b/packages/core/src/baguette.ts @@ -7,12 +7,12 @@ import { createServer } from "node:net"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; import { z } from "zod"; -import { errorMessage, normalizeDevices, parseBaseUrl, udidSchema } from "../shared/protocol.ts"; -import type { Status } from "../shared/protocol.ts"; -import { buttonMarginsSchema } from "../shared/bezel.ts"; -import { definitionDeviceState, getDefinitionDiagnostic, parseDefinitionDiagnostic, setDefinitionDiagnostic } from "../shared/simulator-definition-diagnostics.ts"; +import { errorMessage, normalizeDevices, parseBaseUrl, udidSchema } from "@mobile-dev/shared/protocol.ts"; +import type { Status } from "@mobile-dev/shared/protocol.ts"; +import { buttonMarginsSchema } from "@mobile-dev/shared/bezel.ts"; +import { definitionDeviceState, getDefinitionDiagnostic, parseDefinitionDiagnostic, setDefinitionDiagnostic } from "@mobile-dev/shared/simulator-definition-diagnostics.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { baguetteEnvironment } from "./baguette-runtime.ts"; import { captureServerError, recordAccessibilityBridgeRestart } from "./telemetry.ts"; diff --git a/src/server/bezel.ts b/packages/core/src/bezel.ts similarity index 92% rename from src/server/bezel.ts rename to packages/core/src/bezel.ts index fc4bbc97..0f164c66 100644 --- a/src/server/bezel.ts +++ b/packages/core/src/bezel.ts @@ -1,6 +1,6 @@ import type { Baguette } from "./baguette.ts"; -import { compositeBezelGeometry } from "../shared/bezel.ts"; -import type { Bezel } from "../shared/bezel.ts"; +import { compositeBezelGeometry } from "@mobile-dev/shared/bezel.ts"; +import type { Bezel } from "@mobile-dev/shared/bezel.ts"; async function png(baguette: Baguette, udid: string, path: string): Promise { const url = new URL(path, baguette.baseUrl); diff --git a/src/server/clipboard.ts b/packages/core/src/clipboard.ts similarity index 100% rename from src/server/clipboard.ts rename to packages/core/src/clipboard.ts diff --git a/src/server/cpu/android-counters.ts b/packages/core/src/cpu/android-counters.ts similarity index 100% rename from src/server/cpu/android-counters.ts rename to packages/core/src/cpu/android-counters.ts diff --git a/src/server/cpu/android.ts b/packages/core/src/cpu/android.ts similarity index 99% rename from src/server/cpu/android.ts rename to packages/core/src/cpu/android.ts index 8e41c289..f6358fcd 100644 --- a/src/server/cpu/android.ts +++ b/packages/core/src/cpu/android.ts @@ -10,7 +10,7 @@ import { adbPath } from "../native-logs.ts"; import type { CpuReading } from "./counters.ts"; import type { CpuMonitor } from "./monitor.ts"; import { AndroidCpuSampler, androidReadySchema, androidSampleSchema } from "./android-counters.ts"; -import { errorMessage } from "../../shared/protocol.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { nativeCollectorCommand, NativeTelemetryRelay } from "../native-telemetry.ts"; const execute = promisify(execFile); diff --git a/src/server/cpu/buffer.ts b/packages/core/src/cpu/buffer.ts similarity index 94% rename from src/server/cpu/buffer.ts rename to packages/core/src/cpu/buffer.ts index 6ae8f834..f3b829fd 100644 --- a/src/server/cpu/buffer.ts +++ b/packages/core/src/cpu/buffer.ts @@ -1,5 +1,5 @@ -import { CPU_HISTORY_SECONDS, CPU_MAX_SAMPLES } from "../../shared/cpu.ts"; -import type { CpuBatch, CpuPhase, CpuSample, MemoryMetric } from "../../shared/cpu.ts"; +import { CPU_HISTORY_SECONDS, CPU_MAX_SAMPLES } from "@mobile-dev/shared/cpu.ts"; +import type { CpuBatch, CpuPhase, CpuSample, MemoryMetric } from "@mobile-dev/shared/cpu.ts"; export class CpuBuffer { private samples: Array<{ revision: number; sample: CpuSample }> = []; diff --git a/src/server/cpu/counters.ts b/packages/core/src/cpu/counters.ts similarity index 100% rename from src/server/cpu/counters.ts rename to packages/core/src/cpu/counters.ts diff --git a/src/server/cpu/gdb.ts b/packages/core/src/cpu/gdb.ts similarity index 100% rename from src/server/cpu/gdb.ts rename to packages/core/src/cpu/gdb.ts diff --git a/src/server/cpu/monitor.ts b/packages/core/src/cpu/monitor.ts similarity index 100% rename from src/server/cpu/monitor.ts rename to packages/core/src/cpu/monitor.ts diff --git a/src/server/cpu/physical.ts b/packages/core/src/cpu/physical.ts similarity index 100% rename from src/server/cpu/physical.ts rename to packages/core/src/cpu/physical.ts diff --git a/src/server/cpu/sessions.ts b/packages/core/src/cpu/sessions.ts similarity index 95% rename from src/server/cpu/sessions.ts rename to packages/core/src/cpu/sessions.ts index de411a0b..56dcaffe 100644 --- a/src/server/cpu/sessions.ts +++ b/packages/core/src/cpu/sessions.ts @@ -1,9 +1,9 @@ -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { randomBytes } from "node:crypto"; import type { z } from "zod"; -import type { CpuBatch, CpuTarget, MemoryMetric } from "../../shared/cpu.ts"; -import { cpuTargetSchema } from "../../shared/cpu.ts"; -import { errorMessage } from "../../shared/protocol.ts"; +import type { CpuBatch, CpuTarget, MemoryMetric } from "@mobile-dev/shared/cpu.ts"; +import { cpuTargetSchema } from "@mobile-dev/shared/cpu.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { runningDeviceApps } from "../device-apps/apps.ts"; import { CpuBuffer } from "./buffer.ts"; import { startCpuMonitor } from "./source.ts"; diff --git a/src/server/cpu/simulator.ts b/packages/core/src/cpu/simulator.ts similarity index 100% rename from src/server/cpu/simulator.ts rename to packages/core/src/cpu/simulator.ts diff --git a/src/server/cpu/source.ts b/packages/core/src/cpu/source.ts similarity index 95% rename from src/server/cpu/source.ts rename to packages/core/src/cpu/source.ts index 38ddbf87..ea9010cb 100644 --- a/src/server/cpu/source.ts +++ b/packages/core/src/cpu/source.ts @@ -1,4 +1,4 @@ -import type { CpuTarget } from "../../shared/cpu.ts"; +import type { CpuTarget } from "@mobile-dev/shared/cpu.ts"; import type { CpuReading } from "./counters.ts"; import { startIosCpuMonitor } from "./monitor.ts"; import { openPhysicalDebugserver } from "./physical.ts"; diff --git a/src/server/cpu/tools.ts b/packages/core/src/cpu/tools.ts similarity index 94% rename from src/server/cpu/tools.ts rename to packages/core/src/cpu/tools.ts index e0a5e5f1..77b980c9 100644 --- a/src/server/cpu/tools.ts +++ b/packages/core/src/cpu/tools.ts @@ -1,17 +1,17 @@ import { parseResourceInput } from "../resource-input.ts"; -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { captureServerError } from "../telemetry.ts"; import { z } from "zod"; -import { cpuTargetSchema } from "../../shared/cpu.ts"; -import type { CpuTarget } from "../../shared/cpu.ts"; -import { errorMessage } from "../../shared/protocol.ts"; +import { cpuTargetSchema } from "@mobile-dev/shared/cpu.ts"; +import type { CpuTarget } from "@mobile-dev/shared/cpu.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import type { Baguette } from "../baguette.ts"; import { readDeviceApps } from "../device-apps/sources.ts"; -import { withDeviceAppsDiagnostic } from "../../shared/device-apps-diagnostics.ts"; +import { withDeviceAppsDiagnostic } from "@mobile-dev/shared/device-apps-diagnostics.ts"; import { registerDeviceAppsTool } from "../device-apps/tools.ts"; import { listAndroidLogDevices } from "../native-logs.ts"; import { listIosDevices } from "../ios-devices.ts"; diff --git a/src/server/device-apps/apps.ts b/packages/core/src/device-apps/apps.ts similarity index 97% rename from src/server/device-apps/apps.ts rename to packages/core/src/device-apps/apps.ts index 078730d9..8698d983 100644 --- a/src/server/device-apps/apps.ts +++ b/packages/core/src/device-apps/apps.ts @@ -1,10 +1,10 @@ -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; -import type { DeviceApp } from "../../shared/device-apps.ts"; +import type { DeviceApp } from "@mobile-dev/shared/device-apps.ts"; import { adbPath } from "../native-logs.ts"; import { runDiscoveryCommand } from "./command.ts"; diff --git a/src/server/device-apps/command.ts b/packages/core/src/device-apps/command.ts similarity index 97% rename from src/server/device-apps/command.ts rename to packages/core/src/device-apps/command.ts index 4c5ca6c3..f4645ab0 100644 --- a/src/server/device-apps/command.ts +++ b/packages/core/src/device-apps/command.ts @@ -2,8 +2,8 @@ import { execFile } from "node:child_process"; import type { ExecFileOptionsWithStringEncoding } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; -import { foregroundCauses, setDiscoveryCommandDiagnostic } from "../../shared/device-apps-command-diagnostics.ts"; -import type { DiscoveryCommand, DiscoveryCause, DiscoveryCommandDiagnostic, CommandTermination } from "../../shared/device-apps-command-diagnostics.ts"; +import { foregroundCauses, setDiscoveryCommandDiagnostic } from "@mobile-dev/shared/device-apps-command-diagnostics.ts"; +import type { DiscoveryCommand, DiscoveryCause, DiscoveryCommandDiagnostic, CommandTermination } from "@mobile-dev/shared/device-apps-command-diagnostics.ts"; const execute = promisify(execFile); const version = z.literal(1); diff --git a/src/server/device-apps/foreground.ts b/packages/core/src/device-apps/foreground.ts similarity index 96% rename from src/server/device-apps/foreground.ts rename to packages/core/src/device-apps/foreground.ts index 16fbbd74..8aefce7e 100644 --- a/src/server/device-apps/foreground.ts +++ b/packages/core/src/device-apps/foreground.ts @@ -3,8 +3,8 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import { adbPath } from "../native-logs.ts"; import { baguetteEnvironment, BAGUETTE_RUNTIME_TIMEOUT_MS } from "../baguette-runtime.ts"; -import type { ForegroundApp } from "../../shared/device-apps.ts"; -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; +import type { ForegroundApp } from "@mobile-dev/shared/device-apps.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; const number = z.number(); const integer = number.int(); diff --git a/src/server/device-apps/sources.ts b/packages/core/src/device-apps/sources.ts similarity index 90% rename from src/server/device-apps/sources.ts rename to packages/core/src/device-apps/sources.ts index 9d0d27d3..63781359 100644 --- a/src/server/device-apps/sources.ts +++ b/packages/core/src/device-apps/sources.ts @@ -1,7 +1,7 @@ -import type { DeviceApps, ForegroundApp } from "../../shared/device-apps.ts"; +import type { DeviceApps, ForegroundApp } from "@mobile-dev/shared/device-apps.ts"; import { runningDeviceApps } from "./apps.ts"; import { foregroundPhysicalPid, foregroundSimulatorPid, foregroundAndroidApp } from "./foreground.ts"; -import { withDeviceAppsDiagnostic } from "../../shared/device-apps-diagnostics.ts"; +import { withDeviceAppsDiagnostic } from "@mobile-dev/shared/device-apps-diagnostics.ts"; const defaultSources = { apps: runningDeviceApps, diff --git a/src/server/device-apps/tools.ts b/packages/core/src/device-apps/tools.ts similarity index 84% rename from src/server/device-apps/tools.ts rename to packages/core/src/device-apps/tools.ts index c7070f51..48bef9e5 100644 --- a/src/server/device-apps/tools.ts +++ b/packages/core/src/device-apps/tools.ts @@ -1,13 +1,13 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { z } from "zod"; -import { cpuDeviceSchema } from "../../shared/cpu.ts"; -import type { DeviceApps } from "../../shared/device-apps.ts"; -import { errorMessage } from "../../shared/protocol.ts"; +import { cpuDeviceSchema } from "@mobile-dev/shared/cpu.ts"; +import type { DeviceApps } from "@mobile-dev/shared/device-apps.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "../telemetry.ts"; -import { FailureEpisodes, expectedOutcome } from "../../shared/error-reporting.ts"; -import { errorReportSignature } from "../../shared/telemetry.ts"; -import { deviceAppsDiagnosticTags, annotateDeviceAppsError, getDeviceAppsDiagnostic, DEVICE_APPS_DIAGNOSTIC_META } from "../../shared/device-apps-diagnostics.ts"; +import { FailureEpisodes, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; +import { errorReportSignature } from "@mobile-dev/shared/telemetry.ts"; +import { deviceAppsDiagnosticTags, annotateDeviceAppsError, getDeviceAppsDiagnostic, DEVICE_APPS_DIAGNOSTIC_META } from "@mobile-dev/shared/device-apps-diagnostics.ts"; type Device = z.infer; diff --git a/src/server/device-choice-thumbnails.ts b/packages/core/src/device-choice-thumbnails.ts similarity index 100% rename from src/server/device-choice-thumbnails.ts rename to packages/core/src/device-choice-thumbnails.ts diff --git a/src/server/device-choice-tools.ts b/packages/core/src/device-choice-tools.ts similarity index 91% rename from src/server/device-choice-tools.ts rename to packages/core/src/device-choice-tools.ts index 115ec663..8d56370c 100644 --- a/src/server/device-choice-tools.ts +++ b/packages/core/src/device-choice-tools.ts @@ -1,12 +1,12 @@ import * as Sentry from "@sentry/node"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; import type { OpenAIExtensions, OpenAIForm, OpenAIFormOption } from "@openai/mcp-extensions/server"; -import { deviceChoiceInputSchema } from "../shared/device-choice.ts"; -import type { DeviceChoice, DeviceChoiceInput, DeviceChoiceReference } from "../shared/device-choice.ts"; -import type { PhysicalIosDevice } from "../shared/ios-devices.ts"; -import type { SimulatorDevice, Status } from "../shared/protocol.ts"; -import { errorMessage } from "../shared/protocol.ts"; +import { deviceChoiceInputSchema } from "@mobile-dev/shared/device-choice.ts"; +import type { DeviceChoice, DeviceChoiceInput, DeviceChoiceReference } from "@mobile-dev/shared/device-choice.ts"; +import type { PhysicalIosDevice } from "@mobile-dev/shared/ios-devices.ts"; +import type { SimulatorDevice, Status } from "@mobile-dev/shared/protocol.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "./telemetry.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; import { deviceChoiceThumbnails } from "./device-choice-thumbnails.ts"; @@ -86,6 +86,11 @@ export async function resolveDeviceChoices(references: DeviceChoiceReference[], }); } +export function supportsDeviceChoiceForms(capabilities: ClientCapabilities | undefined): boolean { + const formCapability = capabilities?.extensions?.["openai/elicitation"]; + return typeof formCapability === "object" && formCapability !== null && "form" in formCapability; +} + export function registerDeviceChoiceTools(server: McpServer, extensions: Pick, sources: DeviceChoiceSources) { server.registerTool("mobile_choose_devices", { title: "Choose a device", @@ -96,9 +101,7 @@ export function registerDeviceChoiceTools(server: McpServer, extensions: Pick { const scope = Sentry.getIsolationScope(); const data = scope.getScopeData(); diff --git a/src/server/ios-devices.ts b/packages/core/src/ios-devices.ts similarity index 93% rename from src/server/ios-devices.ts rename to packages/core/src/ios-devices.ts index d9cb4f34..8fdce5bd 100644 --- a/src/server/ios-devices.ts +++ b/packages/core/src/ios-devices.ts @@ -2,10 +2,10 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { physicalIosDeviceSchema } from "../shared/ios-devices.ts"; -import type { PhysicalIosDevice } from "../shared/ios-devices.ts"; -import { errorMessage } from "../shared/protocol.ts"; -import { errorCategory, FailureEpisodes } from "../shared/error-reporting.ts"; +import { physicalIosDeviceSchema } from "@mobile-dev/shared/ios-devices.ts"; +import type { PhysicalIosDevice } from "@mobile-dev/shared/ios-devices.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; +import { errorCategory, FailureEpisodes } from "@mobile-dev/shared/error-reporting.ts"; import { captureServerError } from "./telemetry.ts"; const execute = promisify(execFile); diff --git a/src/server/ios-log-filter.ts b/packages/core/src/ios-log-filter.ts similarity index 100% rename from src/server/ios-log-filter.ts rename to packages/core/src/ios-log-filter.ts diff --git a/src/server/ios-mirror-client.ts b/packages/core/src/ios-mirror-client.ts similarity index 97% rename from src/server/ios-mirror-client.ts rename to packages/core/src/ios-mirror-client.ts index 052b8d6a..f47bc135 100644 --- a/src/server/ios-mirror-client.ts +++ b/packages/core/src/ios-mirror-client.ts @@ -3,8 +3,8 @@ import type { Socket } from "node:net"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; -import { IosMirrorInputBusyError } from "../shared/ios-mirror-errors.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { IosMirrorInputBusyError } from "@mobile-dev/shared/ios-mirror-errors.ts"; import { mirrorReplySchema, mirrorSocketPath, maximumReplyBytes } from "./ios-mirror-rpc.ts"; import type { MirrorCommand, MirrorReply } from "./ios-mirror-rpc.ts"; import type { IosCapture } from "./ios-native-capture.ts"; diff --git a/src/server/ios-mirror-hub.ts b/packages/core/src/ios-mirror-hub.ts similarity index 98% rename from src/server/ios-mirror-hub.ts rename to packages/core/src/ios-mirror-hub.ts index 0c3e46f1..627d9a0e 100644 --- a/src/server/ios-mirror-hub.ts +++ b/packages/core/src/ios-mirror-hub.ts @@ -1,5 +1,5 @@ -import type { IosVideoBatch } from "../shared/ios-video.ts"; -import { IosMirrorInputBusyError } from "../shared/ios-mirror-errors.ts"; +import type { IosVideoBatch } from "@mobile-dev/shared/ios-video.ts"; +import { IosMirrorInputBusyError } from "@mobile-dev/shared/ios-mirror-errors.ts"; import type { IosCapture, NativeCapture, NativeTouchSample } from "./ios-native-capture.ts"; type Subscriber = { diff --git a/src/server/ios-mirror-rpc.ts b/packages/core/src/ios-mirror-rpc.ts similarity index 94% rename from src/server/ios-mirror-rpc.ts rename to packages/core/src/ios-mirror-rpc.ts index 7c744cd3..803276b3 100644 --- a/src/server/ios-mirror-rpc.ts +++ b/packages/core/src/ios-mirror-rpc.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { iosVideoBatchSchema } from "../shared/ios-video.ts"; +import { iosVideoBatchSchema } from "@mobile-dev/shared/ios-video.ts"; const id = z.number().int().positive(); export const mirrorRequestSchema = z.discriminatedUnion("method", [ diff --git a/src/server/ios-mirror-server.ts b/packages/core/src/ios-mirror-server.ts similarity index 97% rename from src/server/ios-mirror-server.ts rename to packages/core/src/ios-mirror-server.ts index d9995196..2d648b26 100644 --- a/src/server/ios-mirror-server.ts +++ b/packages/core/src/ios-mirror-server.ts @@ -4,7 +4,7 @@ import { mirrorRequestSchema, maximumReplyBytes } from "./ios-mirror-rpc.ts"; import type { MirrorRequest } from "./ios-mirror-rpc.ts"; import type { IosCapture } from "./ios-native-capture.ts"; import type { IosMirrorHub } from "./ios-mirror-hub.ts"; -import { IosMirrorInputBusyError } from "../shared/ios-mirror-errors.ts"; +import { IosMirrorInputBusyError } from "@mobile-dev/shared/ios-mirror-errors.ts"; export function createIosMirrorServer(hub: IosMirrorHub, idle: () => void, active: () => void) { const sockets = new Set(); diff --git a/src/server/ios-mirror-service.ts b/packages/core/src/ios-mirror-service.ts similarity index 97% rename from src/server/ios-mirror-service.ts rename to packages/core/src/ios-mirror-service.ts index 1a19da14..594395c4 100644 --- a/src/server/ios-mirror-service.ts +++ b/packages/core/src/ios-mirror-service.ts @@ -7,7 +7,7 @@ import { openNativeIosCapture } from "./ios-native-capture.ts"; import { createIosMirrorServer } from "./ios-mirror-server.ts"; import { mirrorSocketPath } from "./ios-mirror-rpc.ts"; import { captureServerError, closeServerTelemetry, recordIosMirrorSharing } from "./telemetry.ts"; -import { TELEMETRY_INTERVAL_MS } from "../shared/telemetry.ts"; +import { TELEMETRY_INTERVAL_MS } from "@mobile-dev/shared/telemetry.ts"; const lockPath = mirrorSocketPath + ".lock"; const run = promisify(execFile); diff --git a/src/server/ios-mirror-tools.ts b/packages/core/src/ios-mirror-tools.ts similarity index 96% rename from src/server/ios-mirror-tools.ts rename to packages/core/src/ios-mirror-tools.ts index 7e5f517b..efd1ca70 100644 --- a/src/server/ios-mirror-tools.ts +++ b/packages/core/src/ios-mirror-tools.ts @@ -1,12 +1,12 @@ import { parseResourceInput } from "./resource-input.ts"; import { openRequestSession } from "./request-session.ts"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; -import { IosMirrorInputBusyError } from "../shared/ios-mirror-errors.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { IosMirrorInputBusyError } from "@mobile-dev/shared/ios-mirror-errors.ts"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; -import { errorMessage, touchInputSchema } from "../shared/protocol.ts"; +import { errorMessage, touchInputSchema } from "@mobile-dev/shared/protocol.ts"; import { listIosDevices } from "./ios-devices.ts"; import { IosMirrorSessions } from "./ios-mirror.ts"; import { readPhysicalIosBezel } from "./physical-ios-bezel.ts"; diff --git a/src/server/ios-mirror.ts b/packages/core/src/ios-mirror.ts similarity index 95% rename from src/server/ios-mirror.ts rename to packages/core/src/ios-mirror.ts index 5e3af566..b7f3032d 100644 --- a/src/server/ios-mirror.ts +++ b/packages/core/src/ios-mirror.ts @@ -1,7 +1,7 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { randomBytes } from "node:crypto"; -import type { IosVideoBatch } from "../shared/ios-video.ts"; -import type { TouchInput } from "../shared/protocol.ts"; +import type { IosVideoBatch } from "@mobile-dev/shared/ios-video.ts"; +import type { TouchInput } from "@mobile-dev/shared/protocol.ts"; import { openSharedIosCapture } from "./ios-mirror-client.ts"; import type { IosCapture, NativeTouchSample } from "./ios-native-capture.ts"; export type { NativeCapture, NativeTouchSample } from "./ios-native-capture.ts"; diff --git a/src/server/ios-native-capture.ts b/packages/core/src/ios-native-capture.ts similarity index 88% rename from src/server/ios-native-capture.ts rename to packages/core/src/ios-native-capture.ts index 1533e9d7..5058b463 100644 --- a/src/server/ios-native-capture.ts +++ b/packages/core/src/ios-native-capture.ts @@ -1,8 +1,8 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; -import type { IosVideoBatch } from "../shared/ios-video.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import type { IosVideoBatch } from "@mobile-dev/shared/ios-video.ts"; export type NativeConfiguration = { revision: number; width: number; height: number; codec: string; description: Buffer }; export type NativeBatch = { generation: number; frames: { data: Buffer; timestamp: number; key: boolean }[]; configuration?: NativeConfiguration; dropped: number }; @@ -16,7 +16,7 @@ export async function openNativeIosCapture(udid: string): Promise if (process.platform !== "darwin" || process.arch !== "arm64") throw new ExpectedOperationError("unsupported_platform", "Physical iOS mirroring requires an Apple Silicon Mac."); if (addon === undefined) { const bundled = import.meta.url.endsWith("/ios-mirror-service.mjs"); - const root = bundled ? "./ios-mirror/" : "../../vendor/ios-mirror/"; + const root = bundled ? "./ios-mirror/" : "../../native/vendor/ios-mirror/"; const url = new URL(`${root}darwin-arm64.node`, import.meta.url); const path = fileURLToPath(url); if (existsSync(path) === false) throw new Error("The physical iOS capture addon is missing. Run npm run rebuild:ios-mirror and rebuild the plugin."); diff --git a/src/server/log-buffer.ts b/packages/core/src/log-buffer.ts similarity index 98% rename from src/server/log-buffer.ts rename to packages/core/src/log-buffer.ts index c83fc7e8..612053e7 100644 --- a/src/server/log-buffer.ts +++ b/packages/core/src/log-buffer.ts @@ -1,4 +1,4 @@ -import type { LogBatch, LogEntry, LogRecord, LogSourceStatus } from "../shared/logs.ts"; +import type { LogBatch, LogEntry, LogRecord, LogSourceStatus } from "@mobile-dev/shared/logs.ts"; export class LogBuffer { private entries: LogEntry[] = []; diff --git a/src/server/log-parsers.ts b/packages/core/src/log-parsers.ts similarity index 98% rename from src/server/log-parsers.ts rename to packages/core/src/log-parsers.ts index 5102f305..36cf800a 100644 --- a/src/server/log-parsers.ts +++ b/packages/core/src/log-parsers.ts @@ -1,4 +1,4 @@ -import type { LogRecord } from "../shared/logs.ts"; +import type { LogRecord } from "@mobile-dev/shared/logs.ts"; import { shouldExcludeIOSLog } from "./ios-log-filter.ts"; function level(value: unknown): LogRecord["level"] { diff --git a/src/server/log-sessions.ts b/packages/core/src/log-sessions.ts similarity index 93% rename from src/server/log-sessions.ts rename to packages/core/src/log-sessions.ts index 3977dbd1..76ac3986 100644 --- a/src/server/log-sessions.ts +++ b/packages/core/src/log-sessions.ts @@ -1,7 +1,7 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { randomBytes } from "node:crypto"; -import type { LogBatch, LogOptions } from "../shared/logs.ts"; -import { logOptionsSchema } from "../shared/logs.ts"; +import type { LogBatch, LogOptions } from "@mobile-dev/shared/logs.ts"; +import { logOptionsSchema } from "@mobile-dev/shared/logs.ts"; import { LogBuffer } from "./log-buffer.ts"; import { startNativeLogs } from "./native-logs.ts"; import type { LogSink, StopLogSource } from "./native-logs.ts"; diff --git a/src/server/log-tools.ts b/packages/core/src/log-tools.ts similarity index 91% rename from src/server/log-tools.ts rename to packages/core/src/log-tools.ts index 74b7312c..b3ea682b 100644 --- a/src/server/log-tools.ts +++ b/packages/core/src/log-tools.ts @@ -1,13 +1,13 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; import type { Baguette } from "./baguette.ts"; import type { LogSessions } from "./log-sessions.ts"; -import { logOptionsSchema } from "../shared/logs.ts"; -import type { LogOptions } from "../shared/logs.ts"; -import { errorMessage, parseBaseUrl } from "../shared/protocol.ts"; +import { logOptionsSchema } from "@mobile-dev/shared/logs.ts"; +import type { LogOptions } from "@mobile-dev/shared/logs.ts"; +import { errorMessage, parseBaseUrl } from "@mobile-dev/shared/protocol.ts"; import { listAndroidLogDevices } from "./native-logs.ts"; import { metroTargets } from "./metro-logs.ts"; import { listIosDevices } from "./ios-devices.ts"; @@ -66,7 +66,9 @@ export function registerLogTools(server: McpServer, logs: LogSessions, baguette: } if (options.metro) parseBaseUrl(options.metro.url, "Metro URL"); const id = logs.open(options); - return { content: [{ type: "text", text: "Log stream started." }], structuredContent: { options }, _meta: { sessionId: id, logsUri: `logs://mobile-dev/${id}/batch?after=0` } }; + const handles = { sessionId: id, logsUri: `logs://mobile-dev/${id}/batch?after=0` }; + // Models need the handle for subsequent reads; _meta is only for the app. + return { content: [{ type: "text", text: JSON.stringify({ status: "started", ...handles }) }], structuredContent: { options, ...handles }, _meta: handles }; })); registerAppTool(server, "mobile_read_logs", { diff --git a/src/server/metro-logs.ts b/packages/core/src/metro-logs.ts similarity index 97% rename from src/server/metro-logs.ts rename to packages/core/src/metro-logs.ts index e3442d04..d931ef7f 100644 --- a/src/server/metro-logs.ts +++ b/packages/core/src/metro-logs.ts @@ -1,7 +1,7 @@ import { WebSocket } from "ws"; import { setTimeout as delay } from "node:timers/promises"; -import type { MetroLogTarget, MetroTarget } from "../shared/logs.ts"; -import { parseBaseUrl, errorMessage } from "../shared/protocol.ts"; +import type { MetroLogTarget, MetroTarget } from "@mobile-dev/shared/logs.ts"; +import { parseBaseUrl, errorMessage } from "@mobile-dev/shared/protocol.ts"; import { parseMetroEvent } from "./log-parsers.ts"; import type { LogSink, StopLogSource } from "./native-logs.ts"; diff --git a/src/server/native-logs.ts b/packages/core/src/native-logs.ts similarity index 98% rename from src/server/native-logs.ts rename to packages/core/src/native-logs.ts index 685bccfd..6cde5e9c 100644 --- a/src/server/native-logs.ts +++ b/packages/core/src/native-logs.ts @@ -4,8 +4,8 @@ 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 type { LogRecord, LogSourceStatus, NativeLogTarget } from "../shared/logs.ts"; -import { errorMessage } from "../shared/protocol.ts"; +import type { LogRecord, LogSourceStatus, NativeLogTarget } from "@mobile-dev/shared/logs.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { androidSdkRoots } from "./android-sdk.ts"; import { parseIOSLog, parseLogcat } from "./log-parsers.ts"; import { physicalIosLogCommand } from "./physical-ios-logs.ts"; diff --git a/src/server/native-telemetry.ts b/packages/core/src/native-telemetry.ts similarity index 98% rename from src/server/native-telemetry.ts rename to packages/core/src/native-telemetry.ts index 2a8ec29b..c507f78b 100644 --- a/src/server/native-telemetry.ts +++ b/packages/core/src/native-telemetry.ts @@ -1,6 +1,6 @@ import { makeNodeTransport } from "@sentry/node"; import type { ErrorEvent } from "@sentry/node"; -import { SENTRY_NATIVE_DSN, SENTRY_RELEASE } from "../shared/telemetry.ts"; +import { SENTRY_NATIVE_DSN, SENTRY_RELEASE } from "@mobile-dev/shared/telemetry.ts"; import { TELEMETRY_ENVIRONMENT } from "./telemetry-environment.ts"; import { getTelemetryIdentity } from "./telemetry-identity.ts"; diff --git a/src/server/network-throttle/android.ts b/packages/core/src/network-throttle/android.ts similarity index 96% rename from src/server/network-throttle/android.ts rename to packages/core/src/network-throttle/android.ts index 0fc4cd6b..685e423e 100644 --- a/src/server/network-throttle/android.ts +++ b/packages/core/src/network-throttle/android.ts @@ -1,6 +1,6 @@ -import { ExpectedOperationError } from "../../shared/error-reporting.ts"; -import { errorMessage } from "../../shared/protocol.ts"; -import { networkPresets, type NetworkPreset } from "../../shared/device-settings.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; +import { networkPresets, type NetworkPreset } from "@mobile-dev/shared/device-settings.ts"; import { runAdb, systemCommands, type Commands } from "../app-control/commands.ts"; import { proxyToken, ThrottleProxy } from "./proxy.ts"; diff --git a/src/server/network-throttle/proxy.ts b/packages/core/src/network-throttle/proxy.ts similarity index 100% rename from src/server/network-throttle/proxy.ts rename to packages/core/src/network-throttle/proxy.ts diff --git a/src/server/performance-recordings.ts b/packages/core/src/performance-recordings.ts similarity index 96% rename from src/server/performance-recordings.ts rename to packages/core/src/performance-recordings.ts index 7d0b4197..b10fcc3d 100644 --- a/src/server/performance-recordings.ts +++ b/packages/core/src/performance-recordings.ts @@ -2,15 +2,15 @@ import { randomUUID } from "node:crypto"; import { mkdir, readFile, readdir, rename, stat, writeFile, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { CpuSample, CpuTarget } from "../shared/cpu.ts"; -import { recordingIdSchema, recordingSchema } from "../shared/recordings.ts"; -import type { PerformanceRecording } from "../shared/recordings.ts"; -import { errorMessage } from "../shared/protocol.ts"; +import type { CpuSample, CpuTarget } from "@mobile-dev/shared/cpu.ts"; +import { recordingIdSchema, recordingSchema } from "@mobile-dev/shared/recordings.ts"; +import type { PerformanceRecording } from "@mobile-dev/shared/recordings.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "./telemetry.ts"; import type { CpuSessions } from "./cpu/sessions.ts"; import type { DisplayFpsSessions } from "./fps/sessions.ts"; -import type { DisplayFpsSample } from "../shared/display-fps.ts"; -import { displayFrameTime } from "../shared/display-fps.ts"; +import type { DisplayFpsSample } from "@mobile-dev/shared/display-fps.ts"; +import { displayFrameTime } from "@mobile-dev/shared/display-fps.ts"; export const RECORDINGS_DIRECTORY = join(homedir(), "Library/Application Support/mobile-dev/recordings"); diff --git a/src/server/physical-ios-bezel.ts b/packages/core/src/physical-ios-bezel.ts similarity index 96% rename from src/server/physical-ios-bezel.ts rename to packages/core/src/physical-ios-bezel.ts index 46cc10d2..878466d8 100644 --- a/src/server/physical-ios-bezel.ts +++ b/packages/core/src/physical-ios-bezel.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { z } from "zod"; -import { bezelGeometrySchema } from "../shared/bezel.ts"; -import type { Bezel } from "../shared/bezel.ts"; -import type { PhysicalIosDevice } from "../shared/ios-devices.ts"; +import { bezelGeometrySchema } from "@mobile-dev/shared/bezel.ts"; +import type { Bezel } from "@mobile-dev/shared/bezel.ts"; +import type { PhysicalIosDevice } from "@mobile-dev/shared/ios-devices.ts"; import { baguetteEnvironment } from "./baguette-runtime.ts"; const execute = promisify(execFile); @@ -76,7 +76,7 @@ async function renderBezel(device: PhysicalIosDevice, run: BezelCommand, runtime } if (selected === undefined) throw new Error(`Xcode has no Apple device frame for ${device.model} (${device.productType}). Install its device type in Xcode.`); const { type, profile } = selected; - const root = import.meta.url.endsWith("/server.mjs") ? "./baguette/" : "../../vendor/baguette/"; + const root = import.meta.url.endsWith("/server.mjs") ? "./baguette/" : "../../native/vendor/baguette/"; const url = new URL(`${root}Baguette`, import.meta.url); const executable = fileURLToPath(url); const signal = AbortSignal.timeout(commandOptions.timeout); diff --git a/src/server/physical-ios-logs.ts b/packages/core/src/physical-ios-logs.ts similarity index 93% rename from src/server/physical-ios-logs.ts rename to packages/core/src/physical-ios-logs.ts index 3e2caa6a..a5751498 100644 --- a/src/server/physical-ios-logs.ts +++ b/packages/core/src/physical-ios-logs.ts @@ -1,8 +1,8 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { access } from "node:fs/promises"; import { constants } from "node:fs"; import { fileURLToPath } from "node:url"; -import type { NativeLogTarget } from "../shared/logs.ts"; +import type { NativeLogTarget } from "@mobile-dev/shared/logs.ts"; import { listIosDevices } from "./ios-devices.ts"; export type PhysicalIosLogTarget = Extract; diff --git a/src/server/plugin.ts b/packages/core/src/plugin.ts similarity index 83% rename from src/server/plugin.ts rename to packages/core/src/plugin.ts index d592bdc8..4621c3a9 100644 --- a/src/server/plugin.ts +++ b/packages/core/src/plugin.ts @@ -3,12 +3,12 @@ import { openRequestSession } from "./request-session.ts"; import type { ServerRequestContext } from "./request-session.ts"; import { addToolIcons, PHONE_ICONS } from "./tool-icons.ts"; import type { UIResource } from "./ui-resource.ts"; -import { PluginUpdates, registerPluginUpdateTools } from "./plugin-updates.ts"; -import { LIVE_UI_URI } from "../shared/live-ui.ts"; +import type { ServerFeature } from "./server-feature.ts"; +import { LIVE_UI_URI } from "@mobile-dev/shared/live-ui.ts"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { PLUGIN_VERSION } from "../shared/version.ts"; -import { SENTRY_ORIGIN } from "../shared/telemetry.ts"; -import { captureServerError, instrumentMcpServer } from "./telemetry.ts"; +import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; +import { SENTRY_ORIGIN } from "@mobile-dev/shared/telemetry.ts"; +import { captureServerError, instrumentMcpServer, setServerTelemetryHost } from "./telemetry.ts"; import { resolveTelemetryEnvironment } from "./telemetry-environment.ts"; import { getTelemetryIdentity } from "./telemetry-identity.ts"; import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; @@ -23,7 +23,7 @@ import { registerAndroidTools } from "./android-tools.ts"; import { Baguette } from "./baguette.ts"; import { registerIosMirrorTools } from "./ios-mirror-tools.ts"; import { listIosDevices, registerIosDeviceTools } from "./ios-devices.ts"; -import { registerDeviceChoiceTools } from "./device-choice-tools.ts"; +import { registerDeviceChoiceTools, supportsDeviceChoiceForms } from "./device-choice-tools.ts"; import { StreamSessions } from "./stream-sessions.ts"; import { LogSessions } from "./log-sessions.ts"; import { registerLogTools } from "./log-tools.ts"; @@ -32,9 +32,9 @@ import { CpuSessions, createCpuSessions } from "./cpu/sessions.ts"; import { registerCpuTools } from "./cpu/tools.ts"; import { PerformanceRecordings } from "./performance-recordings.ts"; import { registerRecordingTools } from "./recording-tools.ts"; -import { RECORDING_URI } from "../shared/recordings.ts"; -import { COMPARISON_URI } from "../shared/performance-comparison.ts"; -import { TRACESIFT_URI } from "../shared/tracesift.ts"; +import { RECORDING_URI } from "@mobile-dev/shared/recordings.ts"; +import { COMPARISON_URI } from "@mobile-dev/shared/performance-comparison.ts"; +import { TRACESIFT_URI } from "@mobile-dev/shared/tracesift.ts"; import { TraceSiftClient } from "./tracesift-client.ts"; import { registerTraceSiftTools } from "./tracesift-tools.ts"; import { startStorageMetrics } from "./storage-metrics.ts"; @@ -44,11 +44,11 @@ import { SimulatorInputService } from "./simulator-input.ts"; 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 { errorMessage, inputSchema, streamMessageSchema, udidSchema } from "@mobile-dev/shared/protocol.ts"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; -import { panelTargetSchema } from "../shared/panel-target.ts"; -import type { PanelTarget } from "../shared/panel-target.ts"; +import { panelTargetSchema } from "@mobile-dev/shared/panel-target.ts"; +import type { PanelTarget } from "@mobile-dev/shared/panel-target.ts"; export const APP_URI = `ui://mobile-dev/${PLUGIN_VERSION}/simulator.html`; export const WORKSPACE_URI = `ui://mobile-dev/${PLUGIN_VERSION}/workspace.html`; @@ -89,23 +89,51 @@ 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) { +/** Gives a served UI page the server's telemetry environment, opt-out and anonymous identity. */ +export function configureUI(content: string): string { + const telemetryEnvironment = resolveTelemetryEnvironment(); + const identity = getTelemetryIdentity(); + const enabled = identity ? "on" : "off"; + const identityMeta = identity + ? `` + : ""; + const metadata = `${identityMeta}`; + return content.replace(//, metadata); +} + +/** Services the server runs on; tests replace them with fakes. `features` are the host's additions. */ +export type PluginOptions = { + baguette?: Baguette; + simulatorInput?: SimulatorInput; + logs?: LogSessions; + android?: ServeEmu; + copyScreenshot?: typeof copyPNGToClipboard; + cpu?: CpuSessions; + features?: ServerFeature[]; +}; + +export async function createPlugin(html: string | (() => Promise), options: PluginOptions = {}) { + const { baguette = new Baguette(), logs = new LogSessions(), android = new ServeEmu(), copyScreenshot = copyPNGToClipboard, features = [] } = options; + const simulatorInput = options.simulatorInput ?? new SimulatorInputService(udid => baguette.repairInput(udid)); + const cpu = options.cpu ?? await createCpuSessions(); 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: "For mobile app development, open mobile_open_simulator before the first device launch, or reuse the open view. Use selected device IDs. If a task has ambiguous device targets, discover candidates and ask the user with mobile_choose_devices when it is available, otherwise in chat; proceed only after they choose. 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, when installed, 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, - }); + // The native form is a client extension; list the tool only for hosts that render it. + let deviceChoiceRegistered = false; + server.server.oninitialized = () => { + setServerTelemetryHost(server.server.getClientVersion()?.name); + if (deviceChoiceRegistered || !supportsDeviceChoiceForms(server.server.getClientCapabilities())) return; + deviceChoiceRegistered = true; + registerDeviceChoiceTools(server, extensions, { + simulators: () => baguette.start(), android: () => android.list(), physicalIos: listIosDevices, + }); + }; registerIosDeviceTools(server); const closeIosMirror = registerIosMirrorTools(server, APP_URI); registerLogTools(server, logs, baguette); @@ -158,16 +186,6 @@ export async function createPlugin(html: string | (() => Promise), b }; } - function configureUI(content: string): string { - const telemetryEnvironment = resolveTelemetryEnvironment(); - const identity = getTelemetryIdentity(); - const enabled = identity ? "on" : "off"; - const identityMeta = identity - ? `` - : ""; - const metadata = `${identityMeta}`; - return content.replace(//, metadata); - } const readApp = async (uri: URL) => { const resource = typeof html === "string" ? { html } : await html(); const content = configureUI(resource.html); @@ -177,7 +195,7 @@ export async function createPlugin(html: string | (() => Promise), b ? content.replace('data-view="panel"', 'data-view="workspace"').replace('data-layout="stacked"', 'data-layout="split"') : content, _meta: { - ui: { csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, + ui: { permissions: { clipboardWrite: {} }, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, "openai/ui": { preferredDisplayMode: "fullscreen", availableDisplayModes: ["inline", "fullscreen"] }, }, }], @@ -206,7 +224,7 @@ export async function createPlugin(html: string | (() => Promise), b 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: [] } }, + ui: { prefersBorder: true, permissions: { clipboardWrite: {} }, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }, "openai/ui": { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] }, } }] }; }; @@ -249,7 +267,7 @@ 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: "Open the Mobile Dev workspace, with logs and performance tools beside the devices, when the user asks for fullscreen or for those tools. It opens fullscreen where the host supports it. Pass established platform, kind, or discovered deviceId to select the initial device; omit undecided fields. For app development alongside 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"] }, @@ -259,7 +277,7 @@ 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: "Open Mobile Dev alongside the chat for local mobile app development, before the first device launch unless a view 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"] }, @@ -423,11 +441,12 @@ export async function createPlugin(html: string | (() => Promise), b streams.reset(sessionId); return result({}, "Restarting simulator capture."); })); + const featureCleanups = features.flatMap(feature => feature(server) ?? []); return { server, async close() { - updates.close(); + await Promise.allSettled(featureCleanups.map(cleanup => cleanup())); stopRecordingStorageMetrics(); // Clearing Android throttles waits on adb, so it runs alongside the other cleanup. const deviceSettingsClosed = closeDeviceSettings(); diff --git a/src/server/profile-picker.ts b/packages/core/src/profile-picker.ts similarity index 100% rename from src/server/profile-picker.ts rename to packages/core/src/profile-picker.ts diff --git a/src/server/react-native-inspector.ts b/packages/core/src/react-native-inspector.ts similarity index 98% rename from src/server/react-native-inspector.ts rename to packages/core/src/react-native-inspector.ts index 88e9e0d0..417cd779 100644 --- a/src/server/react-native-inspector.ts +++ b/packages/core/src/react-native-inspector.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { metroTargets } from "./metro-logs.ts"; import type { InspectorTarget } from "./metro-logs.ts"; import { collectReactNativeTree } from "./react-native-snapshot.js"; -import type { ScreenBounds, ReactElementContext } from "../shared/screen-annotations.ts"; +import type { ScreenBounds, ReactElementContext } from "@mobile-dev/shared/screen-annotations.ts"; import { resolveReactNativeSources } from "./react-native-source.ts"; import { captureServerError } from "./telemetry.ts"; diff --git a/src/server/react-native-snapshot.d.ts b/packages/core/src/react-native-snapshot.d.ts similarity index 100% rename from src/server/react-native-snapshot.d.ts rename to packages/core/src/react-native-snapshot.d.ts diff --git a/src/server/react-native-snapshot.js b/packages/core/src/react-native-snapshot.js similarity index 100% rename from src/server/react-native-snapshot.js rename to packages/core/src/react-native-snapshot.js diff --git a/src/server/react-native-source.ts b/packages/core/src/react-native-source.ts similarity index 95% rename from src/server/react-native-source.ts rename to packages/core/src/react-native-source.ts index 1d6c2f35..3ce40b6a 100644 --- a/src/server/react-native-source.ts +++ b/packages/core/src/react-native-source.ts @@ -1,6 +1,6 @@ -import { parseBaseUrl } from "../shared/protocol.ts"; +import { parseBaseUrl } from "@mobile-dev/shared/protocol.ts"; import type { InspectorNode } from "./react-native-inspector.ts"; -import type { ReactElementContext } from "../shared/screen-annotations.ts"; +import type { ReactElementContext } from "@mobile-dev/shared/screen-annotations.ts"; export type CreationFrame = { url: number; line: number; column: number; methodName: string }; type MappedFrame = { file?: string; lineNumber?: number; column?: number; methodName?: string; collapse?: boolean }; diff --git a/src/server/recording-frames.ts b/packages/core/src/recording-frames.ts similarity index 81% rename from src/server/recording-frames.ts rename to packages/core/src/recording-frames.ts index 01a7ab6b..92b0688e 100644 --- a/src/server/recording-frames.ts +++ b/packages/core/src/recording-frames.ts @@ -1,8 +1,8 @@ -import { displayFrameTime } from "../shared/display-fps.ts"; -import type { DisplayFrame } from "../shared/display-fps.ts"; -import type { PerformanceRecording, RecordingRange } from "../shared/recordings.ts"; -import { summarizeDisplayFrames } from "../shared/frame-statistics.ts"; -import type { DisplayFrameStats } from "../shared/frame-statistics.ts"; +import { displayFrameTime } from "@mobile-dev/shared/display-fps.ts"; +import type { DisplayFrame } from "@mobile-dev/shared/display-fps.ts"; +import type { PerformanceRecording, RecordingRange } from "@mobile-dev/shared/recordings.ts"; +import { summarizeDisplayFrames } from "@mobile-dev/shared/frame-statistics.ts"; +import type { DisplayFrameStats } from "@mobile-dev/shared/frame-statistics.ts"; export type RecordingDisplayFrame = DisplayFrame & { time: number }; type RecordingFramesPage = { diff --git a/src/server/recording-tools.ts b/packages/core/src/recording-tools.ts similarity index 96% rename from src/server/recording-tools.ts rename to packages/core/src/recording-tools.ts index a228c46e..074cee57 100644 --- a/src/server/recording-tools.ts +++ b/packages/core/src/recording-tools.ts @@ -2,16 +2,16 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { cpuTargetSchema } from "../shared/cpu.ts"; -import type { CpuTarget } from "../shared/cpu.ts"; -import { RECORDING_URI, recordingIdSchema, recordingRangeSchema, recordingSchema, summarizeRecording } from "../shared/recordings.ts"; -import { errorMessage } from "../shared/protocol.ts"; +import { cpuTargetSchema } from "@mobile-dev/shared/cpu.ts"; +import type { CpuTarget } from "@mobile-dev/shared/cpu.ts"; +import { RECORDING_URI, recordingIdSchema, recordingRangeSchema, recordingSchema, summarizeRecording } from "@mobile-dev/shared/recordings.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; import { captureServerError } from "./telemetry.ts"; import type { PerformanceRecordings } from "./performance-recordings.ts"; -import { displayFrameSchema } from "../shared/display-fps.ts"; +import { displayFrameSchema } from "@mobile-dev/shared/display-fps.ts"; import { readRecordingFrames } from "./recording-frames.ts"; -import { nullableDisplayFrameStatsSchema } from "../shared/frame-statistics.ts"; -import { COMPARISON_URI, comparisonIdsSchema, comparisonSchema, comparisonTitleSchema, summarizeComparison } from "../shared/performance-comparison.ts"; +import { nullableDisplayFrameStatsSchema } from "@mobile-dev/shared/frame-statistics.ts"; +import { COMPARISON_URI, comparisonIdsSchema, comparisonSchema, comparisonTitleSchema, summarizeComparison } from "@mobile-dev/shared/performance-comparison.ts"; const read = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; const visibility: ("app" | "model")[] = ["app", "model"]; diff --git a/src/server/request-session.ts b/packages/core/src/request-session.ts similarity index 93% rename from src/server/request-session.ts rename to packages/core/src/request-session.ts index c0602f44..c0151214 100644 --- a/src/server/request-session.ts +++ b/packages/core/src/request-session.ts @@ -1,6 +1,6 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { captureServerError, registerRequestSessionResponse } from "./telemetry.ts"; export type ServerRequestContext = RequestHandlerExtra; diff --git a/src/server/resource-input.ts b/packages/core/src/resource-input.ts similarity index 84% rename from src/server/resource-input.ts rename to packages/core/src/resource-input.ts index b115b630..7980312e 100644 --- a/src/server/resource-input.ts +++ b/packages/core/src/resource-input.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; class InvalidResourceInputError extends ExpectedOperationError { readonly code = -32602; diff --git a/packages/core/src/schema-dialect.ts b/packages/core/src/schema-dialect.ts new file mode 100644 index 00000000..22701a3a --- /dev/null +++ b/packages/core/src/schema-dialect.ts @@ -0,0 +1,23 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +const DRAFT_07 = "http://json-schema.org/draft-07/schema#"; +export const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; + +/** + * The SDK labels every tool schema draft-07, but MCP clients validate JSON Schema + * 2020-12, and Claude Desktop rejects a tool whose output schema names another + * dialect. Our schemas use no keyword whose meaning differs between the two (a test + * checks), so declaring 2020-12 describes them exactly. + */ +export function declareSchemaDialect(transport: Transport) { + const send = transport.send.bind(transport); + transport.send = (message, options) => { + const tools = "result" in message ? (message.result as { tools?: unknown }).tools : undefined; + if (Array.isArray(tools)) { + for (const tool of tools as Array<{ inputSchema?: { $schema?: string }; outputSchema?: { $schema?: string } }>) { + for (const schema of [tool.inputSchema, tool.outputSchema]) if (schema?.$schema === DRAFT_07) schema.$schema = JSON_SCHEMA_2020_12; + } + } + return send(message, options); + }; +} diff --git a/src/server/serve-emu.ts b/packages/core/src/serve-emu.ts similarity index 98% rename from src/server/serve-emu.ts rename to packages/core/src/serve-emu.ts index 71fd5732..5552f786 100644 --- a/src/server/serve-emu.ts +++ b/packages/core/src/serve-emu.ts @@ -10,12 +10,12 @@ import { setTimeout as delay } from "node:timers/promises"; import { z } from "zod"; import { bootAndroidEmulator, parseAvdName } from "./android-emulator.ts"; import { adbPath } from "./native-logs.ts"; -import { errorMessage, parseBaseUrl } from "../shared/protocol.ts"; -import type { SimulatorDevice, Status } from "../shared/protocol.ts"; +import { errorMessage, parseBaseUrl } from "@mobile-dev/shared/protocol.ts"; +import type { SimulatorDevice, Status } from "@mobile-dev/shared/protocol.ts"; 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 { androidStartupMessageSchema, androidDeviceState, setAndroidStartupDiagnostic } from "@mobile-dev/shared/android-startup-diagnostics.ts"; +import type { AndroidStartupContext, AndroidStartupFailure, AndroidStartupSummary } from "@mobile-dev/shared/android-startup-diagnostics.ts"; import { androidSdkRoots } from "./android-sdk.ts"; const execute = promisify(execFile); diff --git a/packages/core/src/server-feature.ts b/packages/core/src/server-feature.ts new file mode 100644 index 00000000..030f82a9 --- /dev/null +++ b/packages/core/src/server-feature.ts @@ -0,0 +1,8 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * A host's addition to the shared server, such as Codex's self-updater or the Mac + * app's page. Each host's server entry passes its own; the shared server knows none. + * Returns its cleanup, if it has any. + */ +export type ServerFeature = (server: McpServer) => void | (() => void | Promise); diff --git a/src/server/simulator-input.ts b/packages/core/src/simulator-input.ts similarity index 98% rename from src/server/simulator-input.ts rename to packages/core/src/simulator-input.ts index 283b5998..05707d85 100644 --- a/src/server/simulator-input.ts +++ b/packages/core/src/simulator-input.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { udidSchema } from "../shared/protocol.ts"; +import { udidSchema } from "@mobile-dev/shared/protocol.ts"; export type InputStatus = { state: "ready" | "blocked" | "unknown" }; export interface SimulatorInput { diff --git a/src/server/simulator-unavailable.ts b/packages/core/src/simulator-unavailable.ts similarity index 65% rename from src/server/simulator-unavailable.ts rename to packages/core/src/simulator-unavailable.ts index 3da2357c..ea75dab3 100644 --- a/src/server/simulator-unavailable.ts +++ b/packages/core/src/simulator-unavailable.ts @@ -1,4 +1,4 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; export class SimulatorUnavailableError extends ExpectedOperationError { constructor(message: string) { diff --git a/packages/core/src/stdio-server.ts b/packages/core/src/stdio-server.ts new file mode 100644 index 00000000..2f16b4a8 --- /dev/null +++ b/packages/core/src/stdio-server.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { loadUIResource } from "./ui-resource.ts"; +import { createPlugin } from "./plugin.ts"; +import type { ServerFeature } from "./server-feature.ts"; +import { closeServerTelemetry, installTracePropagation } from "./telemetry.ts"; +import { declareSchemaDialect } from "./schema-dialect.ts"; + +/** + * Runs the server over stdio, as every host launches it, with the host's own features. + * Import `./instrument.ts` before this module so telemetry starts first. + */ +export async function runStdioServer(features: ServerFeature[] = []) { + const html = await readFile(new URL("./app.html", import.meta.url), "utf8"); + const plugin = await createPlugin(() => loadUIResource(html, new URL("../ui-dev.json", import.meta.url)), { features }); + const transport = new StdioServerTransport(); + installTracePropagation(transport); + declareSchemaDialect(transport); + let closing = false; + async function close() { + if (closing) return; + closing = true; + try { await plugin.close(); } + finally { await closeServerTelemetry(); } + } + transport.onclose = () => { void close(); }; + process.stdin.on("end", () => { void close(); }); + process.on("SIGINT", () => { void close(); }); + process.on("SIGTERM", () => { void close(); }); + process.on("uncaughtException", error => { console.error(error); void close().finally(() => { process.exitCode = 1; }); }); + process.on("unhandledRejection", error => { console.error(error); void close().finally(() => { process.exitCode = 1; }); }); + await plugin.server.connect(transport); +} diff --git a/src/server/storage-metrics.ts b/packages/core/src/storage-metrics.ts similarity index 100% rename from src/server/storage-metrics.ts rename to packages/core/src/storage-metrics.ts diff --git a/src/server/stream-sessions.ts b/packages/core/src/stream-sessions.ts similarity index 97% rename from src/server/stream-sessions.ts rename to packages/core/src/stream-sessions.ts index 3c5e4200..59fd3778 100644 --- a/src/server/stream-sessions.ts +++ b/packages/core/src/stream-sessions.ts @@ -1,10 +1,10 @@ -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; import { randomBytes } from "node:crypto"; import { WebSocket } from "ws"; -import { errorMessage, streamMessageSchema } from "../shared/protocol.ts"; +import { errorMessage, streamMessageSchema } from "@mobile-dev/shared/protocol.ts"; import { SimulatorUnavailableError } from "./simulator-unavailable.ts"; -import { epochNow } from "../shared/stream.ts"; -import type { FrameRead } from "../shared/stream.ts"; +import { epochNow } from "@mobile-dev/shared/stream.ts"; +import type { FrameRead } from "@mobile-dev/shared/stream.ts"; import type { Baguette } from "./baguette.ts"; type Frame = { sequence: number; bytes: Buffer; receivedAt: number; data?: string }; diff --git a/src/server/telemetry-environment.json b/packages/core/src/telemetry-environment.json similarity index 100% rename from src/server/telemetry-environment.json rename to packages/core/src/telemetry-environment.json diff --git a/src/server/telemetry-environment.ts b/packages/core/src/telemetry-environment.ts similarity index 78% rename from src/server/telemetry-environment.ts rename to packages/core/src/telemetry-environment.ts index 04459be2..91a8e21e 100644 --- a/src/server/telemetry-environment.ts +++ b/packages/core/src/telemetry-environment.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; -import { validateTelemetryEnvironment } from "../shared/telemetry.ts"; -import type { TelemetryEnvironment } from "../shared/telemetry.ts"; +import { validateTelemetryEnvironment } from "@mobile-dev/shared/telemetry.ts"; +import type { TelemetryEnvironment } from "@mobile-dev/shared/telemetry.ts"; const configUrl = new URL("./telemetry-environment.json", import.meta.url); const configText = readFileSync(configUrl, "utf8"); diff --git a/src/server/telemetry-identity.ts b/packages/core/src/telemetry-identity.ts similarity index 92% rename from src/server/telemetry-identity.ts rename to packages/core/src/telemetry-identity.ts index 0d824a1a..3144c6f5 100644 --- a/src/server/telemetry-identity.ts +++ b/packages/core/src/telemetry-identity.ts @@ -2,8 +2,8 @@ import { randomBytes } from "node:crypto"; import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { isAnonymousUserId } from "../shared/telemetry-identity.ts"; -import type { TelemetryIdentity } from "../shared/telemetry-identity.ts"; +import { isAnonymousUserId } from "@mobile-dev/shared/telemetry-identity.ts"; +import type { TelemetryIdentity } from "@mobile-dev/shared/telemetry-identity.ts"; const home = homedir(); export const TELEMETRY_IDENTITY_DIRECTORY = join(home, "Library/Application Support/mobile-dev/telemetry"); diff --git a/src/server/telemetry.ts b/packages/core/src/telemetry.ts similarity index 93% rename from src/server/telemetry.ts rename to packages/core/src/telemetry.ts index 6c3cf328..990e0e94 100644 --- a/src/server/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -2,15 +2,15 @@ import * as Sentry from "@sentry/node"; import { AsyncLocalStorage } from "node:async_hooks"; import type { McpServer, ResourceTemplate, ResourceMetadata, ReadResourceCallback, ReadResourceTemplateCallback, RegisteredResource, RegisteredResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { MeasurementWindow, TELEMETRY_INTERVAL_MS, TELEMETRY_META_KEY } from "../shared/telemetry.ts"; -import { ExpectedOperationError, expectedOutcome } from "../shared/error-reporting.ts"; +import { MeasurementWindow, TELEMETRY_HOSTS, TELEMETRY_INTERVAL_MS, TELEMETRY_META_KEY, telemetryHost } from "@mobile-dev/shared/telemetry.ts"; +import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts"; import { closeNativeTelemetry } from "./native-telemetry.ts"; import { closeHttpRuntime } from "./http-runtime.ts"; -import { definitionDiagnosticTags } from "../shared/simulator-definition-diagnostics.ts"; -import { deviceAppsDiagnosticTags } from "../shared/device-apps-diagnostics.ts"; -import { getDiscoveryCommandDiagnostic } from "../shared/device-apps-command-diagnostics.ts"; -import { androidStartupDiagnosticTags } from "../shared/android-startup-diagnostics.ts"; -import type { AndroidStartupSummary, AndroidStartupContext, AndroidStartupFailure, AndroidDeviceState } from "../shared/android-startup-diagnostics.ts"; +import { definitionDiagnosticTags } from "@mobile-dev/shared/simulator-definition-diagnostics.ts"; +import { deviceAppsDiagnosticTags } from "@mobile-dev/shared/device-apps-diagnostics.ts"; +import { getDiscoveryCommandDiagnostic } from "@mobile-dev/shared/device-apps-command-diagnostics.ts"; +import { androidStartupDiagnosticTags } from "@mobile-dev/shared/android-startup-diagnostics.ts"; +import type { AndroidStartupSummary, AndroidStartupContext, AndroidStartupFailure, AndroidDeviceState } from "@mobile-dev/shared/android-startup-diagnostics.ts"; export function recordIosMirrorSharing(captures: number, subscribers: number, dropped: number) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; @@ -20,13 +20,6 @@ export function recordIosMirrorSharing(captures: number, subscribers: number, dr if (dropped > 0) Sentry.metrics.count("ios.mirror.shared.queue_dropped", dropped, { attributes }); } -export function recordPluginUpdate(operation: "check" | "install", outcome: "disabled" | "unavailable" | "current" | "available" | "updated" | "failed", duration: number) { - if (process.env.MOBILE_DEV_TELEMETRY === "off") return; - const attributes = { component: "server", operation, outcome }; - Sentry.metrics.count("plugin.update.operations", 1, { attributes }); - Sentry.metrics.gauge("plugin.update.duration", duration, { unit: "millisecond", attributes }); -} - export type AppStorageOperation = "read" | "clear" | "reset" | "table" | "file"; export function recordAppStorageOperation(operation: AppStorageOperation, platform: "ios" | "android", outcome: "success" | "failed", duration: number) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; @@ -252,9 +245,16 @@ const resourceKinds: Record = { }; const allowedContext: Record = { surface: ["logs", "performance", "storage", "simulator", "recording", "comparison", "tracesift"], view: ["panel", "workspace", "recording", "comparison", "tracesift"], layout: ["ios", "android", "both", "none"], - device_platform: ["ios", "android", "mixed"], device_kind: ["physical", "simulator", "emulator", "none"], + device_platform: ["ios", "android", "mixed"], device_kind: ["physical", "simulator", "emulator", "none"], host: TELEMETRY_HOSTS, }; +/** Attributes server telemetry to the host that launched it: the Mac app sets `MOBILE_DEV_HOST`, other hosts are named by their MCP client. */ +export function setServerTelemetryHost(clientName: string | undefined) { + const host = process.env.MOBILE_DEV_HOST === "mac" ? "mac" : telemetryHost(clientName); + Sentry.setTag("host", host); + Sentry.setAttribute("host", host); +} + export function installTracePropagation(transport: Transport) { const send = transport.send.bind(transport); transport.send = async (message, options) => { diff --git a/src/server/tool-icons.ts b/packages/core/src/tool-icons.ts similarity index 100% rename from src/server/tool-icons.ts rename to packages/core/src/tool-icons.ts diff --git a/src/server/tracesift-client.ts b/packages/core/src/tracesift-client.ts similarity index 99% rename from src/server/tracesift-client.ts rename to packages/core/src/tracesift-client.ts index e6eed5c1..ee961505 100644 --- a/src/server/tracesift-client.ts +++ b/packages/core/src/tracesift-client.ts @@ -6,8 +6,8 @@ import { homedir } from "node:os"; import { basename, join } from "node:path"; import { createServer } from "node:net"; import { setTimeout as delay } from "node:timers/promises"; -import { traceSiftAnalysisSchema, traceSiftHandoffSchema, type TraceSiftAnalysis, type TraceSiftProfileType, type TraceSiftReactHandoff, type TraceSiftTaskHandoff } from "../shared/tracesift.ts"; -import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { traceSiftAnalysisSchema, traceSiftHandoffSchema, type TraceSiftAnalysis, type TraceSiftProfileType, type TraceSiftReactHandoff, type TraceSiftTaskHandoff } from "@mobile-dev/shared/tracesift.ts"; +import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts"; const STARTUP_TIMEOUT_MS = 60_000; const REQUEST_TIMEOUT_MS = 10 * 60_000; diff --git a/src/server/tracesift-tools.ts b/packages/core/src/tracesift-tools.ts similarity index 98% rename from src/server/tracesift-tools.ts rename to packages/core/src/tracesift-tools.ts index bebb2de7..11a4edb5 100644 --- a/src/server/tracesift-tools.ts +++ b/packages/core/src/tracesift-tools.ts @@ -2,11 +2,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { errorMessage } from "../shared/protocol.ts"; -import { TRACESIFT_URI, traceSiftAnalysisSchema, traceSiftHandoffSchema, traceSiftProfileInputSchema, traceSiftReactCardSchema, traceSiftTaskCardSchema } from "../shared/tracesift.ts"; +import { errorMessage } from "@mobile-dev/shared/protocol.ts"; +import { TRACESIFT_URI, traceSiftAnalysisSchema, traceSiftHandoffSchema, traceSiftProfileInputSchema, traceSiftReactCardSchema, traceSiftTaskCardSchema } from "@mobile-dev/shared/tracesift.ts"; import { gzip as gzipCallback } from "node:zlib"; import { promisify } from "node:util"; -import { TRACE_SIFT_PAYLOAD_GZIP_MIN_BYTES, TRACE_SIFT_PAYLOAD_MAX_BYTES, traceSiftExploreEnvelopeSchema, TRACE_SIFT_PAYLOAD_TOO_LARGE, traceSiftPayloadTooDeep } from "../shared/tracesift-explore.ts"; +import { TRACE_SIFT_PAYLOAD_GZIP_MIN_BYTES, TRACE_SIFT_PAYLOAD_MAX_BYTES, traceSiftExploreEnvelopeSchema, TRACE_SIFT_PAYLOAD_TOO_LARGE, traceSiftPayloadTooDeep } from "@mobile-dev/shared/tracesift-explore.ts"; import { captureServerError, recordTraceSiftOperation, recordTraceSiftPayload } from "./telemetry.ts"; import type { TraceSiftClient } from "./tracesift-client.ts"; import { TraceSiftError } from "./tracesift-client.ts"; diff --git a/src/server/ui-resource.ts b/packages/core/src/ui-resource.ts similarity index 100% rename from src/server/ui-resource.ts rename to packages/core/src/ui-resource.ts diff --git a/tests/agent-device-adapter.test.ts b/packages/core/test/agent-device-adapter.test.ts similarity index 94% rename from tests/agent-device-adapter.test.ts rename to packages/core/test/agent-device-adapter.test.ts index eeaa3d97..3ca5a19c 100644 --- a/tests/agent-device-adapter.test.ts +++ b/packages/core/test/agent-device-adapter.test.ts @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { compactAgentDeviceTool } from "../src/server/agent-device-catalog.ts"; -import { adapterClient, upstreamCatalog } from "./agent-device-fixtures.ts"; +import { compactAgentDeviceTool } from "../src/agent-device-catalog.ts"; +import { adapterClient, upstreamCatalog } from "../../test-support/agent-device-fixtures.ts"; const tools = upstreamCatalog.tools.map(compactAgentDeviceTool); function tool(name: string) { @@ -51,15 +51,15 @@ test("device selection belongs to setup and discovery while interactions require assert.equal(schema.properties?.[option], undefined); } } - const configUrl = new URL("../runtimes/agent-device/config.json", import.meta.url); + const configUrl = new URL("../../runtimes/agent-device/config.json", import.meta.url); const text = await readFile(configUrl, "utf8"); const config = JSON.parse(text); assert.deepEqual(config, {}, "Global platform and form selectors must not override a named session"); }); test("packaged defaults do not bypass native session device binding", async () => { - const configUrl = new URL("../runtimes/agent-device/config.json", import.meta.url); - const cliConfigUrl = new URL("../runtimes/agent-device/node_modules/agent-device/dist/src/cli-config.js", import.meta.url); + const configUrl = new URL("../../runtimes/agent-device/config.json", import.meta.url); + const cliConfigUrl = new URL("../../runtimes/agent-device/node_modules/agent-device/dist/src/cli-config.js", import.meta.url); // These exports belong to the pinned 0.20.9 runtime; keep this check with its version update. const configuration = await import(cliConfigUrl.href); assert.equal(typeof configuration.t, "function"); diff --git a/tests/android-cpu.test.ts b/packages/core/test/android-cpu.test.ts similarity index 94% rename from tests/android-cpu.test.ts rename to packages/core/test/android-cpu.test.ts index 14572fc4..a136d587 100644 --- a/tests/android-cpu.test.ts +++ b/packages/core/test/android-cpu.test.ts @@ -6,13 +6,13 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { createHash } from "node:crypto"; -import { AndroidCpuSampler, androidSampleSchema } from "../src/server/cpu/android-counters.ts"; -import { startAndroidCpuMonitor, deployAndroidCollector, loadAndroidCollector } from "../src/server/cpu/android.ts"; -import { createCpuSessions } from "../src/server/cpu/sessions.ts"; -import type { CpuSessions } from "../src/server/cpu/sessions.ts"; -import { parseAndroidApps } from "../src/server/device-apps/apps.ts"; -import { cpuTargetSchema } from "../src/shared/cpu.ts"; -import type { CpuTarget } from "../src/shared/cpu.ts"; +import { AndroidCpuSampler, androidSampleSchema } from "../src/cpu/android-counters.ts"; +import { startAndroidCpuMonitor, deployAndroidCollector, loadAndroidCollector } from "../src/cpu/android.ts"; +import { createCpuSessions } from "../src/cpu/sessions.ts"; +import type { CpuSessions } from "../src/cpu/sessions.ts"; +import { parseAndroidApps } from "../src/device-apps/apps.ts"; +import { cpuTargetSchema } from "../../shared/src/cpu.ts"; +import type { CpuTarget } from "../../shared/src/cpu.ts"; const sample = (timestampUs = "1000000", processTicks = "100", threads = [ { tid: 123, start: "50", ticks: "60", name: "main" }, { tid: 124, start: "51", ticks: "40", name: "worker" }, @@ -101,7 +101,7 @@ test("closing during Android collector startup cancels and waits for the helper" test("Android deployment verifies ABI binaries and atomically replaces the executable", async () => { const commands: string[][] = []; - const root = new URL("../vendor/android-cpu/", import.meta.url); + const root = new URL("../../native/vendor/android-cpu/", import.meta.url); const collector = await loadAndroidCollector(root); const arm64 = collector.get("arm64-v8a"); assert.ok(arm64); @@ -145,7 +145,7 @@ test("Android CPU sessions reconnect after the original plugin cache is removed" } }); const original = join(temporary, "plugin-cache", "old-version", "dist", "android-cpu"); - await cp("vendor/android-cpu", original, { recursive: true }); + await cp("packages/native/vendor/android-cpu", original, { recursive: true }); const root = pathToFileURL(`${original}/`); const sessions = await createCpuSessions(root); cpu = sessions; @@ -217,7 +217,7 @@ test("collector startup verifies every ABI before retaining its bytes", async t const prefix = join(temporaryBase, "mobile-dev-cpu-integrity-test-"); const temporary = await mkdtemp(prefix); t.after(() => rm(temporary, { recursive: true, force: true })); - await cp("vendor/android-cpu", temporary, { recursive: true }); + await cp("packages/native/vendor/android-cpu", temporary, { recursive: true }); const binary = join(temporary, "x86", "mobile-dev-cpu"); await writeFile(binary, "corrupted"); const root = pathToFileURL(`${temporary}/`); @@ -226,7 +226,7 @@ test("collector startup verifies every ABI before retaining its bytes", async t }); test("failed Android deployment removes host staging and the partial device upload", async () => { - const root = new URL("../vendor/android-cpu/", import.meta.url); + const root = new URL("../../native/vendor/android-cpu/", import.meta.url); const collector = await loadAndroidCollector(root); const commands: string[][] = []; let stagedFile = ""; @@ -248,7 +248,7 @@ test("failed Android deployment removes host staging and the partial device uplo test("native /proc parser handles spaces, closing parentheses and newlines in thread names", async t => { const temporary = await mkdtemp(join(tmpdir(), "mobile-cpu-parser-")); t.after(() => rm(temporary, { recursive: true, force: true })); - const path = resolve("native/android-cpu/collector.c"); + const path = resolve("packages/native/android-cpu/collector.c"); const name = "worker ) name\n"; const fixture = `123 (${name}) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20`; const harness = `#define COLLECTOR_TEST\n#include ${JSON.stringify(path)}\n#include \nint main(void) { Counter c = {0}; char stat[] = ${JSON.stringify(fixture)}; assert(parse_stat(stat, &c) == 0); assert(c.ticks == 23); assert(c.start == 19); assert(strcmp(c.name, ${JSON.stringify(name)}) == 0); json_string(c.name); char bad[] = "123 (name) S 1"; assert(parse_stat(bad, &c) == -1); return 0; }\n`; @@ -265,7 +265,7 @@ test("native memory parser uses the device page size and rejects malformed or ov const prefix = join(temporaryBase, "mobile-memory-parser-"); const temporary = await mkdtemp(prefix); t.after(() => rm(temporary, { recursive: true, force: true })); - const sourcePath = resolve("native/android-cpu/collector.c"); + const sourcePath = resolve("packages/native/android-cpu/collector.c"); const include = JSON.stringify(sourcePath); const harness = `#define COLLECTOR_TEST #include ${include} diff --git a/tests/android-emulator.test.ts b/packages/core/test/android-emulator.test.ts similarity index 97% rename from tests/android-emulator.test.ts rename to packages/core/test/android-emulator.test.ts index 9e12b762..b18971a1 100644 --- a/tests/android-emulator.test.ts +++ b/packages/core/test/android-emulator.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import type { spawn } from "node:child_process"; -import { bootAndroidEmulator, parseAvdName } from "../src/server/android-emulator.ts"; +import { bootAndroidEmulator, parseAvdName } from "../src/android-emulator.ts"; const status = { connected: true, managed: false, baseUrl: "", devices: [] }; function fakeLauncher(fail?: "spawn" | "exit") { diff --git a/tests/android-shutdown.test.ts b/packages/core/test/android-shutdown.test.ts similarity index 97% rename from tests/android-shutdown.test.ts rename to packages/core/test/android-shutdown.test.ts index 735f7dce..4392b203 100644 --- a/tests/android-shutdown.test.ts +++ b/packages/core/test/android-shutdown.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ServeEmu } from "../src/server/serve-emu.ts"; +import { ServeEmu } from "../src/serve-emu.ts"; test("shutdown waits for adb removal, preserves the AVD name, and tolerates a repeat stop", async t => { const sdk = await mkdtemp(join(tmpdir(), "mobile-dev-shutdown-")); diff --git a/tests/android.test.ts b/packages/core/test/android.test.ts similarity index 96% rename from tests/android.test.ts rename to packages/core/test/android.test.ts index 02bca7a4..38d13d74 100644 --- a/tests/android.test.ts +++ b/packages/core/test/android.test.ts @@ -4,12 +4,12 @@ import { createServer } from "node:http"; import { WebSocketServer } from "ws"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { ServeEmu, androidDisplaySize } from "../src/server/serve-emu.ts"; -import { AndroidStreams, androidInput } from "../src/server/android-streams.ts"; -import { videoPacket } from "../src/ui/android-video.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { createTestPlugin, fakeBaguette, fakeSimulatorInput, PNG } from "./fixtures.ts"; -import { fakeInspectionSdk } from "./android-inspection-fixtures.ts"; +import { ServeEmu, androidDisplaySize } from "../src/serve-emu.ts"; +import { AndroidStreams, androidInput } from "../src/android-streams.ts"; +import { videoPacket } from "../../ui/src/android-video.ts"; +import { Baguette } from "../src/baguette.ts"; +import { createTestPlugin, fakeBaguette, fakeSimulatorInput, PNG } from "../../test-support/fixtures.ts"; +import { fakeInspectionSdk } from "../../test-support/android-inspection-fixtures.ts"; const ID = "emulator-5554"; const SCREEN = { width: 1080, height: 2400 }; @@ -152,7 +152,7 @@ test("Android streams relay video, validate batches, and refuse a device switch" test("Android MCP flow lists without booting, captures, streams, and closes", async t => { const fake = await fakeAndroid(); const ios = await fakeBaguette(); - const plugin = await createTestPlugin("", new Baguette(ios.url), fakeSimulatorInput(), undefined, fake.backend, async bytes => { assert.deepEqual(bytes, PNG); }); + const plugin = await createTestPlugin("", { baguette: new Baguette(ios.url), simulatorInput: fakeSimulatorInput(), android: fake.backend, copyScreenshot: async bytes => { assert.deepEqual(bytes, PNG); } }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "android-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); await ios.close(); }); diff --git a/tests/app-storage-redaction.test.ts b/packages/core/test/app-storage-redaction.test.ts similarity index 94% rename from tests/app-storage-redaction.test.ts rename to packages/core/test/app-storage-redaction.test.ts index 356c135f..0ac37010 100644 --- a/tests/app-storage-redaction.test.ts +++ b/packages/core/test/app-storage-redaction.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as Sentry from "@sentry/node"; import type { Envelope } from "@sentry/core"; -import { scrubMetric } from "../src/shared/telemetry.ts"; +import { scrubMetric } from "../../shared/src/telemetry.ts"; import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,14 +10,14 @@ import { execFileSync } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { REDACTED, redactStorageValue } from "../src/server/app-storage/redaction.ts"; -import { registerAppStorageTools } from "../src/server/app-storage/tools.ts"; -import { SimulatorContainer } from "../src/server/app-storage/container.ts"; -import type { AppContainer } from "../src/server/app-storage/container.ts"; -import { systemCommands } from "../src/server/app-control/commands.ts"; -import type { AppDriver } from "../src/server/app-control/driver.ts"; -import { ExpectedOperationError } from "../src/shared/error-reporting.ts"; -import { storageSnapshotSchema, storageTableSchema } from "../src/shared/app-storage.ts"; +import { REDACTED, redactStorageValue } from "../src/app-storage/redaction.ts"; +import { registerAppStorageTools } from "../src/app-storage/tools.ts"; +import { SimulatorContainer } from "../src/app-storage/container.ts"; +import type { AppContainer } from "../src/app-storage/container.ts"; +import { systemCommands } from "../src/app-control/commands.ts"; +import type { AppDriver } from "../src/app-control/driver.ts"; +import { ExpectedOperationError } from "../../shared/src/error-reporting.ts"; +import { storageSnapshotSchema, storageTableSchema } from "../../shared/src/app-storage.ts"; const target = { platform: "ios", deviceId: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE", bundleId: "com.example" }; const token = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"; diff --git a/tests/app-storage.test.ts b/packages/core/test/app-storage.test.ts similarity index 93% rename from tests/app-storage.test.ts rename to packages/core/test/app-storage.test.ts index 4d1de0ab..3640c6a3 100644 --- a/tests/app-storage.test.ts +++ b/packages/core/test/app-storage.test.ts @@ -6,18 +6,18 @@ import { join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { decodeMmkv, guessMmkvValue } from "../src/server/app-storage/mmkv.ts"; -import { classifyFiles, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries } from "../src/server/app-storage/formats.ts"; -import { clearStore, readStorage } from "../src/server/app-storage/reader.ts"; -import type { AppContainer } from "../src/server/app-storage/container.ts"; -import { runAsError } from "../src/server/app-storage/container.ts"; -import { registerAppStorageTools } from "../src/server/app-storage/tools.ts"; -import type { AppDriver } from "../src/server/app-control/driver.ts"; -import type { Commands } from "../src/server/app-control/commands.ts"; -import { storageSnapshotSchema } from "../src/shared/app-storage.ts"; -import { expectedOutcome } from "../src/shared/error-reporting.ts"; +import { decodeMmkv, guessMmkvValue } from "../src/app-storage/mmkv.ts"; +import { classifyFiles, parseDataStorePreferences, parsePlistXml, parseSharedPreferences, plistEntries } from "../src/app-storage/formats.ts"; +import { clearStore, readStorage } from "../src/app-storage/reader.ts"; +import type { AppContainer } from "../src/app-storage/container.ts"; +import { runAsError } from "../src/app-storage/container.ts"; +import { registerAppStorageTools } from "../src/app-storage/tools.ts"; +import type { AppDriver } from "../src/app-control/driver.ts"; +import type { Commands } from "../src/app-control/commands.ts"; +import { storageSnapshotSchema } from "../../shared/src/app-storage.ts"; +import { expectedOutcome } from "../../shared/src/error-reporting.ts"; -const fixtures = join(import.meta.dirname, "fixtures/app-storage/mmkv"); +const fixtures = join(import.meta.dirname, "../../test-support/fixtures/app-storage/mmkv"); const mmkv = async (id: string) => [await readFile(join(fixtures, id)), await readFile(join(fixtures, `${id}.crc`))] as const; test("MMKV files written by MMKV 2.4 decode with the last write winning and removals applied", async () => { @@ -211,8 +211,8 @@ test("storage tools stop the app before clearing and reset everything when no st }); test("the change feed records keys added, changed, and removed between reads, and labels SDK stores", async () => { - const { diffSnapshots } = await import("../src/ui/storage-panel.ts"); - const { changedFields, bytesAsText, isSdkStore, storeTitle } = await import("../src/ui/storage-labels.ts"); + const { diffSnapshots } = await import("../../ui/src/storage-panel.ts"); + const { changedFields, bytesAsText, isSdkStore, storeTitle } = await import("../../ui/src/storage-labels.ts"); const snapshot = (readAt: number, entries: [string, string][], entryCount = entries.length) => storageSnapshotSchema.parse({ platform: "ios", bundleId: "com.example", readAt, files: [], fileCount: 0, totalBytes: 0, stores: [{ id: "mmkv:a", kind: "mmkv", name: "a", path: "a", status: "ok", bytes: 0, entryCount, entries: entries.map(([key, value]) => ({ key, type: "string", value, bytes: value.length })) }] }); const first = snapshot(1, [["kept", "1"], ["edited", "1"], ["dropped", "x"]]); @@ -234,7 +234,7 @@ test("the change feed records keys added, changed, and removed between reads, an test("simulator resets empty the container but keep its layout, metadata, and cfprefsd in sync", async () => { const { mkdtemp, mkdir, writeFile, readdir, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); - const { SimulatorContainer } = await import("../src/server/app-storage/container.ts"); + const { SimulatorContainer } = await import("../src/app-storage/container.ts"); const root = await mkdtemp(join(tmpdir(), "mobile-dev-storage-")); try { for (const directory of ["Documents/mmkv", "Library/Preferences", "Library/Caches/images", "tmp", "SystemData"]) await mkdir(join(root, directory), { recursive: true }); @@ -252,7 +252,7 @@ test("simulator resets empty the container but keep its layout, metadata, and cf }); test("the change feed hides SDK changes until the toggle shows them", async () => { - const { StoragePanel, shownChanges } = await import("../src/ui/storage-panel.ts"); + const { StoragePanel, shownChanges } = await import("../../ui/src/storage-panel.ts"); const deviceApps = { subscribe: () => () => {}, getSnapshot: () => ({ device: undefined }) }; const panel = new StoragePanel({} as never, deviceApps as never); const store = { id: "s", kind: "user-defaults" as const, name: "n", path: "p" }; @@ -270,9 +270,9 @@ test("database tables are read page by page with blobs redacted, and unknown tab const { mkdtemp, mkdir, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { execFileSync } = await import("node:child_process"); - const { SimulatorContainer } = await import("../src/server/app-storage/container.ts"); - const { readTable } = await import("../src/server/app-storage/reader.ts"); - const { systemCommands } = await import("../src/server/app-control/commands.ts"); + const { SimulatorContainer } = await import("../src/app-storage/container.ts"); + const { readTable } = await import("../src/app-storage/reader.ts"); + const { systemCommands } = await import("../src/app-control/commands.ts"); const root = await mkdtemp(join(tmpdir(), "mobile-dev-table-")); try { await mkdir(join(root, "Documents"), { recursive: true }); @@ -294,7 +294,7 @@ test("database tables are read page by page with blobs redacted, and unknown tab }); test("XML parsers skip comments, declarations, and doctypes, including ones that hide elements", async () => { - const { parsePlistXml, parseSharedPreferences } = await import("../src/server/app-storage/formats.ts"); + const { parsePlistXml, parseSharedPreferences } = await import("../src/app-storage/formats.ts"); const plist = ` keptyes`; assert.deepEqual(parsePlistXml(plist), { kept: "yes" }); @@ -304,7 +304,7 @@ test("XML parsers skip comments, declarations, and doctypes, including ones that }); test("Expo's internal preferences count as SDK data and AsyncStorage's Android database is titled like iOS", async () => { - const { isSdkStore, storeTitle } = await import("../src/ui/storage-labels.ts"); + const { isSdkStore, storeTitle } = await import("../../ui/src/storage-labels.ts"); assert.equal(isSdkStore({ name: "expo.modules.kotlin.PersistentDataManager", path: "shared_prefs/expo.modules.kotlin.PersistentDataManager.xml" }), true); assert.equal(isSdkStore({ name: "dev.mobile.storagetest_preferences", path: "shared_prefs/dev.mobile.storagetest_preferences.xml" }), false); assert.equal(storeTitle({ kind: "async-storage", name: "RKStorage" }, "dev.mobile.storagetest"), "default"); diff --git a/tests/baguette-runtime.test.ts b/packages/core/test/baguette-runtime.test.ts similarity index 97% rename from tests/baguette-runtime.test.ts rename to packages/core/test/baguette-runtime.test.ts index 300f21c2..d63c1289 100644 --- a/tests/baguette-runtime.test.ts +++ b/packages/core/test/baguette-runtime.test.ts @@ -5,8 +5,8 @@ import { chmod, copyFile, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { baguetteEnvironment } from "../src/server/baguette-runtime.ts"; -import { removeBaguetteToolchainRpaths } from "../scripts/baguette-rpaths.mjs"; +import { baguetteEnvironment } from "../src/baguette-runtime.ts"; +import { removeBaguetteToolchainRpaths } from "../../../tooling/baguette-rpaths.mjs"; const execute = promisify(execFile); const spanLoadCommands = `Load command 1 @@ -157,7 +157,7 @@ test("a relocated Baguette loads the selected Xcode library after its build-tool const root = await mkdtemp(prefix); t.after(() => rm(root, { recursive: true, force: true })); const binary = join(root, "Baguette"); - await copyFile("vendor/baguette/Baguette", binary); + await copyFile("packages/native/vendor/baguette/Baguette", binary); await removeBaguetteToolchainRpaths(binary); const commands = await execute("otool", ["-l", binary]); const blocks = commands.stdout.split("Load command"); diff --git a/tests/bezel.test.ts b/packages/core/test/bezel.test.ts similarity index 91% rename from tests/bezel.test.ts rename to packages/core/test/bezel.test.ts index f6516308..28385613 100644 --- a/tests/bezel.test.ts +++ b/packages/core/test/bezel.test.ts @@ -1,11 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; -import { Baguette } from "../src/server/baguette.ts"; -import { compositeBezelGeometry } from "../src/shared/bezel.ts"; -import { definitionSchema } from "../src/server/baguette.ts"; -import { readBezel } from "../src/server/bezel.ts"; -import { UDID, PNG } from "./fixtures.ts"; +import { Baguette } from "../src/baguette.ts"; +import { compositeBezelGeometry } from "../../shared/src/bezel.ts"; +import { definitionSchema } from "../src/baguette.ts"; +import { readBezel } from "../src/bezel.ts"; +import { UDID, PNG } from "../../test-support/fixtures.ts"; const geometry = { rect: { x: 18, y: 18, width: 400, height: 872 }, viewport: { width: 436, height: 908 }, clipRadius: 62 }; diff --git a/tests/cpu-counters.test.ts b/packages/core/test/cpu-counters.test.ts similarity index 98% rename from tests/cpu-counters.test.ts rename to packages/core/test/cpu-counters.test.ts index a8713449..3680d76a 100644 --- a/tests/cpu-counters.test.ts +++ b/packages/core/test/cpu-counters.test.ts @@ -1,6 +1,6 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; -import { CpuCounterSampler, parseCpuCounters } from "../src/server/cpu/counters.ts"; +import { CpuCounterSampler, parseCpuCounters } from "../src/cpu/counters.ts"; const profile = (time: number, retired: number, threads: string) => `elapsed_usec:${time};task_used_usec:${retired};phys_footprint:104857600;${threads}`; diff --git a/tests/cpu-foreground.test.ts b/packages/core/test/cpu-foreground.test.ts similarity index 97% rename from tests/cpu-foreground.test.ts rename to packages/core/test/cpu-foreground.test.ts index e2a6eb9e..85a8f219 100644 --- a/tests/cpu-foreground.test.ts +++ b/packages/core/test/cpu-foreground.test.ts @@ -4,9 +4,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { foregroundPhysicalPid, foregroundSimulatorPid, foregroundAndroidApp, parseAndroidForegroundPackage } from "../src/server/device-apps/foreground.ts"; -import { readDeviceApps } from "../src/server/device-apps/sources.ts"; -import type { DeviceApp } from "../src/shared/device-apps.ts"; +import { foregroundPhysicalPid, foregroundSimulatorPid, foregroundAndroidApp, parseAndroidForegroundPackage } from "../src/device-apps/foreground.ts"; +import { readDeviceApps } from "../src/device-apps/sources.ts"; +import type { DeviceApp } from "../../shared/src/device-apps.ts"; const udid = "00008150-001068280AE8C01C"; diff --git a/tests/cpu-gdb.test.ts b/packages/core/test/cpu-gdb.test.ts similarity index 98% rename from tests/cpu-gdb.test.ts rename to packages/core/test/cpu-gdb.test.ts index 88deb91a..61a577ee 100644 --- a/tests/cpu-gdb.test.ts +++ b/packages/core/test/cpu-gdb.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { encodePacket, GdbPacketDecoder, ProfileAssembler } from "../src/server/cpu/gdb.ts"; +import { encodePacket, GdbPacketDecoder, ProfileAssembler } from "../src/cpu/gdb.ts"; test("handles packets split at every TCP byte boundary and acknowledgements", () => { const decoder = new GdbPacketDecoder(); diff --git a/tests/cpu-monitor.test.ts b/packages/core/test/cpu-monitor.test.ts similarity index 97% rename from tests/cpu-monitor.test.ts rename to packages/core/test/cpu-monitor.test.ts index d875d988..12ddb03c 100644 --- a/tests/cpu-monitor.test.ts +++ b/packages/core/test/cpu-monitor.test.ts @@ -1,9 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer, type Socket } from "node:net"; -import { GdbPacketDecoder, encodePacket } from "../src/server/cpu/gdb.ts"; -import { startIosCpuMonitor } from "../src/server/cpu/monitor.ts"; -import type { CpuReading } from "../src/server/cpu/counters.ts"; +import { GdbPacketDecoder, encodePacket } from "../src/cpu/gdb.ts"; +import { startIosCpuMonitor } from "../src/cpu/monitor.ts"; +import type { CpuReading } from "../src/cpu/counters.ts"; import { createConnection } from "node:net"; async function connectSocket(host: string, port: number, signal: AbortSignal): Promise { diff --git a/tests/cpu-physical.test.ts b/packages/core/test/cpu-physical.test.ts similarity index 94% rename from tests/cpu-physical.test.ts rename to packages/core/test/cpu-physical.test.ts index af3b4aa7..f6890e3b 100644 --- a/tests/cpu-physical.test.ts +++ b/packages/core/test/cpu-physical.test.ts @@ -10,15 +10,15 @@ import { setImmediate } from "node:timers/promises"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { cpuDeviceSchema } from "../src/shared/cpu.ts"; -import type { CpuTarget } from "../src/shared/cpu.ts"; -import { parsePhysicalApps, runningPhysicalApps } from "../src/server/device-apps/apps.ts"; -import { openPhysicalDebugserver } from "../src/server/cpu/physical.ts"; -import { CpuSessions } from "../src/server/cpu/sessions.ts"; -import { registerCpuTools } from "../src/server/cpu/tools.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { fakeBaguette } from "./fixtures.ts"; -import type { PhysicalIosDevice } from "../src/shared/ios-devices.ts"; +import { cpuDeviceSchema } from "../../shared/src/cpu.ts"; +import type { CpuTarget } from "../../shared/src/cpu.ts"; +import { parsePhysicalApps, runningPhysicalApps } from "../src/device-apps/apps.ts"; +import { openPhysicalDebugserver } from "../src/cpu/physical.ts"; +import { CpuSessions } from "../src/cpu/sessions.ts"; +import { registerCpuTools } from "../src/cpu/tools.ts"; +import { Baguette } from "../src/baguette.ts"; +import { fakeBaguette } from "../../test-support/fixtures.ts"; +import type { PhysicalIosDevice } from "../../shared/src/ios-devices.ts"; const udid = "00008150-001068280AE8C01C"; const target: CpuTarget = { platform: "ios", kind: "physical", deviceId: udid, bundleId: "com.example.dev" }; diff --git a/tests/cpu-sessions.test.ts b/packages/core/test/cpu-sessions.test.ts similarity index 91% rename from tests/cpu-sessions.test.ts rename to packages/core/test/cpu-sessions.test.ts index 045def14..0454a34c 100644 --- a/tests/cpu-sessions.test.ts +++ b/packages/core/test/cpu-sessions.test.ts @@ -1,12 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setImmediate } from "node:timers/promises"; -import { CpuSessions } from "../src/server/cpu/sessions.ts"; -import { CpuBuffer } from "../src/server/cpu/buffer.ts"; -import { parseRunningApps } from "../src/server/device-apps/apps.ts"; -import type { CpuMonitor } from "../src/server/cpu/monitor.ts"; -import type { CpuReading } from "../src/server/cpu/counters.ts"; -import { UDID } from "./fixtures.ts"; +import { CpuSessions } from "../src/cpu/sessions.ts"; +import { CpuBuffer } from "../src/cpu/buffer.ts"; +import { parseRunningApps } from "../src/device-apps/apps.ts"; +import type { CpuMonitor } from "../src/cpu/monitor.ts"; +import type { CpuReading } from "../src/cpu/counters.ts"; +import { UDID } from "../../test-support/fixtures.ts"; const target = { deviceId: UDID, bundleId: "com.example.app" }; const pending = () => new Promise(() => {}); diff --git a/tests/cpu-tools.test.ts b/packages/core/test/cpu-tools.test.ts similarity index 97% rename from tests/cpu-tools.test.ts rename to packages/core/test/cpu-tools.test.ts index e71e6719..239dc354 100644 --- a/tests/cpu-tools.test.ts +++ b/packages/core/test/cpu-tools.test.ts @@ -4,10 +4,10 @@ import { setImmediate } from "node:timers/promises"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { CpuSessions } from "../src/server/cpu/sessions.ts"; -import { registerCpuTools } from "../src/server/cpu/tools.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { fakeBaguette, UDID } from "./fixtures.ts"; +import { CpuSessions } from "../src/cpu/sessions.ts"; +import { registerCpuTools } from "../src/cpu/tools.ts"; +import { Baguette } from "../src/baguette.ts"; +import { fakeBaguette, UDID } from "../../test-support/fixtures.ts"; function textData(result: { content?: unknown }) { assert.ok(Array.isArray(result.content)); diff --git a/tests/device-choice-tools.test.ts b/packages/core/test/device-choice-tools.test.ts similarity index 97% rename from tests/device-choice-tools.test.ts rename to packages/core/test/device-choice-tools.test.ts index 2c8199bf..b077704e 100644 --- a/tests/device-choice-tools.test.ts +++ b/packages/core/test/device-choice-tools.test.ts @@ -6,10 +6,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { OpenAIExtensions, OpenAIFormSchema } from "@openai/mcp-extensions/server"; import type { OpenAIFormResult } from "@openai/mcp-extensions/server"; -import { deviceChoiceForm, registerDeviceChoiceTools, resolveDeviceChoices } from "../src/server/device-choice-tools.ts"; -import type { DeviceChoiceSources } from "../src/server/device-choice-tools.ts"; -import { deviceChoiceInputSchema } from "../src/shared/device-choice.ts"; -import type { Status } from "../src/shared/protocol.ts"; +import { deviceChoiceForm, registerDeviceChoiceTools, resolveDeviceChoices } from "../src/device-choice-tools.ts"; +import type { DeviceChoiceSources } from "../src/device-choice-tools.ts"; +import { deviceChoiceInputSchema } from "../../shared/src/device-choice.ts"; +import type { Status } from "../../shared/src/protocol.ts"; const simulatorId = "11111111-1111-4111-8111-111111111111"; const input = { diff --git a/tests/device-settings.test.ts b/packages/core/test/device-settings.test.ts similarity index 97% rename from tests/device-settings.test.ts rename to packages/core/test/device-settings.test.ts index 6cbb0f83..18427447 100644 --- a/tests/device-settings.test.ts +++ b/packages/core/test/device-settings.test.ts @@ -1,11 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { DeviceSettingsService } from "../src/server/device-settings.ts"; -import { DeviceSettingsStore } from "../src/ui/device-settings.ts"; -import type { Baguette } from "../src/server/baguette.ts"; -import type { ServeEmu } from "../src/server/serve-emu.ts"; -import type { AndroidNetworkThrottle, ThrottleReading } from "../src/server/network-throttle/android.ts"; -import { deviceSettingSchema } from "../src/shared/device-settings.ts"; +import { DeviceSettingsService } from "../src/device-settings.ts"; +import { DeviceSettingsStore } from "../../ui/src/device-settings.ts"; +import type { Baguette } from "../src/baguette.ts"; +import type { ServeEmu } from "../src/serve-emu.ts"; +import type { AndroidNetworkThrottle, ThrottleReading } from "../src/network-throttle/android.ts"; +import { deviceSettingSchema } from "../../shared/src/device-settings.ts"; const ios = { platform: "ios", id: "B5C969F6-58A4-4C31-AB12-FB9E56D681DE" } as const; const android = { platform: "android", id: "emulator-5554" } as const; diff --git a/tests/http-runtime.test.ts b/packages/core/test/http-runtime.test.ts similarity index 95% rename from tests/http-runtime.test.ts rename to packages/core/test/http-runtime.test.ts index d4227ef8..1b11fef8 100644 --- a/tests/http-runtime.test.ts +++ b/packages/core/test/http-runtime.test.ts @@ -111,8 +111,8 @@ async function probe(moduleUrl: URL, closeExport: string) { } test("server startup prevents socket QoS crashes and preserves fetch behavior and shutdown", async () => { - const instrument = new URL("../src/server/instrument.ts", import.meta.url); - const telemetry = new URL("../src/server/telemetry.ts", import.meta.url); + const instrument = new URL("../src/instrument.ts", import.meta.url); + const telemetry = new URL("../src/telemetry.ts", import.meta.url); // The startup import selects the dispatcher before any outgoing request. const contents = `import ${JSON.stringify(instrument.href)}; export { closeServerTelemetry } from ${JSON.stringify(telemetry.href)};`; const encoded = encodeURIComponent(contents); @@ -125,7 +125,7 @@ test("the bundled dispatcher works without dependencies beside the generated mod const prefix = join(temporary, "mobile-dev-http-runtime-"); const directory = await mkdtemp(prefix); t.after(() => rm(directory, { recursive: true, force: true })); - const sourceUrl = new URL("../src/server/http-runtime.ts", import.meta.url); + const sourceUrl = new URL("../src/http-runtime.ts", import.meta.url); const source = fileURLToPath(sourceUrl); const outfile = join(directory, "runtime.mjs"); await build({ diff --git a/tests/ios-devices.test.ts b/packages/core/test/ios-devices.test.ts similarity index 98% rename from tests/ios-devices.test.ts rename to packages/core/test/ios-devices.test.ts index 3f3cf88a..ec24497c 100644 --- a/tests/ios-devices.test.ts +++ b/packages/core/test/ios-devices.test.ts @@ -4,8 +4,8 @@ import { execFile } from "node:child_process"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { listIosDevices, registerIosDeviceTools } from "../src/server/ios-devices.ts"; -import { physicalIosDeviceSchema, physicalConnectionLabel } from "../src/shared/ios-devices.ts"; +import { listIosDevices, registerIosDeviceTools } from "../src/ios-devices.ts"; +import { physicalIosDeviceSchema, physicalConnectionLabel } from "../../shared/src/ios-devices.ts"; const coreDeviceId = "11111111-1111-4111-8111-111111111111"; const udid = "00008110-000A0B1C2D3E4000"; diff --git a/tests/ios-lifecycle.test.ts b/packages/core/test/ios-lifecycle.test.ts similarity index 97% rename from tests/ios-lifecycle.test.ts rename to packages/core/test/ios-lifecycle.test.ts index 2b17d449..5a8d66dd 100644 --- a/tests/ios-lifecycle.test.ts +++ b/packages/core/test/ios-lifecycle.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { Baguette } from "../src/server/baguette.ts"; -import { fakeBaguette, UDID } from "./fixtures.ts"; +import { Baguette } from "../src/baguette.ts"; +import { fakeBaguette, UDID } from "../../test-support/fixtures.ts"; test("booting a running simulator skips the route that can repair input", async t => { const fake = await fakeBaguette(); diff --git a/tests/ios-log-filter.test.ts b/packages/core/test/ios-log-filter.test.ts similarity index 97% rename from tests/ios-log-filter.test.ts rename to packages/core/test/ios-log-filter.test.ts index 0b58ba72..3f88d213 100644 --- a/tests/ios-log-filter.test.ts +++ b/packages/core/test/ios-log-filter.test.ts @@ -3,11 +3,11 @@ import assert from "node:assert/strict"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { parseIOSLog, parseLogcat, parseMetroEvent } from "../src/server/log-parsers.ts"; -import { startNativeLogs } from "../src/server/native-logs.ts"; -import { nativeLogTargetSchema } from "../src/shared/logs.ts"; -import type { LogRecord, NativeLogTarget } from "../src/shared/logs.ts"; -import { UDID } from "./fixtures.ts"; +import { parseIOSLog, parseLogcat, parseMetroEvent } from "../src/log-parsers.ts"; +import { startNativeLogs } from "../src/native-logs.ts"; +import { nativeLogTargetSchema } from "../../shared/src/logs.ts"; +import type { LogRecord, NativeLogTarget } from "../../shared/src/logs.ts"; +import { UDID } from "../../test-support/fixtures.ts"; function parse(fields: Record, hideSystemLogs = true) { const line = JSON.stringify({ eventMessage: "fixture", ...fields }); diff --git a/tests/ios-mirror-hub.test.ts b/packages/core/test/ios-mirror-hub.test.ts similarity index 98% rename from tests/ios-mirror-hub.test.ts rename to packages/core/test/ios-mirror-hub.test.ts index 80f256ab..32986d04 100644 --- a/tests/ios-mirror-hub.test.ts +++ b/packages/core/test/ios-mirror-hub.test.ts @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setImmediate as tick } from "node:timers/promises"; -import { IosMirrorHub } from "../src/server/ios-mirror-hub.ts"; -import type { NativeBatch, NativeCapture, NativeTouchSample } from "../src/server/ios-native-capture.ts"; +import { IosMirrorHub } from "../src/ios-mirror-hub.ts"; +import type { NativeBatch, NativeCapture, NativeTouchSample } from "../src/ios-native-capture.ts"; function fixture() { let opens = 0; diff --git a/tests/ios-mirror-rpc.test.ts b/packages/core/test/ios-mirror-rpc.test.ts similarity index 92% rename from tests/ios-mirror-rpc.test.ts rename to packages/core/test/ios-mirror-rpc.test.ts index 9e5be209..c209ed44 100644 --- a/tests/ios-mirror-rpc.test.ts +++ b/packages/core/test/ios-mirror-rpc.test.ts @@ -7,10 +7,10 @@ import { createConnection } from "node:net"; import { fileURLToPath } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; import { randomUUID } from "node:crypto"; -import { IosMirrorHub } from "../src/server/ios-mirror-hub.ts"; -import { createIosMirrorServer } from "../src/server/ios-mirror-server.ts"; -import { connectIosCapture } from "../src/server/ios-mirror-client.ts"; -import type { NativeCapture } from "../src/server/ios-native-capture.ts"; +import { IosMirrorHub } from "../src/ios-mirror-hub.ts"; +import { createIosMirrorServer } from "../src/ios-mirror-server.ts"; +import { connectIosCapture } from "../src/ios-mirror-client.ts"; +import type { NativeCapture } from "../src/ios-native-capture.ts"; async function message(child: ChildProcess) { const result = await once(child, "message", { signal: AbortSignal.timeout(5000) }); @@ -38,7 +38,7 @@ test("independent client processes share capture and a crashed client leaves its service.server.listen(path); await once(service.server, "listening"); t.after(() => service.close()); - const worker = fileURLToPath(new URL("./fixtures/ios-mirror-client.ts", import.meta.url)); + const worker = fileURLToPath(new URL("../../test-support/fixtures/ios-mirror-client.ts", import.meta.url)); const a = fork(worker, [path], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); const b = fork(worker, [path], { stdio: ["ignore", "ignore", "pipe", "ipc"] }); t.after(() => { a.kill(); b.kill(); }); diff --git a/tests/ios-mirror-tools.test.ts b/packages/core/test/ios-mirror-tools.test.ts similarity index 97% rename from tests/ios-mirror-tools.test.ts rename to packages/core/test/ios-mirror-tools.test.ts index 085ebe07..41e6a8cb 100644 --- a/tests/ios-mirror-tools.test.ts +++ b/packages/core/test/ios-mirror-tools.test.ts @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { registerIosMirrorTools } from "../src/server/ios-mirror-tools.ts"; -import { IosMirrorSessions } from "../src/server/ios-mirror.ts"; -import type { PhysicalIosDevice } from "../src/shared/ios-devices.ts"; -import type { Bezel } from "../src/shared/bezel.ts"; +import { registerIosMirrorTools } from "../src/ios-mirror-tools.ts"; +import { IosMirrorSessions } from "../src/ios-mirror.ts"; +import type { PhysicalIosDevice } from "../../shared/src/ios-devices.ts"; +import type { Bezel } from "../../shared/src/bezel.ts"; const screenshotBytes = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); diff --git a/tests/ios-mirror.test.ts b/packages/core/test/ios-mirror.test.ts similarity index 96% rename from tests/ios-mirror.test.ts rename to packages/core/test/ios-mirror.test.ts index 99add3df..d1721684 100644 --- a/tests/ios-mirror.test.ts +++ b/packages/core/test/ios-mirror.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { IosMirrorSessions } from "../src/server/ios-mirror.ts"; -import type { IosCapture, NativeTouchSample } from "../src/server/ios-native-capture.ts"; +import { IosMirrorSessions } from "../src/ios-mirror.ts"; +import type { IosCapture, NativeTouchSample } from "../src/ios-native-capture.ts"; function fixture() { let closed = 0; diff --git a/tests/logs.test.ts b/packages/core/test/logs.test.ts similarity index 92% rename from tests/logs.test.ts rename to packages/core/test/logs.test.ts index 64d45f8f..3841f69e 100644 --- a/tests/logs.test.ts +++ b/packages/core/test/logs.test.ts @@ -5,16 +5,16 @@ import { once } from "node:events"; import { WebSocketServer } from "ws"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { parseIOSLog, parseLogcat, parseMetroEvent } from "../src/server/log-parsers.ts"; -import { LogBuffer } from "../src/server/log-buffer.ts"; -import { LogSessions } from "../src/server/log-sessions.ts"; -import { metroTargets, startMetroLogs } from "../src/server/metro-logs.ts"; -import { runLogProcess } from "../src/server/native-logs.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { stackLogs, formatLogContext, logOptionsSchema } from "../src/shared/logs.ts"; -import type { LogEntry, LogRecord } from "../src/shared/logs.ts"; -import type { LogSink } from "../src/server/native-logs.ts"; -import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID, OTHER_UDID } from "./fixtures.ts"; +import { parseIOSLog, parseLogcat, parseMetroEvent } from "../src/log-parsers.ts"; +import { LogBuffer } from "../src/log-buffer.ts"; +import { LogSessions } from "../src/log-sessions.ts"; +import { metroTargets, startMetroLogs } from "../src/metro-logs.ts"; +import { runLogProcess } from "../src/native-logs.ts"; +import { Baguette } from "../src/baguette.ts"; +import { stackLogs, formatLogContext, logOptionsSchema } from "../../shared/src/logs.ts"; +import type { LogEntry, LogRecord } from "../../shared/src/logs.ts"; +import type { LogSink } from "../src/native-logs.ts"; +import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID, OTHER_UDID } from "../../test-support/fixtures.ts"; const record: LogRecord = { timestamp: "2026-09-30T12:00:00.000Z", level: "warn", source: "js", origin: "ios", process: "Example", message: "Request failed" }; @@ -133,7 +133,7 @@ test("Metro connects only the chosen target, enables Runtime, and cleans up its test("MCP log sessions validate devices and expose batches without browser network access", async t => { const fake = await fakeBaguette(); let sink: LogSink | undefined; let stopped = false; const logs = new LogSessions({ native: (_target, next) => { sink = next; return async () => { stopped = true; }; }, metro: () => async () => {} }); - const plugin = await createTestPlugin("Logs", new Baguette(fake.url), fakeSimulatorInput(), logs); + const plugin = await createTestPlugin("Logs", { baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput(), logs }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "logs-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); }); await plugin.server.connect(serverTransport); await client.connect(clientTransport); @@ -141,9 +141,11 @@ test("MCP log sessions validate devices and expose batches without browser netwo assert.equal(invalid.isError, true); assert.equal(sink, undefined); const opened = await client.callTool({ name: "mobile_logs_session", arguments: { options: { native: { platform: "ios", deviceId: UDID, process: "Example" } } } }); assert.equal(opened.isError, undefined); sink!.log(record); + assert.equal(opened.structuredContent?.sessionId, opened._meta?.sessionId); + assert.equal(JSON.parse((opened.content as Array<{ text: string }>)[0].text).sessionId, opened.structuredContent?.sessionId); const batch = await client.readResource({ uri: opened._meta?.logsUri as string }); assert.equal(JSON.parse(batch.contents[0].text as string).entries[0].message, record.message); - const next = await client.callTool({ name: "mobile_read_logs", arguments: { sessionId: opened._meta?.sessionId, after: 1 } }); + const next = await client.callTool({ name: "mobile_read_logs", arguments: { sessionId: opened.structuredContent?.sessionId, after: 1 } }); assert.deepEqual(next.structuredContent?.entries, []); const keepAlive = await client.callTool({ name: "mobile_logs_keep_alive", arguments: { sessionId: opened._meta?.sessionId } }); assert.equal(keepAlive.isError, undefined); diff --git a/tests/native-logs.test.ts b/packages/core/test/native-logs.test.ts similarity index 96% rename from tests/native-logs.test.ts rename to packages/core/test/native-logs.test.ts index d647a5de..5c6684fa 100644 --- a/tests/native-logs.test.ts +++ b/packages/core/test/native-logs.test.ts @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { startNativeLogs } from "../src/server/native-logs.ts"; -import type { LogRecord } from "../src/shared/logs.ts"; -import { UDID } from "./fixtures.ts"; +import { startNativeLogs } from "../src/native-logs.ts"; +import type { LogRecord } from "../../shared/src/logs.ts"; +import { UDID } from "../../test-support/fixtures.ts"; test("simulator foreground logs select the PID upstream and retain only that process", { skip: process.platform !== "darwin" }, async t => { const directory = await mkdtemp(join(tmpdir(), "mobile-dev-log-pid-")); diff --git a/tests/native-storage.test.ts b/packages/core/test/native-storage.test.ts similarity index 93% rename from tests/native-storage.test.ts rename to packages/core/test/native-storage.test.ts index f8022aee..1998ae98 100644 --- a/tests/native-storage.test.ts +++ b/packages/core/test/native-storage.test.ts @@ -4,15 +4,15 @@ import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { classifyFiles, protobufEntries } from "../src/server/app-storage/formats.ts"; -import { readStorage } from "../src/server/app-storage/reader.ts"; -import { previewFile, unarchive } from "../src/server/app-storage/preview.ts"; -import { SimulatorContainer } from "../src/server/app-storage/container.ts"; -import type { AppContainer } from "../src/server/app-storage/container.ts"; -import { systemCommands } from "../src/server/app-control/commands.ts"; -import { expectedOutcome } from "../src/shared/error-reporting.ts"; +import { classifyFiles, protobufEntries } from "../src/app-storage/formats.ts"; +import { readStorage } from "../src/app-storage/reader.ts"; +import { previewFile, unarchive } from "../src/app-storage/preview.ts"; +import { SimulatorContainer } from "../src/app-storage/container.ts"; +import type { AppContainer } from "../src/app-storage/container.ts"; +import { systemCommands } from "../src/app-control/commands.ts"; +import { expectedOutcome } from "../../shared/src/error-reporting.ts"; -const fixture = (name: string) => readFile(new URL(`./fixtures/app-storage/native/${name}`, import.meta.url)); +const fixture = (name: string) => readFile(new URL(`../../test-support/fixtures/app-storage/native/${name}`, import.meta.url)); const udid = "B5C969F6-58A4-4C31-AB12-FB9E56D681DE"; const preview = { maxBytes: 1024 * 1024, maxLength: 10000, run: systemCommands.run }; @@ -142,7 +142,7 @@ test("SQLite files with any name list every table's rows and columns in one read }); test("a slower earlier table page never replaces the page opened after it", async () => { - const { StoragePanel } = await import("../src/ui/storage-panel.ts"); + const { StoragePanel } = await import("../../ui/src/storage-panel.ts"); const pending: { offset: number; resolve: (result: unknown) => void }[] = []; const app = { callServerTool: ({ arguments: args }: { arguments: { offset: number } }) => new Promise(resolve => pending.push({ offset: args.offset, resolve })) }; const device = { platform: "ios", udid, name: "iPhone", state: "Booted", kind: "simulator" }; diff --git a/tests/native-telemetry.test.ts b/packages/core/test/native-telemetry.test.ts similarity index 94% rename from tests/native-telemetry.test.ts rename to packages/core/test/native-telemetry.test.ts index 7fa5585d..6c8fd171 100644 --- a/tests/native-telemetry.test.ts +++ b/packages/core/test/native-telemetry.test.ts @@ -1,9 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -import { NativeTelemetryRelay, nativeCollectorCommand, parseNativeEnvelope } from "../src/server/native-telemetry.ts"; -import { SENTRY_NATIVE_DSN, SENTRY_RELEASE } from "../src/shared/telemetry.ts"; -import { getTelemetryIdentity } from "../src/server/telemetry-identity.ts"; +import { NativeTelemetryRelay, nativeCollectorCommand, parseNativeEnvelope } from "../src/native-telemetry.ts"; +import { SENTRY_NATIVE_DSN, SENTRY_RELEASE } from "../../shared/src/telemetry.ts"; +import { getTelemetryIdentity } from "../src/telemetry-identity.ts"; function encode(type: string, payload: unknown, dsn = SENTRY_NATIVE_DSN) { const json = JSON.stringify(payload); @@ -91,7 +91,7 @@ test("Android launch commands propagate release, environment, anonymous identity }); test("the C SDK and the host relay use the same native project", async () => { - const sourceUrl = new URL("../native/telemetry/telemetry.c", import.meta.url); + const sourceUrl = new URL("../../native/telemetry/telemetry.c", import.meta.url); const source = await readFile(sourceUrl, "utf8"); assert.ok(source.includes(SENTRY_NATIVE_DSN)); }); diff --git a/tests/network-throttle.test.ts b/packages/core/test/network-throttle.test.ts similarity index 98% rename from tests/network-throttle.test.ts rename to packages/core/test/network-throttle.test.ts index ca028b48..ca0c55d8 100644 --- a/tests/network-throttle.test.ts +++ b/packages/core/test/network-throttle.test.ts @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { createServer, request, type Server } from "node:http"; import { connect, createServer as createNetServer } from "node:net"; import { PassThrough } from "node:stream"; -import { LinkPacer, ThrottleProxy, isLocalDestination, relay } from "../src/server/network-throttle/proxy.ts"; -import { AndroidNetworkThrottle, parseThrottleState } from "../src/server/network-throttle/android.ts"; -import type { Commands } from "../src/server/app-control/commands.ts"; -import { expectedOutcome } from "../src/shared/error-reporting.ts"; +import { LinkPacer, ThrottleProxy, isLocalDestination, relay } from "../src/network-throttle/proxy.ts"; +import { AndroidNetworkThrottle, parseThrottleState } from "../src/network-throttle/android.ts"; +import type { Commands } from "../src/app-control/commands.ts"; +import { expectedOutcome } from "../../shared/src/error-reporting.ts"; const marker = "mobile_dev_network_throttle"; diff --git a/tests/performance-recordings.test.ts b/packages/core/test/performance-recordings.test.ts similarity index 97% rename from tests/performance-recordings.test.ts rename to packages/core/test/performance-recordings.test.ts index 22102bbe..a23c37c3 100644 --- a/tests/performance-recordings.test.ts +++ b/packages/core/test/performance-recordings.test.ts @@ -4,11 +4,11 @@ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout } from "node:timers/promises"; -import { DisplayFpsSessions } from "../src/server/fps/sessions.ts"; -import { CpuSessions } from "../src/server/cpu/sessions.ts"; -import { PerformanceRecordings, RecordingStore } from "../src/server/performance-recordings.ts"; -import { summarizeRecording } from "../src/shared/recordings.ts"; -import { recordingFixture, unavailableFps } from "./recording-fixtures.ts"; +import { DisplayFpsSessions } from "../src/fps/sessions.ts"; +import { CpuSessions } from "../src/cpu/sessions.ts"; +import { PerformanceRecordings, RecordingStore } from "../src/performance-recordings.ts"; +import { summarizeRecording } from "../../shared/src/recordings.ts"; +import { recordingFixture, unavailableFps } from "../../test-support/recording-fixtures.ts"; test("range summaries weight interval overlap, preserve CPU above 100%, zero memory, and missing values", () => { const recording = recordingFixture(); diff --git a/tests/physical-ios-bezel.test.ts b/packages/core/test/physical-ios-bezel.test.ts similarity index 97% rename from tests/physical-ios-bezel.test.ts rename to packages/core/test/physical-ios-bezel.test.ts index a3ecaa3a..cae138d4 100644 --- a/tests/physical-ios-bezel.test.ts +++ b/packages/core/test/physical-ios-bezel.test.ts @@ -2,8 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { access, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { createPhysicalIosBezelReader } from "../src/server/physical-ios-bezel.ts"; -import type { PhysicalIosDevice } from "../src/shared/ios-devices.ts"; +import { createPhysicalIosBezelReader } from "../src/physical-ios-bezel.ts"; +import type { PhysicalIosDevice } from "../../shared/src/ios-devices.ts"; const phone: PhysicalIosDevice = { udid: "00008150-0000111122223333", coreDeviceId: "11111111-1111-4111-8111-111111111111", diff --git a/tests/physical-ios-logs.test.ts b/packages/core/test/physical-ios-logs.test.ts similarity index 93% rename from tests/physical-ios-logs.test.ts rename to packages/core/test/physical-ios-logs.test.ts index 22d007dd..3b327c73 100644 --- a/tests/physical-ios-logs.test.ts +++ b/packages/core/test/physical-ios-logs.test.ts @@ -11,17 +11,17 @@ import { pathToFileURL } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { nativeLogTargetSchema } from "../src/shared/logs.ts"; -import type { LogRecord, LogSourceStatus } from "../src/shared/logs.ts"; -import type { PhysicalIosDevice } from "../src/shared/ios-devices.ts"; -import { physicalIosLogCommand } from "../src/server/physical-ios-logs.ts"; -import type { PhysicalIosLogTarget } from "../src/server/physical-ios-logs.ts"; -import { startNativeLogs } from "../src/server/native-logs.ts"; -import { LogSessions } from "../src/server/log-sessions.ts"; -import { registerLogTools } from "../src/server/log-tools.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { parseIOSLog } from "../src/server/log-parsers.ts"; -import { listIosDevices } from "../src/server/ios-devices.ts"; +import { nativeLogTargetSchema } from "../../shared/src/logs.ts"; +import type { LogRecord, LogSourceStatus } from "../../shared/src/logs.ts"; +import type { PhysicalIosDevice } from "../../shared/src/ios-devices.ts"; +import { physicalIosLogCommand } from "../src/physical-ios-logs.ts"; +import type { PhysicalIosLogTarget } from "../src/physical-ios-logs.ts"; +import { startNativeLogs } from "../src/native-logs.ts"; +import { LogSessions } from "../src/log-sessions.ts"; +import { registerLogTools } from "../src/log-tools.ts"; +import { Baguette } from "../src/baguette.ts"; +import { parseIOSLog } from "../src/log-parsers.ts"; +import { listIosDevices } from "../src/ios-devices.ts"; const execute = promisify(execFile); const phone: PhysicalIosDevice = { @@ -219,13 +219,13 @@ test("MCP validates physical devices independently of Baguette and closes the au test("the native decoder handles missing labels, escapes arbitrary messages, and rejects malformed bounds", { skip: process.platform !== "darwin" }, async t => { const temporary = await mkdtemp(join(tmpdir(), "mobile-dev-ios-log-decoder-")); t.after(() => rm(temporary, { recursive: true, force: true })); - const root = resolve("vendor/ios-logs"); + const root = resolve("packages/native/vendor/ios-logs"); for (const name of ["libplist-2.8.0", "libimobiledevice-1.4.0"]) { await execute("tar", ["-xjf", `${root}/sources/${name}.tar.bz2`, "-C", temporary]); } const binary = join(temporary, "decoder-test"); await execute("clang", ["-std=c11", "-Wall", "-Wextra", "-Werror", `-I${temporary}/libplist-2.8.0/include`, - `-I${temporary}/libimobiledevice-1.4.0/include`, "tests/fixtures/ios-log-decoder.c", `${root}/libimobiledevice-1.0.6.dylib`, + `-I${temporary}/libimobiledevice-1.4.0/include`, "packages/test-support/fixtures/ios-log-decoder.c", `${root}/libimobiledevice-1.0.6.dylib`, `${root}/libplist-2.0.12.dylib`, "-o", binary]); const result = await execute(binary, [], { env: { ...process.env, DYLD_LIBRARY_PATH: root } }); const lines = result.stdout.trim().split("\n"); diff --git a/tests/plugin.test.ts b/packages/core/test/plugin.test.ts similarity index 92% rename from tests/plugin.test.ts rename to packages/core/test/plugin.test.ts index 244c601f..6233ffba 100644 --- a/tests/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,26 +1,26 @@ -import { SENTRY_ORIGIN } from "../src/shared/telemetry.ts"; +import { SENTRY_ORIGIN } from "../../shared/src/telemetry.ts"; import { setImmediate } from "node:timers/promises"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { SimulatorInputService } from "../src/server/simulator-input.ts"; +import { SimulatorInputService } from "../src/simulator-input.ts"; import test from "node:test"; import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { OpenAIUiToolMetadataSchema, OpenAIUiResourceMetadataSchema } from "@openai/mcp-extensions/server"; -import { APP_URI, WORKSPACE_URI } from "../src/server/plugin.ts"; -import { RECORDING_URI } from "../src/shared/recordings.ts"; -import { COMPARISON_URI } from "../src/shared/performance-comparison.ts"; -import { TRACESIFT_URI } from "../src/shared/tracesift.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { parseBaseUrl } from "../src/shared/protocol.ts"; -import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID, OTHER_UDID, SCREEN, PNG } from "./fixtures.ts"; -import { getTelemetryIdentity } from "../src/server/telemetry-identity.ts"; -import manifest from "../.codex-plugin/plugin.json" with { type: "json" }; +import { APP_URI, WORKSPACE_URI } from "../src/plugin.ts"; +import { RECORDING_URI } from "../../shared/src/recordings.ts"; +import { COMPARISON_URI } from "../../shared/src/performance-comparison.ts"; +import { TRACESIFT_URI } from "../../shared/src/tracesift.ts"; +import { Baguette } from "../src/baguette.ts"; +import { parseBaseUrl } from "../../shared/src/protocol.ts"; +import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID, OTHER_UDID, SCREEN, PNG } from "../../test-support/fixtures.ts"; +import { getTelemetryIdentity } from "../src/telemetry-identity.ts"; +import manifest from "../../../package.json" with { type: "json" }; test("both opening tools accept optional device intent and preserve it in their results", async t => { const fake = await fakeBaguette(); - const plugin = await createTestPlugin("", new Baguette(fake.url), fakeSimulatorInput()); + const plugin = await createTestPlugin("", { baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "opening-target-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); }); @@ -39,6 +39,25 @@ test("both opening tools accept optional device intent and preserve it in their assert.equal(boots.length, 0); }); +test("the device choice tool is listed only for clients that render native forms", async t => { + const fake = await fakeBaguette(); + t.after(() => fake.close()); + for (const forms of [false, true]) { + const plugin = await createTestPlugin("", { baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const capabilities = forms ? { extensions: { "openai/elicitation": { form: {} } } } : {}; + const client = new Client({ name: "device-choice-host", version: "1" }, { capabilities }); + await plugin.server.connect(serverTransport); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + const choice = tools.find(tool => tool.name === "mobile_choose_devices"); + assert.equal(!!choice, forms); + if (choice) assert.ok(choice.icons?.length); + assert.match(client.getInstructions() ?? "", /mobile_choose_devices when it is available, otherwise in chat/); + await client.close(); await plugin.close(); + } +}); + test("live UI reads use MCP and allow only Sentry browser connections", async t => { let revision = "a".repeat(64); const plugin = await createTestPlugin(async () => ({ html: 'jonas', liveRevision: revision })); @@ -48,7 +67,7 @@ test("live UI reads use MCP and allow only Sentry browser connections", async t await plugin.server.connect(serverTransport); await client.connect(clientTransport); const first = await client.readResource({ uri: APP_URI }); - assert.deepEqual(first.contents[0]._meta?.ui, { csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); + assert.deepEqual(first.contents[0]._meta?.ui, { permissions: { clipboardWrite: {} }, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); const uri = `ui://mobile-dev/live?after=${revision}`; const unchanged = await client.readResource({ uri }); assert.deepEqual(JSON.parse(unchanged.contents[0].text as string), { revision }); @@ -190,7 +209,7 @@ test("cached side tabs load the current UI through old resource addresses", asyn test("MCP tools expose native entrypoints and complete the simulator workflow", async t => { const fake = await fakeBaguette(); - const plugin = await createTestPlugin('Mobile Dev', new Baguette(fake.url), fakeSimulatorInput()); + const plugin = await createTestPlugin('Mobile Dev', { baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); }); @@ -219,7 +238,7 @@ test("MCP tools expose native entrypoints and complete the simulator workflow", assert.match(recordingResource.contents[0].text as string, /data-view="recording"/); assert.equal(recordingResource.contents[0].mimeType, "text/html;profile=mcp-app"); assert.deepEqual(recordingResource.contents[0]._meta?.["openai/ui"], { preferredDisplayMode: "inline", availableDisplayModes: ["inline", "fullscreen"] }); - assert.deepEqual(recordingResource.contents[0]._meta?.ui, { prefersBorder: true, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); + assert.deepEqual(recordingResource.contents[0]._meta?.ui, { prefersBorder: true, permissions: { clipboardWrite: {} }, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); const renderRecording = tools.tools.find(tool => tool.name === "mobile_render_performance_recording"); assert.deepEqual(renderRecording?._meta?.ui, { resourceUri: RECORDING_URI, visibility: ["app", "model"] }); const comparisonResource = await client.readResource({ uri: COMPARISON_URI }); @@ -256,7 +275,7 @@ test("MCP tools expose native entrypoints and complete the simulator workflow", OpenAIUiResourceMetadataSchema.parse(resource.contents[0]._meta?.["openai/ui"]); assert.equal(resource.contents[0].mimeType, "text/html;profile=mcp-app"); assert.match(resource.contents[0].text as string, /data-view="panel" data-layout="stacked"/); - assert.deepEqual(resource.contents[0]._meta?.ui, { csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); + assert.deepEqual(resource.contents[0]._meta?.ui, { permissions: { clipboardWrite: {} }, csp: { connectDomains: [SENTRY_ORIGIN], resourceDomains: [] } }); const status = await client.callTool({ name: "mobile_open_simulator", arguments: {} }); assert.equal(status.structuredContent?.connected, true); assert.equal((status.structuredContent?.devices as unknown[]).length, 2); @@ -327,10 +346,10 @@ test("the toolbar capture copies the returned PNG and still returns it when clip const fake = await fakeBaguette(); const copies: Buffer[] = []; let copyFails = false; - const plugin = await createTestPlugin("Mobile Dev", new Baguette(fake.url), fakeSimulatorInput(), undefined, undefined, async bytes => { + const plugin = await createTestPlugin("Mobile Dev", { baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput(), copyScreenshot: async bytes => { if (copyFails) throw new Error("Clipboard unavailable"); copies.push(bytes); - }); + } }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "screenshot-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); }); @@ -362,7 +381,7 @@ test("the toolbar capture copies the returned PNG and still returns it when clip test("Device Hub blockage repairs automatically, limits repeated repairs, and permits manual recovery", async t => { const fake = await fakeBaguette(); const input = fakeSimulatorInput(); - const plugin = await createTestPlugin("Mobile Dev", new Baguette(fake.url), input); + const plugin = await createTestPlugin("Mobile Dev", { baguette: new Baguette(fake.url), simulatorInput: input }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "blocked-input-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await fake.close(); }); @@ -469,7 +488,7 @@ test("panel input reaches the native socket during a pending refresh and blocks }); }); }); - const plugin = await createTestPlugin("", new Baguette(fake.url), input); + const plugin = await createTestPlugin("", { baguette: new Baguette(fake.url), simulatorInput: input }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "background-input-test", version: "1" }); t.after(async () => { resolveRefresh("state 0"); await client.close(); await plugin.close(); await fake.close(); }); diff --git a/tests/react-native-inspector.test.ts b/packages/core/test/react-native-inspector.test.ts similarity index 97% rename from tests/react-native-inspector.test.ts rename to packages/core/test/react-native-inspector.test.ts index 4f47ab72..84588d92 100644 --- a/tests/react-native-inspector.test.ts +++ b/packages/core/test/react-native-inspector.test.ts @@ -2,15 +2,15 @@ import test, { type TestContext } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { WebSocketServer } from "ws"; -import { inspectReactNative, readSnapshot, type InspectorNode } from "../src/server/react-native-inspector.ts"; -import { collectReactNativeTree } from "../src/server/react-native-snapshot.js"; +import { inspectReactNative, readSnapshot, type InspectorNode } from "../src/react-native-inspector.ts"; +import { collectReactNativeTree } from "../src/react-native-snapshot.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { Baguette } from "../src/server/baguette.ts"; -import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID } from "./fixtures.ts"; -import { screenComponents, componentAt, componentsAt } from "../src/shared/screen-annotations.ts"; -import { ServeEmu } from "../src/server/serve-emu.ts"; -import { fakeInspectionSdk } from "./android-inspection-fixtures.ts"; +import { Baguette } from "../src/baguette.ts"; +import { createTestPlugin, fakeBaguette, fakeSimulatorInput, UDID } from "../../test-support/fixtures.ts"; +import { screenComponents, componentAt, componentsAt } from "../../shared/src/screen-annotations.ts"; +import { ServeEmu } from "../src/serve-emu.ts"; +import { fakeInspectionSdk } from "../../test-support/android-inspection-fixtures.ts"; const card = { source: "react-native", role: "Pressable", label: "Row", frame: { x: 10, y: 100, width: 350, height: 70 }, children: [ { source: "react-native", role: "Text", label: "Title", frame: { x: 30, y: 120, width: 150, height: 20 }, children: [] }, @@ -224,7 +224,7 @@ test("runtime adapter reads native bounds, keeps logical parents and excludes un test("the panel receives runtime and native elements through an MCP tool, with native fallback", async t => { const inspector = await backend(t); const native = await fakeBaguette(); - const plugin = await createTestPlugin("", new Baguette(native.url), fakeSimulatorInput()); + const plugin = await createTestPlugin("", { baguette: new Baguette(native.url), simulatorInput: fakeSimulatorInput() }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "inspection-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await native.close(); }); @@ -255,7 +255,7 @@ test("Android MCP inspection aligns native and React bounds with downscaled vide ] } }; } } - const plugin = await createTestPlugin("", new Baguette(native.url), fakeSimulatorInput(), undefined, new Android()); + const plugin = await createTestPlugin("", { baguette: new Baguette(native.url), simulatorInput: fakeSimulatorInput(), android: new Android() }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "android-inspection-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await native.close(); }); @@ -281,7 +281,7 @@ test("deep runtime trees cross MCP as shallow records with their parent links in for (let i = 0; i < 90; i++) tree = { source: "react-native", role: `Wrapper${i}`, frame: { x: 0, y: 0, width: 400, height: 800 }, children: [tree] }; inspector.setResponse({ available: true, tree: [tree], windowWidth: 400, truncated: false }); const native = await fakeBaguette(); - const plugin = await createTestPlugin("", new Baguette(native.url), fakeSimulatorInput()); + const plugin = await createTestPlugin("", { baguette: new Baguette(native.url), simulatorInput: fakeSimulatorInput() }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "inspection-depth-test", version: "1" }); t.after(async () => { await client.close(); await plugin.close(); await native.close(); }); diff --git a/tests/recording-frames.test.ts b/packages/core/test/recording-frames.test.ts similarity index 92% rename from tests/recording-frames.test.ts rename to packages/core/test/recording-frames.test.ts index 05b406f4..b29a2298 100644 --- a/tests/recording-frames.test.ts +++ b/packages/core/test/recording-frames.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readRecordingFrames } from "../src/server/recording-frames.ts"; -import { recordingFixture } from "./recording-fixtures.ts"; +import { readRecordingFrames } from "../src/recording-frames.ts"; +import { recordingFixture } from "../../test-support/recording-fixtures.ts"; test("frame pages distinguish unsupported or historical data from a measured idle interval", () => { const recording = recordingFixture(); diff --git a/tests/recording-tools.test.ts b/packages/core/test/recording-tools.test.ts similarity index 95% rename from tests/recording-tools.test.ts rename to packages/core/test/recording-tools.test.ts index 1e16d049..303eb46e 100644 --- a/tests/recording-tools.test.ts +++ b/packages/core/test/recording-tools.test.ts @@ -6,11 +6,11 @@ import { tmpdir } from "node:os"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { RecordingStore, PerformanceRecordings } from "../src/server/performance-recordings.ts"; -import { registerRecordingTools } from "../src/server/recording-tools.ts"; -import { RECORDING_URI } from "../src/shared/recordings.ts"; -import { COMPARISON_URI } from "../src/shared/performance-comparison.ts"; -import { recordingFixture, unavailableFps } from "./recording-fixtures.ts"; +import { RecordingStore, PerformanceRecordings } from "../src/performance-recordings.ts"; +import { registerRecordingTools } from "../src/recording-tools.ts"; +import { RECORDING_URI } from "../../shared/src/recordings.ts"; +import { COMPARISON_URI } from "../../shared/src/performance-comparison.ts"; +import { recordingFixture, unavailableFps } from "../../test-support/recording-fixtures.ts"; test("recording tools distinguish data, inline rendering, workspace opening and exact-range retrieval", async t => { const directory = await mkdtemp(join(tmpdir(), "mobile-dev-recording-tools-")); diff --git a/packages/core/test/schema-dialect.test.ts b/packages/core/test/schema-dialect.test.ts new file mode 100644 index 00000000..25af7a96 --- /dev/null +++ b/packages/core/test/schema-dialect.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { declareSchemaDialect, JSON_SCHEMA_2020_12 } from "../src/schema-dialect.ts"; +import { createTestPlugin } from "../../test-support/fixtures.ts"; + +// Keywords whose meaning differs between draft-07 and 2020-12. Relabelling the +// SDK's draft-07 output is exact only while no tool schema uses them. +const draft07Only = new Set(["definitions", "dependencies", "additionalItems"]); + +function draft07Keywords(schema: unknown, path = ""): string[] { + if (Array.isArray(schema)) return schema.flatMap((item, index) => draft07Keywords(item, `${path}/${index}`)); + if (schema === null || typeof schema !== "object") return []; + return Object.entries(schema).flatMap(([key, value]) => { + // Property names are data, not keywords. + if (key === "properties" && value && typeof value === "object") return Object.entries(value).flatMap(([name, property]) => draft07Keywords(property, `${path}/properties/${name}`)); + const own = draft07Only.has(key) || (key === "items" && Array.isArray(value)) || (key === "$ref" && String(value).includes("/definitions/")) ? [`${path}/${key}`] : []; + return [...own, ...draft07Keywords(value, `${path}/${key}`)]; + }); +} + +test("every tool declares JSON Schema 2020-12 and uses no draft-07-only keyword", async t => { + const plugin = await createTestPlugin(""); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + declareSchemaDialect(serverTransport); + const client = new Client({ name: "schema-dialect", version: "1" }); + t.after(async () => { await client.close(); await plugin.close(); }); + await plugin.server.connect(serverTransport); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + assert.ok(tools.length > 40); + for (const tool of tools) { + for (const schema of [tool.inputSchema, tool.outputSchema]) { + if (schema === undefined) continue; + assert.equal(schema.$schema, JSON_SCHEMA_2020_12, tool.name); + assert.deepEqual(draft07Keywords(schema), [], tool.name); + } + } +}); diff --git a/tests/serve-emu-startup.test.ts b/packages/core/test/serve-emu-startup.test.ts similarity index 93% rename from tests/serve-emu-startup.test.ts rename to packages/core/test/serve-emu-startup.test.ts index ce2a3692..bb845841 100644 --- a/tests/serve-emu-startup.test.ts +++ b/packages/core/test/serve-emu-startup.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; -import { buildServeEmu } from "../scripts/build-serve-emu.mjs"; +import { buildServeEmu } from "../../../tooling/build-serve-emu.mjs"; import type { Envelope } from "@sentry/core"; test("owned startup receives child summaries, preserves the original failure and checks post-failure device state", async t => { @@ -25,7 +25,7 @@ test("owned startup receives child summaries, preserves the original failure and const output = join(directory, "server.mjs"); const repository = resolve("."); await build({ - stdin: { contents: 'export { ServeEmu } from "./src/server/serve-emu.ts"; export { androidStartupDiagnosticTags } from "./src/shared/android-startup-diagnostics.ts"; export * as Sentry from "@sentry/node";', resolveDir: repository, loader: "ts" }, + stdin: { contents: 'export { ServeEmu } from "./packages/core/src/serve-emu.ts"; export { androidStartupDiagnosticTags } from "./packages/shared/src/android-startup-diagnostics.ts"; export * as Sentry from "@sentry/node";', resolveDir: repository, loader: "ts" }, outfile: output, bundle: true, format: "esm", platform: "node", target: "node22.18", banner: { js: 'import { createRequire as testCreateRequire } from "node:module"; const require = testCreateRequire(import.meta.url);' }, }); diff --git a/tests/simulator-input.test.ts b/packages/core/test/simulator-input.test.ts similarity index 97% rename from tests/simulator-input.test.ts rename to packages/core/test/simulator-input.test.ts index af36a829..ac2a5766 100644 --- a/tests/simulator-input.test.ts +++ b/packages/core/test/simulator-input.test.ts @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setImmediate } from "node:timers/promises"; -import { SimulatorInputService } from "../src/server/simulator-input.ts"; -import { UDID, OTHER_UDID } from "./fixtures.ts"; +import { SimulatorInputService } from "../src/simulator-input.ts"; +import { UDID, OTHER_UDID } from "../../test-support/fixtures.ts"; function deferred() { let resolve!: (value: T) => void; diff --git a/tests/stream.test.ts b/packages/core/test/stream.test.ts similarity index 98% rename from tests/stream.test.ts rename to packages/core/test/stream.test.ts index ddf14c92..8e4f6ebd 100644 --- a/tests/stream.test.ts +++ b/packages/core/test/stream.test.ts @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { StreamSessions } from "../src/server/stream-sessions.ts"; -import { Baguette } from "../src/server/baguette.ts"; -import { fakeBaguette, UDID, SCREEN, PNG } from "./fixtures.ts"; +import { StreamSessions } from "../src/stream-sessions.ts"; +import { Baguette } from "../src/baguette.ts"; +import { fakeBaguette, UDID, SCREEN, PNG } from "../../test-support/fixtures.ts"; test("panel sessions carry frames and validated input without a browser socket", async t => { const fake = await fakeBaguette(); diff --git a/tests/telemetry-environment.test.ts b/packages/core/test/telemetry-environment.test.ts similarity index 95% rename from tests/telemetry-environment.test.ts rename to packages/core/test/telemetry-environment.test.ts index 45353659..178e0ae9 100644 --- a/tests/telemetry-environment.test.ts +++ b/packages/core/test/telemetry-environment.test.ts @@ -5,7 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { build } from "esbuild"; -import { assertBuildEnvironment, telemetryBuildEnvironment } from "../scripts/telemetry-build.mjs"; +import { assertBuildEnvironment, telemetryBuildEnvironment } from "../../../tooling/telemetry-build.mjs"; const execute = promisify(execFile); @@ -41,9 +41,9 @@ test("packaged Node and native telemetry share the explicit build environment", const probe = join(directory, "probe.mjs"); const configPath = join(directory, "telemetry-environment.json"); const contents = ` - import "./src/server/instrument.ts"; + import "./packages/core/src/instrument.ts"; import * as Sentry from "@sentry/node"; - import { nativeCollectorCommand } from "./src/server/native-telemetry.ts"; + import { nativeCollectorCommand } from "./packages/core/src/native-telemetry.ts"; const client = Sentry.getClient(); const options = client.getOptions(); const command = nativeCollectorCommand("/tmp/collector"); diff --git a/tests/telemetry-identity.test.ts b/packages/core/test/telemetry-identity.test.ts similarity index 93% rename from tests/telemetry-identity.test.ts rename to packages/core/test/telemetry-identity.test.ts index a3f2c09b..cdf9e4d2 100644 --- a/tests/telemetry-identity.test.ts +++ b/packages/core/test/telemetry-identity.test.ts @@ -6,8 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { build } from "esbuild"; -import { loadTelemetryIdentity } from "../src/server/telemetry-identity.ts"; -import { isAnonymousUserId, isTelemetrySessionId, validateTelemetryIdentity } from "../src/shared/telemetry-identity.ts"; +import { loadTelemetryIdentity } from "../src/telemetry-identity.ts"; +import { isAnonymousUserId, isTelemetrySessionId, validateTelemetryIdentity } from "../../shared/src/telemetry-identity.ts"; const execute = promisify(execFile); @@ -40,7 +40,7 @@ test("concurrent MCP processes publish one complete installation ID", async t => const root = await mkdtemp(prefix); t.after(() => rm(root, { recursive: true, force: true })); const directory = join(root, "telemetry"); - const moduleUrl = new URL("../src/server/telemetry-identity.ts", import.meta.url); + const moduleUrl = new URL("../src/telemetry-identity.ts", import.meta.url); const moduleLiteral = JSON.stringify(moduleUrl.href); const code = `import { loadTelemetryIdentity } from ${moduleLiteral}; const identity = loadTelemetryIdentity(process.argv[1]); console.log(JSON.stringify(identity));`; const jobs = Array.from({ length: 12 }, () => execute(process.execPath, ["--input-type=module", "-e", code, directory])); @@ -83,10 +83,10 @@ test("server startup sets the same user and session on Node scope and both nativ const config = join(output, "telemetry-environment.json"); await writeFile(config, '{"environment":"development"}'); const contents = ` - import "./src/server/instrument.ts"; + import "./packages/core/src/instrument.ts"; import * as Sentry from "@sentry/node"; - import { getTelemetryIdentity } from "./src/server/telemetry-identity.ts"; - import { nativeCollectorCommand } from "./src/server/native-telemetry.ts"; + import { getTelemetryIdentity } from "./packages/core/src/telemetry-identity.ts"; + import { nativeCollectorCommand } from "./packages/core/src/native-telemetry.ts"; const client = Sentry.getClient(); const transport = client.getTransport(); transport.send = async () => ({ statusCode: 200 }); diff --git a/tests/telemetry.test.ts b/packages/core/test/telemetry.test.ts similarity index 96% rename from tests/telemetry.test.ts rename to packages/core/test/telemetry.test.ts index ec5651b6..cc6810ce 100644 --- a/tests/telemetry.test.ts +++ b/packages/core/test/telemetry.test.ts @@ -10,20 +10,21 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { z } from "zod"; -import { MeasurementWindow, sampleTrace, scrubErrorEvent, scrubMetric, scrubSpan, sampleMetric, createErrorEventFilter, METRIC_SAMPLE_RATE, TELEMETRY_META_KEY } from "../src/shared/telemetry.ts"; -import { captureServerError, installTracePropagation, instrumentMcpServer, IOSLogProcessingTelemetry, recordAndroidBackendStartup, recordPluginUpdate, recordIosMirrorSharing } from "../src/server/telemetry.ts"; -import { ExpectedOperationError, FailureEpisodes } from "../src/shared/error-reporting.ts"; -import { parseResourceInput } from "../src/server/resource-input.ts"; -import { openRequestSession } from "../src/server/request-session.ts"; -import { registerDeviceAppsTool } from "../src/server/device-apps/tools.ts"; -import { directoryBytes } from "../src/server/storage-metrics.ts"; -import { SimulatorUnavailableError } from "../src/server/simulator-unavailable.ts"; -import { registerDeviceChoiceTools } from "../src/server/device-choice-tools.ts"; -import { listIosDevices, registerIosDeviceTools } from "../src/server/ios-devices.ts"; -import { createDrivers, registerAppControlTools } from "../src/server/app-control/tools.ts"; +import { MeasurementWindow, sampleTrace, scrubErrorEvent, scrubMetric, scrubSpan, sampleMetric, createErrorEventFilter, METRIC_SAMPLE_RATE, TELEMETRY_META_KEY } from "../../shared/src/telemetry.ts"; +import { captureServerError, installTracePropagation, instrumentMcpServer, IOSLogProcessingTelemetry, recordAndroidBackendStartup, recordIosMirrorSharing } from "../src/telemetry.ts"; +import { recordPluginUpdate } from "../../../apps/codex-plugin/server/plugin-updates.ts"; +import { ExpectedOperationError, FailureEpisodes } from "../../shared/src/error-reporting.ts"; +import { parseResourceInput } from "../src/resource-input.ts"; +import { openRequestSession } from "../src/request-session.ts"; +import { registerDeviceAppsTool } from "../src/device-apps/tools.ts"; +import { directoryBytes } from "../src/storage-metrics.ts"; +import { SimulatorUnavailableError } from "../src/simulator-unavailable.ts"; +import { registerDeviceChoiceTools } from "../src/device-choice-tools.ts"; +import { listIosDevices, registerIosDeviceTools } from "../src/ios-devices.ts"; +import { createDrivers, registerAppControlTools } from "../src/app-control/tools.ts"; import type { OpenAIFormResult } from "@openai/mcp-extensions/server"; -import { adapterClient } from "./agent-device-fixtures.ts"; -import { setDiscoveryCommandDiagnostic } from "../src/shared/device-apps-command-diagnostics.ts"; +import { adapterClient } from "../../test-support/agent-device-fixtures.ts"; +import { setDiscoveryCommandDiagnostic } from "../../shared/src/device-apps-command-diagnostics.ts"; function contains(text: string, fragment: string, expected = true) { const included = text.includes(fragment); @@ -448,6 +449,15 @@ test("UI trace context crosses the MCP bridge and handled server errors exclude assert.equal(observedTags?.surface, "tracesift"); assert.equal(observedTags?.view, "tracesift"); assert.equal(observedTags?.profilePath, undefined); + // Every host shares this server, so a known host value survives the allowlist; anything else is dropped. + await client.callTool({ name: "test_action", arguments: { secret: "PRIVATE_HOST_ARGUMENT" }, _meta: { + [TELEMETRY_META_KEY]: { surface: "logs", view: "workspace", host: "claude" }, + } }); + assert.equal(observedTags?.host, "claude"); + await client.callTool({ name: "test_action", arguments: { secret: "PRIVATE_HOST_ARGUMENT" }, _meta: { + [TELEMETRY_META_KEY]: { surface: "logs", view: "workspace", host: "PRIVATE_HOST" }, + } }); + assert.equal(observedTags?.host, undefined); await Sentry.flush(); const encoded = JSON.stringify(envelopes); contains(encoded, "PRIVATE_", false); @@ -456,14 +466,14 @@ test("UI trace context crosses the MCP bridge and handled server errors exclude contains(encoded, "Handled tool failed"); const eventItems = envelopes.flatMap(envelope => envelope[1]); const errors = eventItems.filter(item => item[0].type === "event"); - // One handled failure per tool call above: logs, recording, comparison, TraceSift. - assert.equal(errors.length, 4); + // One handled failure per tool call above: logs, recording, comparison, TraceSift and two host checks. + assert.equal(errors.length, 6); const unavailable = new SimulatorUnavailableError("Expected stopped simulator"); captureServerError(unavailable, "expected"); await Sentry.flush(); const afterItems = envelopes.flatMap(envelope => envelope[1]); const afterErrors = afterItems.filter(item => item[0].type === "event"); - assert.equal(afterErrors.length, 4); + assert.equal(afterErrors.length, 6); }); test("error filters retain only generated identity while metrics and spans omit user dimensions", async () => { @@ -655,7 +665,7 @@ test("Node runtime metrics report CPU, memory and event-loop measurements with c }); test("definition failure tags survive Sentry scrubbing with surface, release and anonymous attribution", async t => { - const { parseDefinitionDiagnostic, setDefinitionDiagnostic } = await import("../src/shared/simulator-definition-diagnostics.ts"); + const { parseDefinitionDiagnostic, setDefinitionDiagnostic } = await import("../../shared/src/simulator-definition-diagnostics.ts"); const envelopes: Envelope[] = []; Sentry.init({ dsn: "https://public@example.com/1", defaultIntegrations: false, beforeSend: scrubErrorEvent, @@ -816,8 +826,8 @@ test("failure episode memory is bounded and inactivity starts a new episode", () }); test("Android startup diagnostics retain stage, device state and anonymous error attribution without private content", async t => { - const { recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } = await import("../src/server/telemetry.ts"); - const { setAndroidStartupDiagnostic } = await import("../src/shared/android-startup-diagnostics.ts"); + const { recordAndroidStartupStages, recordAndroidStartupContext, recordAndroidStartupDeviceState, recordAndroidBackendStop } = await import("../src/telemetry.ts"); + const { setAndroidStartupDiagnostic } = await import("../../shared/src/android-startup-diagnostics.ts"); const envelopes: Envelope[] = []; Sentry.init({ dsn: "https://public@example.com/1", defaultIntegrations: false, environment: "release", release: "mobile-dev@test", diff --git a/tests/tracesift-client.test.ts b/packages/core/test/tracesift-client.test.ts similarity index 99% rename from tests/tracesift-client.test.ts rename to packages/core/test/tracesift-client.test.ts index e771b134..35060353 100644 --- a/tests/tracesift-client.test.ts +++ b/packages/core/test/tracesift-client.test.ts @@ -5,7 +5,7 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { TraceSiftClient, TraceSiftError } from "../src/server/tracesift-client.ts"; +import { TraceSiftClient, TraceSiftError } from "../src/tracesift-client.ts"; const token = "integration-token-long-enough"; const instance = "service-instance"; diff --git a/tests/tracesift-telemetry.test.ts b/packages/core/test/tracesift-telemetry.test.ts similarity index 96% rename from tests/tracesift-telemetry.test.ts rename to packages/core/test/tracesift-telemetry.test.ts index a040de7f..12d20e49 100644 --- a/tests/tracesift-telemetry.test.ts +++ b/packages/core/test/tracesift-telemetry.test.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as Sentry from "@sentry/node"; import type { Envelope } from "@sentry/core"; -import { scrubMetric } from "../src/shared/telemetry.ts"; -import { recordTraceSiftOperation, recordTraceSiftPayload } from "../src/server/telemetry.ts"; +import { scrubMetric } from "../../shared/src/telemetry.ts"; +import { recordTraceSiftOperation, recordTraceSiftPayload } from "../src/telemetry.ts"; test("TraceSift operation metrics retain only bounded product attribution", async t => { const envelopes: Envelope[] = []; diff --git a/tests/tracesift-tools.test.ts b/packages/core/test/tracesift-tools.test.ts similarity index 98% rename from tests/tracesift-tools.test.ts rename to packages/core/test/tracesift-tools.test.ts index d56592b7..2f10d4b2 100644 --- a/tests/tracesift-tools.test.ts +++ b/packages/core/test/tracesift-tools.test.ts @@ -3,8 +3,8 @@ import test from "node:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { registerTraceSiftTools } from "../src/server/tracesift-tools.ts"; -import type { TraceSiftClient } from "../src/server/tracesift-client.ts"; +import { registerTraceSiftTools } from "../src/tracesift-tools.ts"; +import type { TraceSiftClient } from "../src/tracesift-client.ts"; /** * The payload crosses the boundary the card's frame will: the SDK validates the diff --git a/tests/ui-resource.test.ts b/packages/core/test/ui-resource.test.ts similarity index 96% rename from tests/ui-resource.test.ts rename to packages/core/test/ui-resource.test.ts index ec3a6547..4da61350 100644 --- a/tests/ui-resource.test.ts +++ b/packages/core/test/ui-resource.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, writeFile, rm, mkdir, utimes } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { loadUIResource } from "../src/server/ui-resource.ts"; +import { loadUIResource } from "../src/ui-resource.ts"; test("live UI is opt-in, changes revision on edits, and falls back when the watcher stops", async t => { const directory = await mkdtemp(join(tmpdir(), "mobile-dev-ui-")); diff --git a/packages/mcp-app/package.json b/packages/mcp-app/package.json new file mode 100644 index 00000000..1a701e5c --- /dev/null +++ b/packages/mcp-app/package.json @@ -0,0 +1,8 @@ +{ + "name": "@mobile-dev/mcp-app", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The MCP Apps panel shell shared by every MCP Apps host: Codex, Claude Desktop and others.", + "exports": { "./*": "./src/*" } +} diff --git a/packages/mcp-app/src/app.tsx b/packages/mcp-app/src/app.tsx new file mode 100644 index 00000000..47c487a6 --- /dev/null +++ b/packages/mcp-app/src/app.tsx @@ -0,0 +1,51 @@ +import "@fontsource-variable/inter"; +import "@mobile-dev/ui/theme.css"; +import "@mobile-dev/ui/style.css"; +import { OpenAIExtensions } from "@openai/mcp-extensions/app"; +import { traceSiftProfileInputSchema } from "@mobile-dev/shared/tracesift.ts"; +import { ComparisonController } from "@mobile-dev/ui/comparison-controller.ts"; +import { ComparisonCard } from "@mobile-dev/ui/components/comparison-card.tsx"; +import { RecordingCard } from "@mobile-dev/ui/components/recording-card.tsx"; +import { TraceSiftCards } from "@mobile-dev/ui/components/tracesift-cards.tsx"; +import { RecordingController } from "@mobile-dev/ui/recording-controller.ts"; +import { TraceSiftController } from "@mobile-dev/ui/tracesift-controller.ts"; +import { startCardApp } from "./card-app.tsx"; +import { startWorkspaceApp } from "./workspace-app.tsx"; +import type { WorkspaceOptions } from "./workspace-extension.ts"; + +/** + * Starts the view the served page asks for. A host's page entry calls this, passing + * any chrome it adds to the workspace. + */ +export function startMcpApp(options: { workspace?: WorkspaceOptions } = {}) { + const view = document.documentElement.dataset.view; + if (view === "recording") startCardApp({ + name: "mobile-dev-recording", surface: "recording", fallback: "This recording could not render. Reopen it to try again.", + create: app => { + const extensions = new OpenAIExtensions(app); + return new RecordingController(app, () => extensions.modelContext); + }, + render: controller => , + }); + else if (view === "comparison") startCardApp({ + name: "mobile-dev-comparison", surface: "comparison", fallback: "This comparison could not render. Reopen it to try again.", + create: app => new ComparisonController(app), + render: controller => , + }); + else if (view === "tracesift") startCardApp({ + name: "mobile-dev-tracesift", surface: "tracesift", fallback: "This TraceSift analysis could not render. Reopen it to try again.", + create: app => new TraceSiftController(app), + render: controller => , + hostChanged: (controller, host) => { + const viewId = host?.toolInfo?.id; + if (typeof viewId === "string" || typeof viewId === "number") controller.setViewId(viewId); + }, + toolInput: (controller, input) => { + const parsed = traceSiftProfileInputSchema.safeParse(input); + if (parsed.success) controller.setProfileInput(parsed.data.path, parsed.data.profileType); + }, + // The inline card investigates too, so it needs the same project the panel saved. + connected: controller => void controller.loadProject(), + }); + else startWorkspaceApp(options.workspace); +} diff --git a/packages/mcp-app/src/card-app.tsx b/packages/mcp-app/src/card-app.tsx new file mode 100644 index 00000000..e0d705a3 --- /dev/null +++ b/packages/mcp-app/src/card-app.tsx @@ -0,0 +1,62 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { Surface } from "@mobile-dev/shared/telemetry.ts"; +import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; +import { ErrorBoundary, captureUiError, setUiSurface, startUiTelemetry, stopUiTelemetry } from "@mobile-dev/ui/telemetry.ts"; +import { applyHostStyles } from "./host-styles.ts"; +import { attributeHost } from "./host-telemetry.ts"; + +type CardController = { hostChanged(): void; accept(result: CallToolResult): void; dispose(): void }; + +/** + * Runs one chat card: a single controller fed by the tool call that rendered it. + * The card owns its telemetry surface, host styling and teardown; `hostChanged`, + * `toolInput` and `connected` cover what a card needs beyond that. + */ +export function startCardApp(card: { + name: string; + surface: Surface & ("recording" | "comparison" | "tracesift"); + fallback: string; + create(app: App): C; + render(controller: C): ReactNode; + hostChanged?(controller: C, host: ReturnType): void; + toolInput?(controller: C, input: Record | undefined): void; + connected?(controller: C): void; +}) { + const app = new App({ name: card.name, version: PLUGIN_VERSION }); + startUiTelemetry(app); + setUiSurface(card.surface); + const controller = card.create(app); + const root = createRoot(document.getElementById("root")!); + root.render({card.fallback}

}>{card.render(controller)}
); + function hostChanged() { + const host = applyHostStyles(app); + controller.hostChanged(); + card.hostChanged?.(controller, host); + } + app.addEventListener("hostcontextchanged", hostChanged); + if (card.toolInput) app.ontoolinput = ({ arguments: input }) => card.toolInput!(controller, input); + app.ontoolresult = result => { + try { controller.accept(result); } + catch (error) { captureUiError(error, `${card.surface}.result`); } + }; + let closing: Promise | undefined; + function close() { + if (closing) return closing; + controller.dispose(); + root.unmount(); + window.removeEventListener("pagehide", pageHide); + app.removeEventListener("hostcontextchanged", hostChanged); + return closing = stopUiTelemetry(); + } + function pageHide(event: PageTransitionEvent) { if (!event.persisted) void close(); } + window.addEventListener("pagehide", pageHide); + app.onteardown = async () => { await close(); return {}; }; + void app.connect().then(() => { + attributeHost(app); + hostChanged(); + card.connected?.(controller); + }).catch(error => captureUiError(error, `${card.surface}.connect`)); +} diff --git a/packages/mcp-app/src/components/context-attachments.tsx b/packages/mcp-app/src/components/context-attachments.tsx new file mode 100644 index 00000000..f666b617 --- /dev/null +++ b/packages/mcp-app/src/components/context-attachments.tsx @@ -0,0 +1,44 @@ +import { useState, useSyncExternalStore } from "react"; +import type { PanelContext } from "@mobile-dev/ui/model-context.ts"; +import { Button } from "@mobile-dev/ui/components/ui/button.tsx"; +import { captureUiError } from "@mobile-dev/ui/telemetry.ts"; + +export function ContextAttachments({ context }: { context: PanelContext }) { + const count = useSyncExternalStore(context.subscribe, () => context.attachmentCount); + const notes = useSyncExternalStore(context.subscribe, () => context.screenAnnotations); + const [busy, setBusy] = useState<"add" | "clear" | "remove">(); + const [error, setError] = useState(""); + const [addedNotes, setAddedNotes] = useState(); + if (context.usesHostAttachments || !count) return null; + return
+ {!!notes.length &&
+ {notes.length} {notes.length === 1 ? "note" : "notes"} +
    + {notes.map(note =>
  • + {note.simulator.name} · #{note.number} {note.component.label ?? note.component.name} +

    {note.text}

    + +
  • )} +
+
} + {!!notes.length && context.canSendMessage && } + + {addedNotes === notes && Added to chat input. Review and send there before clearing these notes.} + {error && {error}} +
; +} diff --git a/packages/mcp-app/src/components/display-mode-button.tsx b/packages/mcp-app/src/components/display-mode-button.tsx new file mode 100644 index 00000000..8e15cdcf --- /dev/null +++ b/packages/mcp-app/src/components/display-mode-button.tsx @@ -0,0 +1,30 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { useCallback, useState, useSyncExternalStore } from "react"; +import { Button } from "@mobile-dev/ui/components/ui/button.tsx"; +import { captureUiError, countUiEvent } from "@mobile-dev/ui/telemetry.ts"; + +// Shown only when the host advertises the other display mode, so hosts that offer a +// single mode get no extra control. Hosts with their own fullscreen controls turn +// it off through WorkspaceOptions.displayModeControl. +export function DisplayModeButton({ app }: { app: App }) { + const subscribe = useCallback((onChange: () => void) => { + app.addEventListener("hostcontextchanged", onChange); + return () => app.removeEventListener("hostcontextchanged", onChange); + }, [app]); + const mode = useSyncExternalStore(subscribe, () => { + const host = app.getHostContext(); + const next = host?.displayMode === "fullscreen" ? "inline" : "fullscreen"; + return host?.availableDisplayModes?.includes(next) ? next : undefined; + }); + const [busy, setBusy] = useState(false); + if (!mode) return null; + return ; +} diff --git a/src/ui/components/workspace.tsx b/packages/mcp-app/src/components/workspace.tsx similarity index 81% rename from src/ui/components/workspace.tsx rename to packages/mcp-app/src/components/workspace.tsx index 04ce3172..2baa5da2 100644 --- a/src/ui/components/workspace.tsx +++ b/packages/mcp-app/src/components/workspace.tsx @@ -1,28 +1,24 @@ import { ActivityIcon, DatabaseIcon, TerminalIcon, PanelsTopLeftIcon, ChartNoAxesCombinedIcon } from "lucide-react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import type { LogsPanel } from "../logs-panel.ts"; -import type { PerformancePanel } from "../performance-panel.ts"; -import type { StoragePanel } from "../storage-panel.ts"; -import type { TraceSiftPanel } from "../tracesift-panel.ts"; -import { PerformanceView } from "./performance-view"; -import { SimulatorView } from "./simulator-view"; -import { LogsView } from "./logs-view"; -import { StorageView } from "./storage-view"; -import { TraceSiftView } from "./tracesift-view"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "./ui/base-select"; -import { Button } from "./ui/button"; +import type { ReactNode } from "react"; +import type { WorkspaceTools } from "@mobile-dev/ui/workspace-tools.ts"; +import { PerformanceView } from "@mobile-dev/ui/components/performance-view.tsx"; +import { SimulatorView } from "@mobile-dev/ui/components/simulator-view.tsx"; +import { LogsView } from "@mobile-dev/ui/components/logs-view.tsx"; +import { StorageView } from "@mobile-dev/ui/components/storage-view.tsx"; +import { TraceSiftView } from "@mobile-dev/ui/components/tracesift-view.tsx"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@mobile-dev/ui/components/ui/base-select.tsx"; +import { Button } from "@mobile-dev/ui/components/ui/button.tsx"; import { usePanelRef, useGroupRef } from "react-resizable-panels"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable"; -import { useMediaQuery } from "./use-media-query"; -import { markUiSurfaceReady, setUiSurface, setUiTelemetryContext } from "../telemetry.ts"; -import type { RecordingController } from "../recording-controller.ts"; -import { RecordingCard } from "./recording-card"; -import { PluginUpdateBanner } from "./plugin-update-banner"; -import type { PluginUpdateController } from "../plugin-updates.ts"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@mobile-dev/ui/components/ui/resizable.tsx"; +import { useMediaQuery } from "@mobile-dev/ui/components/use-media-query.ts"; +import { setUiSurface, setUiTelemetryContext } from "@mobile-dev/ui/telemetry.ts"; +import type { RecordingController } from "@mobile-dev/ui/recording-controller.ts"; +import { RecordingCard } from "@mobile-dev/ui/components/recording-card.tsx"; -import type { DeviceLayout } from "../device-layout.ts"; -export type { DeviceLayout } from "../device-layout.ts"; +import type { DeviceLayout } from "@mobile-dev/ui/device-layout.ts"; +export type { DeviceLayout } from "@mobile-dev/ui/device-layout.ts"; // Menu item IDs stay literal so tests and the package smoke check can find them in the bundle. const tools = { @@ -34,7 +30,8 @@ const tools = { type Tool = keyof typeof tools; const isTool = (value: string): value is Tool => value in tools; -export function Workspace({ logs, performance, storage, tracesift, recordingController, updates, automaticLayout, onLayout }: { logs: LogsPanel; performance: PerformancePanel; storage: StoragePanel; tracesift: TraceSiftPanel; recordingController: RecordingController; updates?: PluginUpdateController; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { +export function Workspace({ panels, recordingController, banner, displayModeControl, contextControl, automaticLayout, onLayout }: { panels: WorkspaceTools; recordingController: RecordingController; banner?: ReactNode; displayModeControl?: ReactNode; contextControl?: ReactNode; automaticLayout?: DeviceLayout; onLayout: (layout: DeviceLayout, manual?: boolean) => void }) { + const { logs, performance, storage, tracesift } = panels; const [tool, setTool] = useState("logs"); const [savedVisible, setSavedVisible] = useState(false); const [simulatorsCollapsed, setSimulatorsCollapsed] = useState(false); @@ -53,10 +50,7 @@ export function Workspace({ logs, performance, storage, tracesift, recordingCont setSavedVisible(true); setLayout("none"); onLayout("none"); - logs.hide(); - performance.hide(); - storage.hide(); - tracesift.hide(); + panels.hide(); }, [recordingState.recording?.id]); const performanceState = useSyncExternalStore(performance.subscribe, performance.getSnapshot); const wide = useMediaQuery("(min-width: 900px)"); @@ -67,30 +61,21 @@ export function Workspace({ logs, performance, storage, tracesift, recordingCont const storageState = useSyncExternalStore(storage.subscribe, storage.getSnapshot); const traceSiftState = useSyncExternalStore(tracesift.subscribe, tracesift.getSnapshot); // A saved recording shows in the performance tool, so it counts as that tool being open. - const panels = { logs, performance, storage, tracesift }; const open = { logs: logState.open, performance: savedVisible || performanceState.open, storage: storageState.open, tracesift: traceSiftState.open }[tool]; useEffect(() => { setUiSurface(open ? savedVisible ? "recording" : tool : "simulator"); }, [tool, open, savedVisible]); useEffect(() => { setUiTelemetryContext({ logs_open: logState.open, logs_paused: logState.paused, performance_running: performanceState.monitoring || performanceState.fpsMonitoring }); }, [logState.open, logState.paused, performanceState.monitoring, performanceState.fpsMonitoring]); - const isOpen = () => (tool === "performance" && savedVisible) || panels[tool].getSnapshot().open; + const isOpen = () => (tool === "performance" && savedVisible) || panels.panel(tool).getSnapshot().open; const closeTools = () => { setSavedVisible(false); - if (logs.getSnapshot().open) logs.toggle(); - performance.hide(); - storage.hide(); - tracesift.hide(); - void performance.disconnect(); + panels.close(); }; const showTool = (next: Tool) => { - const startedAt = globalThis.performance.now(); if (next === tool && isOpen()) { closeTools(); return; } - setUiSurface(next); setTool(next); setSavedVisible(false); - for (const [name, panel] of Object.entries(panels)) if (name !== next) panel.hide(); - panels[next].show(); - markUiSurfaceReady(startedAt); + panels.show(next); }; const logsRef = usePanelRef(); const simulatorGroupRef = useGroupRef(); @@ -112,6 +97,7 @@ export function Workspace({ logs, performance, storage, tracesift, recordingCont const menuSide = bottomToolbar ? "top" : "bottom"; const ToolIcon = tools[tool].icon; const toolbar =