Files
Ignacy Łątka 80661fad8f feat: Linux support (#249)
This PR makes Linux work.

# AI Summary 

<details>
Brings Argent up on Linux as a host: per-platform npm bundle, renderer
selection that actually works on hybrid-GPU / Wayland / containerized
Linux, hot-boot guarding that survives the SwiftShader composite gap, a
real Linux preflight, and a Wayland-headless E2E job in CI. macOS stays
on
the path it was on before (no `-gpu` flag was passed; AVD config
decided);
nothing about the iOS Simulator behavior changes.

The diff is broader than "ship the Linux artifact" — calling it out
below
because several pieces affect macOS too and reviewers should be primed
for
that rather than skim past expecting "just packaging."

### What's actually in here

**npm packaging (the original framing — still load-bearing)**

| File | Change |
|---|---|
| `packages/argent/scripts/argent-simulator-server.cjs` (new) | Node
dispatcher exposed as the `argent-simulator-server` bin entry. Joins
`process.platform` into the bin path and execs. Forwards
SIGTERM/SIGINT/SIGHUP parent→child so non-TTY supervisors can't orphan
the binary. |
| `packages/argent/scripts/bundle-tools.cjs` | Copies per-platform
binaries from `packages/native-devtools-ios/bin/<platform>/` into
`packages/argent/bin/<platform>/`. darwin required, linux best-effort. |
| `packages/argent/package.json` | `bin.argent-simulator-server` points
at the dispatcher. |
| `packages/native-devtools-ios/src/index.ts` | Resolver joins
`process.platform` into the simulator-server bin path. iOS-only dylibs
and `ax-service` throw `"requires a macOS host"` on Linux callers so
they get the root cause, not "file not found". |
| `scripts/download-simulator-server.sh` | Downloads
`simulator-server-argent-{macos,linux}` into platform-keyed subdirs.
**Purges each platform dir before its download attempt** so a failed
download can't leave stale binaries in place from a prior run. |
| `scripts/dev.cjs` | Local dev copies the current host's binary into
`bin/<process.platform>/` to match the resolver layout. |
| `.github/workflows/publish{,-next}.yml` | Replaces single `test -x
darwin/...` with a per-platform inventory loop. darwin always required;
Linux gated by `ARGENT_REQUIRE_LINUX_BINARY=1` (flip once radon#118
lands). |

**Linux runtime — emulator boot path**

| File | Change |
|---|---|
| `packages/tool-server/src/tools/devices/boot-device.ts` |
`selectGpuMode()`: Linux defaults to `-gpu swiftshader` (`auto` lands on
lavapipe with ~10× cold-boot slowdown; `-gpu host` produces silent
black-window failures on Optimus / NVIDIA+libglvnd / Wayland-on-hybrid /
containers). macOS keeps `auto` (ANGLE→Metal).
`ARGENT_EMULATOR_GPU_MODE` overrides, **validated against `emulator
-help-gpu`'s whitelist** so a typoed value fails fast instead of burning
the whole boot budget. `ARGENT_EMULATOR_NO_WINDOW=1` appends
`-no-window` for headless contexts (CI / containers / Wayland sessions
with no Qt wayland plugin). |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Cold-boot:** `awaitFirstRealFrame` polls SwiftShader's lockscreen
composite (PNG byte-count via `screencap -p`); blocks `booted:true`
until a real frame shows up. Without this, callers chaining `boot-device
→ screenshot` get an all-black PNG because `sys.boot_completed=1` fires
5–60 s before the first composite. |
| `packages/tool-server/src/tools/devices/boot-device.ts` (cont.) |
**Hot-boot:** `assertScreencapAlive` polls within an 8 s budget instead
of single-probing. The earlier single-probe caught the sticky-blank
state but also killed the emulator on every transient snapshot-restore
blank, forcing "every boot is cold" cycles. Sticky vs transient is
discriminated by whether the budget exhausts. |
| `packages/tool-server/src/utils/linux-preflight.ts` (new) |
`linuxBootDiagnostics()` checks `/dev/kvm` access. `diagnoseAvdSizing()`
warns when a target AVD's `hw.ramSize`/`vm.heapSize` is below the floor
an aggressive RN/Expo app needs (2G/228M avdmanager defaults wedge under
load). |
| `packages/tool-server/src/utils/android-binary.ts` |
`defaultAndroidRoots()` probes typical SDK install locations when
`ANDROID_HOME`/`ANDROID_SDK_ROOT` are unset — **cross-platform**,
benefits macOS launches from GUI-launched MCP hosts (Claude Code Desktop
etc.) where shell rc env doesn't propagate. |

**Cross-platform fixes that landed alongside**

| File | Change |
|---|---|
| `packages/argent-installer/src/utils.ts` | `getLatestVersion()` now
redirects `npm view`'s stderr to `ignore` so `EBADDEVENGINES` warnings
from a consumer project's `package.json` (e.g. Bluesky's pnpm pin) don't
leak to the user's terminal. **Cross-platform fix**, not Linux-specific.
|

