Physical-device iOS support (#64) (#66)

* feat(companion): add appState, eraseText, pressKey runner handlers

The Go runner transport already calls these methods; the in-device runner
implemented them only latently. They become load-bearing on the device
path, where the hybrid's legacy-companion fallback is absent. Backward
compatible: the simulator hybrid never calls them.

* feat(ios): resolve physical devices from devicectl

ResolveDevice parses xcrun devicectl list devices into Device{Name,
HardwareUDID, CoreDeviceID}: the hardware UDID feeds xcodebuild/iproxy
and the CoreDevice id feeds devicectl install. Matches by name or either
id; errors list candidates on none/ambiguous. Fixes the stale sidecar
comment on ResolveTarget.

* feat(ioscompanion): runner-only device driver mode

NewDevice reuses Driver with d.companion set to the runner dialed over an
iproxy usbmux tunnel, hybrid=false, runnerClient=nil. The existing accessor
seams then route launch/snapshot/text/gesture to the runner with no new
DeviceDriver methods. Device seams swap clear-state to a devicectl
reinstall, container reset to a warn-once no-op, and paste grant to a no-op.
realSpawnDeviceRunner builds and signs the runner at run time via the App
Store Connect API key (no Xcode UI), caching on a source hash.

* test(ioscompanion): cover device wiring, routing, and shell-out argv

Seam-driven NewDevice wiring + gesture/text routing (asserting no keyboard
HID), devicectl/build/test/iproxy argv builders, xctestrun test-target dict
name parsing, signing-credential env checks, and source-hash cache keying.

* feat(testrun): route physical-device iOS runs to the device driver

Execute resolves a non-simulator iOS target through ios.ResolveDevice into
its hardware UDID and CoreDevice id; buildDriver constructs NewDevice via a
seam instead of rejecting the device. Generalizes the --ios-device and
--ios-app-path help to cover the device path; signing stays env-read, never
a flag.

* feat(doctor): device prereqs replace java/sidecar for ios-device

iosDeviceChecks now verifies devicectl, iproxy on PATH, a connected+paired
device (via ios.ConnectedDevices), and App Store Connect signing creds (via
ioscompanion.VerifyDeviceSigning). The retired JVM sidecar checks stay only
under android.

* feat(conformance): device backend uses iphoneos app and tunnel orphan checks

The device backend now builds via just ios-device, points --ios-app-path at
the Debug-iphoneos bundle, and reinstalls each run for clear-state. The G5
orphan scan replaces the retired sidecar.jar check with lingering iproxy and
device test-without-building sessions (destination platform=iOS,id=).

* feat(folio): device build linking the iosArm64 framework

project.yml selects the Kotlin framework slice by SDK (iosArm64 for
iphoneos, iosSimulatorArm64 for simulator) and links via -framework Shared
on the SDK-conditional search path. New ios-device/test-ios-device recipes
mirror ios/test-ios, signing the Debug-iphoneos build with the .env API key.

* docs(cli): document ios-device doctor checks and the device flags

The --ios-device flag now also selects a connected device; --ios-app-path
covers the device install; the doctor gains an ios-device platform whose
checks are devicectl, iproxy, a paired device, and signing credentials.
Corrects the --clear-data default to true.

* fix(ioscompanion): resolve signing key path to absolute

xcodebuild's -authenticationKeyPath requires an absolute path, but .env
files commonly carry a repo-relative one. Resolve it against the working
directory before the stat so a relative ASC_API_KEY_PATH still signs.

* fix(ioscompanion): re-enable signing for the device runner build

companion/project.yml disables code signing for the simulator build, so
the device build inherited it and produced an unsigned runner that the
device rejected at install (0xe8008018). build-for-testing now forces
CODE_SIGNING_ALLOWED/REQUIRED=YES so automatic provisioning signs it.

* fix(ioscompanion): key the device build cache on signing identity

The cache marker hashed only sources, so switching signing team or key
reused a runner signed with the stale identity, which the device rejects at
install (0xe8008018). Fold team + key id into the cache key so a signing
change forces a rebuild.

* docs(getting-started): document physical iOS device setup

Lists the iproxy requirement and the App Store Connect signing env vars
(SANDERLING_IOS_TEAM, ASC_API_*) a device run needs, plus the
test-ios-device recipe and the doctor check.

* feat(ios): native usbmux client and in-process tunnel forwarder

Talk to macOS usbmuxd directly instead of shelling out to iproxy, so the
device path depends on nothing beyond macOS + Xcode.

* refactor(ios): drive device tunnel via io.Closer seam

Replace the tunnelChild *exec.Cmd and spawnTunnel seam with a tunnel
io.Closer and startTunnel seam backed by the in-process usbmux forwarder.

* refactor(ios): remove iproxy spawn from device runner

* test(ios): cover tunnel close via io.Closer not child process

* feat(doctor): check usbmuxd socket instead of iproxy on PATH

* chore(conformance): drop iproxy orphan check; tunnel is in-process

* docs(ios): device tunnel uses native usbmux, nothing to install

* chore: gitignore the signing keys directory

* feat(folio): add Android launcher icon (black bg, white dot)

* feat(folio): add iOS app icon (black bg, white dot)

* feat(folio): add web favicon (black bg, white dot)

* docs(ioscompanion): fix stale const comments

* refactor(ioscompanion): inline single-use devicectl argv builders

* refactor(ioscompanion): inline xcodegenArgs, drop tautological argv tests

* refactor(ioscompanion): inline firstNonEmpty

* refactor(doctor): dedup usbmuxd socket path via ioscompanion seam

* test(doctor): trim redundant signing-check test

* refactor(ioscompanion): deliver COMPANION_PORT via TEST_RUNNER_ env

* fix(testrun): seam preflight so iOS routing tests pass on CI without xcrun
This commit is contained in:
pj authored and GitHub committed 2026-06-09 18:38:52 +05:30
1 parent e04631d4c9
commit 90224dfd06
35 files changed
+2377 -88

No files matched your search

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Folio"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -6
View File
@@ -19,25 +19,33 @@ targets:
sources:
- iosApp
dependencies:
- framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework
embed: false
- sdk: libsqlite3.tbd
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: app.folio
PRODUCT_NAME: iosApp
INFOPLIST_FILE: iosApp/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The Kotlin framework slice differs by SDK: device builds link the
# iosArm64 framework, simulator builds the iosSimulatorArm64 one. The
# search path and the prebuild gradle task both follow this variable.
KOTLIN_FRAMEWORK_DIR[sdk=iphoneos*]: iosArm64
KOTLIN_FRAMEWORK_DIR[sdk=iphonesimulator*]: iosSimulatorArm64
FRAMEWORK_SEARCH_PATHS:
- $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
OTHER_LDFLAGS:
- -ObjC
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
- -framework
- Shared
preBuildScripts:
- name: Build Kotlin framework
script: |
cd "$SRCROOT/../.."
if [[ "$PLATFORM_NAME" == iphoneos ]]; then
task=linkDebugFrameworkIosArm64
else
task=linkDebugFrameworkIosSimulatorArm64
fi
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64
outputFiles:
- $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared
./gradlew :app:shared:$task
basedOnDependencyAnalysis: false
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Folio</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='black'/><circle cx='32' cy='32' r='14' fill='white'/></svg>">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
#app { height: 100%; }
+37
View File
@@ -8,6 +8,7 @@ seed := env_var_or_default("SEED", "0")
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
default:
@just --list
@@ -122,6 +123,25 @@ ios:
xcrun simctl install booted "{{ios_app}}"
xcrun simctl launch booted app.folio
# Build the folio app for a connected physical iOS device (signed via .env creds).
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
# sanderling installs the build on the device per run via devicectl.
ios-device:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
just ios-gen
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
-destination 'generic/platform=iOS' \
-derivedDataPath app/iosApp/build \
-allowProvisioningUpdates \
-authenticationKeyPath "$ASC_API_KEY_PATH" \
-authenticationKeyID "$ASC_API_KEY_ID" \
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="${SANDERLING_IOS_TEAM:-${DEVELOPMENT_TEAM:-}}" \
build | tail -5
# Run 'sanderling test' against the folio app. Uses a connected device if one is
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
# Depends on install so the run always fuzzes the current build, matching
@@ -173,3 +193,20 @@ test-ios:
--duration "{{duration}}" \
--seed "{{seed}}" \
--output "{{output}}"
# Requires App Store Connect signing creds in .env and IOS_DEVICE set to the
# connected device's name (or UDID / CoreDevice id).
# Build + install + run sanderling spec on a connected physical iOS device.
test-ios-device:
#!/usr/bin/env bash
set -euo pipefail
just ios-device
"{{sanderling}}" test \
--platform ios \
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
--bundle-id app.folio \
--ios-app-path "{{ios_app_device}}" \
--ios-device "{{ios_device}}" \
--duration "{{duration}}" \
--seed "{{seed}}" \
--output "{{output}}"