Skip to content

Commit 6a3b6be

Browse files
justrachclaude
andcommitted
release: v0.4.11 — stop hijacking the foreground, fix real-device terminate
kuri could not be run on a machine someone was using. Every Simulator input command raised Simulator.app first, because CGEventPost(kCGHIDEventTap) injects into the global event stream and lands wherever focus happens to be. `type` was worse: it shelled out to AppleScript `keystroke`, which goes to the frontmost app, so without stealing focus it would have typed into the user's own window. And `wait-for-ui` polls every 250ms, re-stealing the foreground each time. Input now goes to Simulator.app by pid via CGEventPostToPid, `type` uses Unicode CGEvents, the observation commands do not activate at all, and `open-sim` opens in the background. `--activate` restores the old behaviour per command. Two real-device bugs, both found by writing the tests: - `terminate --device` built `devicectl device process terminate --device <udid> <bundle-id>`, but devicectl's terminate takes `--pid` and no bundle id, so it could only ever die on devicectl's argument parser. `launch --device` now surfaces the pid; `terminate` takes `--pid` or resolves a bundle id itself. - `list-apps --device` silently hid every system app: devicectl's app listing defaults to developer apps only and says nothing about it. Also: `uitree` reported "Simulator.app is not running" when it was running but had no window, which sent you to restart an app that was already up. Verified on hardware — 24 passed / 0 failed against an iPhone 16 Pro Max, 54 passed / 0 failed against a booted simulator, 42 unit tests. The e2e suite now runs in CI on macOS, which is what would have caught the 0.4.6-to-0.4.10 silent `--device` no-op in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012gzdKWXk7UbHm5TtSGqYBJ
1 parent a363c22 commit 6a3b6be

20 files changed

Lines changed: 1457 additions & 202 deletions

.github/workflows/ci.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ jobs:
3535
- name: Run tests
3636
run: zig build test -Doptimize=ReleaseSafe
3737

38+
- name: Run kuri-mobile tests
39+
# kuri-mobile ships in the same tarball but has its own build.zig, so
40+
# the root `test` step never reached it. Its platform-independent
41+
# half — the adb wire protocol, the devicectl JSON decoding, the tool
42+
# registry — is worth checking on Linux too; the macOS-only half is
43+
# covered by the mobile-macos job.
44+
working-directory: kuri-mobile
45+
run: zig build test --summary all -Doptimize=ReleaseSafe
46+
3847
- name: Regression — --help / --version do NOT spawn Chrome (issue #156)
3948
# Old kuri binaries (≤ v0.1.0) silently fell through to the daemon
4049
# path on `--help`, launching headless Chrome and binding :8080.
@@ -69,6 +78,93 @@ jobs:
6978
echo "::endgroup::"
7079
done
7180
81+
mobile-macos:
82+
# kuri-mobile is the one part of the tree that can be exercised against
83+
# real Apple tooling, and GitHub's macOS runners ship Xcode plus iOS
84+
# simulator runtimes. Before this job the e2e suite only ever ran by hand,
85+
# so nothing caught a regression on the device path — which is exactly how
86+
# the silent `--device` no-op survived from 0.4.6 to 0.4.10.
87+
#
88+
# The suite reports SKIP rather than failing for anything the runner
89+
# cannot provide (notably the Accessibility grant that uitree/find/
90+
# wait-for-ui need, which no CI runner can give), so it is a stable gate
91+
# rather than a flaky one.
92+
runs-on: macos-latest
93+
defaults:
94+
run:
95+
working-directory: kuri-mobile
96+
97+
steps:
98+
- name: Checkout
99+
uses: actions/checkout@v4
100+
101+
- name: Install Zig
102+
uses: mlugg/setup-zig@v2
103+
with:
104+
version: 0.17.0-dev.813+2153f8143
105+
106+
- name: Cache Zig build artifacts
107+
uses: actions/cache@v4
108+
with:
109+
path: |
110+
kuri-mobile/.zig-cache
111+
~/.cache/zig
112+
key: zig-mobile-${{ runner.os }}-${{ hashFiles('kuri-mobile/build.zig') }}
113+
restore-keys: |
114+
zig-mobile-${{ runner.os }}-
115+
116+
- name: Show toolchain
117+
run: |
118+
xcode-select -p
119+
xcodebuild -version
120+
ls "$(xcode-select -p)/usr/bin/devicectl" || echo "no devicectl in the selected toolchain"
121+
122+
- name: Build
123+
run: zig build -Doptimize=ReleaseSafe
124+
125+
- name: Unit tests
126+
run: zig build test --summary all
127+
128+
- name: Doctor
129+
# Exits 3 when it finds blocking problems, which on a runner it will
130+
# (no Accessibility grant). Informational only — the assertions live
131+
# in the e2e suite.
132+
continue-on-error: true
133+
run: ./zig-out/bin/kuri-mobile doctor
134+
135+
- name: Boot a simulator
136+
# Deliberately picks and boots through kuri-mobile rather than `xcrun
137+
# simctl`. Two reasons: it exercises `ios list-devices` and `ios boot`
138+
# for real, and bare `xcrun` resolves through xcode-select — the very
139+
# indirection whose failure mode this project exists to avoid. simctl
140+
# is then called by absolute path for `bootstatus`, which kuri has no
141+
# equivalent for.
142+
run: |
143+
set -euo pipefail
144+
udid=$(./zig-out/bin/kuri-mobile ios list-devices \
145+
| awk -F'\t' '$1 == "simulator" && $4 ~ /iPhone/ { print $2; exit }')
146+
if [ -z "${udid}" ]; then
147+
echo "::error::no iPhone simulator available on this runner"
148+
./zig-out/bin/kuri-mobile ios list-devices
149+
exit 1
150+
fi
151+
echo "booting ${udid}"
152+
./zig-out/bin/kuri-mobile ios boot --udid "${udid}"
153+
# simctl reports Booted well before the runtime has finished coming
154+
# up, so wait on bootstatus — otherwise the first e2e command races
155+
# the boot and fails for a reason unrelated to the change under test.
156+
"$(xcode-select -p)/usr/bin/simctl" bootstatus "${udid}" -b
157+
158+
- name: End-to-end (simulator)
159+
run: zig build e2e-ios
160+
161+
- name: End-to-end (physical device)
162+
# No phone is attached to a runner, so this must report SKIP and exit
163+
# 0. Running it anyway keeps the device suite compiling and proves its
164+
# no-hardware path stays clean — the suite is useless as a local gate
165+
# if it has quietly stopped building.
166+
run: zig build e2e-ios-device
167+
72168
startup-smoke:
73169
runs-on: ubuntu-latest
74170

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@
22

33
All notable changes to kuri are documented here.
44

5+
## [0.4.11] — 2026-07-25
6+
7+
### Fixes — kuri no longer takes over your machine
8+
9+
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.
10+
11+
- **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`
12+
- **`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
13+
- **`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
14+
- **`button` and `background` no longer activate.** They already used `AXPress`, which never needed focus
15+
- **`open-sim` launches in the background** (`open -g`). Opening a simulator is a setup step, not a request to be interrupted
16+
- **`--activate` restores the old behaviour** per command, for the case where a gesture genuinely needs Simulator.app to be key. Off by default
17+
18+
### Fixes — real devices
19+
20+
- **`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
21+
- **`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
22+
- **`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
23+
24+
### Fixes — diagnosis
25+
26+
- **"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
27+
28+
### Tests
29+
30+
- **`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
31+
- **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
32+
- **`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
33+
- **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
34+
- 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`)
35+
36+
### CI
37+
38+
- **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
39+
- 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
40+
- The job never opens Simulator.app, so it runs headless and the accessibility cases skip
41+
- 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
42+
543
## [0.4.10] — 2026-07-25
644

745
### Fixes

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri,
3-
.version = "0.4.10",
3+
.version = "0.4.11",
44
.dependencies = .{
55
.quickjs = .{
66
.url = "https://github.com/mitchellh/zig-quickjs-ng/archive/main.tar.gz",

kuri-mobile/build.zig

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,27 +50,67 @@ pub fn build(b: *std.Build) void {
5050
const test_step = b.step("test", "Run unit tests");
5151
test_step.dependOn(&b.addRunArtifact(unit_tests).step);
5252

53-
// End-to-end suite. Kept off `test` on purpose: it drives a real booted
54-
// simulator, which CI does not have. It receives the built binary's path
55-
// as argv[1] so it exercises exactly the artifact that would ship.
56-
const e2e_mod = b.createModule(.{
57-
.root_source_file = b.path("src/test/e2e_ios.zig"),
53+
// End-to-end suites. Kept off `test` on purpose: they shell out to the
54+
// real Xcode toolchain, and most cases need a booted simulator or an
55+
// attached phone. Each receives the built binary's path as argv[1] so it
56+
// exercises exactly the artifact that would ship.
57+
//
58+
// 0.17 restricts imports to a module's own path, so the shared helpers
59+
// come in as named modules rather than relative paths.
60+
const io_mod = b.createModule(.{
61+
.root_source_file = b.path("src/common/io.zig"),
5862
.target = target,
5963
.optimize = optimize,
6064
.link_libc = true,
6165
});
62-
// 0.17 restricts imports to a module's own path, so the shared io helpers
63-
// come in as a named module rather than a relative path.
64-
e2e_mod.addImport("io", b.createModule(.{
65-
.root_source_file = b.path("src/common/io.zig"),
66+
const harness_mod = b.createModule(.{
67+
.root_source_file = b.path("src/test/harness.zig"),
6668
.target = target,
6769
.optimize = optimize,
6870
.link_libc = true,
69-
}));
70-
const e2e = b.addExecutable(.{ .name = "e2e-ios", .root_module = e2e_mod });
71-
const run_e2e = b.addRunArtifact(e2e);
72-
run_e2e.addArtifactArg(exe);
73-
run_e2e.addPassthruArgs();
74-
const e2e_step = b.step("e2e-ios", "Run iOS end-to-end tests (needs a booted simulator)");
75-
e2e_step.dependOn(&run_e2e.step);
71+
});
72+
harness_mod.addImport("io", io_mod);
73+
74+
const Suite = struct { step: []const u8, src: []const u8, desc: []const u8 };
75+
const suites = [_]Suite{
76+
.{
77+
.step = "e2e-ios",
78+
.src = "src/test/e2e_ios.zig",
79+
.desc = "Run iOS end-to-end tests (simulator; skips what the host cannot provide)",
80+
},
81+
.{
82+
.step = "e2e-ios-device",
83+
.src = "src/test/e2e_ios_device.zig",
84+
.desc = "Run iOS end-to-end tests against an attached physical device",
85+
},
86+
};
87+
for (suites) |s| {
88+
const mod = b.createModule(.{
89+
.root_source_file = b.path(s.src),
90+
.target = target,
91+
.optimize = optimize,
92+
.link_libc = true,
93+
});
94+
mod.addImport("io", io_mod);
95+
mod.addImport("harness", harness_mod);
96+
const exe_suite = b.addExecutable(.{ .name = s.step, .root_module = mod });
97+
const run_suite = b.addRunArtifact(exe_suite);
98+
run_suite.addArtifactArg(exe);
99+
run_suite.addPassthruArgs();
100+
b.step(s.step, s.desc).dependOn(&run_suite.step);
101+
102+
// The suites' own parsing helpers — which udid a listing names, which
103+
// pid a launch printed — decide what the hardware cases assert on, so
104+
// they belong in `zig build test` where they run everywhere. Needs a
105+
// second module: a test root cannot be shared with an executable root.
106+
const test_suite_mod = b.createModule(.{
107+
.root_source_file = b.path(s.src),
108+
.target = target,
109+
.optimize = optimize,
110+
.link_libc = true,
111+
});
112+
test_suite_mod.addImport("io", io_mod);
113+
test_suite_mod.addImport("harness", harness_mod);
114+
test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = test_suite_mod })).step);
115+
}
76116
}

kuri-mobile/build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri_mobile,
3-
.version = "0.4.10",
3+
.version = "0.4.11",
44
.dependencies = .{},
55
.fingerprint = 0x41cfa5929f72d020,
66
.minimum_zig_version = "0.17.0-dev.813+2153f8143",

kuri-mobile/src/common/io.zig

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,43 @@ pub fn sleepMs(ms: u64) void {
176176
_ = std.c.nanosleep(&ts, null);
177177
}
178178

179+
/// Read a whole file into an allocated slice. Caller frees.
180+
///
181+
/// Needed because `devicectl` never writes JSON to stdout — its only
182+
/// machine-readable channel is `--json-output <path>`, so reading it back off
183+
/// disk is the entire structured-output path for real devices.
184+
pub fn readFile(allocator: std.mem.Allocator, path: []const u8, max_bytes: usize) ![]u8 {
185+
var pbuf: [4096]u8 = undefined;
186+
if (path.len >= pbuf.len) return error.NameTooLong;
187+
@memcpy(pbuf[0..path.len], path);
188+
pbuf[path.len] = 0;
189+
const fd = std.c.open(pbuf[0..path.len :0], .{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0));
190+
if (fd < 0) return error.OpenFailed;
191+
defer _ = std.c.close(fd);
192+
193+
var out: std.ArrayList(u8) = .empty;
194+
errdefer out.deinit(allocator);
195+
var buf: [4096]u8 = undefined;
196+
while (true) {
197+
const n = std.c.read(fd, &buf, buf.len);
198+
if (n < 0) return error.ReadFailed;
199+
if (n == 0) break;
200+
const bytes: usize = @intCast(n);
201+
if (out.items.len + bytes > max_bytes) return error.FileTooLarge;
202+
try out.appendSlice(allocator, buf[0..bytes]);
203+
}
204+
return out.toOwnedSlice(allocator);
205+
}
206+
207+
/// Best-effort unlink, for scratch files whose removal is not worth an error.
208+
pub fn removeFile(path: []const u8) void {
209+
var pbuf: [4096]u8 = undefined;
210+
if (path.len >= pbuf.len) return;
211+
@memcpy(pbuf[0..path.len], path);
212+
pbuf[path.len] = 0;
213+
_ = std.c.unlink(pbuf[0..path.len :0]);
214+
}
215+
179216
/// Write bytes to a file path (overwriting). Uses libc to avoid std.fs.File.
180217
pub fn writeFile(path: []const u8, data: []const u8) !void {
181218
var pbuf: [4096]u8 = undefined;

kuri-mobile/src/doctor.zig

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,18 @@ pub fn run(gpa: std.mem.Allocator) !u8 {
8383
// --- Simulator.app --------------------------------------------------
8484
if (sim_ax.simulatorPid(gpa)) |maybe_pid| {
8585
if (maybe_pid) |pid| {
86-
const d = try std.fmt.allocPrint(rep.arena, "running (pid {d})", .{pid});
87-
rep.line(.ok, "Simulator.app", d);
86+
// Running is not sufficient: the accessibility tree hangs off a
87+
// window, and `simctl boot` does not open one. Reporting only
88+
// the process made a windowless Simulator look healthy right up
89+
// until uitree failed.
90+
if (sim_ax.hasOpenWindow(gpa)) {
91+
const d = try std.fmt.allocPrint(rep.arena, "running (pid {d}), window on screen", .{pid});
92+
rep.line(.ok, "Simulator.app", d);
93+
} else {
94+
const d = try std.fmt.allocPrint(rep.arena, "running (pid {d}) but no window — uitree/find/wait-for-ui need one", .{pid});
95+
rep.line(.warn, "Simulator.app", d);
96+
rep.hint("kuri-mobile ios open-sim");
97+
}
8898
} else {
8999
rep.line(.warn, "Simulator.app", "not running — input and uitree need it open");
90100
rep.hint("kuri-mobile ios open-sim");

0 commit comments

Comments
 (0)