**CI**

| File | Change |
|---|---|
| `.github/workflows/wayland-e2e.yml` (new) | Boots an AVD through the
tool-server under headless Weston; asserts `hw.gpu.mode = swiftshader`
and that screenshots + gesture-tap work without a display. Currently
runs on `pull_request` for the affected paths + `workflow_dispatch`.
Pinned to `radon-daily` for the simulator-server until radon#118 ships a
Linux asset to `radon-main`. |

### Tests added in this PR

- `packages/argent/test/dispatcher.test.ts` — e2e against tmpdir-staged
dispatcher + fake `<platform>/simulator-server`: missing-binary error
path, exit-code passthrough, argv passthrough, SIGTERM forwarding.
- `packages/tool-server/test/native-devtools-ios-resolver.test.ts` —
`requireDarwin` gates for dylibs and `ax-service` on Linux, per-platform
`simulator-server` path resolution, missing-binary error,
`ARGENT_NATIVE_DEVTOOLS_DIR` override.
- `packages/tool-server/test/linux-preflight.test.ts` — KVM diagnostic
shape + `diagnoseAvdSizing` (ram low, heap low, both, neither, missing
keys).
- `packages/tool-server/test/boot-device-hotboot.test.ts` — extended
with `it.each([linux→swiftshader, darwin→auto])` so a single CI runner
exercises both GPU branches (was runner-platform-dependent, regressions
could pass on both runners individually). New cases for the wedged-frame
fallback (under fake timers because the polling budget is short but
real-time enough to exceed vitest's 5 s default), the GPU-mode whitelist
rejection, and the cold-boot composite-wait timeout.
- `packages/tool-server/test/android-binary.test.ts` — extended for
`defaultAndroidRoots` probe ordering and HOME pinning.

### Verification

- `tsc --build`: clean apart from pre-existing
`@argent/native-devtools-android` import errors unrelated to this
branch.
- `vitest run` in `packages/tool-server` and `packages/argent`: 689 + 11
= 700 tests passing; the 4 pre-existing test-file load failures
(native-devtools-android missing) reproduce on `main` and are out of
scope.
- `npm pack -w @swmansion/argent`: produces `swmansion-argent-0.8.0.tgz`
with the per-platform layout.
- Wayland-e2e: triggered on this branch — link will be in a PR comment
once the run completes.
- Manually verified end-to-end on a Wayland-on-hybrid-GPU developer
laptop: AVD boots through the tool-server with `-gpu swiftshader`,
screencap returns real frames, gesture tools work.

### Dependencies / what still gates this

- **radon#118** must land before the Linux artifact ships through the
default channel. The download script tolerates the asset being absent
today (best-effort), so this PR is technically safe to merge before —
Linux installs just won't get a working `simulator-server` until the
upstream artifact is published. Once it lands:
  - Drop the `radon-daily` arg in `.github/workflows/wayland-e2e.yml`.
  - Flip `ARGENT_REQUIRE_LINUX_BINARY` to `1` in both publish workflows.
- **wayland-e2e is not a required status check** today (no branch
protection on `main` requires it). Worth turning on before merging
anything else that touches the Linux boot path.

### Related

- radon#118 — produces the `simulator-server-argent-linux` artifact this
PR consumes.
- argent-private#11 — cross-builds the iOS Simulator dylibs on Linux.
Strictly orthogonal: those dylibs are macOS-runtime-only regardless of
where they're built, and this PR keeps them under `dylibs/` with a clean
error path on Linux callers.

</details>

Fixes #246
2026-06-03 15:55:39 +02:00

195 lines
6.9 KiB
Bash

#!/usr/bin/env bash
# Build native-devtools-ios dylibs for Argent.
# ObjC source lives in the argent-private submodule at packages/argent-private.
# Run from the workspace root: bash packages/native-devtools-ios/scripts/build.sh [dev|release] [--transport unix|tcp]
# Or from this package: bash scripts/build.sh [dev|release] [--transport unix|tcp]
#
# Usage: build.sh [dev|release] [--transport unix|tcp]
# mode: dev (fast) or release (optimized, optional signing). Default: release.
# --transport: unix (default) builds against AF_UNIX sockets at /tmp/*.sock paths;
# tcp builds with -DARGENT_USE_TCP=1 so the dylib/daemon use AF_INET
# on 127.0.0.1 with a port. Artifacts go to dylibs/tcp/ and bin/tcp/
# so both variants can coexist.
#
# Environment:
# PREBUILT_NATIVE_DEVTOOLS_IOS - if set, copy this path to dylibs/ instead of building (CI on non-macOS)
# PREBUILT_KEYBOARD_PATCH - same for keyboard patch dylib
# PREBUILT_INJECTION_BOOTSTRAP - same for bootstrap dylib
# Release signing: set IDENTITY (or CODESIGN_IDENTITY), or for CI keychain import set
# CERTIFICATE, PRIVATE_KEY_BASE64, PRIVATE_KEY_PASSWORD, KEYCHAIN_PASSWORD, IDENTITY.
set -euo pipefail
MODE="release"
TRANSPORT="unix"
while (($#)); do
case "$1" in
dev|release)
MODE="$1"; shift ;;
--transport)
TRANSPORT="${2:-}"; shift 2 ;;
--transport=*)
TRANSPORT="${1#--transport=}"; shift ;;
*)
echo "Usage: build.sh [dev|release] [--transport unix|tcp]" >&2
exit 1 ;;
esac
done
if [[ "$TRANSPORT" != "unix" && "$TRANSPORT" != "tcp" ]]; then
echo "Invalid --transport '$TRANSPORT'. Expected unix or tcp." >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKSPACE_DIR="$(cd "${ROOT_DIR}/../.." && pwd)"
SUBMODULE_DIR="${WORKSPACE_DIR}/packages/argent-private/packages/native-devtools-ios"
SRC_DIR="${SUBMODULE_DIR}/Sources/NativeDevtoolsIos"
if [[ "$TRANSPORT" == "tcp" ]]; then
DEST_DIR="${ROOT_DIR}/dylibs/tcp"
BIN_DIR="${ROOT_DIR}/bin/darwin/tcp"
else
DEST_DIR="${ROOT_DIR}/dylibs"
BIN_DIR="${ROOT_DIR}/bin/darwin"
fi
# Verify the submodule is initialised before trying to build.
if [[ ! -d "${SRC_DIR}" ]]; then
echo "Error: ObjC source not found at ${SRC_DIR}" >&2
echo " Run: git submodule update --init packages/argent-private" >&2
exit 1
fi
DEST_FILE="${DEST_DIR}/libNativeDevtoolsIos.dylib"
DEST_FILE_KB="${DEST_DIR}/libKeyboardPatch.dylib"
DEST_FILE_BS="${DEST_DIR}/libArgentInjectionBootstrap.dylib"
# If pre-built dylibs are provided, copy them and exit.
if [[ -n "${PREBUILT_NATIVE_DEVTOOLS_IOS:-}" ]] && [[ -n "${PREBUILT_KEYBOARD_PATCH:-}" ]] && [[ -n "${PREBUILT_INJECTION_BOOTSTRAP:-}" ]]; then
echo "Using pre-built dylibs"
mkdir -p "$DEST_DIR"
cp "$PREBUILT_NATIVE_DEVTOOLS_IOS" "$DEST_FILE"
cp "$PREBUILT_KEYBOARD_PATCH" "$DEST_FILE_KB"
cp "$PREBUILT_INJECTION_BOOTSTRAP" "$DEST_FILE_BS"
exit 0
fi
# On non-macOS we cannot build.
if [[ "$OSTYPE" != "darwin"* ]]; then
echo "Skipping native-devtools-ios build on non-macOS host ($OSTYPE)"
exit 0
fi
# In release mode, set up keychain for CI signing when secrets are present.
if [[ "$MODE" == "release" && -n "${CERTIFICATE:-}" && -n "${PRIVATE_KEY_BASE64:-}" ]]; then
KEYCHAIN_PATH="${KEYCHAIN_PATH:-${TMPDIR:-/tmp}/codesign.keychain-db}"
security create-keychain -p "${KEYCHAIN_PASSWORD:?}" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "${KEYCHAIN_PASSWORD:?}" "$KEYCHAIN_PATH"
CERTIFICATE_PATH="${TMPDIR:-/tmp}/certificate.pem"
PRIVATE_KEY_PATH="${TMPDIR:-/tmp}/private_key.p12"
echo -n "$CERTIFICATE" >"$CERTIFICATE_PATH"
echo -n "$PRIVATE_KEY_BASE64" | base64 --decode -o "$PRIVATE_KEY_PATH"
security import "$CERTIFICATE_PATH" -A -t cert -f pemseq -k "$KEYCHAIN_PATH"
security import "$PRIVATE_KEY_PATH" -A -t priv -f pkcs12 -P "${PRIVATE_KEY_PASSWORD:?}" -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
export IDENTITY="${IDENTITY:?}"
fi
mkdir -p "$DEST_DIR"
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
EXTRA_CFLAGS=()
if [[ "$MODE" == "release" ]]; then
EXTRA_CFLAGS=(-Os -DNDEBUG)
fi
if [[ "$TRANSPORT" == "tcp" ]]; then
EXTRA_CFLAGS+=(-DARGENT_USE_TCP=1)
fi
echo "Building libNativeDevtoolsIos.dylib..."
xcrun --sdk iphonesimulator clang \
-dynamiclib \
-fobjc-arc \
-fmodules \
${EXTRA_CFLAGS[@]+"${EXTRA_CFLAGS[@]}"} \
-isysroot "${SDK_PATH}" \
-mios-simulator-version-min=13.0 \
-arch arm64 \
-arch x86_64 \
-framework Foundation \
-framework UIKit \
"${SRC_DIR}/InjectionEntry.m" \
"${SRC_DIR}/DevtoolsConnection.m" \
"${SRC_DIR}/NetworkInspector.m" \
"${SRC_DIR}/BodyStreamForwarder.m" \
"${SRC_DIR}/NativeDevtoolsURLProtocol.m" \
"${SRC_DIR}/ViewHierarchy/ViewHierarchyUtils.m" \
"${SRC_DIR}/ViewHierarchy/GetFullHierarchy.m" \
"${SRC_DIR}/ViewHierarchy/FindViews.m" \
"${SRC_DIR}/ViewHierarchy/ViewAtPoint.m" \
"${SRC_DIR}/ViewHierarchy/UserInteractableViewAtPoint.m" \
"${SRC_DIR}/ViewHierarchy/DescribeScreen.m" \
-o "${DEST_FILE}"
echo "Building libKeyboardPatch.dylib..."
xcrun --sdk iphonesimulator clang \
-dynamiclib \
-fobjc-arc \
-fmodules \
${EXTRA_CFLAGS[@]+"${EXTRA_CFLAGS[@]}"} \
-isysroot "${SDK_PATH}" \
-mios-simulator-version-min=13.0 \
-arch arm64 \
-arch x86_64 \
-framework Foundation \
"${SRC_DIR}/KeyboardPatch.m" \
-o "${DEST_FILE_KB}"
echo "Building libArgentInjectionBootstrap.dylib..."
xcrun --sdk iphonesimulator clang \
-dynamiclib \
${EXTRA_CFLAGS[@]+"${EXTRA_CFLAGS[@]}"} \
-isysroot "${SDK_PATH}" \
-mios-simulator-version-min=13.0 \
-arch arm64 \
-arch x86_64 \
"${SRC_DIR}/InjectionBootstrap.c" \
-o "${DEST_FILE_BS}"
echo "Building ax-service..."
AX_SRC_DIR="${SUBMODULE_DIR}/Sources/AXService"
AX_DEST="${BIN_DIR}/ax-service"
mkdir -p "$(dirname "$AX_DEST")"
if [[ -n "${PREBUILT_AX_SERVICE:-}" ]]; then
cp "$PREBUILT_AX_SERVICE" "$AX_DEST"
else
xcrun --sdk iphonesimulator clang \
-fobjc-arc \
${EXTRA_CFLAGS[@]+"${EXTRA_CFLAGS[@]}"} \
-isysroot "${SDK_PATH}" \
-target arm64-apple-ios17.0-simulator \
-framework Foundation -framework UIKit -framework CoreGraphics \
-lobjc \
-o "${AX_DEST}" "${AX_SRC_DIR}/ax_service.m"
fi
if [[ "$MODE" == "release" ]]; then
IDENTITY="${CODESIGN_IDENTITY:-${IDENTITY:-}}"
if [[ -n "$IDENTITY" ]]; then
codesign --force --options runtime --sign "$IDENTITY" "${DEST_FILE}"
codesign --verify --verbose "${DEST_FILE}"
codesign --force --options runtime --sign "$IDENTITY" "${DEST_FILE_KB}"
codesign --verify --verbose "${DEST_FILE_KB}"
codesign --force --options runtime --sign "$IDENTITY" "${DEST_FILE_BS}"
codesign --verify --verbose "${DEST_FILE_BS}"
fi
fi
echo "Done. Dylibs written to ${DEST_DIR}/ (transport=${TRANSPORT})"