native-sim 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +395 -0
- package/bin/native-sim.js +8 -0
- package/package.json +31 -0
- package/src/cli.js +151 -0
- package/src/commands/doctor.js +54 -0
- package/src/commands/down.js +48 -0
- package/src/commands/init.js +64 -0
- package/src/commands/r2.js +62 -0
- package/src/commands/status.js +30 -0
- package/src/commands/turn.js +83 -0
- package/src/commands/up.js +288 -0
- package/src/commands/upload.js +60 -0
- package/src/lib/gh.js +170 -0
- package/src/lib/ghrelease.js +50 -0
- package/src/lib/git.js +68 -0
- package/src/lib/proc.js +37 -0
- package/src/lib/project.js +35 -0
- package/src/lib/r2.js +94 -0
- package/src/lib/session.js +24 -0
- package/src/lib/ui.js +44 -0
- package/templates/gate.cjs +184 -0
- package/templates/native-sim.yml +654 -0
package/README.md
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# native-sim
|
|
2
|
+
|
|
3
|
+
Push an Expo app to GitHub, build it on a GitHub-hosted macOS runner, and stream
|
|
4
|
+
the live iOS Simulator back to your browser through [`@expo/serve-sim`](https://github.com/expo/serve-sim).
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
cd my-expo-app
|
|
8
|
+
native-sim up --public --minutes 45
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
› Preparing repository
|
|
13
|
+
✓ committed on main
|
|
14
|
+
✓ pushed to bidah/my-expo-app
|
|
15
|
+
› Dispatching build
|
|
16
|
+
✓ run https://github.com/bidah/my-expo-app/actions/runs/1234567
|
|
17
|
+
⠹ Build and install the app (5/12)
|
|
18
|
+
|
|
19
|
+
● Simulator is live
|
|
20
|
+
https://calm-river-1234.trycloudflare.com/?k=…
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it does
|
|
24
|
+
|
|
25
|
+
1. `git init` / commit / push, creating the GitHub repo if there isn't one.
|
|
26
|
+
2. Dispatches `.github/workflows/native-sim.yml` with a session id and a one-time access key.
|
|
27
|
+
3. The runner boots a simulator, `expo prebuild` + `xcodebuild`s a Release build, installs and launches it.
|
|
28
|
+
4. `serve-sim --detach` captures the simulator; a small auth gate fronts it; `cloudflared` opens a tunnel.
|
|
29
|
+
5. The runner publishes the URL as a **commit status** — the one GitHub surface readable *while a job is still running* (logs and artifacts only land after it ends).
|
|
30
|
+
6. The CLI polls for that status and opens your browser.
|
|
31
|
+
|
|
32
|
+
For how the stream reaches your browser and what keeps it alive, see
|
|
33
|
+
[docs/how-the-connection-works.md](docs/how-the-connection-works.md). To run an
|
|
34
|
+
already-built app from a repo containing no source, see
|
|
35
|
+
[docs/prebuilt-app-flow.md](docs/prebuilt-app-flow.md).
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `native-sim up` | Push, build, stream. Runs `init` implicitly. |
|
|
42
|
+
| `native-sim init` | Write the workflow and auth gate into the project. |
|
|
43
|
+
| `native-sim status` | Current session, run state, stream URL. |
|
|
44
|
+
| `native-sim down` | Cancel the run — the stream and the runner stop together. |
|
|
45
|
+
| `native-sim doctor` | Check prerequisites. |
|
|
46
|
+
| `native-sim upload` | Upload a simulator build to R2, print a presigned URL. |
|
|
47
|
+
| `native-sim r2` | Configure R2 credentials (`--status` to inspect). |
|
|
48
|
+
| `native-sim turn` | Store TURN credentials as repo secrets, for `--transport webrtc`. |
|
|
49
|
+
|
|
50
|
+
### `native-sim up` options
|
|
51
|
+
|
|
52
|
+
| Flag | Default | |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `--minutes <n>` | `30` | Stream lifetime. Max 350; GitHub kills hosted jobs at 6h. |
|
|
55
|
+
| `--device <name>` | `iPhone 17 Pro` | Falls back to the newest available iPhone. |
|
|
56
|
+
| `--mode <build\|app\|go>` | `build` | `app` installs a prebuilt archive and skips compiling entirely. `go` uses Expo Go — fast, but only for projects without custom native code. |
|
|
57
|
+
| `--scheme <name>` | workspace name | Xcode scheme. |
|
|
58
|
+
| `--runner <label>` | `macos-26` | Must be arm64: `serve-sim` ships arm64-only native binaries. `macos-26`/`macos-latest` and `macos-15` are arm64; the `-large`/`-intel` labels are x64 and will not work. |
|
|
59
|
+
| `--codec <c>` | `mjpeg` | `mjpeg` is the only codec that works: `/stream.avcc` (h264) returns `200` and emits zero bytes on GitHub's macOS runners. |
|
|
60
|
+
| `--max-dimension <px>` | `900` | Caps captured width/height. Native (~1206×2622) is far more than the browser displays, and pixels cost encode CPU *and* bandwidth quadratically. `0` keeps native. |
|
|
61
|
+
| `--fps <n>` | `30` | MJPEG frame rate. serve-sim's own default is 60. |
|
|
62
|
+
| `--quality <n>` | `0.7` | MJPEG quality, 0.05–1. |
|
|
63
|
+
| `--agent` | off | Also expose an [agent-device](#driving-the-simulator-from-a-coding-agent) proxy so a coding agent can drive the simulator. |
|
|
64
|
+
| `--export` | off | Download the built `.app` archive (`--out <dir>`). |
|
|
65
|
+
| `--no-cache` | off | Force a full native rebuild. The build cache is **on by default**; this is an escape hatch for verifying a clean build or a suspected stale cache. |
|
|
66
|
+
| `--public` | off | Create the repo public. **Public repos get unlimited free Actions minutes.** |
|
|
67
|
+
| `--repo <name>` | directory name | Repo name when creating one. |
|
|
68
|
+
| `--no-open` | | Don't open the browser. |
|
|
69
|
+
|
|
70
|
+
## Build caching (on by default)
|
|
71
|
+
|
|
72
|
+
The native build is the whole cost: **28.4 minutes** measured cold. native-sim caches the
|
|
73
|
+
built `.app` keyed on [`@expo/fingerprint`](https://www.npmjs.com/package/@expo/fingerprint),
|
|
74
|
+
which hashes the inputs that affect the *native* build — dependencies, config plugins,
|
|
75
|
+
native `app.json` fields — and deliberately ignores your application JS.
|
|
76
|
+
|
|
77
|
+
On a cache hit the build is skipped entirely and `expo export:embed` rewrites
|
|
78
|
+
`main.jsbundle` inside the restored `.app`, so you get the cached native shell running
|
|
79
|
+
your current JavaScript.
|
|
80
|
+
|
|
81
|
+
| | Stream URL | App on screen |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Cold cache | ~5 min | ~33 min |
|
|
84
|
+
| Warm cache | ~3 min | **7 min (measured)** |
|
|
85
|
+
|
|
86
|
+
Change JS → cache hit. Change a native dependency or config plugin → fingerprint moves
|
|
87
|
+
→ full rebuild → new cache entry. Caches are 10 GB/repo and evict after 7 days unused.
|
|
88
|
+
|
|
89
|
+
Requires no configuration and no agent involvement — it is part of the workflow.
|
|
90
|
+
|
|
91
|
+
## Running an already-built app
|
|
92
|
+
|
|
93
|
+
If you already have a simulator build, native-sim can skip compiling entirely:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
native-sim up --app https://expo.dev/artifacts/eas/xxxx.tar.gz --public
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Your source is never pushed.** The repo holds only `.github/workflows/native-sim.yml`;
|
|
100
|
+
the runner downloads the archive, installs it, and streams it. This is the way to use a
|
|
101
|
+
free public repo without publishing private code — the code simply never goes there.
|
|
102
|
+
|
|
103
|
+
Requirements:
|
|
104
|
+
|
|
105
|
+
- **It must be a *simulator* build**, not a device build. A device `.ipa` cannot run on a
|
|
106
|
+
simulator; the workflow checks `CFBundleSupportedPlatforms` and fails with a clear
|
|
107
|
+
message rather than a confusing `simctl` error. With EAS, that means a profile with
|
|
108
|
+
`ios.simulator: true`.
|
|
109
|
+
- **The URL must be reachable by the runner.** A local path cannot work — the runner
|
|
110
|
+
cannot reach your machine. EAS build URLs, release assets, or any `https://` link are
|
|
111
|
+
fine. Signed URLs expire, so fetch a fresh one per session.
|
|
112
|
+
- `.tar.gz` or `.zip` containing the `.app`.
|
|
113
|
+
|
|
114
|
+
### Hosting your own builds
|
|
115
|
+
|
|
116
|
+
By default a local build is uploaded as an asset on a **draft release of the same repo the
|
|
117
|
+
workflow runs in**, and the runner fetches it with the job's own `GITHUB_TOKEN`:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
native-sim up --app-file ./MyApp.app # upload + run in one step
|
|
121
|
+
native-sim upload ./MyApp.app # just upload
|
|
122
|
+
native-sim up --app-release MyApp.app.tar.gz # run something already uploaded
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This needs no third-party account and no credentials file. It works for **public and private
|
|
126
|
+
repos alike**: a private repo's release assets are readable only by people who can read the
|
|
127
|
+
repo, and there is no signed URL to expire mid-session.
|
|
128
|
+
|
|
129
|
+
The release is created as a **draft**, which is not listed publicly — so a public repo does
|
|
130
|
+
not start publishing your binaries as a side effect. If a release ever would be publicly
|
|
131
|
+
downloadable, the CLI says so.
|
|
132
|
+
|
|
133
|
+
All builds land on one reused tag, `native-sim-build`, with the asset clobbered each time,
|
|
134
|
+
so the release list does not fill up with build noise. Deleting that release is safe.
|
|
135
|
+
|
|
136
|
+
### Hosting on R2 instead
|
|
137
|
+
|
|
138
|
+
Pass `--r2` when the build must live outside the repo entirely. native-sim puts it on
|
|
139
|
+
Cloudflare R2 and hands the runner a **presigned** URL:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
native-sim r2 # one-time: account, bucket, API token
|
|
143
|
+
native-sim upload ./MyApp.app --r2 # prints a presigned URL
|
|
144
|
+
native-sim up --app-file ./MyApp.app --r2 # upload + run in one step
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The bucket stays **private**; the runner gets a time-limited signed URL (2h via
|
|
148
|
+
`--app-file`, `--expires` on `upload`). This matters — a public bucket would leave your
|
|
149
|
+
build permanently downloadable by anyone who found the key.
|
|
150
|
+
|
|
151
|
+
A `.app` directory is tarred automatically. Credentials live in `~/.native-sim/r2.json`
|
|
152
|
+
(mode 0600) or the `R2_ACCOUNT_ID` / `R2_BUCKET` / `R2_ACCESS_KEY_ID` /
|
|
153
|
+
`R2_SECRET_ACCESS_KEY` environment variables, which take precedence. Create the API
|
|
154
|
+
token at **dash.cloudflare.com → R2 → Manage API Tokens** with Object Read & Write.
|
|
155
|
+
Uses the `aws` CLI against R2's S3-compatible API.
|
|
156
|
+
|
|
157
|
+
Getting an EAS build URL:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
eas build:list --platform ios --limit 5 --json --non-interactive \
|
|
161
|
+
| jq -r '.[0].artifacts.applicationArchiveUrl'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Driving the simulator from a coding agent
|
|
165
|
+
|
|
166
|
+
`--agent` runs an [`agent-device`](https://agent-device.dev/) proxy next to the simulator,
|
|
167
|
+
reachable at `<url>/agent-device` on the **same tunnel and the same key** as the stream. A
|
|
168
|
+
coding agent on your machine can then tap, type, scroll and read the accessibility tree of
|
|
169
|
+
an app running on a GitHub runner — while you watch the same session in a browser.
|
|
170
|
+
|
|
171
|
+
The runner is a Mac with simulator access for the length of your session, which is exactly
|
|
172
|
+
the topology `agent-device proxy` is built for.
|
|
173
|
+
|
|
174
|
+
### Setup
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
npm install -g agent-device # once; 0.20.0 or newer
|
|
178
|
+
native-sim up --public --agent
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The runner installs **the same agent-device version you have locally** (native-sim reads
|
|
182
|
+
`agent-device --version` and pins it), because the client and the proxied daemon should
|
|
183
|
+
match. When the session comes up, the CLI prints the connect command with the URL and key
|
|
184
|
+
already filled in:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
Drive it from an agent (agent-device)
|
|
188
|
+
|
|
189
|
+
agent-device connect proxy \
|
|
190
|
+
--daemon-base-url https://<tunnel>.trycloudflare.com/agent-device \
|
|
191
|
+
--daemon-auth-token <key>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Using it
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
agent-device connect proxy --daemon-base-url <url>/agent-device --daemon-auth-token <key>
|
|
198
|
+
|
|
199
|
+
agent-device devices --platform ios # confirm the runner's simulator
|
|
200
|
+
agent-device open com.your.bundle.id --platform ios --device "iPhone 17 Pro"
|
|
201
|
+
agent-device snapshot -i # interactive elements + refs
|
|
202
|
+
agent-device press 'label="Explore"' --settle # a real touch injection
|
|
203
|
+
agent-device scroll down
|
|
204
|
+
|
|
205
|
+
agent-device close # ALWAYS close before disconnect
|
|
206
|
+
agent-device disconnect
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A real session looks like this — note the session state path is on the runner, not your Mac:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
Opened: com.anonymous.my-app
|
|
213
|
+
Session state: /Users/runner/.agent-device/sessions/proxy_adc-e8554d
|
|
214
|
+
|
|
215
|
+
Tapped label="Explore" (244, 822)
|
|
216
|
+
settled after 1003ms: +15 -10 (~5 unchanged)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Gotchas worth knowing before you hit them
|
|
220
|
+
|
|
221
|
+
- **`connect proxy` succeeds even against a dead daemon.** It allocates no device lease
|
|
222
|
+
until `open`, so a broken session only surfaces on your first real command as
|
|
223
|
+
`Remote daemon is unavailable`. Check first:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
curl -H "Authorization: Bearer <key>" "<url>/agent-device/health"
|
|
227
|
+
# want: {"ok":true,...,"upstream":{"ok":true,...}}
|
|
228
|
+
# a proxy with a dead daemon answers HTTP 200 with {"ok":false,"error":"fetch failed"}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
That `200` matters: `curl -f` scores it a success, so a naive health check will not
|
|
232
|
+
catch it.
|
|
233
|
+
|
|
234
|
+
- **`close` before `disconnect`, and never delete the client state dir mid-session.** The
|
|
235
|
+
state dir holds the session's ownership credentials. Delete it and the runner keeps the
|
|
236
|
+
device claimed by an orphaned session; `open` then fails `DEVICE_IN_USE` and there is no
|
|
237
|
+
way back — `--force` does not cover device claims. The only fix is a new session.
|
|
238
|
+
|
|
239
|
+
- **A fresh tunnel hostname takes up to a minute to resolve.** Quick-tunnel names are
|
|
240
|
+
created seconds before you get them, and a resolver that answers `NXDOMAIN` may cache
|
|
241
|
+
that negative answer. If DNS fails immediately after a session starts, wait and retry.
|
|
242
|
+
|
|
243
|
+
- **The first snapshot needs an XCTest runner.** The workflow builds it during the session
|
|
244
|
+
(`agent-device prepare ios-runner`), which takes several minutes cold and seconds on a
|
|
245
|
+
cache hit. It runs *after* the stream URL is published, so it delays agent-readiness,
|
|
246
|
+
not the stream.
|
|
247
|
+
|
|
248
|
+
### What the workflow sets, and why
|
|
249
|
+
|
|
250
|
+
Three agent-device defaults assume a developer laptop and are wrong for a single-tenant
|
|
251
|
+
runner that is destroyed when the job ends:
|
|
252
|
+
|
|
253
|
+
| Setting | Default | native-sim | Why |
|
|
254
|
+
|---|---|---|---|
|
|
255
|
+
| `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` | 5 min | `0` | The daemon reaps itself 5 minutes after the last command, and health probes do not count as activity — so it is usually gone before the agent ever connects. |
|
|
256
|
+
| `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` | 5 min | `0` | Same, for the XCTest runner. |
|
|
257
|
+
| `AGENT_DEVICE_LEASE_TTL_MS` | 5 min | job cap | The device lease taken by `open` expires, after which every command fails `UNAUTHORIZED`. |
|
|
258
|
+
|
|
259
|
+
Nothing leaks by disabling these: the hold loop *is* the session, and the VM dies with it.
|
|
260
|
+
|
|
261
|
+
The hold loop also **supervises the daemon** — any time `/health` is not `ok:true` it
|
|
262
|
+
restarts the proxy, at most once a minute. The daemon has died from an idle reap and from
|
|
263
|
+
a killed `xcodebuild`, so the session is built to outlive any single daemon rather than to
|
|
264
|
+
enumerate causes.
|
|
265
|
+
|
|
266
|
+
## Stopping a session
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
native-sim down # cancel the most recent session
|
|
270
|
+
native-sim down --all # cancel every native-sim run still in flight
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`down` reads `.git/native-sim-session.json`, which every `native-sim up` overwrites — so it
|
|
274
|
+
only knows the **latest** session. If you have started several, plain `down` will miss
|
|
275
|
+
the older ones; it warns when it detects others and `--all` catches them. You can also
|
|
276
|
+
cancel by id, or use the repo's Actions tab:
|
|
277
|
+
|
|
278
|
+
```sh
|
|
279
|
+
gh run cancel <run-id> -R <owner>/<repo>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Two things worth knowing:
|
|
283
|
+
|
|
284
|
+
- **Cancelling kills the stream instantly.** The VM is destroyed, `cloudflared` dies with
|
|
285
|
+
it, and the URL stops resolving immediately. There is no graceful drain, and the URL
|
|
286
|
+
can never be revived.
|
|
287
|
+
- **Sessions stop by themselves** when the hold loop reaches `--minutes`. Nothing leaks
|
|
288
|
+
if you do nothing. Cancelling only reclaims a runner sooner — you are capped at 5
|
|
289
|
+
concurrent macOS jobs — or frees your attention. On a public repo, letting them expire
|
|
290
|
+
costs no minutes.
|
|
291
|
+
|
|
292
|
+
## Stream smoothness
|
|
293
|
+
|
|
294
|
+
Choppiness is usually one of three things, in this order:
|
|
295
|
+
|
|
296
|
+
1. **Quality and resolution.** `--quality` and `--max-dimension` are the real levers.
|
|
297
|
+
Measured on a live session, going from serve-sim's defaults (60fps / q0.7 / 900px) to
|
|
298
|
+
24fps / q0.5 / 640px cut bandwidth **2.4×**, from 1.25 to 0.52 Mbit/s — and lower
|
|
299
|
+
resolution is reported to feel *more responsive*, because smaller frames spend less
|
|
300
|
+
time on the wire. native-sim ships **900px / q0.7 / 30fps**: 640 was measurably snappier
|
|
301
|
+
but visibly soft on a retina display, where the preview is drawn at roughly 2× CSS
|
|
302
|
+
pixels. Drop to `--max-dimension 640 --quality 0.5` if you want responsiveness over
|
|
303
|
+
detail.
|
|
304
|
+
|
|
305
|
+
Note this does **not** fix latency. Measured round trip through a quick tunnel to a US
|
|
306
|
+
runner from Santiago is 320–550 ms, of which only ~45 ms is reaching Cloudflare. Smaller
|
|
307
|
+
frames stop *adding* to that; only a closer runner reduces it.
|
|
308
|
+
2. **Frame rate — but mind which one.** serve-sim defaults MJPEG to **60 fps**, and
|
|
309
|
+
`serve-sim --video-fps` only sets the *h264* rate. native-sim's `--fps` now sets
|
|
310
|
+
`--mjpeg-fps`, which is the stream that actually runs.
|
|
311
|
+
3. **Not the codec.** `/stream.avcc` (H.264) returns `200` and then emits **zero bytes**
|
|
312
|
+
on GitHub's macOS runners, reproducibly. The browser is pointed at `.mjpeg` regardless,
|
|
313
|
+
so `--codec h264` changes nothing. Default is `mjpeg`.
|
|
314
|
+
4. **CPU contention.** Encoding is CPU-bound and the runner has 3 cores. A cold
|
|
315
|
+
`xcodebuild` running alongside the stream can saturate it hard enough that the tunnel
|
|
316
|
+
returns `530`. A warm build cache removes the build entirely.
|
|
317
|
+
|
|
318
|
+
**WebRTC is available but is not the default, and did not help here.** In theory its
|
|
319
|
+
congestion control should beat MJPEG over a long link. Tested against Cloudflare Realtime
|
|
320
|
+
TURN from Santiago to a US runner, it was not noticeably better — and TURN is a **paid,
|
|
321
|
+
per-GB relay**, so every session costs money while it runs. Reach for it only if plain
|
|
322
|
+
HTTP is failing you.
|
|
323
|
+
|
|
324
|
+
A quick tunnel carries no UDP, so **TURN is mandatory** for WebRTC; STUN alone cannot
|
|
325
|
+
traverse it.
|
|
326
|
+
|
|
327
|
+
```sh
|
|
328
|
+
native-sim turn # store TURN creds as repo secrets
|
|
329
|
+
native-sim up --transport webrtc --public
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Credentials go in **repo secrets**, never workflow inputs — dispatch inputs are visible
|
|
333
|
+
to anyone who can read the repo, which on a public repo is everyone. Cloudflare Realtime,
|
|
334
|
+
Twilio, Metered, or self-hosted coturn all work. `native-sim turn --status` shows what is set.
|
|
335
|
+
|
|
336
|
+
## Access control
|
|
337
|
+
|
|
338
|
+
`serve-sim` has no authentication, so a bare tunnel would hand simulator control
|
|
339
|
+
to anyone who found the URL. `native-sim` generates a per-session key and the runner
|
|
340
|
+
puts `.github/native-sim/gate.cjs` — a dependency-free reverse proxy — in front of it.
|
|
341
|
+
The key is accepted once from `?k=`, traded for an `HttpOnly` cookie, and required
|
|
342
|
+
on every request *and* on the control-WebSocket upgrade.
|
|
343
|
+
|
|
344
|
+
It is still a shareable bearer link. Anyone you send it to can drive the simulator.
|
|
345
|
+
|
|
346
|
+
**On a public repo, never publish the key anywhere GitHub exposes.** Commit statuses are
|
|
347
|
+
world-readable with no authentication, so the runner publishes only the bare tunnel URL;
|
|
348
|
+
the CLI appends the key it generated locally. Job logs and step summaries require auth,
|
|
349
|
+
but treat them the same way.
|
|
350
|
+
|
|
351
|
+
## Cost
|
|
352
|
+
|
|
353
|
+
**Public repos are free and unlimited.** This is the whole reason native-sim is viable;
|
|
354
|
+
`--public` is not a detail.
|
|
355
|
+
|
|
356
|
+
Private repos bill macOS at **$0.062/min**, and against included minutes macOS carries a
|
|
357
|
+
**10× multiplier** — so one hour of streaming consumes 600 quota minutes, or $3.72 once
|
|
358
|
+
you are past your allowance. (Linux is $0.006/min for comparison.)
|
|
359
|
+
|
|
360
|
+
| Plan | Included/mo | One hour costs | macOS hours/mo |
|
|
361
|
+
|---|---|---|---|
|
|
362
|
+
| Free | 2,000 | 30% of the month | ~3.3 h |
|
|
363
|
+
| Pro / Team | 3,000 | 20% | 5 h |
|
|
364
|
+
| Enterprise | 50,000 | 1.2% | ~83 h |
|
|
365
|
+
|
|
366
|
+
A **cold build alone** is ~28 min — $1.74, or 280 quota minutes, before you see a single
|
|
367
|
+
frame. A warm cache reduces that to ~1 min, so on private repos the build cache is most
|
|
368
|
+
of the bill rather than a convenience.
|
|
369
|
+
|
|
370
|
+
`native-sim` warns when the repo is private. Free/Pro/Team also cap concurrent macOS jobs
|
|
371
|
+
at 5.
|
|
372
|
+
|
|
373
|
+
Rates verified against GitHub's billing docs; they change, so re-check before relying on
|
|
374
|
+
them.
|
|
375
|
+
|
|
376
|
+
## Limits worth knowing
|
|
377
|
+
|
|
378
|
+
- **Ephemeral.** The runner VM is destroyed when the job ends. There is no persistent simulator.
|
|
379
|
+
- **6 hours, hard.** GitHub kills hosted jobs at 360 minutes.
|
|
380
|
+
- **MJPEG over a tunnel**, not WebRTC. WebRTC needs a UDP path a quick tunnel can't provide without TURN; pass `--turn-url` to `serve-sim` in the workflow if you have one.
|
|
381
|
+
- **A small box.** Standard runners are ~3 vCPU / 7 GB running Xcode, a simulator, and a video encoder. During a *cold* build, `xcodebuild` and the video capture compete for those 3 cores, which shows up as `control socket connect timeout` and "connecting" churn in the preview. A warm cache avoids the build entirely and the stream stays smooth.
|
|
382
|
+
- **GitHub's Actions terms** cover building, testing and publishing *the software in that repo*. Occasional PR-preview sessions fit; a 24/7 public simulator host does not, and GitHub reserves the right to throttle it.
|
|
383
|
+
|
|
384
|
+
If you want this often, a Mac mini as a self-hosted runner is cheaper and faster
|
|
385
|
+
than fighting the constraints.
|
|
386
|
+
|
|
387
|
+
## Requirements
|
|
388
|
+
|
|
389
|
+
- `gh` CLI, authenticated (`gh auth login`)
|
|
390
|
+
- Node 20+
|
|
391
|
+
- An Expo project (`expo` in `package.json`)
|
|
392
|
+
|
|
393
|
+
## License
|
|
394
|
+
|
|
395
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "native-sim",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stream a live iOS Simulator of your React Native or Expo app from a GitHub-hosted macOS runner to your browser — and let a coding agent drive it.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"expo",
|
|
7
|
+
"react-native",
|
|
8
|
+
"ios-simulator",
|
|
9
|
+
"github-actions",
|
|
10
|
+
"simulator",
|
|
11
|
+
"agent-device",
|
|
12
|
+
"serve-sim",
|
|
13
|
+
"preview"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://reactnativefeel.com/sim",
|
|
16
|
+
"author": "Rodrigo Figueroa (https://x.com/bidah)",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"native-sim": "bin/native-sim.js"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"templates",
|
|
29
|
+
"README.md"
|
|
30
|
+
]
|
|
31
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { init } from './commands/init.js';
|
|
2
|
+
import { up } from './commands/up.js';
|
|
3
|
+
import { status } from './commands/status.js';
|
|
4
|
+
import { down } from './commands/down.js';
|
|
5
|
+
import { doctor } from './commands/doctor.js';
|
|
6
|
+
import { upload } from './commands/upload.js';
|
|
7
|
+
import { r2Setup } from './commands/r2.js';
|
|
8
|
+
import { turn } from './commands/turn.js';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { bold, dim, cyan } from './lib/ui.js';
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
${bold('native-sim')} — stream an iOS Simulator of your Expo app from a GitHub Actions runner
|
|
14
|
+
|
|
15
|
+
${bold('USAGE')}
|
|
16
|
+
native-sim <command> [options]
|
|
17
|
+
|
|
18
|
+
${bold('COMMANDS')}
|
|
19
|
+
up Push, build on a macOS runner, and open the live simulator ${dim('(default)')}
|
|
20
|
+
init Write .github/workflows/native-sim.yml and the auth gate
|
|
21
|
+
status Show the current session and its stream URL
|
|
22
|
+
down Cancel the run and tear the stream down ${dim('(--all for every session)')}
|
|
23
|
+
doctor Check prerequisites
|
|
24
|
+
upload Upload a simulator build to this repo's release ${dim('(--r2 for R2)')}
|
|
25
|
+
r2 Configure R2 credentials, for --r2 ${dim('(--status to inspect)')}
|
|
26
|
+
turn Store TURN credentials as repo secrets, for --transport webrtc
|
|
27
|
+
|
|
28
|
+
${bold('OPTIONS')} ${dim('(native-sim up)')}
|
|
29
|
+
--minutes <n> How long to hold the stream open ${dim('default 30, max 350')}
|
|
30
|
+
--device <name> Simulator device ${dim('default "iPhone 17 Pro"')}
|
|
31
|
+
--app-file <p> Upload a local .app/.tar.gz and run it ${dim('(hosted on this repo)')}
|
|
32
|
+
--app-release <a> Run an asset already on this repo's native-sim-build release
|
|
33
|
+
--r2 Host --app-file on Cloudflare R2 instead ${dim('(needs: native-sim r2)')}
|
|
34
|
+
--app <url> Install an already-built simulator .app from a URL
|
|
35
|
+
${dim('no source is pushed; the repo holds only the workflow')}
|
|
36
|
+
--mode <m> build | app | go ${dim('default build')}
|
|
37
|
+
--scheme <name> Xcode scheme ${dim('default: generated workspace name')}
|
|
38
|
+
--runner <label> Runner label ${dim('default macos-26')}
|
|
39
|
+
--repo <name> Repo name when creating one
|
|
40
|
+
--transport <t> http | webrtc ${dim('default http; webrtc is paid + no better')}
|
|
41
|
+
--codec <c> mjpeg | h264 ${dim('default mjpeg; h264 is dead on GH runners')}
|
|
42
|
+
--max-dimension Cap captured px; 0 = native ${dim('default 900; lower = snappier, softer')}
|
|
43
|
+
--fps <n> MJPEG frame rate ${dim('default 30; serve-sim default is 60')}
|
|
44
|
+
--quality <n> MJPEG quality 0.05-1 ${dim('default 0.7')}
|
|
45
|
+
--agent Also expose an agent-device proxy so a coding agent can
|
|
46
|
+
drive the simulator ${dim('(prints the connect command)')}
|
|
47
|
+
--export Also download the built .app archive ${dim('(--out <dir>)')}
|
|
48
|
+
--no-cache Force a full native rebuild ${dim('(cache is on by default)')}
|
|
49
|
+
--public Create the repo public ${dim('(unlimited free macOS minutes)')}
|
|
50
|
+
--message <msg> Commit message
|
|
51
|
+
--no-open Do not open the browser
|
|
52
|
+
|
|
53
|
+
${bold('EXAMPLES')}
|
|
54
|
+
${cyan('native-sim up --public --minutes 45')}
|
|
55
|
+
${cyan('native-sim up --public --agent')} ${dim('# stream + agent-device control')}
|
|
56
|
+
${cyan('native-sim up --mode go --device "iPhone 17"')}
|
|
57
|
+
${cyan('native-sim up --app https://expo.dev/artifacts/eas/xxxx.tar.gz --public')}
|
|
58
|
+
${cyan('native-sim up --app-file ./MyApp.app')} ${dim('# hosted on your own repo')}
|
|
59
|
+
${cyan('native-sim upload ./build/MyApp.app.tar.gz')}
|
|
60
|
+
${cyan('native-sim down')}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const NEEDS_VALUE = new Set(['minutes', 'device', 'mode', 'scheme', 'runner', 'repo', 'message', 'app', 'app-file', 'app-release', 'expires', 'out', 'codec', 'max-dimension', 'fps', 'quality', 'transport']);
|
|
64
|
+
|
|
65
|
+
export function parseArgs(argv) {
|
|
66
|
+
const flags = {};
|
|
67
|
+
const positional = [];
|
|
68
|
+
|
|
69
|
+
// Single-dash aliases. Without this `-h` is pushed as a positional and comes
|
|
70
|
+
// back as `Unknown command "-h"`, which is a poor first impression.
|
|
71
|
+
const SHORT = { h: 'help', v: 'version' };
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const arg = argv[i];
|
|
75
|
+
if (!arg.startsWith('--')) {
|
|
76
|
+
if (/^-[a-z]+$/.test(arg)) {
|
|
77
|
+
for (const ch of arg.slice(1)) {
|
|
78
|
+
if (SHORT[ch]) flags[SHORT[ch]] = true;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
positional.push(arg);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let key = arg.slice(2);
|
|
86
|
+
let value;
|
|
87
|
+
const eq = key.indexOf('=');
|
|
88
|
+
if (eq !== -1) {
|
|
89
|
+
value = key.slice(eq + 1);
|
|
90
|
+
key = key.slice(0, eq);
|
|
91
|
+
}
|
|
92
|
+
if (key.startsWith('no-')) {
|
|
93
|
+
flags[key.slice(3)] = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (NEEDS_VALUE.has(key)) {
|
|
97
|
+
value ??= argv[++i];
|
|
98
|
+
if (value === undefined) throw new Error(`--${key} needs a value`);
|
|
99
|
+
flags[key] = key === 'minutes' ? Number(value) : value;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
flags[key] = value ?? true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
flags._positional = positional.slice(1);
|
|
106
|
+
return { command: positional[0] ?? 'up', flags };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function main(argv) {
|
|
110
|
+
const { command, flags } = parseArgs(argv);
|
|
111
|
+
|
|
112
|
+
if (flags.help || command === 'help') {
|
|
113
|
+
console.log(HELP);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (flags.version) {
|
|
118
|
+
const pkg = JSON.parse(
|
|
119
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
120
|
+
);
|
|
121
|
+
console.log(pkg.version);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (flags.minutes !== undefined && (!Number.isFinite(flags.minutes) || flags.minutes < 1 || flags.minutes > 350)) {
|
|
126
|
+
throw new Error('--minutes must be between 1 and 350 (GitHub kills hosted jobs at 6 hours)');
|
|
127
|
+
}
|
|
128
|
+
if (flags.transport && !['http', 'webrtc'].includes(flags.transport)) {
|
|
129
|
+
throw new Error(`--transport must be "http" or "webrtc", got "${flags.transport}"`);
|
|
130
|
+
}
|
|
131
|
+
if (flags.codec && !['h264', 'mjpeg', 'auto'].includes(flags.codec)) {
|
|
132
|
+
throw new Error(`--codec must be "h264", "mjpeg" or "auto", got "${flags.codec}"`);
|
|
133
|
+
}
|
|
134
|
+
if (flags.mode && !['build', 'app', 'go'].includes(flags.mode)) {
|
|
135
|
+
throw new Error(`--mode must be "build", "app" or "go", got "${flags.mode}"`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof flags.app === 'string' && !/^https?:\/\//.test(flags.app)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`--app needs a URL the runner can download, got "${flags.app}".\n` +
|
|
140
|
+
`A local path will not work — the runner cannot reach your machine.\n` +
|
|
141
|
+
`Use an EAS build URL, a release asset, or any reachable https:// link.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const cwd = process.cwd();
|
|
146
|
+
const commands = { up, init, status, down, doctor, upload, r2: r2Setup, turn };
|
|
147
|
+
const handler = commands[command];
|
|
148
|
+
if (!handler) throw new Error(`Unknown command "${command}". Try: native-sim help`);
|
|
149
|
+
|
|
150
|
+
await handler(cwd, flags);
|
|
151
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sh, has } from '../lib/proc.js';
|
|
4
|
+
import { assertExpoProject } from '../lib/project.js';
|
|
5
|
+
import { WORKFLOW_PATH, GATE_PATH } from './init.js';
|
|
6
|
+
import { green, red, yellow, dim, bold } from '../lib/ui.js';
|
|
7
|
+
|
|
8
|
+
const PASS = green('✓');
|
|
9
|
+
const FAIL = red('✗');
|
|
10
|
+
const WARN = yellow('!');
|
|
11
|
+
|
|
12
|
+
export async function doctor(cwd) {
|
|
13
|
+
const checks = [];
|
|
14
|
+
const add = (icon, label, detail) => checks.push(` ${icon} ${label}${detail ? ` ${dim(detail)}` : ''}`);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const pkg = assertExpoProject(cwd);
|
|
18
|
+
add(PASS, 'Expo project', pkg.name);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
add(FAIL, 'Expo project', err.message.split('\n')[0]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
add(has('git') ? PASS : FAIL, 'git');
|
|
24
|
+
|
|
25
|
+
if (!has('gh')) {
|
|
26
|
+
add(FAIL, 'gh CLI', 'brew install gh');
|
|
27
|
+
} else if (!sh('gh', ['auth', 'status']).ok) {
|
|
28
|
+
add(FAIL, 'gh CLI authenticated', 'gh auth login');
|
|
29
|
+
} else {
|
|
30
|
+
const user = sh('gh', ['api', 'user', '-q', '.login']);
|
|
31
|
+
add(PASS, 'gh CLI authenticated', user.out);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
35
|
+
add(major >= 20 ? PASS : FAIL, 'Node >= 20', `v${process.versions.node}`);
|
|
36
|
+
|
|
37
|
+
add(existsSync(join(cwd, WORKFLOW_PATH)) ? PASS : WARN, WORKFLOW_PATH,
|
|
38
|
+
existsSync(join(cwd, WORKFLOW_PATH)) ? '' : 'run native-sim init');
|
|
39
|
+
add(existsSync(join(cwd, GATE_PATH)) ? PASS : WARN, GATE_PATH,
|
|
40
|
+
existsSync(join(cwd, GATE_PATH)) ? '' : 'run native-sim init');
|
|
41
|
+
|
|
42
|
+
const repo = sh('gh', ['repo', 'view', '--json', 'nameWithOwner,visibility', '-q',
|
|
43
|
+
'.nameWithOwner + " (" + .visibility + ")"'], { cwd });
|
|
44
|
+
if (repo.ok) {
|
|
45
|
+
const isPublic = /PUBLIC/.test(repo.out);
|
|
46
|
+
add(isPublic ? PASS : WARN, 'GitHub remote', isPublic ? repo.out : `${repo.out} — macOS minutes bill at 10×`);
|
|
47
|
+
} else {
|
|
48
|
+
add(WARN, 'GitHub remote', 'none yet — native-sim up will create one');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`\n${bold('native-sim doctor')}\n`);
|
|
52
|
+
console.log(checks.join('\n'));
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|