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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ jobs:
- name: Run tests
run: zig build test -Doptimize=ReleaseSafe

- name: Run kuri-mobile tests
# kuri-mobile ships in the same tarball but has its own build.zig, so
# the root `test` step never reached it. Its platform-independent
# half — the adb wire protocol, the devicectl JSON decoding, the tool
# registry — is worth checking on Linux too; the macOS-only half is
# covered by the mobile-macos job.
working-directory: kuri-mobile
run: zig build test --summary all -Doptimize=ReleaseSafe

- name: Regression — --help / --version do NOT spawn Chrome (issue #156)
# Old kuri binaries (≤ v0.1.0) silently fell through to the daemon
# path on `--help`, launching headless Chrome and binding :8080.
Expand Down Expand Up @@ -69,6 +78,93 @@ jobs:
echo "::endgroup::"
done

mobile-macos:
# kuri-mobile is the one part of the tree that can be exercised against
# real Apple tooling, and GitHub's macOS runners ship Xcode plus iOS
# simulator runtimes. Before this job the e2e suite only ever ran by hand,
# so nothing caught a regression on the device path — which is exactly how
# the silent `--device` no-op survived from 0.4.6 to 0.4.10.
#
# The suite reports SKIP rather than failing for anything the runner
# cannot provide (notably the Accessibility grant that uitree/find/
# wait-for-ui need, which no CI runner can give), so it is a stable gate
# rather than a flaky one.
runs-on: macos-latest
defaults:
run:
working-directory: kuri-mobile

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.17.0-dev.813+2153f8143

- name: Cache Zig build artifacts
uses: actions/cache@v4
with:
path: |
kuri-mobile/.zig-cache
~/.cache/zig
key: zig-mobile-${{ runner.os }}-${{ hashFiles('kuri-mobile/build.zig') }}
restore-keys: |
zig-mobile-${{ runner.os }}-

- name: Show toolchain
run: |
xcode-select -p
xcodebuild -version
ls "$(xcode-select -p)/usr/bin/devicectl" || echo "no devicectl in the selected toolchain"

- name: Build
run: zig build -Doptimize=ReleaseSafe

- name: Unit tests
run: zig build test --summary all

- name: Doctor
# Exits 3 when it finds blocking problems, which on a runner it will
# (no Accessibility grant). Informational only — the assertions live
# in the e2e suite.
continue-on-error: true
run: ./zig-out/bin/kuri-mobile doctor

- name: Boot a simulator
# Deliberately picks and boots through kuri-mobile rather than `xcrun
# simctl`. Two reasons: it exercises `ios list-devices` and `ios boot`
# for real, and bare `xcrun` resolves through xcode-select — the very
# indirection whose failure mode this project exists to avoid. simctl
# is then called by absolute path for `bootstatus`, which kuri has no
# equivalent for.
run: |
set -euo pipefail
udid=$(./zig-out/bin/kuri-mobile ios list-devices \
| awk -F'\t' '$1 == "simulator" && $4 ~ /iPhone/ { print $2; exit }')
if [ -z "${udid}" ]; then
echo "::error::no iPhone simulator available on this runner"
./zig-out/bin/kuri-mobile ios list-devices
exit 1
fi
echo "booting ${udid}"
./zig-out/bin/kuri-mobile ios boot --udid "${udid}"
# simctl reports Booted well before the runtime has finished coming
# up, so wait on bootstatus — otherwise the first e2e command races
# the boot and fails for a reason unrelated to the change under test.
"$(xcode-select -p)/usr/bin/simctl" bootstatus "${udid}" -b

- name: End-to-end (simulator)
run: zig build e2e-ios

- name: End-to-end (physical device)
# No phone is attached to a runner, so this must report SKIP and exit
# 0. Running it anyway keeps the device suite compiling and proves its
# no-hardware path stays clean — the suite is useless as a local gate
# if it has quietly stopped building.
run: zig build e2e-ios-device

startup-smoke:
runs-on: ubuntu-latest

Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@

All notable changes to kuri are documented here.

## [0.4.11] — 2026-07-25

### Fixes — kuri no longer takes over your machine

Driving the Simulator used to seize the user's foreground window and move their cursor. Every input command called `sim_window.activate` before doing anything, because `CGEventPost(kCGHIDEventTap, …)` injects into the *global* event stream — events land wherever focus happens to be, so Simulator.app had to be raised first for a tap to hit the right thing. That made kuri unusable on a machine somebody is actually working on.

- **Input is delivered to Simulator.app by pid** — `CGEventPostToPid` instead of the global HID tap. Events go straight into Simulator's own queue, so no window is raised and the cursor is never warped. Covers `tap`, `doubletap`, `longpress`, `swipe`, `gesture`, `touch`, `key`, `key-sequence`, `batch`
- **`type` no longer routes through AppleScript.** It shelled out to System Events `keystroke`, which is delivered to whichever app is frontmost — so `ios type` was doubly hostile: it *had* to steal focus to be correct, and if it ever ran without doing so it would type your text into whatever you had open. Now Unicode `CGEvent`s addressed to the pid, which needs no virtual-keycode table either. `osascript` is gone from this path
- **`uitree`, `find` and `wait-for-ui` no longer activate at all.** They only read the accessibility tree, which works fine on a background app. `wait-for-ui` was the worst offender — it polls every 250ms, so it re-stole the foreground on every poll for the length of the wait
- **`button` and `background` no longer activate.** They already used `AXPress`, which never needed focus
- **`open-sim` launches in the background** (`open -g`). Opening a simulator is a setup step, not a request to be interrupted
- **`--activate` restores the old behaviour** per command, for the case where a gesture genuinely needs Simulator.app to be key. Off by default

### Fixes — real devices

- **`ios terminate --device` could never have succeeded.** It built `devicectl device process terminate --device <udid> <bundle-id>`, but devicectl's terminate takes `--pid` and accepts no bundle id at all — every invocation died on devicectl's own argument parser. `launch --device` now reads the launched pid from `--json-output` and prints `pid=N`; `terminate --device --pid N` does the direct thing; `terminate --device <bundle-id>` resolves the bundle id to a running pid by matching `device info processes` against the app's on-device bundle URL. A launch that reports success without an identifier is now an error rather than a silent zero, which would later terminate an unrelated process
- **`ios list-apps --device` silently hid every system app.** `devicectl device info apps` defaults to *developer apps only* and says nothing about it, so a command documented as "list installed apps" returned a handful of entries on a phone with hundreds — and exited 0. Now passes `--include-default-apps --include-app-clips`. The same defaulting broke bundle-id lookups, so terminate-by-bundle-id could not resolve a system app either
- **`ios list-apps` on the simulator no longer demands `--udid`.** It resolves the booted simulator like `launch`, `screenshot` and `uitree` already did; requiring it made `list-apps` the odd command out for no reason a caller could infer

### Fixes — diagnosis

- **"Simulator.app is not running" when Simulator.app was running.** The accessibility tree hangs off a window, and a device booted with `simctl boot` does not open one — so a running-but-windowless Simulator produced an error that sent you to restart an app that was already up. Now a distinct `SimulatorHasNoWindow` error carrying the actual remedy, and `doctor` reports window presence rather than just the process

### Tests

- **`zig build e2e-ios-device`** — a new end-to-end suite against physically attached hardware: inspection, the install → list-apps → launch → terminate → uninstall round trip both by pid and by bundle id, and assertions that the XCUITest-only commands still refuse cleanly *while a real device is attached*. Skips with a reason when nothing is plugged in, when no bundle id is configured, or when the screen is locked — phones re-lock on their own timeout, and SpringBoard refuses every launch while they are, which is the environment rather than a defect. Verified: **24 passed, 0 failed** against an iPhone 16 Pro Max
- **A real-device command contract group in `e2e-ios`, needing no hardware.** 21 hermetic cases pinning the silent-success class fixed in 0.4.10: every `--device` command must fail loudly against a fake udid, missing arguments must exit 2 rather than 1, and the XCUITest-only commands must exit 3 with an explanation. Two of them assert the *absence* of devicectl's argument-parser complaint, which is what distinguishes "the device is missing" from "we called devicectl wrong" — the exact bug fixed above
- **`e2e-ios` now degrades instead of failing** on preconditions a machine cannot supply. The Accessibility grant, a Simulator window and the Xcode toolchain are each probed and skipped with a reason, which is what lets the suite be a CI gate rather than a red build on a runner that can never hold a TCC grant
- **More simulator coverage** — `list-apps`, `status-bar` override/clear, `ui appearance` set-and-read-back, `set-location`/`reset-location`, `log --last`, `terminate`. Verified: **54 passed, 0 failed** against a booted simulator
- devicectl's JSON shapes are now unit-tested against fixtures — a missing pid must not decode as 0, and a process match must not be made on a coincidental path prefix (`/var/Demo.appendix` is not inside `/var/Demo.app`)

### CI

- **The e2e suite finally runs in CI.** A new `mobile-macos` job builds kuri-mobile, runs its unit tests, boots a simulator and runs both suites. Until now nothing caught a regression on the device path, which is how the silent `--device` no-op survived from 0.4.6 to 0.4.10
- It picks and boots the simulator *through kuri-mobile itself* rather than `xcrun simctl` — partly to exercise `list-devices` and `boot` for real, and partly because bare `xcrun` resolves through `xcode-select`, which is the exact indirection whose failure mode this project exists to avoid
- The job never opens Simulator.app, so it runs headless and the accessibility cases skip
- kuri-mobile's unit tests now also run on the Linux job; they had their own `build.zig` and the root `test` step never reached them

## [0.4.10] — 2026-07-25

### Fixes
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.{
.name = .kuri,
.version = "0.4.10",
.version = "0.4.11",
.dependencies = .{
.quickjs = .{
.url = "https://github.com/mitchellh/zig-quickjs-ng/archive/main.tar.gz",
Expand Down
121 changes: 121 additions & 0 deletions kuri-mobile/COMPARISON.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# kuri-mobile vs XcodeBuildMCP

Compared against [getsentry/XcodeBuildMCP](https://github.com/getsentry/XcodeBuildMCP) at `main`, July 2026 (82 tools).

The headline: these are **not the same kind of tool**. XcodeBuildMCP is an Xcode
*build* server that also drives the simulator. kuri-mobile is a *device driver*
that does not build anything. Most of the surface difference follows from that,
and most of it is deliberate rather than missing.

## Where kuri-mobile is genuinely behind

Everything in this section is a real gap, in rough order of how much it costs.

### 1. No MCP or HTTP transport — CLI only

XcodeBuildMCP is an MCP server (plus a CLI and a background daemon with session
state). kuri-mobile is a CLI and nothing else, so every action pays a process
spawn and there is no session.

This is the largest structural gap. `common/toolinfo.zig` already holds the full
command surface as data and renders `--json`, so a server could be generated
from it rather than hand-written.

### 2. The entire build system

kuri-mobile has **none** of this, and adding it would be a different project:

| Area | XcodeBuildMCP |
|---|---|
| Build | `build_sim`, `build_device`, `build_macos`, `build_run_sim`, `build_run_device`, `build_run_macos` |
| Test | `test_sim`, `test_device`, `test_macos` |
| Coverage | `get_coverage_report`, `get_file_coverage` |
| Discovery | `discover_projs`, `list_schemes`, `show_build_settings`, `get_app_bundle_id`, `get_mac_bundle_id` |
| Artifacts | `get_sim_app_path`, `get_device_app_path`, `get_mac_app_path`, `clean` |
| Scaffolding | `scaffold_ios_project`, `scaffold_macos_project` |
| Swift Package | `swift_package_build/test/run/stop/list/clean` |

`build_run_sim` — build, install, launch, capture logs in one call — is the tool
their docs say agents reach for most. kuri-mobile expects you to hand it a
`.app` that already exists.

### 3. LLDB debugging

`debug_attach_sim`, `debug_breakpoint_add`/`remove`, `debug_continue`,
`debug_stack`, `debug_variables`, `debug_lldb_command`, `debug_detach`. No kuri
equivalent.

### 4. macOS app support

They build, launch, stop and test macOS apps. kuri-mobile is iOS + Android only.

### 5. Session defaults

`session_set_defaults` lets a caller set scheme/project/device once and omit
them afterwards. kuri-mobile repeats `--udid` on every invocation. Cheap to add
and a real ergonomic difference for an agent.

### 6. Xcode IDE bridge

`xcode_ide_*` and `sync_xcode_defaults` talk to a running Xcode. No equivalent,
and no obvious reason to want one.

## Where kuri-mobile is ahead

- **Android.** 27 commands over a native Zig adb wire-protocol client. XcodeBuildMCP is Apple-only.
- **No driver binary.** Their UI automation shells out to a bundled **AXe** binary (`.axe-version`, `ui-automation/shared/axe-command.ts`). kuri talks to `AXUIElement` and `CGEvent` directly, so there is nothing to version-match or ship alongside.
- **Runs in the background.** As of 0.4.11 kuri drives the Simulator without taking the foreground or moving the cursor (`CGEventPostToPid`, Unicode `CGEvent`s, no `activate`). XcodeBuildMCP inherits AXe's behaviour here.
- **Device inspection.** `device-info`, `device-processes`, `lock-state`, `displays`, `reboot` have no XcodeBuildMCP counterpart. `lock-state` in particular explains a failure mode both tools hit — a locked phone refuses every launch.
- **One binary, no runtime.** Zig, no Node.

## Where they are level

UI automation is close to a one-to-one match:

| XcodeBuildMCP | kuri-mobile |
|---|---|
| `tap`, `long_press`, `swipe`, `drag`, `gesture`, `touch` | `tap`, `longpress`, `swipe`, `gesture`/`drag`, `touch`, `doubletap` |
| `type_text`, `key_press`, `key_sequence`, `button` | `type`, `key`, `key-sequence`, `button` |
| `screenshot`, `snapshot_ui`, `wait_for_ui`, `batch` | `screenshot`, `uitree`, `find`, `wait-for-ui`, `batch` |
| `boot_sim`, `open_sim`, `list_sims`, `erase_sims` | `boot`, `open-sim`, `list-devices`, `erase` |
| `set_sim_location`, `reset_sim_location`, `set_sim_appearance`, `sim_statusbar`, `toggle_*_keyboard`, `record_sim_video` | `set-location`, `reset-location`, `ui appearance`, `status-bar`, `keyboard`, `record-video` |
| `install_app_*`, `launch_app_*`, `stop_app_*`, `doctor` | `install`, `launch`, `terminate`, `doctor` |

**Physical-device UI automation: neither tool has it.** This is worth stating
plainly, because it reads like a kuri gap and is not. XcodeBuildMCP's device
tools are build, install, launch, stop and test — their `ui-automation`
category runs through AXe against the *simulator*. Tapping a real iPhone needs
XCUITest or WebDriverAgent, and neither project has taken that on.

## Test strategy — the sharpest difference

XcodeBuildMCP's device "e2e" tests are **mocked**. `e2e-mcp-device-macos.test.ts`
stands up a harness with canned command responses and asserts on the *captured
argv strings*:

```js
expect(commandStrs.some((c) => c.includes('devicectl') && c.includes('install'))).toBe(true);
```

Their CI runs on `ubuntu-latest`. No simulator and no device is touched in CI at
any point.

That buys speed, hermeticity and Linux CI, and it does catch command-construction
regressions. What it cannot catch is anything about how the tool behaves once the
command actually runs — which is precisely where both bugs fixed in kuri 0.4.11
lived:

- `devicectl device info apps` returning only developer apps is invisible to a mock; the argv is correct, the *result* is filtered.
- A locked device refusing every launch has no argv signature at all.

kuri-mobile now runs both tiers, which is the right answer:

- **Hermetic** — 21 real-device contract cases in `e2e-ios` that need no hardware, asserting exit codes and diagnostics against a fake udid. Two assert the *absence* of devicectl's argument-parser complaint, which is the mocked-test idea done through observable behaviour instead of captured strings.
- **Real** — `e2e-ios` against a booted simulator in CI, and `e2e-ios-device` against attached hardware locally. Both skip with a reason rather than failing when the machine cannot supply a precondition.

## If you want one thing next

The MCP/HTTP server (#1). It is the only gap that changes what kuri *is* rather
than how much it covers, `toolinfo.zig` already has the shape to generate it
from, and it removes the per-action process spawn that makes the CLI awkward for
agents. The build system (#2) is a bigger investment and a different product.
Loading
Loading