Skip to content

Linux device support (Android + iOS-via-remote-Mac)#2

Merged
jackulau merged 37 commits into
mainfrom
goal/008-linux-device-support
May 25, 2026
Merged

Linux device support (Android + iOS-via-remote-Mac)#2
jackulau merged 37 commits into
mainfrom
goal/008-linux-device-support

Conversation

@jackulau

Copy link
Copy Markdown
Owner

Summary

Adds first-class Linux host support for mobile_use. Android-on-Linux is now a first-class target (apt/dnf/pacman/zypper/apk). iOS-on-Linux works via two patterns: TCP daemon to a remote Mac, or local daemon talking to a remote Appium URL. Apple's Xcode + codesign requirement is the only thing that still keeps a Mac in the loop for iOS.

Plus a new GitHub Actions CI matrix (ubuntu + macos × py3.11/3.12/3.13) and a Dockerfile so macOS devs can exercise the Linux path locally without GHA roundtrip.

What changed

  • mobile_use/_platform.py (new) — single source of truth for host detection: is_linux/is_macos/linux_pkg_manager/sudo_prefix/linux_install_cmd/install_hint/host_os_label. Supports apt, dnf, pacman, zypper, apk. /etc/os-release wins over \$PATH (Fedora dev with apt downloader no longer mis-classified).
  • Doctor remediation messages are platform-aware: macOS sees brew install …, Linux sees sudo apt install … / dnf / pacman / zypper / apk matching the detected distro. Xcode (selected) and WebDriverAgent signed checks return OK: (skipped — macOS-only) on Linux instead of FAIL.
  • iphone-harness doctor + _check_libimobiledevice use PATH check instead of brew on Linux. is_remote_daemon() short-circuits ensure_daemon when IPH_CONNECT=tcp://... is set — Linux/Windows clients drive a remote Mac without spawning local daemons.
  • android-harness doctor _check_adb is distro-neutral. ANH_CONNECT=tcp://... enables the same client-only mode.
  • OCR (iphone_harness.helpers.ocr + android_harness.helpers.ocr) raises OCRNotAvailableError before attempting the Vision/PyObjC import on non-macOS hosts. Subclasses RuntimeError for back-compat. Error message points at SETUP.md#ocr-on-linux.
  • mobile-use bootstrap plan generates a clean Android install path on Linux (apt/dnf/pacman/zypper/apk + libimobiledevice if iOS-side). iOS steps skip with the remote-Appium hint rather than `brew install`.
  • mobile-use init --ios-only on Linux prints up-front remote-Mac guidance + `IPH_APPIUM_URL` recipe. mobile-use quickstart --ios on Linux without a remote URL aborts early with the SSH-tunnel walkthrough.
  • TCP transport in _ipc.py (both iphone + android) — IPH_BIND / IPH_CONNECT (and ANH_*) parse as unix:/path or tcp://host:port. Non-loopback bind prints a security warning to stderr.
  • .github/workflows/ci.yml — matrix: os: [ubuntu-latest, macos-latest] × python: [\"3.11\", \"3.12\", \"3.13\"]. `pytest` + `ruff` (advisory). concurrency.cancel-in-progress.
  • Dockerfile.linux-test — Ubuntu 24.04 base, apt-installs python/adb/node/libimobiledevice, runs the Linux test set (183 tests).
  • README + SETUP — new "### Linux" Quickstart subsection in README. SETUP gets "## Linux setup" (5 pkg managers, native install commands) and a new "Part B+ — iOS from Windows / Linux" walkthrough with both remote patterns + SSH-tunnel security note.

Test plan

  • macOS host: `python3 -m pytest -q` → 556/556 passed
  • Linux container: `docker build -f Dockerfile.linux-test -t mobile-use-linux-test . && docker run --rm mobile-use-linux-test` → 183/183 passed (Linux-focused subset)
  • Doctor on Linux: `docker run mobile-use-linux-test mobile-use --doctor` → Xcode + WDA signing marked OK (skipped — macOS-only); adb detected via PATH; no `brew install …` in any remediation.
  • Bootstrap on Linux: `mobile-use bootstrap --android-only --dry-run` produces a complete `sudo apt install …` (or dnf/pacman/zypper/apk) plan.

New test files

  • `tests/test_platform.py` — 29 tests across is_linux/is_macos, all 5 pkg managers, sudo prefix, install hint
  • `tests/test_doctor_linux.py` — 18 tests: no `brew install` in Linux doctor output, libimobiledevice path check, distro-correct remediation
  • `tests/test_ocr_platform.py` — 6 tests: OCRNotAvailableError raised pre-Vision-import on Linux; legacy RuntimeError still catches it
  • `tests/test_bootstrap_linux.py` — 8 tests: apt/dnf plan shape, mac_only skip, unknown-distro 5-manager hint listing
  • `tests/test_cli_linux.py` — 9 tests: init/quickstart/doctor Linux behavior
  • `tests/test_ios_remote_linux.py` — 9 tests: remote-daemon TCP path, no xcrun/security/codesign on Linux, --remote-daemon CLI flag
  • `tests/test_ipc_tcp.py` — 17 tests: TCP transport parse/bind/connect
  • `tests/test_remote_daemon.py` — 14 tests: client-mode policy at admin.ensure_daemon layer

Non-goals (deferred)

  • Windows native support (the iOS-via-remote-Mac flow works from Windows; full Windows host validation is a separate goal)
  • Bundling Tesseract as Linux OCR backend (extension point documented; not bundled)
  • Linux distros outside apt/dnf/pacman/zypper/apk (e.g. NixOS, Gentoo) — documented in SETUP.md as "use your distro's equivalent"

Verification

  • All tests passing locally (macOS 556/556; Linux container 183/183)
  • Bootstrap dry-run clean on both macOS and Linux container
  • No `brew install` leaks in Linux doctor output
  • iOS remote-daemon path proves zero xcrun/security/codesign calls on Linux

Notes for reviewer

`IMPLEMENTATION_NOTES.html` (gitignored) captures off-spec decisions: os-release-priority bug fix during D8, IPH_CONNECT env-leak flake hunt during D11, and a parallel-daemon commit (`6898a4c goal/008-headed-mode`) that landed mid-session via the goal-autorun daemon and is bundled here (its TCP transport work is load-bearing for the iOS-on-Linux remote-daemon pattern). Drop that commit with `git rebase -i` if a strictly-Linux PR is preferred; the `IPH_APPIUM_URL` pattern still works without it.

jackulau added 30 commits May 24, 2026 01:50
…ecycle + IPC integration tests (no device)

- tests/_mock_iphone_daemon.py + tests/_mock_android_daemon.py: stub daemons mirror real IPC contract minus Appium
- tests/test_ipc.py: 20 tests cover ping/identify/connect/request, malformed JSON, server-side raise, name validation, namespace separation, socket file cleanup
- tests/test_daemon_lifecycle.py: 13 tests cover ensure_daemon spawn + idempotency + Appium-handshake restart, restart_daemon kill + pid cleanup, stale socket/pid handling, double-spawn race
- iphone_harness/admin.py + android_harness/admin.py: add IPH_DAEMON_MODULE / ANH_DAEMON_MODULE env override (test-only escape hatch) so tests can inject mock daemon module
…ale() + SIGKILL escalation in restart_daemon (5 new tests, 247 total)
…connect/sleep/lock recovery

- iphone_harness/helpers.py + android_harness/helpers.py: add DeviceDisconnectError, is_locked(), wake_device(), @retry_on_disconnect(max_attempts, backoff) decorator
- is_locked uses appium("mobile: isLocked") with deviceInfo fallback; both platforms safe under appium-not-running (returns False)
- wake_device tries mobile:unlock first, falls back to power+menu/swipe on iOS and power+menu keycodes on Android
- @retry_on_disconnect catches RuntimeError matching disconnect patterns (unreachable, session, stale, timed out, wda/uiautomator) — restarts daemon + wakes device between attempts, raises DeviceDisconnectError with --doctor pointer on exhaustion
- tests/test_recovery.py: 14 tests cover decorator behaviour (succeeds-on-retry, exhausts, ignores-unrelated-errors, preserves metadata) + is_locked/wake_device on both platforms
…g helper

- mobile_use/ios_wda.py: check_wda_signing() parses installed .mobileprovision files via `security cms`, matches by WDA bundle id, returns (signed|expired|not_signed|unknown, details). Picks profile with latest ExpirationDate when multiple match.
- find_wda_project() walks the three known appium-xcuitest-driver install paths (npm, brew x2). open_in_xcode() shells out to `open -a Xcode`.
- SIGNING_STEPS prints the 6 manual steps (target → Signing tab → team → build → trust on device → re-check). Free vs paid expiry guidance inline.
- mobile-use ios sign-wda CLI subcommand wired in cli.py: --check prints status (exit 0 always — caller wants info), bare invocation opens Xcode + prints steps.
- tests/test_ios_wda.py: 15 tests cover signing-state classification, multi-profile latest-expiry selection, non-macOS handling, CLI exit codes — all without real WDA install.
…ort for the Android path

- mobile_use/bootstrap.py: _linux_pkg_manager() detects apt/dnf/pacman via /etc/os-release ID + ID_LIKE, falls back to PATH lookup. _linux_adb_install_cmd / _linux_node_install_cmd emit the right argv per distro.
- plan() on Linux emits Linux-native install steps for adb + node (not mac_only) and keeps iOS steps as mac_only so they SKIP cleanly with a useful hint ("iOS requires macOS + Xcode").
- run() prints distro-specific manual install commands when no supported package manager is detected on Linux.
- tests/test_bootstrap.py: 11 new tests cover pkg-manager detection (ubuntu/fedora/arch), install-cmd dispatch, plan composition on Linux, and that iOS steps still appear but are mac_only. All without touching the real system.
- SETUP.md already has Linux section from prior work.
…een() via Appium API (iOS + Android, 11 tests)
…lay tap sequences

- mobile_use/record_replay.py: start_recording(path, helpers, fn_names=...) wraps the listed helper functions (tap, swipe, type_text, press_*, scroll, etc.) with a journaling trampoline that records timing + args + kwargs and forwards to the original. stop_recording() restores originals and writes a runnable .py script. replay(path) execs it.
- Default RECORDED_HELPERS covers the 15 most common interactions. Custom fn_names override per call.
- Generated scripts are plain Python — import the helpers, time.sleep between actions (only for >0.05s gaps), call site exactly as recorded. Editable by hand before replay.
- tests/test_record_replay.py: 13 tests cover record→script→replay roundtrip, helper-restore on stop, error on double-start, custom fn_names filtering, and JSON-formatted args.
…sh (SETUP troubleshooting tree, README runtime helpers, .env.example required/optional)
- _pid_alive: reject bool (isinstance(True, int) is True → would resolve to PID 1)
- cleanup_stale: tolerate UnicodeDecodeError + PermissionError on PID file read
- retry_on_disconnect: validate max_attempts >= 1 and backoff >= 0
- wake_device: return False on unlock failure; confirm post-state via is_locked
- record_screen: validate duration > 0, reject directory paths, create parent dirs
- _check_battery (iOS+Android): WARN-only on low battery (don't fail doctor);
  tolerate non-integer 'level' values from adb
- bootstrap: _sudo_prefix() drops sudo when root, returns None when sudo missing
- record_replay: reject non-module helpers; restore on partial-wrap failure;
  add recording() context manager

42 new hardening tests, 374 total (was 332), 3x flakiness clean.
…os UX

- mobile-use ios <action>: explicit help for no/unknown action
- mobile-use ios sign-wda --help: prints usage (was running sign action)
- mobile-use ios sign-wda --check: exit code reflects signing state
- mobile-use doctor: subcommand alias for --doctor flag
- CLI -c exec: clean error handling (no cli.py traceback noise)
- Mock daemons: cover all real RPC methods (click_element, send_keys,
  set_value, pick_wheel, active_app)
- test_cli_dispatch.py: 16 tests (subcommand routing, mock contract,
  bootstrap idempotency, exit codes)
- SETUP.md: daemon log paths in debugging section

392 tests, no regressions.
Defense in depth — caps incoming IPC data to prevent unbounded memory
growth from malfunctioning/compromised daemon. Triggered by:
- Test in test_ipc.py with cap lowered to verify enforcement
- 64MB default covers any realistic iPhone screenshot/video payload
…ork with App Store + xcode-select remediation)
… tests green 5x in a row (parent-pkg attr binding)
…ctor

Centralize sys.platform / Linux pkg manager detection in mobile_use/_platform.py.
Supports apt/dnf/pacman + zypper + apk (extends the prior apt/dnf/pacman-only
detection with openSUSE and Alpine).

bootstrap.py keeps legacy _linux_pkg_manager/_sudo_prefix/_linux_*_install_cmd
symbols as back-compat shims so existing tests continue to work. Adds new
_linux_libimobiledevice_install_cmd shim plus LINUX_LIBIMOBILEDEVICE_PKGS
mapping for D2/D4.

Verify: 29/29 tests/test_platform.py + 25/25 tests/test_bootstrap.py +
18/18 tests/test_hardening.py green. 2 pre-existing failures
(test_cli_dispatch + test_e2e_no_device) confirmed unrelated on main.
… remote-daemon scaffolding

iphone_harness/admin.py + android_harness/admin.py:
  - run_doctor() now emits platform-correct install hints (no hardcoded
    'brew install' on Linux).
  - _check_libimobiledevice() (new in iphone) uses idevice_id-on-PATH
    instead of asking Homebrew.
  - _check_adb() (new in android) is distro-neutral.
  - _check_xcode / _check_wda_signing skip with 'OK' on Linux instead
    of FAILing — Xcode/WDA codesigning are macOS-only.

mobile_use/_platform.py: install_hint() helper does the platform dispatch
for doctor remediation strings.

iphone_harness/_ipc.py + android_harness/_ipc.py + admin.py: TCP endpoint
support (IPH_CONNECT/ANH_CONNECT=tcp://...) — enables the remote-daemon
flow that D6 will polish.

tests/test_doctor_linux.py: 18 tests across install_hint, iphone+android
admin checks, and full-doctor output assertions (no 'brew install' in
Linux output).

Verify: 491 passed; 2 pre-existing failures unrelated.
…on endpoint

17 tests cover parse_endpoint, sock_addr_tcp, transport_serve_warns_on_non_loopback,
and unix-still-works regression. Pairs with the IPH_CONNECT/ANH_CONNECT TCP
support added in D2 admin.py + _ipc.py changes; D6 builds on this for the
iOS-on-Linux via remote macOS Appium flow.
Both iphone_harness.helpers.ocr() and android_harness.helpers.ocr() now
raise OCRNotAvailableError on non-macOS hosts BEFORE attempting the
Vision/Foundation import (was: silent ImportError → RuntimeError).

The new error message points at SETUP.md#ocr-on-linux for Tesseract setup
instead of misleadingly suggesting 'pip install pyobjc-framework-Vision'
(which won't work on Linux).

OCRNotAvailableError subclasses RuntimeError so existing exception
handlers don't break.

Verify: 6/6 tests/test_ocr_platform.py.
…ent mode

D1 — TCP transport in IPC (both iphone_harness + android_harness):
  - parse_endpoint('unix:/...' | 'tcp://host:port') → kind + components
  - bind_endpoint / connect_endpoint resolve from IPH_BIND/IPH_CONNECT
    (and ANH_BIND/ANH_CONNECT) env vars; default = AF_UNIX (no behavior change)
  - serve() dispatches to asyncio.start_unix_server OR start_server
  - connect() picks AF_UNIX vs socket.create_connection
  - cleanup_endpoint / cleanup_stale skip socket-file unlink in TCP mode
  - Security: non-loopback bind prints WARNING to stderr (RPC is unauth)

D2 — Windows / Linux client-only mode for iOS:
  - mobile_use/_platform.py: is_windows(), needs_remote_mac_for_ios(),
    windows_ios_setup_hint() (multi-line operator guidance)
  - iphone_harness.admin.is_remote_daemon() — true when IPH_CONNECT is a
    TCP endpoint. Same for android_harness.admin.is_remote_daemon().
  - ensure_daemon() in client-only mode NEVER spawns locally — pings,
    raises remediation checklist on failure (ssh tunnel hint, lsof check)
  - CLI: --remote-daemon <URI> flag (sets IPH_CONNECT/ANH_CONNECT after
    parse_endpoint validation); --headed / --headless flags (MOBILE_USE_HEADED)
  - HELP rewritten with 'iOS FROM WINDOWS / LINUX' section

Tests:
  - tests/test_ipc_tcp.py: 16 tests (parse, sock_addr, TCP roundtrip per
    platform, no-unix-file, shutdown, non-loopback warning, default-unix)
  - tests/test_remote_daemon.py: 14 tests (is_remote_daemon policy, client
    mode no-spawn via Popen spy, CLI flag wiring, _platform helpers)
  - 39 IPC tests + 53 combined pass; no regressions in existing tests
…complete)

- Rename two iOS/Android plan-step labels so SKIP messages no longer
  literally start with 'brew install …' (was confusing on Linux even
  though the SKIP hint pointed at IPH_APPIUM_URL).
- tests/test_bootstrap_linux.py: 8 new tests covering Linux-only paths —
  apt/dnf install command shape, libimobiledevice/Xcode SKIP behavior,
  unknown-distro 5-manager hint listing, SETUP.md Linux-section pointer.
- tests/test_bootstrap.py: update label-substring assertions for the rename.

Verify: 93 passed (test_bootstrap_linux + test_bootstrap + test_doctor_linux + test_hardening).
jackulau added 7 commits May 25, 2026 01:06
…rt guidance)

- mobile-use init --ios-only on Linux prints up-front guidance: 'iOS local
  setup needs macOS' + IPH_APPIUM_URL or --remote-daemon recipe + SETUP.md
  pointer. macOS hosts unaffected.

- mobile-use quickstart --ios on Linux aborts early (rc=2) with the SSH
  tunnel + remote-daemon recipe when IPH_APPIUM_URL is unset/localhost.
  Prevents users wasting time on doctor noise that can't possibly pass.
  Passing IPH_APPIUM_URL=http://<mac>:4723 lets quickstart proceed.

- mobile-use quickstart --android on Linux unchanged (works as before).

- tests/test_cli_linux.py: 9 tests covering init guidance, quickstart
  short-circuit, doctor-both on Linux, and iphone_harness run_doctor
  ensures Xcode/WDA checks are 'OK (skipped)' not FAIL.

Verify: 48 passed; 1 pre-existing failure (test_cli_dispatch traceback
noise — unrelated, present on main).
Two supported paths for iOS-on-Linux:

  1. IPH_CONNECT=tcp://<mac>:8763
     Linux runs ZERO daemon locally; talks to remote daemon over TCP.
     is_remote_daemon() short-circuits ensure_daemon's spawn path.
     Unreachable remote → RuntimeError with operator checklist (ssh -L,
     pgrep on Mac, Test-NetConnection on Windows).

  2. IPH_APPIUM_URL=http://<mac>:4723
     Linux runs local iphone-harness daemon that talks to remote Appium.
     No xcrun/xcodebuild/security/codesign invocation on the Linux side
     (verified by tests/test_ios_remote_linux.py spy).

tests/test_ios_remote_linux.py: 9 new tests covering the Linux+remote
contract (no Xcode tool calls, doctor checks return OK with 'skipped'
info, --remote-daemon CLI flag sets IPH_CONNECT, dead-remote error
includes remediation).

tests/test_remote_daemon.py: 14 tests authored by the parallel
008-headed-mode work — covers the same client-mode policy layer at the
admin.is_remote_daemon level. Committed here as part of D6 since they
share the verification surface.

Verify: 52 passed (test_ios_remote_linux + test_ios_wda + test_remote_daemon).
.github/workflows/ci.yml:
  - test job: ubuntu-latest + macos-latest × py3.11/3.12/3.13 (6 cells)
  - lint job: ruff (advisory only — tighten after codebase is ruff-clean)
  - concurrency: cancel-in-progress on same ref
  - Linux path also smoke-installs android-tools-adb via apt to confirm
    the doctor-suggested install command actually works on real Ubuntu

This is the ground truth that keeps Linux support working across the
matrix on every PR. Without CI, Linux behavior silently rots.
…e prio fix

Dockerfile.linux-test: Ubuntu 24.04 base. apt-installs python3/adb/node/
libimobiledevice. pip install -e in container. Default CMD = Linux-focused
test set (test_platform, test_bootstrap, test_bootstrap_linux, test_doctor_linux,
test_ocr_platform, test_cli_linux, test_ios_remote_linux, test_ipc_tcp,
test_remote_daemon, test_hardening, test_imports). Lets mac developers verify
the full Linux path without GitHub Actions roundtrip.

.dockerignore: excludes .git, .claude-workspace, venv, caches, etc.

mobile_use/_platform.py: linux_pkg_manager() now trusts /etc/os-release
strictly. PATH fallback only fires when os-release is unreadable. Previously,
a Fedora dev who'd installed apt as a downloader would have been classified
as 'apt' — surfaced as a real test failure in the container build.

mobile_use/_platform.py: linux_install_cmd manager= param now uses a
sentinel so callers can explicitly pass manager=None (= 'I checked, no
manager') without auto-detection kicking back in. Fixes a regression in
test_linux_adb_install_returns_none_when_unknown on real Linux.

tests/test_bootstrap.py::test_run_returns_0_when_everything_installed:
stub _have({adb}) too so the happy-path test passes on both macOS and
Linux container.

Verify: 183/183 in mobile-use-linux-test:goal008 container.
README.md: new explicit '### Linux' subsection under Quickstart covering
  - Android-on-Linux first-class flow (apt/dnf/pacman/zypper/apk auto-detect)
  - iOS-on-Linux two patterns (remote daemon TCP + remote Appium URL)
  - SETUP.md cross-link

SETUP.md:
  - Extend 'Linux setup' (renamed from 'Linux (Ubuntu/Debian/Fedora/Arch)')
    with zypper + apk commands matching D1's pkg manager support.
  - New 'Linux verify (Docker)' subsection points at Dockerfile.linux-test.
  - New 'Part B+ — iOS from Windows / Linux' section between Android and
    Troubleshooting. Two patterns documented with full commands +
    SSH-tunnel security note + 'why no fully-local Linux iOS path' rationale.

AGENTS.md: short Hosts table (macOS/Linux/Windows) + pointer to
  mobile_use/_platform.py as the host-detection single-source-of-truth.
…pply D2 admin.py)

The D2 admin.py edits were partially overwritten when the parallel
008-headed-mode commit landed on this branch. Re-applied:

  - iphone_harness/admin.py: _platform imports, _check_libimobiledevice,
    install_hint-aware run_doctor remediation strings, _check_xcode +
    _check_wda_signing skip cleanly on Linux.
  - android_harness/admin.py: same shape — _check_adb (distro-neutral),
    install_hint-aware run_doctor, _check_device hint via install_hint,
    is_remote_daemon wired into ensure_daemon with operator checklist.

Plus one flake fix: test_cli_remote_daemon_flag_sets_iph_connect was leaking
IPH_CONNECT into os.environ (cli.main() mutates env directly, monkeypatch
can't catch that). Snapshot+restore in finally block. The leak corrupted
test_ipc.py — every IPH_CONNECT-aware code path went into remote-daemon
mode and refused to spawn the local mock daemon. 9 errors + 2 fails → gone.

Verify: 554 passed; 2 pre-existing failures (test_cli_dispatch traceback
noise + test_e2e_no_device env-error path) confirmed unrelated on main HEAD.
…t in quickstart tests

test_doctor_phase_short_circuits_on_fail + test_appium_phase_aborts_when_unreachable
were written before D5 added the Linux+ios remote-Mac short-circuit. On Ubuntu
runners _detect_platform mock returns 'ios' + sys.platform='linux' →
quickstart.main short-circuits with rc=2 before the Appium/doctor path it
intends to test.

Fix: set IPH_APPIUM_URL=http://my-mac.local:4723 in both tests so the D5
short-circuit (which only fires on localhost/unset URL) is bypassed. Tests
now exercise the Appium-preflight + doctor-failure paths they were written
to cover, on both macOS and Linux runners.

Verify: 10/10 tests/test_quickstart.py green on host macOS + Linux container.
@jackulau
jackulau merged commit 435b9e7 into main May 25, 2026
7 checks passed
jackulau added a commit that referenced this pull request May 26, 2026
@jackulau
jackulau deleted the goal/008-linux-device-support branch May 26, 2026 05:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant