@stim-cli/server 1.14.0 → 1.15.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 +1584 -89
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -496
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5074 -549
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -4421
- package/package.json +3 -3
- package/dist/protocol-IroIg8Td.mjs +0 -865
package/README.md
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
`stim-server` serves Stim state to paired clients, such as the Stim phone app,
|
|
4
4
|
and lets the clients the Mac grants control run `stim reload` and `stim stop`
|
|
5
5
|
in a workspace. It runs on the Mac, next to Stim. It changes Stim state only
|
|
6
|
-
through those two commands
|
|
7
|
-
log under `$STIM_HOME/server
|
|
6
|
+
through those two commands. It writes only its own pairing state and action
|
|
7
|
+
log under `$STIM_HOME/server/`, and the device recordings described under
|
|
8
|
+
[Recording](#recording).
|
|
8
9
|
|
|
9
10
|
The design is in
|
|
10
11
|
[`docs/specs/2026-09-25-stim-server-design.md`](../../docs/specs/2026-09-25-stim-server-design.md).
|
|
@@ -15,29 +16,48 @@ The design is in
|
|
|
15
16
|
stim-server [--port <n>] # serve paired clients, port 7787 by default
|
|
16
17
|
stim-server pair [--port <n>] [--control]
|
|
17
18
|
# print a single-use pairing payload
|
|
18
|
-
stim-server devices [list] # list paired devices
|
|
19
|
-
stim-server devices grant <id> --control|--read
|
|
20
|
-
# let a paired device run actions, or only read
|
|
21
|
-
|
|
19
|
+
stim-server devices [list] # list paired devices, approved clients and access requests
|
|
20
|
+
stim-server devices grant <id> --control|--read|--build|--device-host
|
|
21
|
+
# let a paired device run actions, or only read;
|
|
22
|
+
# --build approves builds; --device-host approves device hosting
|
|
23
|
+
stim-server devices revoke <id> # revoke a paired device or client, or deny a request
|
|
22
24
|
stim-server log # list the actions paired devices ran
|
|
25
|
+
stim-server service install|status|update|rollback|uninstall
|
|
26
|
+
# run stim-server as a macOS LaunchAgent, see below
|
|
23
27
|
```
|
|
24
28
|
|
|
29
|
+
`--env KEY=VALUE` and `--path-prepend <dir>` (each repeatable) apply to the
|
|
30
|
+
serving command: see [Run as a service](#run-as-a-service).
|
|
31
|
+
|
|
25
32
|
`pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
|
|
26
33
|
`devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
|
|
27
|
-
`identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash
|
|
34
|
+
`identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash,
|
|
35
|
+
followed by the [build clients](#build-access) and [device-host clients](#device-host-approval).
|
|
36
|
+
New client records carry `requestedCapability` (`build` or `device-host`),
|
|
37
|
+
including after approval. Pending records also carry `pendingUntil`; a legacy
|
|
38
|
+
pending record without `requestedCapability` is a build request.
|
|
28
39
|
`log --json` prints `{ "actions": [...] }`, the records described under
|
|
29
40
|
[Actions](#actions).
|
|
30
41
|
|
|
31
42
|
`GET http://127.0.0.1:7787/health` answers requests from this Mac with the
|
|
32
|
-
server's name, versions,
|
|
33
|
-
|
|
43
|
+
server's name, versions, `stimBuild` (the digest of the bundled Stim's build,
|
|
44
|
+
which offload compares between Macs), protocol, `stimHome`, `startup`, `busy`
|
|
45
|
+
(the offloaded `builds` and `hostedSessions` running now) and the current
|
|
46
|
+
Tailscale state. It answers HTTP 200 once `startup.state` is `ready`; while the
|
|
47
|
+
state is `pending` or `degraded` (see the LaunchAgent notes below) it answers
|
|
48
|
+
HTTP 503 with the same body. While Tailscale runs, it also carries `route`, read from
|
|
34
49
|
`tailscale serve status --json` on each request: `routed` with the HTTPS
|
|
35
50
|
`port` that proxies to the server, `funneled` with the Funnel `ports` that do,
|
|
36
51
|
`missing`, or `unknown` with a `reason`; the last three carry the `port` the
|
|
37
|
-
setup command would use.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
setup command would use. When the server runs under Stim Host, it also carries
|
|
53
|
+
`host`: `{ name, screenRecording, accessibility }`, the grants macOS gives that
|
|
54
|
+
app (checked at most every 5 seconds), or `null` when the check fails. Stim
|
|
55
|
+
Desktop uses it to find a running server and show its route. A request through `tailscale serve` or on a Tailscale address
|
|
56
|
+
without an `Origin` or `Sec-Fetch-Site` header, which a web page's request
|
|
57
|
+
carries, gets only `{ "server": "stim-server", "version", "protocol" }`, which Stim
|
|
58
|
+
Desktop's Build machines list uses to find stim-server on the other Macs of the
|
|
59
|
+
tailnet. A request from this Mac with a `Host` other than `127.0.0.1` or
|
|
60
|
+
`localhost` gets HTTP 426, like any other plain HTTP request.
|
|
41
61
|
|
|
42
62
|
`stim-server` runs the `stim` version this package was released with, not the
|
|
43
63
|
one on your PATH. It reads the login shell's environment once at start, so
|
|
@@ -52,7 +72,15 @@ cryptography of its own. You need Tailscale on the Mac and on the phone, in the
|
|
|
52
72
|
same tailnet or with the Mac shared to the phone's user.
|
|
53
73
|
|
|
54
74
|
`stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
|
|
55
|
-
addresses, never on every interface.
|
|
75
|
+
addresses, never on every interface. It re-reads the Tailscale state in the
|
|
76
|
+
background, so a server that started before Tailscale was up, or while it did
|
|
77
|
+
not answer, starts listening on the Tailscale addresses once it runs, and stops
|
|
78
|
+
when it goes away. In Stim Desktop, choose **Set up connection** in Settings >
|
|
79
|
+
Phones to create and verify the private route without a terminal command. A
|
|
80
|
+
missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may
|
|
81
|
+
require Tailscale browser approval. Existing routes stay unchanged.
|
|
82
|
+
|
|
83
|
+
For manual server setup, run this once so clients can use
|
|
56
84
|
`wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
|
|
57
85
|
|
|
58
86
|
```bash
|
|
@@ -73,6 +101,15 @@ Funnel port makes it public, and `pair` refuses.
|
|
|
73
101
|
When Tailscale is not running, `stim-server` listens on loopback only and
|
|
74
102
|
says so on stderr. Start Tailscale, then restart `stim-server`.
|
|
75
103
|
|
|
104
|
+
Desktop uses `route.setup` with no parameters on its authenticated loopback
|
|
105
|
+
control connection. The result is a verified `ServeRoute`; an existing route is a
|
|
106
|
+
no-op. The server refuses forwarded, browser-origin or tailnet connections,
|
|
107
|
+
read-only clients, unknown configuration and any route exposing this server
|
|
108
|
+
through Funnel. Only the fixed `tailscale serve --bg --https=<free port>
|
|
109
|
+
http://127.0.0.1:<server port>` invocation runs, followed by route verification.
|
|
110
|
+
No LaunchAgent or Funnel configuration is created. Failure output remains
|
|
111
|
+
available for an explicit retry, including Tailscale's HTTPS approval link.
|
|
112
|
+
|
|
76
113
|
## Pairing
|
|
77
114
|
|
|
78
115
|
`stim-server pair` prints the JSON that the pairing QR code encodes:
|
|
@@ -102,20 +139,907 @@ A connection must send `hello` within 5 seconds. Five failed attempts from the
|
|
|
102
139
|
same peer within a minute block new connections from it for up to a minute.
|
|
103
140
|
|
|
104
141
|
Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
|
|
105
|
-
closes its open connections.
|
|
142
|
+
closes its open connections. The server checks registrations on file changes
|
|
143
|
+
and once a second, so a missed filesystem notification cannot leave a revoked
|
|
144
|
+
connection authorized.
|
|
106
145
|
|
|
107
146
|
## Scopes
|
|
108
147
|
|
|
109
148
|
A paired device has the `read` capability, which serves state, or also
|
|
110
|
-
`control`, which runs [actions](#actions) and [controls devices](#control).
|
|
111
|
-
the
|
|
149
|
+
`control`, which runs [actions](#actions) and [controls devices](#control).
|
|
150
|
+
With the `workspace-diff` feature, `read` also serves changed and untracked
|
|
151
|
+
text file contents in registered workspaces, including any non-ignored
|
|
152
|
+
untracked text file such as an unignored `.env`. Pairing grants `read` only,
|
|
153
|
+
unless the pairing code came from `stim-server pair --control`. On the Mac,
|
|
112
154
|
`stim-server devices grant <id> --control` adds control to a paired device and
|
|
113
155
|
`--read` takes it away. Nothing a client sends changes its own capabilities.
|
|
114
156
|
The server checks the device's capabilities in `devices.json` on every action
|
|
115
157
|
and control session, and ends a device's control sessions when it loses
|
|
116
|
-
`control`, so taking control away applies to open connections
|
|
158
|
+
`control`, so taking control away applies to open connections on the next
|
|
159
|
+
registration check. `hello` reports
|
|
117
160
|
the capabilities and actions of the connection's device when it connects; a
|
|
118
|
-
connection sees a new grant after it reconnects.
|
|
161
|
+
connection sees a new grant after it reconnects. Under Stim Host, an approved
|
|
162
|
+
`hello` also carries the same `host` grants as `/health`, which `stim doctor`
|
|
163
|
+
reports for hosting machines.
|
|
164
|
+
|
|
165
|
+
Every method other than `hello` needs `read`. A device with only `build`
|
|
166
|
+
gets `forbidden` for all of them.
|
|
167
|
+
|
|
168
|
+
## Build access
|
|
169
|
+
|
|
170
|
+
`build` lets another Mac on the tailnet run its project's code on this Mac to
|
|
171
|
+
build for it: config plugins, CocoaPods hooks, Xcode script phases and Gradle
|
|
172
|
+
plugins run as the user `stim-server` runs as. It never comes with `read` or
|
|
173
|
+
`control`, and `devices grant` never turns a paired device into a build
|
|
174
|
+
client. Grant it only to Macs you trust with that. A connection from this Mac,
|
|
175
|
+
over loopback, cannot get it: it has no tailnet node to bind the token to.
|
|
176
|
+
Because the server trusts `X-Forwarded-For` on loopback (see
|
|
177
|
+
[Pairing](#pairing)), a process on this Mac can still claim a tailnet peer's
|
|
178
|
+
address. Run `stim-server` as a user no one else can run processes as when
|
|
179
|
+
other people use this Mac, and approve a request only when you expect it.
|
|
180
|
+
|
|
181
|
+
A Mac gets `build` only by asking and being approved on this Mac. The client
|
|
182
|
+
sends `hello` with `auth` set to `{ "request": "build", "deviceName" }`, a
|
|
183
|
+
name of at most 64 UTF-16 code units with no control or format characters.
|
|
184
|
+
The server records a pending build client bound to the peer's node, answers
|
|
185
|
+
with a `deviceToken`, no capabilities and
|
|
186
|
+
`approval: { "state": "pending", "expiresAt" }`, and closes the connection.
|
|
187
|
+
`stim-server devices` lists it as `pending build`. On this Mac,
|
|
188
|
+
`stim-server devices grant <id> --build` approves it and
|
|
189
|
+
`stim-server devices revoke <id>` denies it; Stim Desktop's **Allow** and
|
|
190
|
+
**Deny** run those commands. Until then, `hello` with its token
|
|
191
|
+
fails with `approval-pending`, which does not count as a failed attempt, while
|
|
192
|
+
the request itself does, so a peer cannot send more requests than failed
|
|
193
|
+
attempts. A request lapses after 15 minutes. Each node has at most one pending
|
|
194
|
+
request, the newest, and the server holds at most 8; more fail with
|
|
195
|
+
`limit-exceeded`. There is no pairing code for `build`: `stim-server pair`
|
|
196
|
+
never grants it.
|
|
197
|
+
|
|
198
|
+
`devices grant` never gives `build` to a paired device, or `read` or
|
|
199
|
+
`control` to a build client. Build clients live in
|
|
200
|
+
`$STIM_HOME/server/build-clients.json`, apart from `devices.json`, so a
|
|
201
|
+
`stim-server` release without `build` never reads them and refuses their
|
|
202
|
+
tokens.
|
|
203
|
+
|
|
204
|
+
## Device-host approval
|
|
205
|
+
|
|
206
|
+
Device hosting has a separate `device-host` capability. An approved client can
|
|
207
|
+
reserve, boot, reconnect to and stop its own iOS simulator or Android emulator
|
|
208
|
+
through the protocol. It can deliver, install and launch a compatible iOS app
|
|
209
|
+
bundle, Android APK or prebuilt macOS app, stream the iOS simulator or macOS app and control it. The hosted iOS
|
|
210
|
+
app connects back to Metro on the client Mac. Automatic CLI placement, client view/control relays and
|
|
211
|
+
Android Metro and viewing remain in [#2266](https://github.com/appandflow/stim/issues/2266).
|
|
212
|
+
|
|
213
|
+
The Stim client can name expected hosts with
|
|
214
|
+
`stim settings set hosting.machines '["<mac>"]'` and request access with
|
|
215
|
+
`stim doctor --fix`. It stores a separate private credential in
|
|
216
|
+
`$STIM_HOME/device-host-machines.json`, pins the worker's tailnet node, and
|
|
217
|
+
reports approval under `deviceHosts` in doctor JSON. Plain doctor makes no
|
|
218
|
+
approval request. This setup does not yet place CLI sessions remotely.
|
|
219
|
+
|
|
220
|
+
A client on the tailnet sends `hello` with
|
|
221
|
+
`auth: { "request": "device-host", "deviceName": "Laptop" }`. As with a build
|
|
222
|
+
request, the server returns a token and pending approval, then closes the
|
|
223
|
+
connection. It binds the token to the peer's tailnet node. Requests expire
|
|
224
|
+
after 15 minutes; each node keeps only its newest pending request of this kind,
|
|
225
|
+
and at most eight device-host requests can be pending. Build requests have a
|
|
226
|
+
separate limit. The same name validation and failed-attempt limit apply.
|
|
227
|
+
|
|
228
|
+
On the hosting Mac, inspect `stim-server devices`, then approve the matching
|
|
229
|
+
request with `stim-server devices grant <id> --device-host`, or use **Allow**
|
|
230
|
+
in Stim Desktop. Approve only an expected request: it authorizes that Mac to
|
|
231
|
+
reserve session-owned simulators or emulators and run native app code. **Deny**
|
|
232
|
+
or `stim-server devices revoke <id>` removes it; revocation also closes its
|
|
233
|
+
open authenticated connections. The local-process trust boundary described in
|
|
234
|
+
[Build access](#build-access) applies here too.
|
|
235
|
+
|
|
236
|
+
Hosting clients live in `$STIM_HOME/server/device-host-clients.json`, separate
|
|
237
|
+
from phone pairings and build clients. A hosting token grants no `read`,
|
|
238
|
+
`control` or `build` access, including access to unrelated workspaces. Existing
|
|
239
|
+
read, control and build tokens cannot gain hosting through `devices grant`.
|
|
240
|
+
Loopback requests and `pair --device-host` are refused.
|
|
241
|
+
|
|
242
|
+
### Hosted availability offers
|
|
243
|
+
|
|
244
|
+
Before reserving, an approved hosting client can send:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{ "id": 1, "method": "device-host.offer", "params": { "platform": "ios", "runtime": "27.0" } }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Use `platform: "android"` with optional `systemImage` and `deviceProfile`, or
|
|
251
|
+
`ios` with optional `deviceType` and `runtime`. Use `macos` without selectors
|
|
252
|
+
for the host architecture and macOS version. Omit selectors for the same
|
|
253
|
+
installed defaults used by reserve. The response includes `platform`, the
|
|
254
|
+
selected SDK `choice` (runtime/model/image and host architecture), `resources`,
|
|
255
|
+
`capacity` and a nullable `declined` reason. An unavailable SDK choice is `null`
|
|
256
|
+
with a reason; unknown or elevated memory pressure declines. The query creates
|
|
257
|
+
no session, simulator, AVD or ownership claim.
|
|
258
|
+
|
|
259
|
+
`capacity.running` counts every reservation not confirmed stopped, including
|
|
260
|
+
unresolved sessions. `max` is `concurrency.maxDevices`; zero means uncapped and
|
|
261
|
+
`available` is then `null`. Android offers also decline when all supported
|
|
262
|
+
console ports are already recorded as occupied. macOS offers decline when all
|
|
263
|
+
64 app slots are reserved. Resource values are advisory:
|
|
264
|
+
`cpus`, five-minute `loadPerCore`, `memoryFreeBytes`, `memoryPressure` (nullable
|
|
265
|
+
when unknown), and `workerDiskFreeBytes` (nullable when unreadable). Disk space
|
|
266
|
+
is measured on the Stim home volume, which can differ from Android AVD storage.
|
|
267
|
+
|
|
268
|
+
An offer is a snapshot, not a reservation or a boot guarantee. Capacity,
|
|
269
|
+
memory, installed SDKs and local device producers can change before reserve;
|
|
270
|
+
reserve still takes atomic admission and revalidates native preflight. A
|
|
271
|
+
non-null choice with a non-null `declined` reason is currently unavailable.
|
|
272
|
+
This protocol does not select a host for `stim ios` or `stim android`.
|
|
273
|
+
|
|
274
|
+
### Agent driver route
|
|
275
|
+
|
|
276
|
+
On the hosting Mac, `hosting.agentDriver` names the tool that lets a client's
|
|
277
|
+
coding agent drive the macOS app hosted for that client. `none` (the default)
|
|
278
|
+
starts nothing. `stim-server` reference-counts the driver's daemon on running
|
|
279
|
+
hosted macOS apps: it starts with the first, stops after the last, on
|
|
280
|
+
revocation and on server shutdown, and restarts if it exits while apps run. The
|
|
281
|
+
daemon is held under an ownership claim at `server/agent-device.claims` whose
|
|
282
|
+
child is the daemon, and its state lives in `server/agent-device`.
|
|
283
|
+
|
|
284
|
+
Each hosted app gets a grant scoped to one client and one app. The grant's
|
|
285
|
+
token is kept in memory only, is returned to that client in `app.launch` and
|
|
286
|
+
`app.attach` results, and is never journaled or logged. The route
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
ANY /device-host/agent/<session>/<driver path>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
on the tailnet `serve` route is forwarded to the loopback daemon, with the
|
|
293
|
+
daemon's own token, only when all of these hold: the request carries that
|
|
294
|
+
session's token as a bearer token, it comes from the tailnet node pinned to the
|
|
295
|
+
session's approved device-host client, the client still holds `device-host`, and
|
|
296
|
+
the session's app is running. Loopback requests, browser requests and other
|
|
297
|
+
sessions' tokens are refused with 403, an unknown or stopped session with 404.
|
|
298
|
+
|
|
299
|
+
The `agent-device` adapter runs the binary that `STIM_AGENT_DEVICE_BIN` names
|
|
300
|
+
in `stim-server`'s environment, or else the first of
|
|
301
|
+
`~/.local/bin/agent-device`, `/opt/homebrew/bin/agent-device` and
|
|
302
|
+
`/usr/local/bin/agent-device`; it never searches `PATH`. It starts
|
|
303
|
+
`agent-device proxy` with the native macOS app backend and a daemon policy that
|
|
304
|
+
admits only requests under a `macos-app` lease and only the commands that drive
|
|
305
|
+
one app. It starts only when the daemon's `/health` lists the `macos-app` lease
|
|
306
|
+
backend (callstack/agent-device#3229); otherwise the grant is
|
|
307
|
+
`{ "driver": "none" }` with a notice, and Stim never hands out the Mac's desktop.
|
|
308
|
+
|
|
309
|
+
For each running hosted app, the adapter allocates a `macos-app` lease for
|
|
310
|
+
`<bundleId>@<pid>` over agent-device's loopback `/admin/leases` route with the
|
|
311
|
+
daemon token, renews it while the app runs and releases it when the app stops
|
|
312
|
+
or the grant is revoked. The grant carries the lease id and owner scope. The
|
|
313
|
+
forward passes only `POST /rpc`, `GET /health` and `GET /artifacts/...`, and it
|
|
314
|
+
rewrites every command and lease call to that session's lease, tenant and
|
|
315
|
+
tenant session isolation, so a client cannot name another lease or session. It
|
|
316
|
+
refuses commands outside the daemon policy's list (also inside a `batch`),
|
|
317
|
+
drops device selectors and runtime hints, and sets `platform` to `macos`.
|
|
318
|
+
|
|
319
|
+
### Hosted iOS session protocol
|
|
320
|
+
|
|
321
|
+
An approved client sends `device-host.reserve` with an opaque attempt ID and
|
|
322
|
+
its workspace/slot identity:
|
|
323
|
+
|
|
324
|
+
```json
|
|
325
|
+
{
|
|
326
|
+
"id": 1,
|
|
327
|
+
"method": "device-host.reserve",
|
|
328
|
+
"params": {
|
|
329
|
+
"workspace": "/client/app",
|
|
330
|
+
"slot": "default",
|
|
331
|
+
"platform": "ios",
|
|
332
|
+
"attempt": "run-1",
|
|
333
|
+
"deviceType": "iPhone 17 Pro",
|
|
334
|
+
"runtime": "27.0"
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Omit `deviceType` and `runtime` to use the worker's installed defaults. The
|
|
340
|
+
response names an opaque session and its `preparing`, `ready`, `stopping`,
|
|
341
|
+
`stopped` or `unknown` state. Native work runs in a separate bounded child, so
|
|
342
|
+
the connection remains available. Poll `device-host.attach` with
|
|
343
|
+
`{"session":"<id>"}` or `{"attempt":"run-1"}`; a ready session includes the
|
|
344
|
+
exact simulator, runtime and architecture selected on the worker.
|
|
345
|
+
|
|
346
|
+
After a lost reply or connection, replay the same reserve request or attach to
|
|
347
|
+
its attempt. That resolves the same session; a changed request with that attempt
|
|
348
|
+
is refused. Another attempt cannot replace an occupied workspace/slot. The
|
|
349
|
+
client never supplies a worker filesystem path or another client's session.
|
|
350
|
+
`device-host.stop` with `{"session":"<id>"}` shuts down only its recorded,
|
|
351
|
+
ledger-owned simulator. Poll attach for completion. A new attempt may reserve
|
|
352
|
+
after the previous one is confirmed stopped. Stopped device records remain for
|
|
353
|
+
ownership and reconciliation; stop does not delete the simulator.
|
|
354
|
+
|
|
355
|
+
The worker persists the journal under `$STIM_HOME/server/device-host-sessions/`
|
|
356
|
+
and chooses an isolated worker home under `$STIM_HOME/device-host/sessions/`.
|
|
357
|
+
An uncertain create, child exit, journal or shutdown outcome retains the slot
|
|
358
|
+
as `unknown`. Explicit stop can reconcile a complete record after a server
|
|
359
|
+
restart; a live or unverifiable owner is refused. Missing ownership records
|
|
360
|
+
need operator investigation, never a replacement inferred from a simulator name.
|
|
361
|
+
Revoking this client's approval stops its sessions without granting access to
|
|
362
|
+
the worker's other devices.
|
|
363
|
+
|
|
364
|
+
`concurrency.maxDevices` bounds hosted reservations atomically, including
|
|
365
|
+
unresolved sessions. Ordinary local producers do not join that reservation
|
|
366
|
+
transaction, so this is not a machine-wide hard capacity guarantee. Unknown
|
|
367
|
+
inventory or elevated/unknown memory pressure refuses native creation. This
|
|
368
|
+
protocol slice does not change `stim ios` placement.
|
|
369
|
+
|
|
370
|
+
### Hosted Android session protocol
|
|
371
|
+
|
|
372
|
+
The same reserve/attach/stop methods accept `platform: "android"` with optional
|
|
373
|
+
`systemImage` (an installed `system-images;android-<api>;<tag>;<abi>` package)
|
|
374
|
+
and `deviceProfile` (an installed avdmanager profile). iOS selectors refuse on
|
|
375
|
+
Android requests. The image must match the host architecture. Omitted selectors
|
|
376
|
+
use Stim's existing compatible installed-image and default-profile choice.
|
|
377
|
+
The worker process needs an Android SDK and a JDK that `avdmanager` can use.
|
|
378
|
+
|
|
379
|
+
```json
|
|
380
|
+
{
|
|
381
|
+
"id": 1,
|
|
382
|
+
"method": "device-host.reserve",
|
|
383
|
+
"params": {
|
|
384
|
+
"workspace": "/client/worktree",
|
|
385
|
+
"slot": "default",
|
|
386
|
+
"platform": "android",
|
|
387
|
+
"attempt": "android-1",
|
|
388
|
+
"systemImage": "system-images;android-30;google_apis;arm64-v8a",
|
|
389
|
+
"deviceProfile": "pixel_6"
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
A preparing Android result includes a server-selected `consolePort`. The journal
|
|
395
|
+
reserves distinct ports among hosted sessions and excludes the ports in the
|
|
396
|
+
host's local config. The worker refuses an observed occupied port before
|
|
397
|
+
creation and before boot. Local producers do not participate in this reservation,
|
|
398
|
+
so a racing local launch can still refuse a hosted boot; this is not a
|
|
399
|
+
machine-wide hard port or device-capacity guarantee.
|
|
400
|
+
|
|
401
|
+
A ready Android `device` has `avdName`, `serial`, `consolePort`, `systemImage`,
|
|
402
|
+
`deviceProfile` and `architecture` (`arm64-v8a` or `x86_64`). The worker verifies
|
|
403
|
+
its exact AVD name and running ABI before reporting ready. Creation remains
|
|
404
|
+
inside the worker's claimed process group. The worker opens no emulator viewer.
|
|
405
|
+
Stop and revocation use centralized teardown only for its private ledger's exact
|
|
406
|
+
AVD. They shut down and retain its AVD data and record; unknown native outcomes
|
|
407
|
+
retain the reservation and require explicit reconciliation. Android Metro
|
|
408
|
+
and screen/input routing remain follow-ups in #2266. Android sessions
|
|
409
|
+
refuse the iOS Metro, view and input routes.
|
|
410
|
+
|
|
411
|
+
### Hosted iOS app delivery
|
|
412
|
+
|
|
413
|
+
App offers, chunks and launches require a ready session held by this server.
|
|
414
|
+
After its owner disappears, session attach reports `unknown`; app operations
|
|
415
|
+
refuse until explicit stop reconciles the retained session. A ready journal
|
|
416
|
+
entry alone does not authorize another native operation.
|
|
417
|
+
|
|
418
|
+
Send `device-host.app.offer` with the ready session, a new opaque app `attempt`,
|
|
419
|
+
the expected `bundleId`, `mode: "development"|"release"`, and
|
|
420
|
+
`manifest: {"sha256":"<digest>","size":<bytes>}`. The manifest is a UTF-8 JSON
|
|
421
|
+
array of `{path, kind, size, sha256}` entries, where `kind` is `file`, `exec` or
|
|
422
|
+
`link`. Paths are relative to the `.app` root. A link's content is its relative
|
|
423
|
+
target; it must resolve inside the bundle. No entry may sit below a file or
|
|
424
|
+
link. Duplicate paths, including Unicode/case aliases, are refused.
|
|
425
|
+
|
|
426
|
+
macOS offers may also include an optional `arguments` string array: at most 32
|
|
427
|
+
entries, 1024 characters per entry and 8192 characters total, with no NUL, CR or
|
|
428
|
+
LF. Empty strings are allowed; an empty array is omitted. Other session platforms
|
|
429
|
+
refuse non-empty arguments. The host passes them as plain executable arguments,
|
|
430
|
+
without environment injection, stores them in the app receipt and echoes them in
|
|
431
|
+
delivery replies. They are visible in process listings, so do not put secrets
|
|
432
|
+
there. Older hosts ignore this field; Stim warns to update `stim-server` on the
|
|
433
|
+
host when its delivery does not report the requested arguments.
|
|
434
|
+
|
|
435
|
+
An offer returns `{delivery, missing}`. Upload each missing digest with
|
|
436
|
+
`device-host.app.chunk` and `{session, attempt, sha256, offset, data}`; `data` is
|
|
437
|
+
base64 for at most 32 KiB of raw bytes. The reply names the next byte `offset`.
|
|
438
|
+
Replay of the same bytes is safe after a lost reply. Re-offer to learn received
|
|
439
|
+
offsets. Upload the manifest first, then re-offer for its missing file content.
|
|
440
|
+
The manifest is limited to 8 MiB and 20,000 entries, with at most 1 GiB per file,
|
|
441
|
+
1 KiB per link, and 4 GiB of declared bundle content. An app attempt cannot
|
|
442
|
+
change its identity, mode, arguments or manifest. Complete a receiving attempt or stop the
|
|
443
|
+
session before starting another transfer.
|
|
444
|
+
|
|
445
|
+
A macOS session can take its content from a build this Mac ran instead: after
|
|
446
|
+
the manifest arrives, send `device-host.app.handoff` with
|
|
447
|
+
`{session, attempt, build: {handoff, sha256}}`, where `handoff` is the token
|
|
448
|
+
and `sha256` the archive digest from that job's `build.artifact` answer. The
|
|
449
|
+
server hands the build over only when the build client that ran it still has
|
|
450
|
+
`build`, the caller has `device-host`, and both approvals belong to the same
|
|
451
|
+
tailnet node. It copies only regular files, and links where the manifest
|
|
452
|
+
declares links, whose directory resolves inside the staged bundle and whose
|
|
453
|
+
bytes match their manifest digest; it answers `{files, bytes}` for what it
|
|
454
|
+
took. The token is spent once the handoff starts. Re-offer and upload whatever
|
|
455
|
+
is still missing; an older server answers `unknown-method`.
|
|
456
|
+
|
|
457
|
+
After every digest is verified, call `device-host.app.launch` with
|
|
458
|
+
`{session, attempt}`. Poll `device-host.app.attach` for `installed` or `unknown`.
|
|
459
|
+
Reconnect to the same app attempt to reconcile a lost launch reply; replay
|
|
460
|
+
does not install or launch twice. Session attach includes the latest
|
|
461
|
+
`appAttempt`. Development offers may include `devClientScheme` for an Expo
|
|
462
|
+
development client; the attempt cannot change that scheme. The worker verifies the plist identity and simulator metadata,
|
|
463
|
+
the executable's Mach-O platform, architecture and minimum OS, then rechecks
|
|
464
|
+
the exact private device ledger before install and launch.
|
|
465
|
+
|
|
466
|
+
Development launch reports `launched: "unverified"`: a bridge or native process
|
|
467
|
+
alone does not prove bundle delivery. Release launch reports `true` only after observing a live native app
|
|
468
|
+
process; absent evidence remains `"unverified"`. Stop and approval revocation
|
|
469
|
+
cancel an in-flight install before shutting down the owned simulator. Uncertain
|
|
470
|
+
native outcomes retain the session as `unknown`; explicitly stop it before
|
|
471
|
+
retrying. Receipts and artifacts remain in the server-chosen session area;
|
|
472
|
+
artifact retention and session reuse/retirement remain under
|
|
473
|
+
[#2266](https://github.com/appandflow/stim/issues/2266) and
|
|
474
|
+
[#2348](https://github.com/appandflow/stim/issues/2348).
|
|
475
|
+
|
|
476
|
+
### Hosted Android app delivery
|
|
477
|
+
|
|
478
|
+
Android sessions use the same app offer/chunk/launch/attach methods, with
|
|
479
|
+
`bundleId` naming the expected Android package. Their manifest has exactly one
|
|
480
|
+
`file` entry named `App.apk`, with the APK's byte size and SHA-256. Upload the
|
|
481
|
+
manifest first, then the APK through the same resumable 32 KiB chunks.
|
|
482
|
+
|
|
483
|
+
The worker verifies both digests, inspects the APK's package identity, minimum
|
|
484
|
+
SDK and native library ABIs with installed Android build-tools, and rechecks the
|
|
485
|
+
private ledger, exact running AVD and ABI before installation and launch. It
|
|
486
|
+
resolves adb from that SDK even when the worker PATH does not include it. A
|
|
487
|
+
signature or installation conflict refuses; hosting does not uninstall an
|
|
488
|
+
existing app to resolve it.
|
|
489
|
+
|
|
490
|
+
Release launch reports `true` only after observing a live package process.
|
|
491
|
+
Development launch remains `unverified`; this driver launches the activity but
|
|
492
|
+
does not wire an Android Metro bridge. Replaying an installed app attempt does
|
|
493
|
+
not install or launch again. Owner loss, stop, revocation and uncertain outcomes
|
|
494
|
+
retain the same reconciliation rules as iOS. Android viewing, Metro and client
|
|
495
|
+
placement remain under [#2266](https://github.com/appandflow/stim/issues/2266).
|
|
496
|
+
|
|
497
|
+
### Hosted macOS app sessions
|
|
498
|
+
|
|
499
|
+
The same offer/reserve/attach/stop methods accept `platform: "macos"` without
|
|
500
|
+
selectors. A macOS reservation counts toward `concurrency.maxDevices` and takes
|
|
501
|
+
the lowest free `appSlot` from 1 through 64. Unresolved sessions retain their
|
|
502
|
+
slot; only a confirmed stop frees it. The ready device describes the host's
|
|
503
|
+
`architecture` (`arm64` or `x86_64`), `macosVersion` and `appSlot`. No simulator,
|
|
504
|
+
emulator or created-device ledger entry is needed.
|
|
505
|
+
|
|
506
|
+
```json
|
|
507
|
+
{
|
|
508
|
+
"id": 1,
|
|
509
|
+
"method": "device-host.reserve",
|
|
510
|
+
"params": {
|
|
511
|
+
"workspace": "/client/app",
|
|
512
|
+
"slot": "default",
|
|
513
|
+
"platform": "macos",
|
|
514
|
+
"attempt": "macos-1"
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Deliver a prebuilt `.app` using the same digest manifest and resumable chunks.
|
|
520
|
+
The manifest must include `Contents/Info.plist` as a file and an executable under
|
|
521
|
+
`Contents/MacOS/`. Only release offers are accepted, with no `devClientScheme`
|
|
522
|
+
and no `com.apple.` bundle identity. The host verifies the plist identity,
|
|
523
|
+
contained executable, architecture slice, MACOS Mach-O platform and minimum OS.
|
|
524
|
+
URL registrations (`CFBundleURLTypes`) and update feeds (`SUFeedURL`) refuse.
|
|
525
|
+
|
|
526
|
+
Before launch the worker stamps `<bundleId>.hosted<appSlot>` and ad hoc signs
|
|
527
|
+
its frameworks and bundle. That identity must fit within 249 characters so its
|
|
528
|
+
`.plist` preferences filename fits within the 255-byte filename limit. The
|
|
529
|
+
existing macOS supervisor owns the app process, with `HOME`, `CFFIXED_USER_HOME`
|
|
530
|
+
and `TMPDIR` pointing into the session's private app home. This is home and
|
|
531
|
+
identity isolation, not an OS sandbox for the client's native code.
|
|
532
|
+
The supervisor and app receive only the host's set `PATH`, `LANG`, `LC_ALL`,
|
|
533
|
+
`LC_CTYPE`, `USER`, `LOGNAME`, `SHELL` and `TERM`, plus the worker `STIM_HOME`
|
|
534
|
+
and isolated home and temporary paths.
|
|
535
|
+
`launched: true` requires matching live app and supervisor process identities.
|
|
536
|
+
Stop and revocation verify and stop those processes, then delete only the last
|
|
537
|
+
recorded hosted bundle identity's preferences domain and plist. A replacement
|
|
538
|
+
app with a different identity removes the prior hosted identity's preferences
|
|
539
|
+
after stopping its processes. macOS preferences use the real user's preferences
|
|
540
|
+
service despite the isolated home. Stop removes the app home and delivered bytes
|
|
541
|
+
from every app attempt, keeping logs and receipts so `app.attach` can still report
|
|
542
|
+
the recorded state.
|
|
543
|
+
|
|
544
|
+
Once a macOS app is `installed`, `device-host.app.launch` and
|
|
545
|
+
`device-host.app.attach` return the agent grant the host's driver issued for that
|
|
546
|
+
session, or `agent: { driver: 'none' }` while no driver runs or it cannot scope
|
|
547
|
+
the app; the driver's reason, if any, is the result's `notice`. The field is
|
|
548
|
+
built for the response and never stored in the receipt or journal; it is absent
|
|
549
|
+
before installation, after stop and for iOS and Android sessions. The worker
|
|
550
|
+
reports the app's pid, `DeviceHost` registers the running app with the agent
|
|
551
|
+
driver before the receipt reads `installed`, and ends that registration when the
|
|
552
|
+
session stops, is revoked or the server closes.
|
|
553
|
+
|
|
554
|
+
`device-host.logs.query` with `{session, cursor?}` returns `{records, cursor, more}`:
|
|
555
|
+
the NDJSON records the session's macOS app wrote to its captured log (stdout and
|
|
556
|
+
stderr as `client` records, and its exit). `cursor` maps each log file name to the
|
|
557
|
+
byte offset after the last complete line read; pass the previous result's cursor to
|
|
558
|
+
receive only newer records, and repeat while `more` is true. Without a cursor, each
|
|
559
|
+
file starts at most 4 MiB before its end. A page holds at most 1 MiB; a file that
|
|
560
|
+
rotated since the cursor is read from the end of its previous generation. The
|
|
561
|
+
session's own client only, for macOS sessions, and also after the session stopped,
|
|
562
|
+
because stop keeps logs. Servers that predate it answer `forbidden` or
|
|
563
|
+
`unknown-method`.
|
|
564
|
+
|
|
565
|
+
macOS sessions refuse Metro. Viewing and control are supported while the hosted
|
|
566
|
+
app is running. CLI placement and client view/control relays remain follow-ups in [#2403](https://github.com/appandflow/stim/issues/2403).
|
|
567
|
+
|
|
568
|
+
### Private hosted Metro
|
|
569
|
+
|
|
570
|
+
The client keeps its verified workspace Metro on loopback. It creates a
|
|
571
|
+
`createMetroGateway` from `@stim-cli/core`, binds it only to its own Tailscale
|
|
572
|
+
address, and supplies the pinned worker's literal tailnet address, local Metro
|
|
573
|
+
port and a fresh 32-byte secret encoded as 64 lowercase hex characters. The
|
|
574
|
+
gateway accepts only that worker address and authenticates each connection
|
|
575
|
+
before forwarding to the fixed local Metro port. It never targets a client
|
|
576
|
+
supplied URL. Close the gateway when its session ends.
|
|
577
|
+
|
|
578
|
+
Call `device-host.metro.open` with `{session, gatewayPort, secret}` on the
|
|
579
|
+
approved hosted connection. The server connects only to that connection's
|
|
580
|
+
authenticated tailnet peer and returns `{port}` for the worker's loopback
|
|
581
|
+
endpoint. Development installation uses that port for `RCT_jsLocation` and,
|
|
582
|
+
when offered, the Expo development-client deep link. HTTP and WebSocket bytes
|
|
583
|
+
stream over WireGuard with socket backpressure; no public tunnel, Funnel or
|
|
584
|
+
Tailscale serve configuration change is needed. The 64 KiB server message
|
|
585
|
+
limit remains unchanged.
|
|
586
|
+
|
|
587
|
+
Replaying the same open request keeps the port. Client disconnection leaves
|
|
588
|
+
the bridge available for the same session while its server owner lives. Call
|
|
589
|
+
`device-host.metro.close` to replace a gateway, then reopen on the same port;
|
|
590
|
+
an occupied port refuses rather than sending the app to another listener.
|
|
591
|
+
Closing a bridge interrupts its active streams. Stop, revocation and server
|
|
592
|
+
shutdown close its sockets before device shutdown. After a server owner
|
|
593
|
+
disappears, the retained session requires explicit stop, as app delivery does.
|
|
594
|
+
|
|
595
|
+
Expo dev-launcher and CLI versions that send and honor the `Forwarded` header
|
|
596
|
+
resolve relative manifest URLs against the worker origin. Older versions may
|
|
597
|
+
embed the client's local port instead; this slice does not rewrite manifests
|
|
598
|
+
or claim that those versions work through a different worker port. Client
|
|
599
|
+
placement still needs to check that contract before selecting hosted Metro.
|
|
600
|
+
Bare React Native uses the worker `RCT_jsLocation`. Bridge readiness and
|
|
601
|
+
manifest requests are not launch proof; development remains `unverified` until
|
|
602
|
+
the workspace observes the app's own bundle delivery.
|
|
603
|
+
This is a protocol API for approved clients; automatic iOS CLI placement,
|
|
604
|
+
client iOS view/control relays and Android Metro/viewing remain in [#2266](https://github.com/appandflow/stim/issues/2266).
|
|
605
|
+
|
|
606
|
+
### Hosted macOS relay
|
|
607
|
+
|
|
608
|
+
The client's stim-server relays a hosted workspace's macOS view and input to
|
|
609
|
+
its host using the client's approved device-host credential over the pinned
|
|
610
|
+
tailnet connection. Stim Desktop and phones keep talking only to their own
|
|
611
|
+
server. The `macos-hosted` feature advertises this relay. All of the client's
|
|
612
|
+
relayed subscriptions and control sessions for one host share one connection,
|
|
613
|
+
whichever local client opened them. Each request still checks the credential
|
|
614
|
+
and the pinned node; a changed credential or endpoint opens a new connection.
|
|
615
|
+
Ending a subscription or session sends `device-host.unsubscribe` or
|
|
616
|
+
`device-host.control.end`, and the connection closes when the last one ends.
|
|
617
|
+
The host's per-connection limits, such as its 32 subscriptions and input
|
|
618
|
+
budgets, therefore apply to all of the client's relayed use of that host.
|
|
619
|
+
While a local client is behind on a relayed H.264 subscription, the relay drops
|
|
620
|
+
its packets until the next keyframe and, when the host advertises the
|
|
621
|
+
`hosted-congestion` feature, sends `device-host.frames.congested` at most every
|
|
622
|
+
250 ms, so the host lowers that app's bitrate as it does for a local subscriber
|
|
623
|
+
whose socket backs up.
|
|
624
|
+
Hosted frames and control reject `physical`, a non-default slot, and replay
|
|
625
|
+
(`at`/`rate`).
|
|
626
|
+
`control.begin` still needs the local `control` grant. Screen Recording for
|
|
627
|
+
viewing and Accessibility for control are granted on the host, not the client.
|
|
628
|
+
|
|
629
|
+
### Hosted iOS and macOS view and input
|
|
630
|
+
|
|
631
|
+
An approved hosting client can subscribe to its ready session's exact owned
|
|
632
|
+
iOS simulator or running macOS app without access to the worker's registered
|
|
633
|
+
workspaces:
|
|
634
|
+
|
|
635
|
+
```json
|
|
636
|
+
{
|
|
637
|
+
"id": 8,
|
|
638
|
+
"method": "device-host.frames.subscribe",
|
|
639
|
+
"params": { "session": "<hosted-session-id>", "fps": 5, "maxEdge": 1280 }
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The result contains a subscription ID. JPEG delivery uses the existing `frame`
|
|
644
|
+
events; `video: ["h264"]` selects the existing H.264 binary stream and its
|
|
645
|
+
backpressure/keyframe rules. `device-host.frames.keyframe`,
|
|
646
|
+
`device-host.frames.congested` and `device-host.unsubscribe` take that
|
|
647
|
+
subscription ID as `params.subscription`. `device-host.frames.congested`
|
|
648
|
+
(feature `hosted-congestion`) reports that a client further downstream is
|
|
649
|
+
behind on that H.264 subscription; it lowers the bitrate the same way a backed-up
|
|
650
|
+
socket does.
|
|
651
|
+
Hosted capture requires the compiled `stim-frames` helper and does not support
|
|
652
|
+
replay, device artwork or screenshot fallback. macOS frames show only the one
|
|
653
|
+
window of the hosted app. Viewing is refused before launch and after the app
|
|
654
|
+
exits. On the hosting Mac, `stim-server service install` runs the server and
|
|
655
|
+
`stim-frames` under Stim Host, whose Screen Recording and control grants they
|
|
656
|
+
need. Install shows macOS's own requests; `stim-server service status` shows the
|
|
657
|
+
grants. Servers launched by Stim Desktop keep Desktop's grants. Missing Screen &
|
|
658
|
+
System Audio Recording ends the stream; missing Device Control and Data Access
|
|
659
|
+
(Accessibility on macOS 26 and earlier) leaves viewing available.
|
|
660
|
+
See [Run as a service](#run-as-a-service) for the settings panes and Dev caveat.
|
|
661
|
+
|
|
662
|
+
Start control with `device-host.control.begin` and
|
|
663
|
+
`{"session":"<hosted-session-id>"}`. Its result returns a connection-bound
|
|
664
|
+
control session ID and `lease: null`: the hosted lifetime claim protects this
|
|
665
|
+
private device. Only one controller can drive the device; `takeOver: true`
|
|
666
|
+
replaces the previous controller. Use the returned control ID with
|
|
667
|
+
`device-host.input.touch|text`, using the same parameters as ordinary input.
|
|
668
|
+
iOS also supports `device-host.input.button|rotate|posture`. macOS also supports
|
|
669
|
+
`device-host.input.scroll|key`; these two methods are for macOS sessions only.
|
|
670
|
+
Touch coordinates range from 0 to 1 on the streamed display.
|
|
671
|
+
End it with `device-host.control.end` and `{"session":"<control-id>"}`.
|
|
672
|
+
|
|
673
|
+
The worker derives the workspace, slot and device or app identity from its owned
|
|
674
|
+
session; callers cannot select arbitrary worker devices. Every begin and input rechecks session
|
|
675
|
+
ownership and current approval. Hosting approval grants neither ordinary
|
|
676
|
+
`frames.subscribe` nor ordinary `control.begin` access. Disconnecting releases
|
|
677
|
+
that connection's capture and input; reconnect to the same hosted session and
|
|
678
|
+
subscribe again.
|
|
679
|
+
|
|
680
|
+
Installation, stop, revocation and server close end capture and input before
|
|
681
|
+
native work reuses the session claim. In-flight native input settles before
|
|
682
|
+
teardown even after disconnect or takeover; a timeout waits for its child to
|
|
683
|
+
terminate. Known capture closes even if the journal
|
|
684
|
+
is unreadable or unwritable; unresolved native state and claims remain retained.
|
|
685
|
+
Hosted posture commands hold a separate child-aware input claim. A surviving
|
|
686
|
+
command or unresolved child identity blocks replacement ownership, install and
|
|
687
|
+
stop even after the server and capture helper exit; the refusal names the claim
|
|
688
|
+
and its manual cleanup command. An unknown or lost owner requires explicit stop
|
|
689
|
+
before replacement. This worker
|
|
690
|
+
protocol does not add CLI placement or a local viewer relay; those remain in
|
|
691
|
+
[#2266](https://github.com/appandflow/stim/issues/2266).
|
|
692
|
+
|
|
693
|
+
## Run as a service
|
|
694
|
+
|
|
695
|
+
`stim-server service install` runs stim-server as a per-user LaunchAgent on
|
|
696
|
+
macOS, so a build machine or a phone-serving Mac keeps it running without a
|
|
697
|
+
terminal:
|
|
698
|
+
|
|
699
|
+
```bash
|
|
700
|
+
stim-server service install [--port <n>] [--label <name>] [--serve]
|
|
701
|
+
[--env KEY=VALUE]... [--path-prepend <dir>]...
|
|
702
|
+
stim-server service status [--label <name>] [--json]
|
|
703
|
+
stim-server service update [--label <name>] --release <version>|--from <dir>
|
|
704
|
+
stim-server service rollback [--label <name>]
|
|
705
|
+
stim-server service uninstall [--label <name>]
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
`install` writes `~/Library/LaunchAgents/<label>.plist` (label `dev.stim.server`
|
|
709
|
+
by default) and starts it with `launchctl bootstrap gui/<uid>`. The job starts
|
|
710
|
+
Stim Host, which stays alive as the parent of the absolute `node` and
|
|
711
|
+
`stim-server.mjs` of the install that ran the command, on
|
|
712
|
+
`--port` (default 7787), with `RunAtLoad`, `KeepAlive` and a 30 second
|
|
713
|
+
`ThrottleInterval`, and logs to `~/Library/Logs/Stim/<label>.log`. When
|
|
714
|
+
`STIM_HOME` or `SHELL` is set in the installing shell, the job carries it. It
|
|
715
|
+
takes the `node` from PATH when that path resolves to the running binary, so a
|
|
716
|
+
Homebrew Node upgrade does not break the plist. Running `install` again
|
|
717
|
+
rewrites the plist and restarts the job; if the new job cannot start, it puts
|
|
718
|
+
the old plist and job back. `install` and `uninstall` act only on a plist
|
|
719
|
+
that `install` wrote, and refuse a port that another stim-server (Stim
|
|
720
|
+
Desktop's, for example) already answers on. Install from a permanent
|
|
721
|
+
installation, not from an `npx` cache, because the plist stores its paths. It never touches pairings, anything
|
|
722
|
+
under `$STIM_HOME/server` or settings. `install` waits up to 15 seconds for a
|
|
723
|
+
`/health` answer that is no longer `pending`. An installed LaunchAgent with no
|
|
724
|
+
answer reports readiness as unavailable, and one that answers `degraded` reports
|
|
725
|
+
the reason; both point to `status` and its log. Installation success alone does
|
|
726
|
+
not prove that the server is ready.
|
|
727
|
+
|
|
728
|
+
Install downloads the Stim Host release this package pins from the
|
|
729
|
+
`host-v<version>` GitHub release, checks the pinned SHA-256 and App & Flow's
|
|
730
|
+
Developer ID signature, and installs **Stim Host** (`dev.stim.host`) at
|
|
731
|
+
`~/Applications/Stim Host.app`; grants then survive Stim Host updates. Install
|
|
732
|
+
keeps an identical bundle untouched. A Mac that ran the earlier **Stim Host Dev**
|
|
733
|
+
keeps `~/Applications/Stim Host Dev.app`; its owner can delete it and its System
|
|
734
|
+
Settings entries.
|
|
735
|
+
|
|
736
|
+
After starting the service, install shows macOS's own permission requests on
|
|
737
|
+
this Mac's screen, one at a time: Stim Host keeps running after install returns,
|
|
738
|
+
asks for Device Control and Data Access once the Screen Recording request is
|
|
739
|
+
answered (it waits up to two minutes for each), and opens that System Settings
|
|
740
|
+
pane with Stim Host listed when macOS shows no request for it. You only
|
|
741
|
+
approve; over SSH, use Screen Sharing to see that screen. If a request does not
|
|
742
|
+
appear, turn Stim Host on in **System
|
|
743
|
+
Settings > Privacy & Security > Screen & System Audio Recording** (**Screen
|
|
744
|
+
Recording** on macOS 14) and **Device Control and Data Access** (**Accessibility**
|
|
745
|
+
on macOS 26 and earlier). Stim never changes these settings itself. Servers
|
|
746
|
+
launched by Stim Desktop keep Desktop's grants. For an older node-first service,
|
|
747
|
+
run `stim-server service install` again to use Stim Host.
|
|
748
|
+
|
|
749
|
+
The server binds its port before it touches the Stim home. It then reads the
|
|
750
|
+
Stim home, its `server` and `workspaces` directories and the recording
|
|
751
|
+
ownership directories in a read-only child process, so a read that never
|
|
752
|
+
returns, such as one on a stalled external volume, cannot block the server's
|
|
753
|
+
event loop. Until that read returns, `/health` answers HTTP 503 with
|
|
754
|
+
`startup: { "state": "pending" }`, WebSocket upgrades and device-host agent
|
|
755
|
+
requests get 503, and no status follower, native helper, file watcher or
|
|
756
|
+
recorder starts. A read that does not return within 10 seconds, plus up to one
|
|
757
|
+
second to stop the child, leaves the server listening with
|
|
758
|
+
`startup: { "state": "degraded", "reason": "..." }` and HTTP 503 on `/health`,
|
|
759
|
+
logs the reason once, and reads again every 30 seconds. When a read returns,
|
|
760
|
+
the server starts its watchers and recorder, `/health` answers 200 with
|
|
761
|
+
`startup: { "state": "ready" }`, and no restart is needed. `service status`
|
|
762
|
+
prints `health: degraded` or `health: starting` and the reason, and `service
|
|
763
|
+
install` reports a listening but degraded server instead of success. Returned
|
|
764
|
+
filesystem errors still go through the recorder's existing claim refusal;
|
|
765
|
+
missing directories are created by that protocol. The check preserves claims and
|
|
766
|
+
recordings. It does not restore an inaccessible volume or change the server's
|
|
767
|
+
permissions, and a stall that starts after the check passes still blocks the
|
|
768
|
+
server until the operating system returns.
|
|
769
|
+
|
|
770
|
+
The job runs in your GUI login session, so it starts when you log in and not at
|
|
771
|
+
boot. On a Mac with no one at the screen, turn on automatic login. Moving the
|
|
772
|
+
install, or changing its Node, needs `install` again.
|
|
773
|
+
|
|
774
|
+
`--serve` adds the tailnet-only route from [Tailscale](#tailscale) on the port
|
|
775
|
+
`stim-server` would suggest. It refuses when Funnel is on for a port that
|
|
776
|
+
reaches the server, and it never enables Funnel. When a route already reaches
|
|
777
|
+
the port, `install` records that it did not create it. `uninstall` removes a
|
|
778
|
+
route only when `install` created it and it still points at this port. Without
|
|
779
|
+
`--serve`, `install` prints the command to run. A route that exists only in a
|
|
780
|
+
foreground `tailscale serve` session counts as present. To move a service that
|
|
781
|
+
created a route to another `--port`, run `uninstall` first.
|
|
782
|
+
|
|
783
|
+
`--env KEY=VALUE` and `--path-prepend <dir>` pin variables and PATH entries for
|
|
784
|
+
the server. stim-server replaces its environment with the login shell's at
|
|
785
|
+
start, so a `PATH` or `GEM_HOME` in the plist alone is lost and an entry in
|
|
786
|
+
`~/.zshrc` changes the terminal too. These flags apply after that capture, to
|
|
787
|
+
the server and to the builds it starts, and the plist stores them as
|
|
788
|
+
`ProgramArguments`. `--path-prepend` puts directories in front of PATH in the
|
|
789
|
+
order given; `--env` cannot set `STIM_HOME`. The values sit in plain text in the plist and in the process arguments, so do not pass secrets. For a private CocoaPods:
|
|
790
|
+
|
|
791
|
+
```bash
|
|
792
|
+
stim-server service install --serve \
|
|
793
|
+
--path-prepend /Volumes/SSD/gems/bin \
|
|
794
|
+
--env GEM_HOME=/Volumes/SSD/gems --env GEM_PATH=/Volumes/SSD/gems \
|
|
795
|
+
--env LANG=en_US.UTF-8 --env LC_ALL=en_US.UTF-8
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`status` prints whether launchd loaded the job, its state, pid, run count and
|
|
799
|
+
last exit code (a server that exits at start, for example on a port in use,
|
|
800
|
+
restarts every 30 seconds), the `/health` answer, the serve route and whether
|
|
801
|
+
`install` created it, the host app and its permission grants, the log path, and the digest of the bundled Stim's build
|
|
802
|
+
next to the `stim` on PATH. Offload needs the same digest on the client, so a
|
|
803
|
+
mismatch there is not an offload match either. `--json` prints the same fields
|
|
804
|
+
as one object.
|
|
805
|
+
|
|
806
|
+
`update` installs another stim-server beside the one the job runs and switches
|
|
807
|
+
the job to it. `--release <version>` takes an exact version from the public npm
|
|
808
|
+
registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and
|
|
809
|
+
npm checks each package against the registry's integrity. `--from <dir>`
|
|
810
|
+
installs the `.tgz` packages in that directory, such as `pnpm pack` output of
|
|
811
|
+
the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and
|
|
812
|
+
`@stim-cli/server` packages of a checkout, and records each file's sha256.
|
|
813
|
+
Either way, `npm audit signatures` must verify the registry signature of every
|
|
814
|
+
package that came from the registry, and the provenance attestation of each one
|
|
815
|
+
that has it, or the update stops before it touches the job. Install scripts
|
|
816
|
+
never run. Each install goes to
|
|
817
|
+
`~/Library/Application Support/Stim/services/<label>/versions/`, in a directory
|
|
818
|
+
named by its version, Stim build and server code, and must start with the job's
|
|
819
|
+
`node` before the switch. `update` then waits up to 30 minutes for `/health` to
|
|
820
|
+
report no offloaded build and no hosted session (a server older than `busy` in
|
|
821
|
+
`/health` restarts without waiting), rewrites only the server script in the
|
|
822
|
+
plist (recording the previous one), restarts the job and waits up to 90 seconds
|
|
823
|
+
for `/health` to answer with the new version and Stim build. When it does not,
|
|
824
|
+
`update` puts the previous plist back and restarts it. Interrupt signals wait
|
|
825
|
+
for the switch to finish. A second update to the same build reuses its
|
|
826
|
+
directory, and after a switch `update` removes the installs neither the current
|
|
827
|
+
nor the previous script uses. `rollback` switches the job back to the previous
|
|
828
|
+
script under the same checks, and running it again returns to the newer one.
|
|
829
|
+
Neither touches the node, the host app, the `--env` and `--path-prepend` pins,
|
|
830
|
+
pairings, approvals, `$STIM_HOME` or the serve route. Install, update, rollback
|
|
831
|
+
and uninstall of one label run one at a time.
|
|
832
|
+
|
|
833
|
+
`uninstall` boots the job out, removes the plist, the servers `update`
|
|
834
|
+
installed and, when `install` created it, the serve route. Logs, pairings and the host app stay; other labels may use the app. Use `--label` and `--port` to run a
|
|
835
|
+
second service beside the first, for example with another `STIM_HOME`.
|
|
836
|
+
|
|
837
|
+
To set up a build machine: install Stim and stim-server on the Mac, run
|
|
838
|
+
`stim-server service install --serve` (with the pins its toolchain needs), set
|
|
839
|
+
`offload.workerRoot` there, and on each client run
|
|
840
|
+
`stim settings set offload.machines '["<mac>"]'` and `stim doctor --fix`.
|
|
841
|
+
Approve the request on the build machine with
|
|
842
|
+
`stim-server devices grant <id> --build`.
|
|
843
|
+
|
|
844
|
+
## Remote update
|
|
845
|
+
|
|
846
|
+
A Mac this one approved for builds (`--build`) or device hosting
|
|
847
|
+
(`--device-host`) can update this Mac's `stim-server service` over its tailnet
|
|
848
|
+
connection, so the build machine keeps up with the client without ssh. A
|
|
849
|
+
server that offers it lists `server-update` in its `hello` features. A paired
|
|
850
|
+
phone, a `read` or `control` device and a connection from this Mac cannot. The
|
|
851
|
+
methods need that approval, not `read`:
|
|
852
|
+
|
|
853
|
+
- `server.update.status` returns `server` (`version` and `stimBuild`),
|
|
854
|
+
`service` (the label of the LaunchAgent this server runs under, or `null` when
|
|
855
|
+
it does not run as a `stim-server service` and so cannot update itself),
|
|
856
|
+
`acceptsClientBuilds`, `running` (the update in progress, with its `state`,
|
|
857
|
+
`missing` package bytes and the last lines of its `log`) and `last` (how the
|
|
858
|
+
label's last `service update` ended, whoever ran it).
|
|
859
|
+
- `server.update.start` takes `{ "release": "<version>" }`, an exact version
|
|
860
|
+
that `stim-server service update --release` installs, or
|
|
861
|
+
`{ "packages": [{ "name", "size", "sha256" }] }`, at most 8 `.tgz` packages
|
|
862
|
+
of the client's own build. Packages need `server.acceptClientBuilds` set to
|
|
863
|
+
`true` on this Mac (`stim settings set server.acceptClientBuilds true`); a
|
|
864
|
+
release needs no setting. One update runs at a time.
|
|
865
|
+
- `server.update.chunk` takes `{ id, name, offset, data }`, at most 32 KiB of
|
|
866
|
+
base64 at the package's next offset. A package whose bytes do not match its
|
|
867
|
+
`sha256` ends the update. The upload lapses after 10 minutes without a chunk.
|
|
868
|
+
|
|
869
|
+
Once the release is named or the last package arrives, the server stops taking
|
|
870
|
+
new offloaded builds and hosted sessions, and runs
|
|
871
|
+
`stim-server service update --label <label> --release|--from` with its own
|
|
872
|
+
node and script as a detached process, which outlives the restart it causes
|
|
873
|
+
and writes to `~/Library/Application Support/Stim/services/<label>/update.log`.
|
|
874
|
+
The connection closes when the job restarts. The client reconnects and reads the
|
|
875
|
+
outcome from `hello` and `server.update.status`. An update that ends while this
|
|
876
|
+
server still runs (a refused upload, a failed install) takes new work again.
|
|
877
|
+
The action log records each update as `server.update.started`, and as
|
|
878
|
+
`server.update.ended` when it ends while this server still runs. A chunk that
|
|
879
|
+
arrives once every package is in gets `action-busy`, and a retried reservation
|
|
880
|
+
of an existing hosted session still answers during the drain.
|
|
881
|
+
|
|
882
|
+
Stim Desktop starts these updates through its own local stim-server with two
|
|
883
|
+
methods that only the local Desktop control connection may call:
|
|
884
|
+
`machines.update.start` takes `{ "machine" }`, an approved `offload.machines`
|
|
885
|
+
or `hosting.machines` entry. It connects to that machine's pinned node with
|
|
886
|
+
the stored credential and asks it for this server's npm release. When this
|
|
887
|
+
server runs from a Stim checkout, it instead packs the checkout's workspace
|
|
888
|
+
packages as `npm pack` would and sends them. `machines.update.status` takes
|
|
889
|
+
the same `{ "machine" }` and returns `remote` (the machine's
|
|
890
|
+
`server.update.status`), `unreachable` (why it could not be read, also while it
|
|
891
|
+
restarts) and `upload` (`sent`, `total` and `error` of the packages).
|
|
892
|
+
|
|
893
|
+
## Offloaded builds
|
|
894
|
+
|
|
895
|
+
A client with `build` runs its iOS simulator builds and Android emulator
|
|
896
|
+
debug builds and macOS SwiftPM Debug builds here with these methods.
|
|
897
|
+
They need `build`, not `read`; a device without `build` gets `forbidden`.
|
|
898
|
+
The server re-reads the build clients on every call, and revoking a client
|
|
899
|
+
closes its connections and cancels its builds.
|
|
900
|
+
|
|
901
|
+
- `build.offer` takes `repo` (the client's name for its repository: letters,
|
|
902
|
+
digits, `.`, `_` and `-`, at most 80) and an optional `lockfile` sha256. It
|
|
903
|
+
returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
|
|
904
|
+
`arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
|
|
905
|
+
`xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
|
|
906
|
+
runtimes with an iPhone simulator to build for; `jdk`, the major version of
|
|
907
|
+
the JDK in `JAVA_HOME` or the macOS default; and `androidSdk`, the `ndk`,
|
|
908
|
+
`buildTools` and `platforms` directories of the SDK in `ANDROID_HOME` or
|
|
909
|
+
`~/Library/Android/sdk`, null without one), `capacity` and `warm`
|
|
910
|
+
(`checkout`, `dependencies` when the last install used that lockfile, and
|
|
911
|
+
`build` once a build ran there, including a non-empty repository `macos/`
|
|
912
|
+
scratch directory) for that repository. `capacity` holds
|
|
913
|
+
`running` and `max` offloaded builds, `diskFreeBytes` of the worker root's
|
|
914
|
+
volume and `minDiskFreeBytes`, `cpus`, `loadPerCore` (the 5-minute load
|
|
915
|
+
average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
|
|
916
|
+
compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
|
|
917
|
+
`concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
|
|
918
|
+
`offload.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
|
|
919
|
+
build now, or null. It declines while it runs its limit of offloaded builds,
|
|
920
|
+
while the worker root's volume has less than 10 GiB free, while `builds`
|
|
921
|
+
reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
|
|
922
|
+
`maxLoadPerCore`. The toolchain is read at most once a minute; capacity on
|
|
923
|
+
every offer.
|
|
924
|
+
- `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
|
|
925
|
+
manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
|
|
926
|
+
a link's blob being its target. Pages accumulate until `done`; the next
|
|
927
|
+
`build.sync` starts a new manifest. A path is relative, with no `.`, `..`,
|
|
928
|
+
empty or `.git` component (in any case). The result's `missing` lists the digests of the
|
|
929
|
+
page this Mac lacks. The client then sends each as binary frames: 32 bytes of
|
|
930
|
+
the sha256, then the next bytes of that blob, one blob after another. A frame
|
|
931
|
+
for a blob that was not asked for, one that overruns its size, or bytes that
|
|
932
|
+
do not match the digest close the connection with 4400. Blobs are kept per
|
|
933
|
+
client and shared by all its repositories.
|
|
934
|
+
- `build.start` takes `repo`, `project` (the app directory in the repository),
|
|
935
|
+
`platform` (`ios`, `android` or `macos`), `configuration`, `scheme`, `runtime` (a
|
|
936
|
+
simulator runtime identifier, required for `ios`, null for `android`),
|
|
937
|
+
`fingerprint`, `packageName`, `isExpo`, `optimizations`, `android` (for
|
|
938
|
+
`android`: `variant`, `abi`, `gradleBuildCache`, `pch` and `compilerCache`,
|
|
939
|
+
`ccache` or `none`) and `stimBuild`, after the whole manifest of `repo` was
|
|
940
|
+
synced on this connection. It returns `{ "job" }`. It fails with
|
|
941
|
+
`build-busy` while the offer would be declined, naming the reason, or while
|
|
942
|
+
another build of the same client and repository runs, or while every one
|
|
943
|
+
of this Mac's `concurrency.maxBuilds` slots is held, and with
|
|
944
|
+
`build-refused` when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
|
|
945
|
+
`STIM_HOME` set to the repository's area. It makes the area's checkout
|
|
946
|
+
hold exactly the manifest (a file git lists as untracked and not ignored,
|
|
947
|
+
and not in the manifest, is deleted; ignored dependencies and generated
|
|
948
|
+
projects stay), installs JavaScript dependencies when the lockfile changed,
|
|
949
|
+
runs prebuild and `pod install` when needed, refuses unless the fingerprint
|
|
950
|
+
equals `fingerprint`, and runs `xcodebuild` for one of this Mac's iPhone
|
|
951
|
+
simulators on `runtime`, which it never boots, or Gradle's
|
|
952
|
+
`assemble<variant>` for `abi` with ccache under the area's Stim home. A
|
|
953
|
+
Gradle daemon leaves the build's process group, so no claim or slot tracks
|
|
954
|
+
it. An Android build keeps the client's daemon warm, with Gradle's idle
|
|
955
|
+
timeout set to `offload.gradleDaemonIdleMinutes` (default 30; 0 stops it
|
|
956
|
+
when the build ends), and stops it when the build gets SIGTERM. Gradle
|
|
957
|
+
fixes a daemon's idle timeout when the daemon starts, so a new value
|
|
958
|
+
applies to the next daemon. Once no build of that client runs, stim-server
|
|
959
|
+
sends SIGTERM to each daemon whose command line runs from the client's
|
|
960
|
+
Gradle home when a build of the client was cancelled, when the client
|
|
961
|
+
loses `build`, and, for every client, while this Mac has less than 2 GB of
|
|
962
|
+
available memory (total memory minus Activity Monitor's Memory Used). It
|
|
963
|
+
checks every minute and on each change under its server directory. A daemon
|
|
964
|
+
whose Gradle home is deleted stops itself within seconds, because Gradle
|
|
965
|
+
expires a daemon whose registry file is gone.
|
|
966
|
+
A macOS request supplies `macos: { product, infoPlist, bundleId }` and
|
|
967
|
+
optionally `resources` (up to 256 destination-to-source entries) and
|
|
968
|
+
`assetCatalog` (a string or null), omitting runtime, configuration, scheme, packageName, isExpo, optimizations and android.
|
|
969
|
+
Product matches `^[A-Za-z0-9_.-]{1,100}$`; infoPlist is a valid relative build
|
|
970
|
+
path within the project; bundleId matches `^[A-Za-z0-9][A-Za-z0-9.-]{0,199}$`.
|
|
971
|
+
Each `resources` destination must be a contained path under
|
|
972
|
+
`Contents/Resources` (no empty, `.` or `..` segments, no backslash, at most
|
|
973
|
+
1024 characters) and each source, like `assetCatalog`, a valid build path
|
|
974
|
+
relative to the repository root, not the project. Missing or malformed
|
|
975
|
+
options get `bad-request`. Its fingerprint is sha256 of
|
|
976
|
+
every manifest entry sorted by path, encoded as `path NUL kind NUL sha256 LF`.
|
|
977
|
+
The worker recomputes it before building and refuses a mismatch. It skips
|
|
978
|
+
JavaScript installation, prebuild, pods and project fingerprinting. It runs
|
|
979
|
+
`swift build -c debug --product <product> --scratch-path <scratch> --cache-path <cache>`,
|
|
980
|
+
then `swift build -c debug --scratch-path <scratch> --cache-path <cache> --show-bin-path`,
|
|
981
|
+
without a jobs limit. Scratch is `repos/<repo>/macos/<project with / replaced by _>`,
|
|
982
|
+
or `root` for an empty project. The job JSON includes `swiftpmCache` alongside
|
|
983
|
+
`blobs`, pointing at the client's `cache/swiftpm`. Swift stays in the job's
|
|
984
|
+
process group so cancellation stops it. A failed Swift command reports
|
|
985
|
+
`swift-failed` with the first error or last three output lines. The worker
|
|
986
|
+
stages the executable, frameworks and resource bundles in
|
|
987
|
+
`out/<job>/<Product>.app`, copies each declared resource from its checkout
|
|
988
|
+
(refusing a source whose realpath leaves it), compiles `assetCatalog` with
|
|
989
|
+
`xcrun actool`, stamps the requested bundle ID and signs ad hoc. A bad
|
|
990
|
+
resource entry also reports `swift-failed`, before Swift runs.
|
|
991
|
+
It returns that app as a tar archive, its manifest fingerprint, timings and
|
|
992
|
+
`compilationCache: {}`. The client verifies the digest, identity, executable
|
|
993
|
+
and signature before promotion and launches locally. Every offload failure
|
|
994
|
+
falls back locally; macOS artifacts are not cached. Matching Xcode and macOS
|
|
995
|
+
SDK are required, and the worker needs network access for the first SwiftPM
|
|
996
|
+
dependency fetch; the dependency cache is shared only within that client.
|
|
997
|
+
- `build.progress` events `{ "event": "build.progress", "job", ... }` carry a
|
|
998
|
+
`phase` and `msg`, a build-log `record`, and last the `outcome`: `ok`, and
|
|
999
|
+
on success `artifact` (`name`, `size`, `sha256` of a tar of the `.app` or
|
|
1000
|
+
`.apk`),
|
|
1001
|
+
`fingerprint`, `compilationCache` and `timings`, otherwise `code` and
|
|
1002
|
+
`message`.
|
|
1003
|
+
- `build.cancel` takes `job` and stops it. Closing the connection with a
|
|
1004
|
+
close frame cancels its jobs too. The build's process group gets SIGTERM,
|
|
1005
|
+
then SIGKILL 5 seconds later. A connection that ends without a close frame
|
|
1006
|
+
(1006, for example when the network drops) leaves its jobs running for 5
|
|
1007
|
+
minutes; a job no connection takes back by then is cancelled, and so is one
|
|
1008
|
+
whose client is revoked meanwhile. A job whose `build.start` answer was lost
|
|
1009
|
+
with the connection is only reclaimed this way.
|
|
1010
|
+
- `build.attach` takes the `job` of this client that another connection
|
|
1011
|
+
holds or held, such as one whose connection dropped or stalled, and moves it
|
|
1012
|
+
to this connection: its later `build.progress` events come here, and
|
|
1013
|
+
`build.cancel` and `build.artifact` take it. It answers `{ "outcome" }`,
|
|
1014
|
+
the job's outcome when it already ended, else null. Progress sent while no
|
|
1015
|
+
connection held the job is not replayed. A job that is gone, or belongs to
|
|
1016
|
+
another client, gets `bad-request`.
|
|
1017
|
+
- `build.artifact` takes the `job` of a successful build and sends the archive
|
|
1018
|
+
as binary frames, each 32 bytes of its sha256 and then the next bytes, then
|
|
1019
|
+
answers `{ "name", "size", "sha256" }`, and deletes it here. For a macOS
|
|
1020
|
+
job the answer also carries `handoff`, a single-use token, and the staged
|
|
1021
|
+
`.app` stays for 10 minutes so a hosted session on this Mac can take it with
|
|
1022
|
+
`device-host.app.handoff`. A client keeps at most one such bundle; its next
|
|
1023
|
+
fetched macOS build or a revoked approval deletes it. An archive nobody fetched is deleted when its job
|
|
1024
|
+
is cancelled; one left by a server that crashed stays under
|
|
1025
|
+
`repos/<repo>/out/` until you delete it.
|
|
1026
|
+
|
|
1027
|
+
The worker root is `offload.workerRoot` in this Mac's Stim settings, or
|
|
1028
|
+
`$STIM_HOME/build-worker`. Each client gets `<root>/<device id>/`, with its
|
|
1029
|
+
blobs, caches (`CP_HOME_DIR`, `CP_CACHE_DIR`, the pnpm store and
|
|
1030
|
+
`GRADLE_USER_HOME` and SwiftPM) and one area
|
|
1031
|
+
per repository under `repos/<repo>/`: the checkout, its own Stim home with
|
|
1032
|
+
DerivedData, the compilation cache and ccache, and the output. A build never reads or
|
|
1033
|
+
writes this Mac's own Stim home. An ownership claim at
|
|
1034
|
+
`repos/<repo>.claims`, whose child is the build's process group, guards each
|
|
1035
|
+
area; it is released only once that group is gone, and a claim whose server
|
|
1036
|
+
and build are both gone is recovered by the next build. With a non-zero
|
|
1037
|
+
`concurrency.maxBuilds`, the build also holds one of this Mac's build slots
|
|
1038
|
+
(`$STIM_HOME/build-slots/`), the same claims local Stim builds take, with the
|
|
1039
|
+
same child, so a local build waits for it and `builds` counts it. Each finished build
|
|
1040
|
+
appends a `build` record to the [action log](#actions) with the repository as
|
|
1041
|
+
`workspace`. The worker root keeps growing with each repository; delete a
|
|
1042
|
+
client's directory to reclaim it.
|
|
119
1043
|
|
|
120
1044
|
## Protocol
|
|
121
1045
|
|
|
@@ -124,17 +1048,51 @@ answered by `{ "id", "result" }` or `{ "id", "error": { "code", "message" } }`.
|
|
|
124
1048
|
Events are `{ "event", "subscription", ... }`.
|
|
125
1049
|
|
|
126
1050
|
- `hello` must come first. Params: `protocol` (1), `client` (`name`,
|
|
127
|
-
`version`), and `auth`,
|
|
128
|
-
`{ "deviceToken" }
|
|
129
|
-
|
|
1051
|
+
`version`), and `auth`, one of `{ "pairingToken", "deviceName" }`,
|
|
1052
|
+
`{ "deviceToken" }`, or `{ "request": "build", "deviceName" }` (see
|
|
1053
|
+
[Build access](#build-access)). The result carries the server name and versions, the
|
|
1054
|
+
device's `capabilities` (see [Scopes](#scopes)), the server's `features`
|
|
1055
|
+
(`physical-ios` and `physical-android` when it serves `physical: true` for
|
|
1056
|
+
that platform: see `frames.subscribe` below for an iPhone and
|
|
1057
|
+
[Physical Android devices](#physical-android-devices), and `notifications`
|
|
1058
|
+
for `notifications.list` and the `notification` event),
|
|
1059
|
+
the `actions` it may run
|
|
130
1060
|
(none without `control`), the paired device, and the new `deviceToken` when
|
|
131
1061
|
the hello paired. `server.home` is the home folder
|
|
132
1062
|
of the user the server runs as, so clients can show paths under it as
|
|
133
1063
|
`~/...`.
|
|
134
1064
|
- `status.subscribe` returns a subscription id. Each `status` event carries a
|
|
135
|
-
full payload as `stim status --watch --json` prints it
|
|
1065
|
+
full payload as `stim status --watch --json` prints it, including each
|
|
1066
|
+
environment's `physicalDevices`, the phones it leases, and its `agents`, the
|
|
1067
|
+
Claude Code and Codex sessions working in it. Those carry the session id,
|
|
1068
|
+
working directory, the short session name the tool keeps, and times, never
|
|
1069
|
+
prompts or conversation. `openUrl` there is a `claude://` or `codex://` link
|
|
1070
|
+
that only the Mac can open; the server has no action that opens it. `webUrl`
|
|
1071
|
+
is a Claude Code session's `https://claude.ai/code/` link while Remote Control
|
|
1072
|
+
is connected, which a phone opens in the Claude app or a browser.
|
|
1073
|
+
`endedAgents` lists the sessions that stopped running there in the last 3
|
|
1074
|
+
days, with the same fields as last found, no `pid`, and `endedAt`. The server
|
|
1075
|
+
drops `CLAUDE_CODE_SESSION_ID` and `CODEX_THREAD_ID` from the environment of
|
|
1076
|
+
the `stim` commands it runs, so a phone's reload is not recorded as the work
|
|
1077
|
+
of the agent session that started the server. All subscribers share
|
|
136
1078
|
one `stim status --watch --json` child, which stops with the last
|
|
137
|
-
subscriber.
|
|
1079
|
+
subscriber. While any status subscription is open, the server keeps a CPU
|
|
1080
|
+
and memory history from each payload's `machine.owners`, and every `status`
|
|
1081
|
+
event carries it beside the payload as `usage`, once it holds a reading:
|
|
1082
|
+
`{ "intervalMs", "endAt", "environments", "devices" }`. It covers the last 10
|
|
1083
|
+
minutes in 15-second slots, the cadence at which `status --watch` rereads
|
|
1084
|
+
machine usage, oldest first, at most 40 points: point `i` of `n`
|
|
1085
|
+
is at `endAt - (n - 1 - i) * intervalMs`, and a slot no payload fell in is
|
|
1086
|
+
`null`. Each `environments` entry is `{ "workspace", "cpuPercent",
|
|
1087
|
+
"memoryMb" }` and sums every machine owner of that environment path; each
|
|
1088
|
+
`devices` entry is `{ "kind", "id", "workspace", "slot"?, "cpuPercent",
|
|
1089
|
+
"memoryMb" }` for a simulator (`id` its UDID) or emulator (`id` its AVD name).
|
|
1090
|
+
`cpuPercent` is ps %CPU, where 100 is one core. The history lives in the
|
|
1091
|
+
server's memory only. While the server holds `stim device lock` leases for
|
|
1092
|
+
phones that control a device, every `status` event also carries their
|
|
1093
|
+
`grantedAt` times as `ownLeases`, so a client can tell a person controlling a
|
|
1094
|
+
device from an agent driving it: an `activity.driver` of `stim device lock`
|
|
1095
|
+
whose `since` is in `ownLeases` is a phone.
|
|
138
1096
|
- `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
|
|
139
1097
|
events: first the last `tail` matching records, then new ones in batches.
|
|
140
1098
|
Both take the Stim Desktop log viewer's filters: `workspace` (required),
|
|
@@ -144,20 +1102,83 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
144
1102
|
scope. They run `stim logs --json` and `stim logs --json --follow` in the
|
|
145
1103
|
workspace. Subscribers with the same workspace and filters share one
|
|
146
1104
|
`--follow` child, which stops with the last of them.
|
|
147
|
-
- `stats.get`
|
|
1105
|
+
- `stats.get` returns the same payload as `stim stats --json`, using the shared
|
|
1106
|
+
core reader in a bounded, cancellable server child. The stats part of
|
|
1107
|
+
`machine.details` uses the same reader. `settings.get` runs
|
|
148
1108
|
`stim settings --json`, which masks sensitive values. Without `workspace`,
|
|
149
|
-
|
|
150
|
-
- `frames.subscribe` takes `workspace`, `platform` (`ios` or `
|
|
1109
|
+
these reads use the home directory as their project context.
|
|
1110
|
+
- `frames.subscribe` takes `workspace`, `platform` (`ios`, `android`, `web` or `macos`),
|
|
151
1111
|
`slot` (`default` when absent), `fps` (1 to 30, 5 by default) and `maxEdge`
|
|
152
1112
|
(240 to 2048 pixels, 1280 by default), and sends `frame` events: a JPEG,
|
|
153
1113
|
base64 in `data`, with `width`, `height` and `capturedAt`, at most `fps` a
|
|
154
1114
|
second and only when the screen changed. It serves only a booted simulator
|
|
155
|
-
or a running emulator that `stim status` lists as owned by that workspace
|
|
1115
|
+
or a running emulator that `stim status` lists as owned by that workspace,
|
|
1116
|
+
or with `web` the page of the workspace's running Stim-owned Chrome from
|
|
1117
|
+
`stim web` (default slot only), or with `physical: true` the physical
|
|
1118
|
+
iPhone (see below) or Android phone (see
|
|
1119
|
+
[Physical Android devices](#physical-android-devices)) the workspace leases
|
|
1120
|
+
in that slot;
|
|
156
1121
|
any other device ends the subscription with a `frames-failed` `error`
|
|
157
1122
|
event, and so does a device that stops or changes owner. A client whose
|
|
158
1123
|
socket has more than two frames unsent skips frames and gets the newest
|
|
159
1124
|
once it catches up.
|
|
160
1125
|
|
|
1126
|
+
A server advertising `device-frames` accepts `deviceFrame: true` for live
|
|
1127
|
+
ordinary iOS simulators and Android emulators. The existing read permission
|
|
1128
|
+
and registered-workspace/owned-device checks apply. `device-frame` events
|
|
1129
|
+
carry `artwork` (or null): PNG `background` and `foreground` layers, outer
|
|
1130
|
+
`width` and `height`, screen `aperture` (`x`, `y`, `width`, `height`),
|
|
1131
|
+
`cornerRadius`, and clockwise `quarterTurns`. Pixels come from installed
|
|
1132
|
+
DeviceKit or Android skins at runtime; no artwork is bundled and no local
|
|
1133
|
+
file paths are exposed. Layers are sent only to opt-in subscriptions and
|
|
1134
|
+
cleared on replay or device changes. The screen bytes stay unchanged.
|
|
1135
|
+
JPEG `artworkTurns` and live H.264 flag bit 5 with quarter-turns in bits 3-4
|
|
1136
|
+
bind housing to its capture, including same-size rotations. A client shows
|
|
1137
|
+
housing only when that rotation and aperture aspect match its screen.
|
|
1138
|
+
Missing, oversized or unsupported artwork falls back to the existing screen:
|
|
1139
|
+
Duo, Android foldables/circular displays, web and physical devices have no
|
|
1140
|
+
housing in this path. Artwork notices stay within the helper's 16 MiB message
|
|
1141
|
+
limit; the combined PNG layers are limited to 10 MiB before base64 encoding.
|
|
1142
|
+
|
|
1143
|
+
With `macos`, it serves the front standard window of the workspace's verified
|
|
1144
|
+
running native app, in the default slot, when hello advertises `macos-window`.
|
|
1145
|
+
The helper verifies the recorded PID, process start time, executable, bundle
|
|
1146
|
+
identifier and bundle path before starting and while capturing. ScreenCaptureKit
|
|
1147
|
+
captures only that app's windows, never the desktop or another process. The
|
|
1148
|
+
helper follows the app's main standard window (else its focused, else its
|
|
1149
|
+
frontmost), with any attached sheet, by matching Accessibility windows to the
|
|
1150
|
+
process's on-screen windows; it moves the stream when the app opens, switches,
|
|
1151
|
+
closes or resizes windows. Without Device Control and Data Access permission it
|
|
1152
|
+
serves only an app whose one window contains the others. A subscription fails
|
|
1153
|
+
when the app has no open window at start; after the app closes its last window
|
|
1154
|
+
the subscription gets `frame-delayed` until another opens. When hello
|
|
1155
|
+
advertises `macos-windows`, the subscription also gets
|
|
1156
|
+
`{ event: "macos-windows", subscription, current, windows, pinned }` after it
|
|
1157
|
+
starts and whenever the windows or the pin change: `current` is the captured
|
|
1158
|
+
window or null, `pinned` says whether `input.window` holds the view on it, and
|
|
1159
|
+
`windows` lists the app's on-screen standard windows front to back, each
|
|
1160
|
+
`{ id, title, frame: { x, y, width, height } }` in points with a top-left global
|
|
1161
|
+
origin. A hosted app's relay forwards the event with the local subscription. It requires existing Screen & System Audio Recording permission (Screen Recording on macOS 14) and
|
|
1162
|
+
never requests or resets grants. The local health payload sets `nativeViewerOpened` after a verified native view attaches, allowing the Desktop host to present its first-use Screen & System Audio Recording and Device Control and Data Access (Accessibility on macOS 26 and earlier) setup. Only explicit buttons in that host invoke normal OS requests. Capture refusal names setup and System Settings guidance; status and logs remain available. Native windows need the
|
|
1163
|
+
helper, have no screenshot fallback, recording or replay, and consume
|
|
1164
|
+
only the paired device's `read` capability.
|
|
1165
|
+
|
|
1166
|
+
A server advertising `duo-frames` also accepts `duoFrame: true` for a live local
|
|
1167
|
+
owned iPhone Duo. The helper composes the installed DeviceKit V68 model with
|
|
1168
|
+
both display surfaces and a current `devicectl` hinge-angle reading. It sends
|
|
1169
|
+
JPEG `frame` events with `duo: { revision, screenID, angle, orientation }` bound
|
|
1170
|
+
to the image. This path does not offer H.264 or replay; other live subscriptions
|
|
1171
|
+
and raw recordings keep their existing capture. Missing model or angle data
|
|
1172
|
+
falls back to the raw screen. The hinge reader runs only while composition is
|
|
1173
|
+
requested and stops on helper stdin EOF or parent exit.
|
|
1174
|
+
|
|
1175
|
+
Clients acknowledge actual image display locally before accepting input and
|
|
1176
|
+
keep the image stable during a drag. `input.touch` takes the matching
|
|
1177
|
+
`duoRevision` instead of `display`; the helper raycasts against the original
|
|
1178
|
+
posed screen, refuses unknown revisions and ignores bezel or hinge touch-down.
|
|
1179
|
+
Move and up stay bound to that pose. Ending Control releases any held touch,
|
|
1180
|
+
even when read subscribers keep the helper alive.
|
|
1181
|
+
|
|
161
1182
|
Frames come from the `stim-frames` helper. When it starts, the server
|
|
162
1183
|
compiles it with `xcrun swiftc` from the Swift sources shipped in
|
|
163
1184
|
`dist/stim-frames/` (its own `main.swift` and the frame and input code it
|
|
@@ -191,8 +1212,8 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
191
1212
|
the encoder, and the next access unit is a keyframe with the new SPS and
|
|
192
1213
|
PPS. The helper encodes with VideoToolbox in real time: Main
|
|
193
1214
|
profile, a keyframe at least every 2 seconds, straight from the
|
|
194
|
-
simulator's IOSurface
|
|
195
|
-
screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
|
|
1215
|
+
simulator's IOSurface, from the emulator's RGBA frames, or from the page's
|
|
1216
|
+
screencast JPEGs, only when the screen changed. A subscriber starts at a keyframe, and `frames.keyframe`
|
|
196
1217
|
with its `subscription` asks for another one, such as after its decoder
|
|
197
1218
|
lost state; a device sends at most one requested keyframe every 250 ms.
|
|
198
1219
|
A subscriber whose socket holds more than 256 KB unsent drops frames
|
|
@@ -224,8 +1245,126 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
224
1245
|
second while it changes, backing off to one capture per second while it
|
|
225
1246
|
does not, with capturing taking at most half of each device's time, and at
|
|
226
1247
|
most two captures run at once. An emulator Stim booted before it passed
|
|
227
|
-
`-grpc` has no endpoint on either path.
|
|
1248
|
+
`-grpc` has no endpoint on either path. A web page is captured with
|
|
1249
|
+
`Page.captureScreenshot`.
|
|
1250
|
+
|
|
1251
|
+
A web page's frames and input go through the owned Chrome's DevTools
|
|
1252
|
+
endpoint, `web.cdpEndpoint` in `stim status`. Both the helper and the
|
|
1253
|
+
screenshot path connect only when `SystemInfo.getProcessInfo` names the
|
|
1254
|
+
Chrome pid status reports, then attach to the page's `targetId`. The
|
|
1255
|
+
helper runs `Page.startScreencast` sized to `maxEdge` and acks every
|
|
1256
|
+
frame; JPEG subscribers get Chrome's JPEG as is, and video decodes it for
|
|
1257
|
+
the encoder. Chrome draws a frame only when the page changes, so a
|
|
1258
|
+
keyframe request re-encodes the last one.
|
|
1259
|
+
|
|
1260
|
+
With `physical: true` and `platform: "ios"`, frames come from the
|
|
1261
|
+
physical iPhone whose unexpired lease `stim status` lists under
|
|
1262
|
+
`deviceLeases` for the workspace and slot. `stim ios --device` holds that
|
|
1263
|
+
lease only while it runs, so `stim device lock ios <udid>` keeps the
|
|
1264
|
+
iPhone watchable between runs. A lease on the workspace's own simulator
|
|
1265
|
+
is skipped. The iPhone must be cabled over USB and trust the Mac; over
|
|
1266
|
+
Wi-Fi it has no screen to capture. The helper, run as
|
|
1267
|
+
`stim-frames iphone <udid> <name>` with the lease's device name, sets
|
|
1268
|
+
CoreMediaIO's `kCMIOHardwarePropertyAllowScreenCaptureDevices`, which
|
|
1269
|
+
makes macOS list cabled iPhones as capture devices, the ones QuickTime
|
|
1270
|
+
Player's New Movie Recording shows. Such a device's unique ID is a
|
|
1271
|
+
random UUID that names neither the UDID nor the USB device, so the
|
|
1272
|
+
helper first checks that an iOS device on USB has the UDID, without
|
|
1273
|
+
dashes, as its serial number. It then opens the iOS capture device
|
|
1274
|
+
named like the lease's device name, or the only one when the lease
|
|
1275
|
+
records no name and one iPhone is cabled. With several iPhones cabled,
|
|
1276
|
+
it waits until each shows a screen and refuses when none or several
|
|
1277
|
+
carry the name. It
|
|
1278
|
+
captures only while a subscriber asks for frames. macOS lets several
|
|
1279
|
+
processes capture the iPhone at once. Frames and video go through the
|
|
1280
|
+
same JPEG and H.264 paths as a simulator. A physical iPhone has no
|
|
1281
|
+
screenshot fallback: a subscription without the helper, or whose helper
|
|
1282
|
+
fails, ends with `frames-failed`, such as when the iPhone is not cabled
|
|
1283
|
+
or is unplugged. While macOS is asking for Camera access, or frames
|
|
1284
|
+
stop, such as while the iPhone is locked, the subscription gets
|
|
1285
|
+
`frame-delayed` with `delayed: true` and a `reason`, keeps its last
|
|
1286
|
+
frame, and gets `delayed: false` once frames can arrive again.
|
|
1287
|
+
|
|
1288
|
+
macOS treats the iPhone's screen as a camera and attributes the helper's
|
|
1289
|
+
request to the app that started stim-server. Stim asks for it with its
|
|
1290
|
+
own Camera usage description and entitlement, so the first capture
|
|
1291
|
+
shows the Camera prompt for Stim. An app without them, such as `node`
|
|
1292
|
+
started from a shell, is denied without a prompt, and the subscription
|
|
1293
|
+
ends with a `frames-failed` that names the Camera pane of System
|
|
1294
|
+
Settings. A physical iPhone is view only: `control.begin` with
|
|
1295
|
+
`physical: true` refuses with `action-failed`. It is never recorded, so
|
|
1296
|
+
`at` and `frames.seek` on it fail with `no-recording`, and it counts
|
|
1297
|
+
toward no idle check.
|
|
1298
|
+
|
|
1299
|
+
- **Replay.** `replay.range` takes `workspace`, `platform` and `slot`
|
|
1300
|
+
(`default` when absent), like `frames.subscribe`, and returns what can be
|
|
1301
|
+
replayed of that device slot's [recording](#recording):
|
|
1302
|
+
- `enabled`: the workspace's `recording.enabled`, as the last status showed
|
|
1303
|
+
it.
|
|
1304
|
+
- `recording`: true while the server records the device now.
|
|
1305
|
+
- `spans`: the recorded time ranges `{ start, end }`, in epoch milliseconds on
|
|
1306
|
+
the Mac's clock, oldest first. Segments less than 1.5 seconds apart form
|
|
1307
|
+
one span; the gaps between spans are time nothing was recorded, such as
|
|
1308
|
+
after `stim stop`.
|
|
1309
|
+
- `markers`: `{ at, kind, command?, label }` from the start of the first span
|
|
1310
|
+
on, oldest first: the newest 400 actions and 100 errors. They come from `stim logs --json` in the
|
|
1311
|
+
workspace. `action` markers are the agent's actions on that device, failed
|
|
1312
|
+
ones included, from agent-device's session log and the owned Chrome page's
|
|
1313
|
+
agent input, with their `command`, such as `press`, `fill`, `open` or
|
|
1314
|
+
`click`. An agent-device action is placed when the command started, since
|
|
1315
|
+
it logs it when it finished, after any `--settle` wait. `error` and
|
|
1316
|
+
`crash` markers are error and fatal records of the workspace. Metro,
|
|
1317
|
+
client and build errors name no device, so they appear on every device of
|
|
1318
|
+
the workspace. `label` is the record's first line.
|
|
1319
|
+
|
|
1320
|
+
It needs only `read`. A device with no recording gets empty `spans` and
|
|
1321
|
+
`markers`, and runs no `stim` command.
|
|
1322
|
+
|
|
1323
|
+
`replay.keyframe` takes the same `workspace`, `platform` and `slot`, and
|
|
1324
|
+
`at` (epoch milliseconds), and returns one still frame for a scrubber
|
|
1325
|
+
preview without touching any subscription. The recording is stored in
|
|
1326
|
+
segments of about 5 seconds that each start at a keyframe; the server picks
|
|
1327
|
+
the segment `frames.seek` would show `at` from (the first one that ends at
|
|
1328
|
+
or after `at`, or the newest one) and reads only its first record. The
|
|
1329
|
+
result has the segment's `start` and `end`, the keyframe's capture time
|
|
1330
|
+
`at`, `width`, `height`, `posture` on an iPhone Duo, and `data`, the
|
|
1331
|
+
base64 Annex-B H.264 access unit with its SPS and PPS, so a client decodes
|
|
1332
|
+
it on its own; the server decodes nothing. The frame is the segment's first,
|
|
1333
|
+
so it can be up to about 5 seconds before `at`. It needs only `read`. It
|
|
1334
|
+
fails with `no-recording` when the device has no footage or that segment's
|
|
1335
|
+
first record is not a whole keyframe yet, as while it is being written. A
|
|
1336
|
+
connection can have 8 of these reads in flight; more fail with
|
|
1337
|
+
`limit-exceeded`.
|
|
1338
|
+
|
|
1339
|
+
A video subscription can replay the recording instead of the live screen.
|
|
1340
|
+
`frames.seek` takes the `subscription`, `at` (epoch milliseconds) and `rate`
|
|
1341
|
+
(0, 1 or 2). The server sends the access units from the keyframe at or
|
|
1342
|
+
before `at` through the frame at `at`, or the newest frame when `at` is past
|
|
1343
|
+
it, as binary video messages right away, then plays on at `rate` times real
|
|
1344
|
+
time; 0 stays paused. The result's `at`
|
|
1345
|
+
is the capture time of the frame shown. Playback skips time nothing was
|
|
1346
|
+
recorded, and at the newest recorded frame it sends a `replay-ended` event
|
|
1347
|
+
with `subscription` and `at` and stays paused there. `frames.live` returns
|
|
1348
|
+
the subscription to the live screen, starting at a keyframe, or fails with
|
|
1349
|
+
`frames-failed` when the device is not running. While a subscription
|
|
1350
|
+
replays, the server sends no live frames on it, and `frames.keyframe`
|
|
1351
|
+
resends the frame shown from its keyframe. A seek on a device with no
|
|
1352
|
+
recording fails with `no-recording` and leaves the subscription live, and a
|
|
1353
|
+
JPEG subscription cannot seek.
|
|
228
1354
|
|
|
1355
|
+
`frames.subscribe` with `at`, and optionally `rate`, starts the subscription
|
|
1356
|
+
replaying, and needs `video: ["h264"]`. It needs no running device, so the
|
|
1357
|
+
footage of a stopped workspace can be replayed; it fails with
|
|
1358
|
+
`no-recording` when nothing was recorded, and sends `replay-ended` at once
|
|
1359
|
+
when that footage holds no frame to show. Clients that never send these
|
|
1360
|
+
messages see no change.
|
|
1361
|
+
|
|
1362
|
+
- `recording.set` takes `enabled` and runs
|
|
1363
|
+
`stim settings set recording.enabled <enabled> --scope machine --json` in
|
|
1364
|
+
the home directory. It needs `control`, is logged like an
|
|
1365
|
+
[action](#actions) with `action` `recording.set`, and returns `enabled` and
|
|
1366
|
+
`recordingsDeleted`, the workspaces whose recordings turning recording off
|
|
1367
|
+
deleted.
|
|
229
1368
|
- `build.plan` takes `workspace`, `platform` (`ios` or `android`) and `slot`
|
|
230
1369
|
(`default` when absent), and returns the payload of
|
|
231
1370
|
`stim <platform> --plan --json` run in the workspace: the fingerprint, the
|
|
@@ -239,6 +1378,33 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
239
1378
|
computed is a `stim-failed` error. One plan runs per workspace at a time,
|
|
240
1379
|
across all connections; later requests wait their turn, and the 150
|
|
241
1380
|
seconds start when the plan starts.
|
|
1381
|
+
- `workspace.files` takes `{ "workspace", "group": "changed" | "untracked" }`
|
|
1382
|
+
and returns `{ "files", "truncated" }`; each file has a repo-relative `path`,
|
|
1383
|
+
`status`, and `staged`, `unstaged`, `untracked` flags. It needs `read` and the
|
|
1384
|
+
`workspace-diff` feature. The registered app project resolves to its canonical
|
|
1385
|
+
Git worktree, including changes elsewhere in that worktree. At most 200 files
|
|
1386
|
+
and 256 KiB are returned; raw Git status exceeding 1 MiB refuses.
|
|
1387
|
+
Changed lists include submodules only when their recorded commit differs;
|
|
1388
|
+
uncommitted edits inside submodules are not listed. Each Git call has a
|
|
1389
|
+
5-second deadline; a request runs several calls in sequence, with no overall
|
|
1390
|
+
deadline. Closing the connection stops a running request.
|
|
1391
|
+
- `workspace.diff` takes `{ "workspace", "path" }` for a current changed file
|
|
1392
|
+
and returns `{ "path", "patches" }`. Each patch has a `section` (`staged`,
|
|
1393
|
+
`unstaged`, `untracked`), `kind` (`text`, `binary`, `too-large`, `unavailable`)
|
|
1394
|
+
and `text`. Text previews total at most 256 KiB. New files must be regular
|
|
1395
|
+
UTF-8 files within the worktree; symlinks, submodules and conflicts report
|
|
1396
|
+
unavailable. Each Git call has a 5-second deadline; a request runs several
|
|
1397
|
+
calls in sequence, with no overall deadline. Raw status output is capped
|
|
1398
|
+
at 1 MiB and patches at 256 KiB. Closing the connection stops a running request.
|
|
1399
|
+
Git reads disable color, external diff, text conversion, fsmonitor
|
|
1400
|
+
commands and lazy fetches, use literal paths and never edit or stage files.
|
|
1401
|
+
Partial clones require Git 2.45 or later so lazy-fetch protection applies
|
|
1402
|
+
across subprocesses; an older or unparseable Git version refuses these reads.
|
|
1403
|
+
Tracked files attributed to configured clean or process filters refuse before
|
|
1404
|
+
status or diff. Unused filters are allowed within the preflight limits: each
|
|
1405
|
+
request checks filter attributes for all tracked paths. The internal path
|
|
1406
|
+
list has a 64 MiB cap; each Git call keeps its 5-second deadline.
|
|
1407
|
+
Git LFS previews are unavailable in this version.
|
|
242
1408
|
- `machine.get` returns cheap machine usage, read in the server process
|
|
243
1409
|
without running `stim`: `volumes`, one per volume that holds a Stim
|
|
244
1410
|
workspace, Stim home, or the simulators, with `mount`, `holds`, `freeBytes`
|
|
@@ -255,13 +1421,55 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
255
1421
|
sample), `memoryUsedBytes`, `memoryPressure` (0 normal, 1 warning, 2
|
|
256
1422
|
critical) and `diskFreeBytes` of the startup volume; a field is null when it
|
|
257
1423
|
cannot be read. `sinceMs` returns only the samples taken after it.
|
|
1424
|
+
- `machine.details` returns `{ "gc", "stats", "measuredAt" }`: the payloads of
|
|
1425
|
+
`stim gc --json` and `stim stats --json`, both run in the home directory, so
|
|
1426
|
+
a phone can show what Stim Desktop's Machine page shows about disk: every
|
|
1427
|
+
simulator, AVD, iOS runtime and system image, shared caches, recordings,
|
|
1428
|
+
workspace build outputs and logs, and what `stim gc --delete` would free. The
|
|
1429
|
+
`gc` run is always the dry run with no other flag; the server never deletes
|
|
1430
|
+
anything for this request. A part is null when its command failed, and
|
|
1431
|
+
`gcError` or `statsError` says why. It needs only `read`. The server keeps
|
|
1432
|
+
one result for 60 seconds, shared by every connection: a request while the
|
|
1433
|
+
commands run waits for them, and `measuredAt` says when they started. The
|
|
1434
|
+
commands fail after 150 seconds. Servers that predate it answer
|
|
1435
|
+
`unknown-method`. It also carries `buildMachines`, the `buildMachines` list
|
|
1436
|
+
of `stim doctor --json --platform ios` (never with `--fix`), so a phone can
|
|
1437
|
+
show whether each build machine takes builds. The server runs doctor only
|
|
1438
|
+
when `offload.machines` names a machine, else the list is empty; it runs it
|
|
1439
|
+
in the registered workspace that ran doctor for iOS most recently, because
|
|
1440
|
+
doctor judges a machine against an app. Like a doctor run from a terminal,
|
|
1441
|
+
that records the run for the workspace. `buildMachines` is null with
|
|
1442
|
+
`buildMachinesError` when doctor failed, the config could not be read, or no
|
|
1443
|
+
registered workspace exists. The reply never waits for doctor, whose offer
|
|
1444
|
+
to an unreachable machine can take about 13 seconds: it carries the last
|
|
1445
|
+
settled `buildMachines`/`buildMachinesError` (with `buildMachinesAt`, when
|
|
1446
|
+
they settled) while doctor refreshes them in the background, at most one run
|
|
1447
|
+
at a time, and sets `buildMachinesPending` while that background run has not
|
|
1448
|
+
settled a result yet, or the settled one is 60 seconds old or older. A client
|
|
1449
|
+
that wants the refreshed result asks `machine.details` again. It also
|
|
1450
|
+
carries `buildClients`, the builds this Mac ran as a build machine, one entry
|
|
1451
|
+
per client Mac read from the `build` records of the audit log, with `id`,
|
|
1452
|
+
`name`, `builds`, `failed`, `buildMs`, `lastAt` and the same three counts
|
|
1453
|
+
for `today`, most recent client first, with `today` on this Mac's local
|
|
1454
|
+
calendar day. Servers that predate it leave it out.
|
|
258
1455
|
- `unsubscribe` ends a subscription.
|
|
259
1456
|
- `push.register` takes `token`, an Expo push token, `events`, one or more
|
|
260
|
-
[push notifications](#push-notifications) the phone wants (`
|
|
261
|
-
`
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
1457
|
+
[push notifications](#push-notifications) the phone wants (`started`,
|
|
1458
|
+
`stuck`, `looping`, `finished`, `machine`, `control`, `attention`), `ref`, an opaque
|
|
1459
|
+
string of up to 128 characters that every push carries back as `data.ref`,
|
|
1460
|
+
and optionally `stuckMinutes` (1 to 240, default 15), `quietHours`
|
|
1461
|
+
(`{ "start", "end", "timeZone" }`, minutes after midnight in an IANA time
|
|
1462
|
+
zone) and `levels`, each event's [delivery level](#delivery-levels) (for
|
|
1463
|
+
example `{ "stuck": "silent", "machine": "alert" }`). It needs only `read`. Registering again replaces the device's
|
|
1464
|
+
registration; `push.unregister` removes it. Phones from before these events
|
|
1465
|
+
may still send `build-failed`, `log-errors`, `disk`, `app-stopped`,
|
|
1466
|
+
`slow-build` and `agentOnly`: `disk` counts as `machine`, and the rest are
|
|
1467
|
+
accepted and ignored.
|
|
1468
|
+
- `notifications.list` returns the [notification history](#notification-history)
|
|
1469
|
+
as `{ "log", "cursor", "notifications" }`, newest first. `since` (a `cursor`
|
|
1470
|
+
from an earlier list) returns only the entries after it. From then on, the
|
|
1471
|
+
connection gets a `notification` event, `{ "event": "notification", "log",
|
|
1472
|
+
"notification" }`, for each entry the server logs. It needs only `read`.
|
|
265
1473
|
- `action` runs an [action](#actions) and returns
|
|
266
1474
|
`{ "action", "workspace", "output" }`.
|
|
267
1475
|
- An `error` event ends a subscription whose source failed, or whose client
|
|
@@ -270,61 +1478,211 @@ Events are `{ "event", "subscription", ... }`.
|
|
|
270
1478
|
`workspace` is an environment `path` from a status payload. Any other path is
|
|
271
1479
|
refused with `unknown-workspace` and runs nothing. A connection holds at most
|
|
272
1480
|
32 subscriptions and runs at most 4 `logs.query`, `stats.get`,
|
|
273
|
-
`settings.get
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1481
|
+
`settings.get`, `build.plan`, `workspace.files` and `workspace.diff` requests at
|
|
1482
|
+
a time. `logs.query`, `stats.get` and `settings.get` fail after 60 seconds,
|
|
1483
|
+
`build.plan` after 150; these four fail at 32 MiB of output. Closing the
|
|
1484
|
+
connection stops running requests. When the command refuses with Stim's error
|
|
1485
|
+
contract on stdout, the `stim-failed` message is its code, message and remedy;
|
|
1486
|
+
otherwise it is the exit status and the end of stderr. A `stim` child that
|
|
278
1487
|
ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
|
|
279
1488
|
4 MiB unsent gets no more batches until it catches up; past 20,000
|
|
280
1489
|
waiting records the server ends that subscription with `slow-client`.
|
|
281
1490
|
|
|
1491
|
+
## Recording
|
|
1492
|
+
|
|
1493
|
+
The server records owned simulators, emulators and the Stim-owned Chrome page
|
|
1494
|
+
so clients can replay what happened while nobody watched. It records a device
|
|
1495
|
+
while `stim status` shows an automation tool driving it (`activity.state` is
|
|
1496
|
+
`driven`), or while a client has a `frames.subscribe` subscription to it. It
|
|
1497
|
+
records nothing else, holds no device awake and changes no device setting. To
|
|
1498
|
+
see drivers, the server keeps one `stim status --watch --json` child running
|
|
1499
|
+
for as long as it runs, shared with status subscribers.
|
|
1500
|
+
|
|
1501
|
+
Recording goes through the device's `stim-frames` helper, shared with live
|
|
1502
|
+
subscribers. The helper runs a second H.264 encoder for it, at 720 pixels on
|
|
1503
|
+
the long edge, 1 Mbps and at most 10 frames a second, so live video keeps its
|
|
1504
|
+
own bitrate. Live frames are captured at no less than 720 pixels and 10 frames
|
|
1505
|
+
a second while the device is recorded. A device on screenshots, without the
|
|
1506
|
+
helper, is not recorded, and a helper that fails is started again after 5
|
|
1507
|
+
seconds, doubling up to 5 minutes. One stim-server records and prunes a Stim
|
|
1508
|
+
home at a time, under an exclusive ownership claim at
|
|
1509
|
+
`$STIM_HOME/server/recorder`; a second one serves replays and records once the
|
|
1510
|
+
claim frees.
|
|
1511
|
+
|
|
1512
|
+
Footage is stored under `$STIM_HOME/workspaces/<id>/recordings/<platform>-<slot>/`
|
|
1513
|
+
as segments of about 5 seconds, each starting at a keyframe; the server asks
|
|
1514
|
+
the helper for a keyframe once a segment is 5 seconds old, so a screen that
|
|
1515
|
+
does not change still gets new segments, and a keyframe request also restarts
|
|
1516
|
+
the recording stream at a keyframe. The segment being
|
|
1517
|
+
written is `<start>.part`, and a closed segment is `<start>-<end>.seg`, in epoch
|
|
1518
|
+
milliseconds. A segment is a sequence of records: a u32 big-endian length of
|
|
1519
|
+
the rest, u8 flags (bit 0 keyframe, bit 1 folded, bit 2 unfolded, as in a video
|
|
1520
|
+
packet), f64 capture time in milliseconds since the epoch, u16 width, u16
|
|
1521
|
+
height, then one Annex-B access unit. The server creates a segment only while
|
|
1522
|
+
the workspace directory has its `workspace.json`, so it never fills a directory
|
|
1523
|
+
that `stim worktree remove` or `stim gc` emptied.
|
|
1524
|
+
|
|
1525
|
+
Every 30 seconds it keeps the last 15 minutes of footage of each device,
|
|
1526
|
+
counting only recorded time, and at most 1 GiB of footage across every
|
|
1527
|
+
workspace, deleting the oldest segments first. It closes, at their last write,
|
|
1528
|
+
the `.part` segments of a server that stopped. When a status payload shows
|
|
1529
|
+
`recording.enabled` false for a workspace (the `recording.enabled` setting, or
|
|
1530
|
+
`STIM_RECORDING` in the server's environment), it stops recording that
|
|
1531
|
+
workspace within seconds and deletes its recordings. `stim stop` ends recording
|
|
1532
|
+
and keeps the footage; `stim worktree remove` and `stim gc` delete it. A gc that
|
|
1533
|
+
deletes the segment being written loses the rest of that segment; the next one
|
|
1534
|
+
is written as usual. Recordings stay on the Mac and are served only to paired
|
|
1535
|
+
clients.
|
|
1536
|
+
|
|
282
1537
|
## Push notifications
|
|
283
1538
|
|
|
284
|
-
A paired phone that sends `push.register` gets
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
- `
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1539
|
+
A paired phone that sends `push.register` gets notifications while its app is
|
|
1540
|
+
in the background or closed. The server keeps the registration with the
|
|
1541
|
+
pairing in `devices.json`, so revoking the device drops it. A token belongs to
|
|
1542
|
+
one pairing: registering it from a new pairing of the same phone removes it
|
|
1543
|
+
from the old one.
|
|
1544
|
+
|
|
1545
|
+
A notification means that your attention changes the outcome, or that work you
|
|
1546
|
+
wait on started or finished. A failed build, new log errors, a stopped app or a
|
|
1547
|
+
slow build on their own are normal agent iteration and do not push; the phone
|
|
1548
|
+
shows them in its attention strip. The server pushes, per device and only for
|
|
1549
|
+
the events the device chose:
|
|
1550
|
+
|
|
1551
|
+
- `started`: a workspace began warming (`phase` `warming`), or an agent first
|
|
1552
|
+
drove one of its devices. It is grouped per Mac, and opens the workspace, or the device viewer
|
|
1553
|
+
once an agent drives it. An agent driving the workspace updates the warming
|
|
1554
|
+
notification in place.
|
|
1555
|
+
- `stuck`: an agent drove the workspace, its devices are still up, and nothing
|
|
1556
|
+
happened for `stuckMinutes`: no agent action, build, Stim run, Metro bundle
|
|
1557
|
+
request or new log error. App log records do not count, because an idle app keeps
|
|
1558
|
+
logging: an idle Stim app writes about 200 UIKit info records a minute. The
|
|
1559
|
+
owned Chrome page counts as a device: an attached tool's input there is an
|
|
1560
|
+
agent action, and while an agent drives it its page log also counts, since an
|
|
1561
|
+
agent's navigations and scripts are not agent actions. It opens the device viewer.
|
|
1562
|
+
- `looping`: the newest three or more iOS or Android builds failed the same
|
|
1563
|
+
way, at the same first compiler diagnostic `file:line`, or with the same
|
|
1564
|
+
error code when there is none, such as three failed launches
|
|
1565
|
+
(`STIM_LAUNCH_FAILED`, which includes an app that exits at launch). It says,
|
|
1566
|
+
for example, `Same Swift error 3x at AppDelegate.swift:71` and opens the
|
|
1567
|
+
build details.
|
|
1568
|
+
- `finished`: the agent stopped driving after a green build and nothing
|
|
1569
|
+
happened for 5 minutes, or stopped the workspace after a green build; the
|
|
1570
|
+
workspace's pull
|
|
1571
|
+
request became ready for review, or merged, which opens the pull request; or,
|
|
1572
|
+
when GitHub cannot be asked, git finds the branch merged into the default
|
|
1573
|
+
branch.
|
|
1574
|
+
- `machine`: a volume holding Stim state has less than 5 GB free (the episode
|
|
1575
|
+
ends once 6 GB are free again), or memory pressure stayed critical for a
|
|
1576
|
+
minute. It opens the machine sheet.
|
|
1577
|
+
- `control`: another client took over a device this phone controls, or an
|
|
1578
|
+
agent started driving it. It opens the device viewer.
|
|
1579
|
+
- `attention`: something only a person can fix, from the needs-attention rule
|
|
1580
|
+
(`needsAttention` in `@stim-cli/core/oversight`): a port held by another app, a
|
|
1581
|
+
supervisor or owned Chrome Stim cannot verify, an owned AVD it could not
|
|
1582
|
+
check, a failed run with a signing or provisioning error code, a physical
|
|
1583
|
+
device lease that expired, or an EAS session running for 30 minutes with no
|
|
1584
|
+
agent driving the workspace. It opens the workspace, or the build for a
|
|
1585
|
+
signing failure. A stuck agent, a loop and a full disk notify through their
|
|
1586
|
+
own categories.
|
|
1587
|
+
|
|
1588
|
+
Stuck and finished are read from device activity, so two cases blur them. An
|
|
1589
|
+
agent that finishes without closing its agent-device session still holds the
|
|
1590
|
+
device, so it gets `stuck`, whose text then names the green build it stopped
|
|
1591
|
+
after. With a `stim` whose status has no `activity.recent`, every app log
|
|
1592
|
+
record counts as activity, so an app that keeps logging never looks stuck or
|
|
1593
|
+
finished.
|
|
1594
|
+
|
|
1595
|
+
Each workspace notifies once per episode: a stuck agent notifies again only
|
|
1596
|
+
after new activity and a new quiet stretch, a loop only after a success or a
|
|
1597
|
+
different failure. A push carries a collapse id for its workspace and
|
|
1598
|
+
category, so a later one replaces the earlier notification on the phone
|
|
1599
|
+
instead of stacking. What is already true when the server starts or a device
|
|
1600
|
+
registers does not push. During the phone's quiet hours nothing pushes: a
|
|
1601
|
+
problem that still holds when they end pushes then, and what started or
|
|
1602
|
+
finished during them does not.
|
|
1603
|
+
|
|
1604
|
+
While at least one device is paired, the server keeps its own
|
|
1605
|
+
`stim status --watch --json` child running, even with no client connected, and
|
|
1606
|
+
reads the free space of Stim's volumes and the memory pressure every minute.
|
|
1607
|
+
While a device wants `finished`, it looks up the pull requests of worktree
|
|
1608
|
+
branches that have an upstream every 5 minutes, with one `gh api graphql` call
|
|
1609
|
+
per repository, the lookup `stim gc` uses. Without `gh`, or when it is signed
|
|
1610
|
+
out or does not answer, that round relies on git, and the next round asks
|
|
1611
|
+
again.
|
|
1612
|
+
|
|
1613
|
+
A device gets at most 20 alerting pushes an hour; silent pushes do not
|
|
1614
|
+
count. More than three at once become one summary push that opens the
|
|
1615
|
+
phone's home screen.
|
|
315
1616
|
|
|
316
1617
|
Pushes go to the Expo push service, `https://exp.host/--/api/v2/push/send`,
|
|
317
|
-
which forwards them to Apple. No APNs key or other secret lives on the Mac.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
`
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1618
|
+
which forwards them to Apple. No APNs key or other secret lives on the Mac. A
|
|
1619
|
+
workspace push carries the workspace title, a one-line cause and the Mac's name
|
|
1620
|
+
as the subtitle; a machine or summary push has the Mac's name as its title. In
|
|
1621
|
+
`data` it carries the `ref`, the screen to open (`home`, `machine`,
|
|
1622
|
+
`workspace`, `device`, `build` or `url`), and the workspace's absolute path,
|
|
1623
|
+
with the platform and slot for a device, the platform for build details and
|
|
1624
|
+
the pull request's URL for `url`. The phone needs the path to open a workspace
|
|
1625
|
+
before it has reconnected. It carries no logs. The server checks the push
|
|
1626
|
+
receipts 15 minutes later and drops a token that Expo reports as
|
|
1627
|
+
`DeviceNotRegistered`, and prints any other refusal, such as missing APNs
|
|
1628
|
+
credentials, on stderr. Pushes are not retried, and nothing is pushed while
|
|
1629
|
+
the server is not running.
|
|
1630
|
+
|
|
1631
|
+
### Delivery levels
|
|
1632
|
+
|
|
1633
|
+
Each event the device registers is delivered at one of two levels; an event it
|
|
1634
|
+
leaves out of `events` is off and never pushes.
|
|
1635
|
+
|
|
1636
|
+
- `alert`: a banner and sound. The push has `sound: "default"`,
|
|
1637
|
+
`interruptionLevel: "active"` and `channelId: "attention"`, the phone's
|
|
1638
|
+
high-importance Android channel.
|
|
1639
|
+
- `silent`: no banner or sound, only the notification list.
|
|
1640
|
+
The push has no sound, `interruptionLevel: "passive"` and
|
|
1641
|
+
`channelId: "updates"`, the phone's low-importance Android channel.
|
|
1642
|
+
|
|
1643
|
+
`levels` in `push.register` sets each event's level. An event it leaves out,
|
|
1644
|
+
and every event of a phone that sends no `levels`, is silent. A summary push is
|
|
1645
|
+
silent only when every notification it sums up is. An older server ignores
|
|
1646
|
+
`levels` and alerts for every event but `started`.
|
|
1647
|
+
|
|
1648
|
+
The server and phone import the same pure rules from `@stim-cli/core/oversight`.
|
|
1649
|
+
The phone formats home attention messages locally from the facts the shared rule selects.
|
|
1650
|
+
`__tests__/oversight-agreement.test.ts` checks their categories, workspace names
|
|
1651
|
+
and needs-attention vectors. Notification delivery remains local to each.
|
|
1652
|
+
|
|
1653
|
+
## Notification history
|
|
1654
|
+
|
|
1655
|
+
The server logs the notifications the rules produce for the Mac, whether or not
|
|
1656
|
+
a phone was pushed, so a phone can list what it missed. While any device is
|
|
1657
|
+
paired, it runs the rules once more for the Mac itself, with every category, no
|
|
1658
|
+
quiet hours and the lowest `stuckMinutes` a registered device asked for (15
|
|
1659
|
+
without one). Each notification it produces is an entry; a `control` conflict is
|
|
1660
|
+
an entry too, listed only to the phone that lost the device. Pull request
|
|
1661
|
+
entries need the GitHub lookup, which runs only while a device wants
|
|
1662
|
+
`finished`.
|
|
1663
|
+
|
|
1664
|
+
An entry is `{ "seq", "at", "id", "category", "title", "body", "quiet",
|
|
1665
|
+
"target", "suppressed"? }`. `seq` grows by one per entry. `id` names the
|
|
1666
|
+
workspace or machine and category, the key a push's collapse id is made from,
|
|
1667
|
+
so a later episode of the same problem shares it. `target` is the screen the
|
|
1668
|
+
push opens, as `{ "kind", ... }` with the fields of the push's `data`.
|
|
1669
|
+
`quiet` is the event's default delivery, always silent; a device's
|
|
1670
|
+
[levels](#delivery-levels) decide how its push was actually delivered.
|
|
1671
|
+
`suppressed` says why no registered device got the notification when it was
|
|
1672
|
+
logged: `muted` when none wants its category, `quiet-hours` when those that do
|
|
1673
|
+
were in quiet hours. A problem that still holds when quiet hours end is pushed
|
|
1674
|
+
then, so a `quiet-hours` entry may still have reached the phone. Without
|
|
1675
|
+
`suppressed`, a registered device was due to get it, or none is registered; a
|
|
1676
|
+
device's hourly budget or a summary push can still stand in for the push
|
|
1677
|
+
itself. A push for a logged notification carries the entry's `seq` as `data.notification`, so the
|
|
1678
|
+
phone can mark it read.
|
|
1679
|
+
|
|
1680
|
+
The log keeps the last 200 entries, none older than 7 days, in
|
|
1681
|
+
`$STIM_HOME/server/notifications.json`, which only the server writes, under
|
|
1682
|
+
`notifications.json.lock`. `log` in `notifications.list` is a random id made
|
|
1683
|
+
with the file: a new one means the history started over, so a cursor or read
|
|
1684
|
+
state kept for the old one no longer applies. What is already true when the
|
|
1685
|
+
server starts is not logged, as it is not pushed.
|
|
328
1686
|
|
|
329
1687
|
## Actions
|
|
330
1688
|
|
|
@@ -365,10 +1723,12 @@ replaced by `?`.
|
|
|
365
1723
|
## Control
|
|
366
1724
|
|
|
367
1725
|
A device with `control` can drive a simulator or emulator that `stim status`
|
|
368
|
-
lists as owned by a workspace
|
|
1726
|
+
lists as owned by a workspace, or a physical Android phone the workspace
|
|
1727
|
+
leases (see [Physical Android devices](#physical-android-devices)). Nothing it
|
|
1728
|
+
sends reaches any other device.
|
|
369
1729
|
|
|
370
1730
|
- `control.begin` takes `workspace`, `platform`, `slot` (`default` when
|
|
371
|
-
absent) and `takeOver`, and returns `{ "session", "platform", "lease",
|
|
1731
|
+
absent), `physical` and `takeOver`, and returns `{ "session", "platform", "lease",
|
|
372
1732
|
"postures" }`. `postures` lists what `input.posture` takes for the device:
|
|
373
1733
|
`folded` and `unfolded` for an iPhone Duo, `folded`, `half-open` and
|
|
374
1734
|
`unfolded` for an emulator with a hinge, such as a `pixel_fold` AVD, and
|
|
@@ -390,15 +1750,88 @@ lists as owned by a workspace. Nothing it sends reaches any other device.
|
|
|
390
1750
|
it.
|
|
391
1751
|
- `input.touch` takes `session`, `phase` (`down`, `move`, `up`), `x` and `y`
|
|
392
1752
|
as fractions of the upright screen, and `display` (0, the main display).
|
|
1753
|
+
Without `display`, a touch on an iPhone Duo goes to the panel its latest
|
|
1754
|
+
frame showed, and before any frame to the cover.
|
|
393
1755
|
`input.text` takes up to 256 printable ASCII characters, where `\n` presses
|
|
394
1756
|
Return, `\t` Tab and `\b` Delete. `input.button` takes `home` or `lock`,
|
|
395
1757
|
and on Android also `back` or `app-switch`. `input.rotate` takes
|
|
396
|
-
`direction` (`left` or `right`) and turns the device a quarter turn.
|
|
397
|
-
|
|
1758
|
+
`direction` (`left` or `right`) and turns the device a quarter turn. An
|
|
1759
|
+
iPhone Duo rotates through its Virtualization provider in folded, half-open
|
|
1760
|
+
and unfolded postures. Apps keep their supported orientations, and its home
|
|
1761
|
+
screen stays portrait, as on other iPhones.
|
|
1762
|
+
`input.posture` takes one of the session's `postures`. A web page takes
|
|
1763
|
+
only `back`, its history back, and refuses rotation and posture. Each answers `{}` once the input
|
|
398
1764
|
is handed to the device: when it goes through the helper, that is when the
|
|
399
|
-
helper receives it, so
|
|
1765
|
+
helper receives it, so native macOS input failures end Control with the reason while its view stays available; other helper failures are logged. A connection may send 120 inputs a second and type 40
|
|
400
1766
|
characters a second, with a burst of 256, and rotate or change posture twice a
|
|
401
1767
|
second; more fail with `limit-exceeded`.
|
|
1768
|
+
- When hello advertises `macos-window-control`, `control.begin` accepts
|
|
1769
|
+
`platform: "macos"` for the verified native app in the default slot. It needs
|
|
1770
|
+
`control` and takes one exclusive server session per app, with the same
|
|
1771
|
+
takeover, disconnect, revocation and five-minute idle rules; `lease` is null
|
|
1772
|
+
because CLI device locks do not cover macOS apps. Existing Device Control and
|
|
1773
|
+
Data Access permission (Accessibility on macOS 26 and earlier) is required, without permission requests or resets. Before each
|
|
1774
|
+
action the helper verifies PID/start time/executable/bundle and that the
|
|
1775
|
+
captured window ID and size are still the app's front standard window, or the
|
|
1776
|
+
window `input.window` pinned. The app's
|
|
1777
|
+
other windows are allowed. A modal dialog window and changed ownership refuse
|
|
1778
|
+
input. Input that arrives while capture moves to another window is dropped with a
|
|
1779
|
+
logged reason and Control continues. A sheet attached to the captured window
|
|
1780
|
+
takes focus and pointer input. A sheet larger than the captured window is not
|
|
1781
|
+
supported. Events are
|
|
1782
|
+
posted only to that PID; the desktop and other apps receive no input.
|
|
1783
|
+
`input.touch` maps normalized captured-window coordinates to mouse events.
|
|
1784
|
+
`input.scroll` takes normalized `x`, `y` and `deltaX`, `deltaY` in pixels,
|
|
1785
|
+
each from -1000 to 1000. When hello advertises `macos-window-select`,
|
|
1786
|
+
`input.window` takes `window`, an id from the `macos-windows` event, to pin the
|
|
1787
|
+
view and input to that window of the app: the helper verifies it is one of the
|
|
1788
|
+
app's on-screen standard windows, makes it the app's main window and raises it
|
|
1789
|
+
among the app's windows without activating the app, and capture stays on it
|
|
1790
|
+
while another window comes to the front.
|
|
1791
|
+
`window: null` follows the front window again. A pin also ends when its window
|
|
1792
|
+
closes or leaves the screen, or when the Control session that set it ends,
|
|
1793
|
+
including takeover, disconnect and the idle timeout. A window that is gone by
|
|
1794
|
+
then, or a modal dialog in the app, drops the input with a logged reason
|
|
1795
|
+
without ending Control. The hosted relay forwards it as `device-host.input.window`.
|
|
1796
|
+
`input.key` accepts Escape, Tab, Return, Backspace,
|
|
1797
|
+
arrows or `a-z` and `0-9`, with unique optional `command/shift/option/control`
|
|
1798
|
+
modifiers. Hello advertises `macos-keyboard-extended` for the expanded keys;
|
|
1799
|
+
older servers accept only `a/c/v/x/z/s/f` plus navigation. Shortcuts for `a-z` and `0-9`
|
|
1800
|
+
use the key that types the character in the Mac's current keyboard layout,
|
|
1801
|
+
using the Command layer when Command is held. Dvorak and Dvorak-QWERTY Command
|
|
1802
|
+
are supported. On Russian and similar layouts (Cyrillic, Greek, Hebrew, Arabic),
|
|
1803
|
+
Latin-letter shortcuts work with Command; Control-only or Option-only letter
|
|
1804
|
+
shortcuts are refused. With Control, the layout's Control table must also yield
|
|
1805
|
+
the requested character or, for letters, its C0 control character; otherwise
|
|
1806
|
+
the shortcut is refused. The Mac's selected input source is read on each key,
|
|
1807
|
+
so switching layouts takes effect on the next key.
|
|
1808
|
+
Letters and digits available only with Shift or Option, or through a dead key
|
|
1809
|
+
(for example digits on AZERTY), are refused with a reason naming the layout,
|
|
1810
|
+
and Control ends. Stim posts the requested modifiers unchanged; it does not
|
|
1811
|
+
add modifiers to reach those characters. Ordinary typing and navigation do
|
|
1812
|
+
not depend on the layout. Symbols such as comma remain unsupported key names.
|
|
1813
|
+
The helper posts input to the owned process without activating it or raising its window,
|
|
1814
|
+
except that `input.window` raises the window it pins among the app's windows.
|
|
1815
|
+
Only when the captured window is not the app's key window does it activate the app (waiting up to one
|
|
1816
|
+
second for focus) and send a `controlActivated` notice, which stim-server logs. Clicks on views that
|
|
1817
|
+
reject the first mouse, such as custom views and SwiftUI `onTapGesture` regions, do not land while the app
|
|
1818
|
+
is in the background. `input.text` retains the printable ASCII contract,
|
|
1819
|
+
and navigation keys do not depend on that host layout. Native windows
|
|
1820
|
+
reject simulator buttons, rotation and posture. The helper dynamically resolves
|
|
1821
|
+
private CoreGraphics `CGEventSetWindowLocation` to annotate PID-targeted pointer
|
|
1822
|
+
events. This is a macOS compatibility limit outside the phone and Mac App Store
|
|
1823
|
+
binaries: a missing symbol refuses Control while read-only capture remains
|
|
1824
|
+
available. An input refusal sends
|
|
1825
|
+
`control-ended` with its reason without ending read-only capture.
|
|
1826
|
+
- On an owned iOS simulator, `control.begin` also reports optional `simulator`
|
|
1827
|
+
capabilities: `canShake` and `slowAnimations` (a boolean, or `null` when
|
|
1828
|
+
unavailable). `input.simulator` takes the session and `action: "shake"`,
|
|
1829
|
+
`action: "read"`, or `action: "slow-animations"` with an explicit boolean
|
|
1830
|
+
`enabled`. It returns the confirmed capabilities and state. Unavailable
|
|
1831
|
+
controls are refused. The native operation times out after 8 seconds and
|
|
1832
|
+
stops when its control session ends; only one option changes per simulator
|
|
1833
|
+
at a time. These controls use the same CoreSimulator guest notifications as
|
|
1834
|
+
Stim Desktop, without opening Device Hub.
|
|
402
1835
|
- `control.end` ends a session. The server also ends it with a
|
|
403
1836
|
`control-ended` event `{ "session", "reason", "message" }` after 5 minutes
|
|
404
1837
|
without input (`idle`), when another client takes the device over
|
|
@@ -417,6 +1850,13 @@ streams its frames:
|
|
|
417
1850
|
Hub does, and from then until the simulator reboots it ignores tools that
|
|
418
1851
|
still use SimulatorKit's legacy HID client. Text is typed key by key on a US
|
|
419
1852
|
layout. `lock` is the side button.
|
|
1853
|
+
- A web page takes DevTools input on the page's `targetId`: touches as
|
|
1854
|
+
`Input.dispatchTouchEvent` (a drag scrolls, on a desktop page too), text as
|
|
1855
|
+
key events, `\n`, `\t` and `\b` as Enter, Tab and Backspace. A web
|
|
1856
|
+
session holds no lease, since `stim device lock` covers devices; a
|
|
1857
|
+
DevTools client other than Stim's attached to the browser (`web.activity`
|
|
1858
|
+
in status, such as Playwright MCP) makes `control.begin` answer
|
|
1859
|
+
`device-busy` unless it takes over.
|
|
420
1860
|
- Emulators take touches through the emulator's gRPC `sendTouch`. Text and
|
|
421
1861
|
buttons go through gRPC `sendKey` when the emulator reports a hardware
|
|
422
1862
|
keyboard, which AVDs Stim creates have. An emulator without one drops key
|
|
@@ -437,7 +1877,62 @@ appends a line to the action log, with `action` set to `control.begin`,
|
|
|
437
1877
|
ended or whom it took the device from. Inputs are not logged.
|
|
438
1878
|
|
|
439
1879
|
The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
|
|
440
|
-
refuse the client until it pairs again or updates;
|
|
1880
|
+
refuse the client until it pairs again or updates; `approval-pending` refuses
|
|
1881
|
+
a build client until the Mac approves it; clients retry the others.
|
|
441
1882
|
|
|
442
1883
|
The package exports the message types, and the build writes their JSON Schema
|
|
443
1884
|
to `dist/protocol.schema.json`, exported as `@stim-cli/server/protocol.schema.json`.
|
|
1885
|
+
The wire types are shared through `@stim-cli/core/protocol`. The phone derives
|
|
1886
|
+
its receiving types from that contract and checks JSON results against the
|
|
1887
|
+
pending method, and known events before dispatch. Its validators are compiled
|
|
1888
|
+
during development, not on app startup. Compatible additional fields and
|
|
1889
|
+
unknown future events remain supported; malformed known data closes the socket
|
|
1890
|
+
and enters the normal reconnect flow without logging the payload.
|
|
1891
|
+
|
|
1892
|
+
## Physical Android devices
|
|
1893
|
+
|
|
1894
|
+
A phone reached with `stim android --device <serial>` (or held with
|
|
1895
|
+
`stim device lock android <serial>`) is used, not owned. With `physical: true`,
|
|
1896
|
+
`frames.subscribe` and `control.begin` pick the phone the workspace holds an
|
|
1897
|
+
unexpired lease on in `slot`, as `deviceLeases` in `stim status` reports it,
|
|
1898
|
+
instead of the Stim-owned emulator. A workspace without that lease gets
|
|
1899
|
+
`frames-failed` or `action-failed`, and so does a slot whose leased device is
|
|
1900
|
+
an emulator. Watching needs `read`, as for an emulator. Control needs
|
|
1901
|
+
`control` and that lease: the session never takes, renews or releases a
|
|
1902
|
+
phone's lease, and `takeOver` cannot move one between workspaces. When the
|
|
1903
|
+
lease is released or expires, the control session ends with `device-gone` and
|
|
1904
|
+
frame subscriptions end with `frames-failed`. Both follow `stim status
|
|
1905
|
+
--watch`: a release reaches the server when status next reports it, usually
|
|
1906
|
+
within seconds and at most about 30 seconds plus one refresh. Input after an
|
|
1907
|
+
expiry is refused at once. A physical Android device is not recorded, so `at`
|
|
1908
|
+
and `frames.seek` answer `no-recording`, and a watched phone is not listed as
|
|
1909
|
+
viewed for Stim's idle checks.
|
|
1910
|
+
|
|
1911
|
+
The `stim-frames` helper reaches the phone over adb only, with the scrcpy
|
|
1912
|
+
server 4.1 (Apache-2.0), shipped in `dist/scrcpy/` with its `LICENSE` and a
|
|
1913
|
+
`NOTICE`. Before every push, the helper checks the jar's sha256 against the one
|
|
1914
|
+
pinned in its source. It then pushes the jar to
|
|
1915
|
+
`/data/local/tmp/stim-scrcpy-<id>.jar`, starts it with `app_process` as the
|
|
1916
|
+
shell user, and connects through an `adb forward` port. The server's cleanup
|
|
1917
|
+
process deletes the jar as soon as it runs; when the helper stops, it removes
|
|
1918
|
+
the forward and deletes the jar again. It installs nothing, and it asks
|
|
1919
|
+
scrcpy for no settings change: no `show_touches`, no `stay_awake` and no
|
|
1920
|
+
screen power change (`power_on=false`), with clipboard sync off. The phone
|
|
1921
|
+
encodes H.264 of its screen as it is oriented, scaled to fit 2048 pixels;
|
|
1922
|
+
the helper decodes it with VideoToolbox and feeds the same encoder, JPEG path,
|
|
1923
|
+
keyframe requests and bitrate adaptation as a simulator or emulator. The
|
|
1924
|
+
stream has no time limit and restarts only with the helper.
|
|
1925
|
+
|
|
1926
|
+
Input goes over scrcpy's control socket: touches as finger events in the
|
|
1927
|
+
current frame's pixels, text as injected text, and `\n`, `\t`, `\b`,
|
|
1928
|
+
`home`, `back`, `app-switch` and `lock` as key events. `input.rotate` and
|
|
1929
|
+
`input.posture` fail with `bad-request`, because a phone turns only in hand.
|
|
1930
|
+
|
|
1931
|
+
Some Android 15 and 16 devices send no frame until their screen changes
|
|
1932
|
+
(scrcpy #6500, #6546), so a tile can stay blank until then. With its screen
|
|
1933
|
+
off, a phone streams what its display shows, such as a Samsung always-on
|
|
1934
|
+
display, or black; the stream never wakes it.
|
|
1935
|
+
|
|
1936
|
+
For testing without a phone, `STIM_SERVER_TEST_ADB_EMULATORS=1` in the
|
|
1937
|
+
server's environment lets a `physical: true` target resolve to an emulator the
|
|
1938
|
+
workspace leases, which then streams and takes input over adb the same way.
|