@palgroup/simstream 0.7.0 → 0.9.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 +14 -0
- package/dist/index.d.ts +313 -1
- package/dist/index.js +211 -2
- package/dist/source.d.ts +50 -0
- package/dist/source.js +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,20 @@ await sim.pushBuild('https://your-bucket/App.zip');
|
|
|
39
39
|
|
|
40
40
|
It goes only to hosts holding *your* sessions.
|
|
41
41
|
|
|
42
|
+
## Keeping builds
|
|
43
|
+
|
|
44
|
+
The registry keeps every build a project uploads, so nobody has to keep a list of URLs on their side:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const build = await sim.uploadBuild(zippedApp, { description: 'main · 26 Sep 18:40' }); // a zipped simulator .app
|
|
48
|
+
const { builds } = await sim.listBuilds(); // newest first
|
|
49
|
+
const session = await sim.runBuild({ build: build.id }, { device: 'iPhone 17', wait: true });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Completing an upload publishes the build and moves its app's `latest` channel to it, so
|
|
53
|
+
`runBuild({ channel: 'com.example.app:latest' })` always opens the newest one. `setChannel(id, 'beta')`
|
|
54
|
+
names others.
|
|
55
|
+
|
|
42
56
|
## When there is nothing free
|
|
43
57
|
|
|
44
58
|
`startSession()` throws a `SimStreamError`. The `code` says what to do about it:
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* customer and the tenth, and the day one of them needs a method the others cannot have, the
|
|
11
11
|
* separation has stopped being real.
|
|
12
12
|
*/
|
|
13
|
+
import type { SourceAnswer } from './source';
|
|
13
14
|
export interface SimStreamOptions {
|
|
14
15
|
/** Coordinator base URL, e.g. https://simstream.example */
|
|
15
16
|
baseUrl: string;
|
|
@@ -41,10 +42,24 @@ export interface DeviceKind {
|
|
|
41
42
|
count: number;
|
|
42
43
|
free: number;
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Which of a simulator's two apps something is.
|
|
47
|
+
*
|
|
48
|
+
* ‼️ A PREVIEW HOST IS NOT THE APP. `preview` is a build whose @main is generated: it carries every
|
|
49
|
+
* screen the project was written with and swaps between them live, which is what to look at while a
|
|
50
|
+
* screen is being built. `app` is the person's own binary, launched from their own @main — the only
|
|
51
|
+
* one that runs their launch sequence, their delegate, their deep links and their navigation, and so
|
|
52
|
+
* the only one that is the app rather than a picture of it.
|
|
53
|
+
*
|
|
54
|
+
* Nothing in a .app says which it is, so whoever built it says so when they push it.
|
|
55
|
+
*/
|
|
56
|
+
export type SimMode = 'app' | 'preview';
|
|
44
57
|
export interface AppStatus {
|
|
45
58
|
/** Empty when nothing has been pushed to this simulator yet. */
|
|
46
59
|
phase?: 'downloading' | 'installing' | 'launching' | 'ready' | 'failed';
|
|
47
60
|
bundle?: string;
|
|
61
|
+
/** Which of the two this install is, when the push said so. */
|
|
62
|
+
mode?: SimMode;
|
|
48
63
|
/** Present only with phase 'failed', and it says what went wrong rather than that it did. */
|
|
49
64
|
error?: string;
|
|
50
65
|
elapsed?: string;
|
|
@@ -52,6 +67,13 @@ export interface AppStatus {
|
|
|
52
67
|
export interface StartOptions {
|
|
53
68
|
/** A .app directory, or a .zip/.tar.gz containing one. Simulator builds, not .ipa. */
|
|
54
69
|
app?: string;
|
|
70
|
+
/**
|
|
71
|
+
* A build in this project's registry, by its opaque id. Use this rather than `app` for anything
|
|
72
|
+
* that was uploaded: a registry record has no URL a caller could pass, on purpose.
|
|
73
|
+
*/
|
|
74
|
+
build?: string;
|
|
75
|
+
/** …or a channel pointer, which moves to whatever was uploaded last. */
|
|
76
|
+
channel?: string;
|
|
55
77
|
/**
|
|
56
78
|
* The kind of device to open — "iPad", "iphone-17", or an exact udid.
|
|
57
79
|
*
|
|
@@ -61,6 +83,11 @@ export interface StartOptions {
|
|
|
61
83
|
device?: string;
|
|
62
84
|
/** Launch it once installed. Default true. */
|
|
63
85
|
launch?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Which of the simulator's two apps `app` is. Say so whenever you intend to push the other one
|
|
88
|
+
* too: an unlabelled build runs perfectly well and simply cannot be switched away from.
|
|
89
|
+
*/
|
|
90
|
+
mode?: SimMode;
|
|
64
91
|
/**
|
|
65
92
|
* Wait for a device when the fleet is full or this customer is at its limit, instead of
|
|
66
93
|
* failing. The wait is usually seconds — a session ends every time somebody closes a tab.
|
|
@@ -93,7 +120,171 @@ export interface CustomerUsage {
|
|
|
93
120
|
live: number;
|
|
94
121
|
cost: number;
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
/** What a simulator says about its own two apps. */
|
|
124
|
+
export interface SessionMode {
|
|
125
|
+
/** What is on screen. Absent on a simulator holding one unlabelled build. */
|
|
126
|
+
running?: SimMode;
|
|
127
|
+
/** What it can be switched to, right now. */
|
|
128
|
+
holds: SimMode[];
|
|
129
|
+
/** The bundle id on screen, so two launches of the same mode are tellable apart. */
|
|
130
|
+
bundle?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Whether what is on screen takes live screen selection. False in `app` mode, and that is not a
|
|
133
|
+
* fault: the person's own binary carries no listener of ours.
|
|
134
|
+
*/
|
|
135
|
+
live: boolean;
|
|
136
|
+
}
|
|
137
|
+
/** A rectangle in device points, in the window's own space. */
|
|
138
|
+
export interface Rect {
|
|
139
|
+
x: number;
|
|
140
|
+
y: number;
|
|
141
|
+
w: number;
|
|
142
|
+
h: number;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* One thing on the screen.
|
|
146
|
+
*
|
|
147
|
+
* ‼️ `witness` IS NOT DECORATION, IT IS HOW MUCH THE ROW KNOWS ABOUT ITSELF. `ax` and `view` rows
|
|
148
|
+
* came from something the app can name — they carry a label, a role, a value. A `layer` row is a
|
|
149
|
+
* rectangle the app draws and cannot name: on a SwiftUI screen that is most of the card backgrounds
|
|
150
|
+
* and images, and it is selectable precisely because it would otherwise be invisible to this. Code
|
|
151
|
+
* that treats the two alike will one day report a shadow as a button.
|
|
152
|
+
*/
|
|
153
|
+
export interface UiNode {
|
|
154
|
+
id: number;
|
|
155
|
+
class: string;
|
|
156
|
+
label?: string;
|
|
157
|
+
/**
|
|
158
|
+
* The `accessibilityIdentifier` the app's author wrote, when there is one.
|
|
159
|
+
*
|
|
160
|
+
* ‼️ PREFER THIS OVER `label` FOR ANYTHING THAT HAS TO BE RIGHT TWICE. A label is what the screen
|
|
161
|
+
* says today: it gets translated, reworded, or drawn as an icon, and two different controls happily
|
|
162
|
+
* share one — the testbed has two buttons both reading "Continue", on purpose. An identifier is
|
|
163
|
+
* written in code and stays. Absent when the app never set one; present on almost everything in an
|
|
164
|
+
* app PalCore generated, because its template requires one on every control.
|
|
165
|
+
*/
|
|
166
|
+
identifier?: string;
|
|
167
|
+
value?: string;
|
|
168
|
+
frame: Rect;
|
|
169
|
+
pressable?: boolean;
|
|
170
|
+
selected?: boolean;
|
|
171
|
+
enabled?: boolean;
|
|
172
|
+
/** `ax`, `view` or `layer`. */
|
|
173
|
+
w?: 'ax' | 'view' | 'layer';
|
|
174
|
+
}
|
|
175
|
+
export interface UiTree {
|
|
176
|
+
/** The device's own size in points — turn a frame into a position with frame/device. */
|
|
177
|
+
device: Rect;
|
|
178
|
+
nodes: UiNode[];
|
|
179
|
+
/**
|
|
180
|
+
* What this reading could not do, said out loud.
|
|
181
|
+
*
|
|
182
|
+
* ‼️ AN EMPTY LIST IS A CLAIM AND A FULL ONE IS AN EXPLANATION. A witness that did not run leaves
|
|
183
|
+
* a sentence here rather than a shorter list with no reason, so "clean screen" and "could not
|
|
184
|
+
* look" are never the same answer.
|
|
185
|
+
*/
|
|
186
|
+
notes?: string[];
|
|
187
|
+
}
|
|
188
|
+
/** Which of the app's own `#Preview`s was on screen when an element was marked. */
|
|
189
|
+
export interface ShownScreen {
|
|
190
|
+
previewId: string;
|
|
191
|
+
group: string;
|
|
192
|
+
state: string;
|
|
193
|
+
/** Swift's fileID — MODULE/FILE, not a path on disk. */
|
|
194
|
+
file: string;
|
|
195
|
+
line: number;
|
|
196
|
+
}
|
|
197
|
+
/** A marked element, with everything the service knows about it. */
|
|
198
|
+
export interface SelectedElement {
|
|
199
|
+
/** Minted by the viewer, so a chip in your UI and this record are the same thing. */
|
|
200
|
+
pickId: string;
|
|
201
|
+
node: number;
|
|
202
|
+
frame: Rect;
|
|
203
|
+
label?: string;
|
|
204
|
+
/** The id the app's author wrote on the element — see {@link UiNode.identifier}. */
|
|
205
|
+
identifier?: string;
|
|
206
|
+
screen?: ShownScreen;
|
|
207
|
+
source: SourceAnswer;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* One build in this project's registry.
|
|
211
|
+
*
|
|
212
|
+
* ‼️ THESE FIELD NAMES ARE THE COORDINATOR'S, AND THAT DIRECTION IS THE POINT. The one definition
|
|
213
|
+
* of the wire is `buildView`'s JSON tags in `coordinator/builds.go`; `cli/client.go` and the Swift
|
|
214
|
+
* SDK read from there too. This interface once spoke camelCase and agreed only with the Swift SDK
|
|
215
|
+
* — the two clients matched each other and neither matched the server. Measured 2026-08-27 against
|
|
216
|
+
* the coordinator's real answer: `page.builds[0].bundleId` came back `undefined`, silently, because
|
|
217
|
+
* the body is an `as` cast and TypeScript has nothing to check it against.
|
|
218
|
+
*
|
|
219
|
+
* There is no bucket, no key and no presigned URL here, and there will not be: an API answer is a
|
|
220
|
+
* place things get logged, and a presigned URL is a bearer token with a clock on it.
|
|
221
|
+
*/
|
|
222
|
+
export interface Build {
|
|
223
|
+
/** Opaque. The only identifier that appears in a link or on the wire. */
|
|
224
|
+
id: string;
|
|
225
|
+
/**
|
|
226
|
+
* `sha256:…` over the .app TREE, not over the archive it arrived in — a zip is not canonical and
|
|
227
|
+
* carries mtimes, so the same directory compressed twice is two byte strings and would be two
|
|
228
|
+
* builds.
|
|
229
|
+
*/
|
|
230
|
+
digest: string;
|
|
231
|
+
/** `uploading`, `published`, `rejected`. Always on the wire. */
|
|
232
|
+
status: string;
|
|
233
|
+
/**
|
|
234
|
+
* ‼️ ALL OPTIONAL, BECAUSE THE COORDINATOR WRITES THEM `omitempty`. A build has a record before
|
|
235
|
+
* it has been extracted, and at that point it has no Info.plist field at all.
|
|
236
|
+
*/
|
|
237
|
+
bundle_id?: string;
|
|
238
|
+
version_name?: string;
|
|
239
|
+
version_code?: string;
|
|
240
|
+
min_os?: string;
|
|
241
|
+
/** `UIDeviceFamily`: 1 iPhone, 2 iPad. */
|
|
242
|
+
device_family?: number[];
|
|
243
|
+
/** Whatever the uploader wrote about this build; the coordinator calls it `description`. */
|
|
244
|
+
description?: string;
|
|
245
|
+
bytes: number;
|
|
246
|
+
/** RFC 3339, UTC. */
|
|
247
|
+
created_at: string;
|
|
248
|
+
}
|
|
249
|
+
export interface BuildPage {
|
|
250
|
+
builds: Build[];
|
|
251
|
+
/**
|
|
252
|
+
* Pass back as `cursor` for the next page. Absent on the last one.
|
|
253
|
+
*
|
|
254
|
+
* ‼️ IT IS CALLED `next` BECAUSE THAT IS WHAT THE WIRE CALLS IT. There was never a `nextCursor`
|
|
255
|
+
* field in the coordinator's answer, so this read `undefined` on every page and pagination
|
|
256
|
+
* stopped silently after the first one — no error, just a short list.
|
|
257
|
+
*/
|
|
258
|
+
next?: string;
|
|
259
|
+
}
|
|
260
|
+
/** Which build to run: the record's own immutable id, or a channel that moves as uploads land. */
|
|
261
|
+
export type BuildRef = {
|
|
262
|
+
build: string;
|
|
263
|
+
} | {
|
|
264
|
+
channel: string;
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* A project the caller can reach: what a build is uploaded to and what a session is billed to.
|
|
268
|
+
*
|
|
269
|
+
* ‼️ THE FIELD NAMES ARE THE COORDINATOR'S, AND THIS DIRECTION IS DELIBERATE. The one definition
|
|
270
|
+
* of this wire shape is `projectView`'s JSON tags in `coordinator/store.go`; the Swift SDK reads
|
|
271
|
+
* the same words. `Build` above learnt this the expensive way — it once spoke camelCase, matched
|
|
272
|
+
* only itself, and every non-empty registry crashed the Swift client with
|
|
273
|
+
* `keyNotFound("bundleId")`. Renaming a field here is now visible work in three clients at once.
|
|
274
|
+
*/
|
|
275
|
+
export interface Project {
|
|
276
|
+
/** Opaque. This is what `--project` names and what a key is issued against. */
|
|
277
|
+
id: string;
|
|
278
|
+
/** Unique inside the organization, which is why it is usable as a human's choice. */
|
|
279
|
+
name: string;
|
|
280
|
+
/** How many sessions this project has run. A measurement, not an invoice. */
|
|
281
|
+
sessions?: number;
|
|
282
|
+
seconds?: number;
|
|
283
|
+
cost?: number;
|
|
284
|
+
}
|
|
285
|
+
export type SimStreamErrorCode = 'unauthorized' | 'quota' | 'capacity' | 'timeout' | 'gone'
|
|
286
|
+
/** The Mac holding this session runs an agent older than the method you called. */
|
|
287
|
+
| 'stale-fleet' | 'http';
|
|
97
288
|
export declare class SimStreamError extends Error {
|
|
98
289
|
readonly code: SimStreamErrorCode;
|
|
99
290
|
readonly status?: number | undefined;
|
|
@@ -128,10 +319,59 @@ export declare class SimStream {
|
|
|
128
319
|
pushBuild(app: string, opts?: {
|
|
129
320
|
version?: string;
|
|
130
321
|
launch?: boolean;
|
|
322
|
+
mode?: SimMode;
|
|
131
323
|
}): Promise<{
|
|
132
324
|
hosts: number;
|
|
133
325
|
version?: string;
|
|
134
326
|
}>;
|
|
327
|
+
/**
|
|
328
|
+
* What this project has uploaded, newest first.
|
|
329
|
+
*
|
|
330
|
+
* ‼️ THE LIST IS THE POINT. Without it a customer keeps its own catalogue of URLs and versions —
|
|
331
|
+
* which is to say the version list lives on their side and this service holds bytes it cannot
|
|
332
|
+
* name. Paged by cursor: send back the `next` you were given, and its absence is the last
|
|
333
|
+
* page. Only this project's records are ever returned.
|
|
334
|
+
*/
|
|
335
|
+
listBuilds(opts?: {
|
|
336
|
+
cursor?: string;
|
|
337
|
+
limit?: number;
|
|
338
|
+
}): Promise<BuildPage>;
|
|
339
|
+
/**
|
|
340
|
+
* Put a build in this project's registry: the .app zipped, as the CLI's `simstream upload` sends it.
|
|
341
|
+
*
|
|
342
|
+
* Three requests, the ones the coordinator reads (`coordinator/builds.go`): start an upload with the archive's size
|
|
343
|
+
* and a description, PUT the bytes to the presigned address that answers, and complete it — which is when the
|
|
344
|
+
* coordinator reads the archive back, refuses a device build or anything that is not a simulator .app, publishes it
|
|
345
|
+
* and moves the app's `latest` channel to it.
|
|
346
|
+
*
|
|
347
|
+
* ‼️ THE PUT CARRIES NO KEY. It goes to the object store, not the coordinator; the address is the capability, and the
|
|
348
|
+
* customer's key has no business at a storage host.
|
|
349
|
+
*
|
|
350
|
+
* ‼️ THE ARCHIVE IS SIZED. A presigned PUT needs its length up front and the coordinator checks the object against the
|
|
351
|
+
* size it was told, so the bytes are a Uint8Array or a Blob rather than a stream of unknown length.
|
|
352
|
+
*/
|
|
353
|
+
uploadBuild(archive: Uint8Array | Blob, opts?: {
|
|
354
|
+
description?: string;
|
|
355
|
+
signal?: AbortSignal;
|
|
356
|
+
}): Promise<Build>;
|
|
357
|
+
/**
|
|
358
|
+
* Point one of a build's app channels at it — `beta`, `demo`, anything of a-z, 0-9, - and _ up to 32 characters.
|
|
359
|
+
* Completing an upload already moves `latest`; this is for the channels a team names itself.
|
|
360
|
+
*/
|
|
361
|
+
setChannel(build: string, channel: string): Promise<{
|
|
362
|
+
channel: string;
|
|
363
|
+
app: string;
|
|
364
|
+
build: string;
|
|
365
|
+
}>;
|
|
366
|
+
/**
|
|
367
|
+
* Open a session with a registry build already on it.
|
|
368
|
+
*
|
|
369
|
+
* This is `startSession` with the bytes named by record rather than by address — same queueing,
|
|
370
|
+
* same errors, same session. Name a build by id to pin a version for good, or a channel to get
|
|
371
|
+
* whatever was uploaded last: `{ channel: 'com.example.app:latest' }` is the same target a share
|
|
372
|
+
* link follows, so a demo and a tester see the same build without anybody reissuing anything.
|
|
373
|
+
*/
|
|
374
|
+
runBuild(ref: BuildRef, opts?: StartOptions): Promise<Session>;
|
|
135
375
|
/**
|
|
136
376
|
* A fresh link to a session that is already running.
|
|
137
377
|
*
|
|
@@ -183,6 +423,27 @@ export declare class SimStream {
|
|
|
183
423
|
screen?: string;
|
|
184
424
|
variant?: string;
|
|
185
425
|
}): Promise<void>;
|
|
426
|
+
/**
|
|
427
|
+
* Which of the simulator's two apps is on screen, and what it can be switched to.
|
|
428
|
+
*
|
|
429
|
+
* ‼️ THIS IS A LAUNCH, AND showPreview IS A MESSAGE. Picking a screen lands inside the process
|
|
430
|
+
* that is already running and arrives in a frame. Switching mode is a different bundle with a
|
|
431
|
+
* different bundle id, so it costs a terminate and a start — a second or two, because both builds
|
|
432
|
+
* are already on the device, rather than the build it would otherwise mean.
|
|
433
|
+
*
|
|
434
|
+
* `setMode` is rejected when that simulator was never given the build being asked for, and the
|
|
435
|
+
* message says which of the two it IS holding — "no app build" and "no build at all" send you to
|
|
436
|
+
* different places. Push both, labelled, and the switch is always available.
|
|
437
|
+
*/
|
|
438
|
+
mode(session: string): Promise<SessionMode>;
|
|
439
|
+
/**
|
|
440
|
+
* Run the person's own app, or go back to the preview host.
|
|
441
|
+
*
|
|
442
|
+
* See `mode()` for reading where a session stands without moving it — which is what a panel
|
|
443
|
+
* drawing the toggle needs on load, because POSTing the mode it GUESSES is current is a relaunch
|
|
444
|
+
* every time the guess is wrong.
|
|
445
|
+
*/
|
|
446
|
+
setMode(session: string, mode: SimMode): Promise<SessionMode>;
|
|
186
447
|
/**
|
|
187
448
|
* Press one of the simulator's hardware buttons.
|
|
188
449
|
*
|
|
@@ -216,6 +477,42 @@ export declare class SimStream {
|
|
|
216
477
|
* of the switch — and on a fleet with one device free, refused the very thing they asked for.
|
|
217
478
|
*/
|
|
218
479
|
switchDevice(session: string, device: string, opts?: StartOptions): Promise<Session>;
|
|
480
|
+
/**
|
|
481
|
+
* Everything on the screen: what the app can name, and what it can only draw.
|
|
482
|
+
*
|
|
483
|
+
* ‼️ IT ANSWERS IN BOTH OF A DEVICE'S MODES. `setMode('app')` reports `live: false`, and that means
|
|
484
|
+
* one thing — the person's own binary carries no listener for a CHOICE OF SCREEN. Reading what is
|
|
485
|
+
* drawn is a different question, and it works while somebody is using their own app.
|
|
486
|
+
*/
|
|
487
|
+
inspect(session: string): Promise<UiTree>;
|
|
488
|
+
/**
|
|
489
|
+
* What is drawn at a point — for a caller with no person clicking.
|
|
490
|
+
*
|
|
491
|
+
* Send both numbers as ratios of the picture (0..1) or both as device points. One of each is
|
|
492
|
+
* refused rather than guessed: a click a hair outside the picture arrives as 1.02, and read as
|
|
493
|
+
* points that is the top-left corner and a completely different element.
|
|
494
|
+
*/
|
|
495
|
+
elementAt(session: string, at: {
|
|
496
|
+
x: number;
|
|
497
|
+
y: number;
|
|
498
|
+
}): Promise<{
|
|
499
|
+
node?: UiNode;
|
|
500
|
+
nothing?: boolean;
|
|
501
|
+
device: Rect;
|
|
502
|
+
}>;
|
|
503
|
+
/** One element's full properties, as the app reports them: colour, font, padding, ancestors. */
|
|
504
|
+
inspectNode(session: string, node: number): Promise<Record<string, unknown>>;
|
|
505
|
+
/**
|
|
506
|
+
* Everything a person has marked on this session's screen.
|
|
507
|
+
*
|
|
508
|
+
* The viewer posts only an identity to your page; this is where the record lives. That is the
|
|
509
|
+
* separation on purpose: the data path is this SDK, and the browser is where the gesture happens.
|
|
510
|
+
*/
|
|
511
|
+
picks(session: string): Promise<SelectedElement[]>;
|
|
512
|
+
/** One of them, by the id the viewer gave your page. */
|
|
513
|
+
pick(session: string, pickId: string): Promise<SelectedElement | undefined>;
|
|
514
|
+
/** Drop one mark, or every mark on this session, and the viewer's highlight goes with it. */
|
|
515
|
+
clearPicks(session: string, pickId?: string): Promise<void>;
|
|
219
516
|
/** What this customer has used, in total. A measurement, not an invoice. */
|
|
220
517
|
usage(): Promise<CustomerUsage[]>;
|
|
221
518
|
/**
|
|
@@ -224,10 +521,25 @@ export declare class SimStream {
|
|
|
224
521
|
* rewrite what somebody already used.
|
|
225
522
|
*/
|
|
226
523
|
sessions(): Promise<UsageRecord[]>;
|
|
524
|
+
/**
|
|
525
|
+
* The projects this credential can reach.
|
|
526
|
+
*
|
|
527
|
+
* Without it a caller holding one credential and several projects has to be told out of band
|
|
528
|
+
* which id to name — and an id somebody pasted from a chat is how a build lands in the wrong
|
|
529
|
+
* project.
|
|
530
|
+
*
|
|
531
|
+
* ‼️ A BARE ARRAY AND A `{"projects":…}` ENVELOPE READ THE SAME, ON PURPOSE. The coordinator
|
|
532
|
+
* writes both shapes today — `/api/usage` answers a bare array, `/api/me` puts projects under a
|
|
533
|
+
* key — and which one this route picks is not this SDK's decision to make. Tolerating both here
|
|
534
|
+
* costs one line; getting it wrong costs an empty list with nothing reporting an error.
|
|
535
|
+
*/
|
|
536
|
+
listProjects(): Promise<Project[]>;
|
|
227
537
|
private sessionUrl;
|
|
228
538
|
private collect;
|
|
229
539
|
private request;
|
|
230
540
|
private errorFor;
|
|
231
541
|
}
|
|
542
|
+
export { resolveSource, isCertain } from './source';
|
|
543
|
+
export type { SourceAnswer, SourceConfidence } from './source';
|
|
232
544
|
export { SimStreamAdmin } from './admin';
|
|
233
545
|
export type { AdminOptions, Customer } from './admin';
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* separation has stopped being real.
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.SimStreamAdmin = exports.SimStream = exports.SimStreamError = void 0;
|
|
15
|
+
exports.SimStreamAdmin = exports.isCertain = exports.resolveSource = exports.SimStream = exports.SimStreamError = void 0;
|
|
16
16
|
class SimStreamError extends Error {
|
|
17
17
|
code;
|
|
18
18
|
status;
|
|
@@ -55,7 +55,10 @@ class SimStream {
|
|
|
55
55
|
async startSession(opts = {}) {
|
|
56
56
|
const body = {
|
|
57
57
|
app: opts.app,
|
|
58
|
+
build: opts.build,
|
|
59
|
+
channel: opts.channel,
|
|
58
60
|
launch: opts.launch,
|
|
61
|
+
mode: opts.mode,
|
|
59
62
|
queue: opts.wait === true,
|
|
60
63
|
device: opts.device,
|
|
61
64
|
};
|
|
@@ -95,11 +98,101 @@ class SimStream {
|
|
|
95
98
|
app,
|
|
96
99
|
version: opts.version,
|
|
97
100
|
launch: opts.launch,
|
|
101
|
+
mode: opts.mode,
|
|
98
102
|
});
|
|
99
103
|
if (res.status !== 200)
|
|
100
104
|
throw await this.errorFor(res);
|
|
101
105
|
return (await res.json());
|
|
102
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* What this project has uploaded, newest first.
|
|
109
|
+
*
|
|
110
|
+
* ‼️ THE LIST IS THE POINT. Without it a customer keeps its own catalogue of URLs and versions —
|
|
111
|
+
* which is to say the version list lives on their side and this service holds bytes it cannot
|
|
112
|
+
* name. Paged by cursor: send back the `next` you were given, and its absence is the last
|
|
113
|
+
* page. Only this project's records are ever returned.
|
|
114
|
+
*/
|
|
115
|
+
async listBuilds(opts = {}) {
|
|
116
|
+
const q = new URLSearchParams();
|
|
117
|
+
if (opts.cursor)
|
|
118
|
+
q.set('cursor', opts.cursor);
|
|
119
|
+
if (opts.limit)
|
|
120
|
+
q.set('limit', String(opts.limit));
|
|
121
|
+
const query = q.toString();
|
|
122
|
+
const res = await this.request('GET', `/api/builds${query ? `?${query}` : ''}`);
|
|
123
|
+
if (res.status !== 200)
|
|
124
|
+
throw await this.errorFor(res);
|
|
125
|
+
// The cast says `builds?` although the wire type does not: TypeScript 5.6+ makes `x ?? y`
|
|
126
|
+
// an error when the left side can never be nullish, and an empty registry answers `{}`.
|
|
127
|
+
const page = (await res.json());
|
|
128
|
+
// ‼️ AN EMPTY STRING IS NOT A CURSOR. `next` has no `omitempty` on the coordinator's side, so
|
|
129
|
+
// the last page arrives as exactly `{"builds":[],"next":""}` — dumped from the handler, not
|
|
130
|
+
// guessed. A loop that treats "" as a cursor asks for `cursor=` forever.
|
|
131
|
+
return { builds: page.builds ?? [], next: page.next || undefined };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Put a build in this project's registry: the .app zipped, as the CLI's `simstream upload` sends it.
|
|
135
|
+
*
|
|
136
|
+
* Three requests, the ones the coordinator reads (`coordinator/builds.go`): start an upload with the archive's size
|
|
137
|
+
* and a description, PUT the bytes to the presigned address that answers, and complete it — which is when the
|
|
138
|
+
* coordinator reads the archive back, refuses a device build or anything that is not a simulator .app, publishes it
|
|
139
|
+
* and moves the app's `latest` channel to it.
|
|
140
|
+
*
|
|
141
|
+
* ‼️ THE PUT CARRIES NO KEY. It goes to the object store, not the coordinator; the address is the capability, and the
|
|
142
|
+
* customer's key has no business at a storage host.
|
|
143
|
+
*
|
|
144
|
+
* ‼️ THE ARCHIVE IS SIZED. A presigned PUT needs its length up front and the coordinator checks the object against the
|
|
145
|
+
* size it was told, so the bytes are a Uint8Array or a Blob rather than a stream of unknown length.
|
|
146
|
+
*/
|
|
147
|
+
async uploadBuild(archive, opts = {}) {
|
|
148
|
+
const bytes = archive instanceof Uint8Array ? archive.byteLength : archive.size;
|
|
149
|
+
const started = await this.request('POST', '/api/builds/upload', {
|
|
150
|
+
bytes,
|
|
151
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
152
|
+
});
|
|
153
|
+
if (started.status !== 200)
|
|
154
|
+
throw await this.errorFor(started);
|
|
155
|
+
const upload = (await started.json());
|
|
156
|
+
if (!upload.upload_id || !upload.url)
|
|
157
|
+
throw new SimStreamError('the coordinator started an upload with no destination', 'http');
|
|
158
|
+
// Checked here as well as on the server, so an archive past the ceiling fails before it is sent rather than after.
|
|
159
|
+
if (upload.max_bytes && bytes > upload.max_bytes) {
|
|
160
|
+
throw new SimStreamError(`the archive is ${bytes} bytes and this coordinator accepts ${upload.max_bytes} bytes`, 'http', 413);
|
|
161
|
+
}
|
|
162
|
+
const put = await this.fetchImpl(upload.url, {
|
|
163
|
+
method: 'PUT',
|
|
164
|
+
headers: { 'content-type': 'application/zip' },
|
|
165
|
+
body: archive,
|
|
166
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
167
|
+
});
|
|
168
|
+
if (!put.ok)
|
|
169
|
+
throw new SimStreamError(`the archive could not be stored (${put.status})`, 'http', put.status);
|
|
170
|
+
const completed = await this.request('POST', `/api/builds/upload/${encodeURIComponent(upload.upload_id)}/complete`);
|
|
171
|
+
if (completed.status !== 200)
|
|
172
|
+
throw await this.errorFor(completed);
|
|
173
|
+
return (await completed.json());
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Point one of a build's app channels at it — `beta`, `demo`, anything of a-z, 0-9, - and _ up to 32 characters.
|
|
177
|
+
* Completing an upload already moves `latest`; this is for the channels a team names itself.
|
|
178
|
+
*/
|
|
179
|
+
async setChannel(build, channel) {
|
|
180
|
+
const res = await this.request('POST', `/api/builds/${encodeURIComponent(build)}/channel`, { channel });
|
|
181
|
+
if (res.status !== 200)
|
|
182
|
+
throw await this.errorFor(res);
|
|
183
|
+
return (await res.json());
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Open a session with a registry build already on it.
|
|
187
|
+
*
|
|
188
|
+
* This is `startSession` with the bytes named by record rather than by address — same queueing,
|
|
189
|
+
* same errors, same session. Name a build by id to pin a version for good, or a channel to get
|
|
190
|
+
* whatever was uploaded last: `{ channel: 'com.example.app:latest' }` is the same target a share
|
|
191
|
+
* link follows, so a demo and a tester see the same build without anybody reissuing anything.
|
|
192
|
+
*/
|
|
193
|
+
async runBuild(ref, opts = {}) {
|
|
194
|
+
return this.startSession({ ...opts, ...ref });
|
|
195
|
+
}
|
|
103
196
|
/**
|
|
104
197
|
* A fresh link to a session that is already running.
|
|
105
198
|
*
|
|
@@ -163,6 +256,37 @@ class SimStream {
|
|
|
163
256
|
if (res.status !== 200)
|
|
164
257
|
throw await this.errorFor(res);
|
|
165
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Which of the simulator's two apps is on screen, and what it can be switched to.
|
|
261
|
+
*
|
|
262
|
+
* ‼️ THIS IS A LAUNCH, AND showPreview IS A MESSAGE. Picking a screen lands inside the process
|
|
263
|
+
* that is already running and arrives in a frame. Switching mode is a different bundle with a
|
|
264
|
+
* different bundle id, so it costs a terminate and a start — a second or two, because both builds
|
|
265
|
+
* are already on the device, rather than the build it would otherwise mean.
|
|
266
|
+
*
|
|
267
|
+
* `setMode` is rejected when that simulator was never given the build being asked for, and the
|
|
268
|
+
* message says which of the two it IS holding — "no app build" and "no build at all" send you to
|
|
269
|
+
* different places. Push both, labelled, and the switch is always available.
|
|
270
|
+
*/
|
|
271
|
+
async mode(session) {
|
|
272
|
+
const res = await this.request('GET', `/api/session/mode?session=${encodeURIComponent(session)}`);
|
|
273
|
+
if (res.status !== 200)
|
|
274
|
+
throw await this.errorFor(res);
|
|
275
|
+
return (await res.json());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Run the person's own app, or go back to the preview host.
|
|
279
|
+
*
|
|
280
|
+
* See `mode()` for reading where a session stands without moving it — which is what a panel
|
|
281
|
+
* drawing the toggle needs on load, because POSTing the mode it GUESSES is current is a relaunch
|
|
282
|
+
* every time the guess is wrong.
|
|
283
|
+
*/
|
|
284
|
+
async setMode(session, mode) {
|
|
285
|
+
const res = await this.request('POST', '/api/session/mode', { session, mode });
|
|
286
|
+
if (res.status !== 200)
|
|
287
|
+
throw await this.errorFor(res);
|
|
288
|
+
return (await res.json());
|
|
289
|
+
}
|
|
166
290
|
/**
|
|
167
291
|
* Press one of the simulator's hardware buttons.
|
|
168
292
|
*
|
|
@@ -206,6 +330,62 @@ class SimStream {
|
|
|
206
330
|
await this.endSession(session);
|
|
207
331
|
return this.startSession({ ...opts, device });
|
|
208
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Everything on the screen: what the app can name, and what it can only draw.
|
|
335
|
+
*
|
|
336
|
+
* ‼️ IT ANSWERS IN BOTH OF A DEVICE'S MODES. `setMode('app')` reports `live: false`, and that means
|
|
337
|
+
* one thing — the person's own binary carries no listener for a CHOICE OF SCREEN. Reading what is
|
|
338
|
+
* drawn is a different question, and it works while somebody is using their own app.
|
|
339
|
+
*/
|
|
340
|
+
async inspect(session) {
|
|
341
|
+
const res = await this.request('GET', `/api/session/inspect?session=${encodeURIComponent(session)}`);
|
|
342
|
+
if (res.status !== 200)
|
|
343
|
+
throw await this.errorFor(res);
|
|
344
|
+
return (await res.json());
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* What is drawn at a point — for a caller with no person clicking.
|
|
348
|
+
*
|
|
349
|
+
* Send both numbers as ratios of the picture (0..1) or both as device points. One of each is
|
|
350
|
+
* refused rather than guessed: a click a hair outside the picture arrives as 1.02, and read as
|
|
351
|
+
* points that is the top-left corner and a completely different element.
|
|
352
|
+
*/
|
|
353
|
+
async elementAt(session, at) {
|
|
354
|
+
const res = await this.request('POST', '/api/session/inspect/at', { session, ...at });
|
|
355
|
+
if (res.status !== 200)
|
|
356
|
+
throw await this.errorFor(res);
|
|
357
|
+
return (await res.json());
|
|
358
|
+
}
|
|
359
|
+
/** One element's full properties, as the app reports them: colour, font, padding, ancestors. */
|
|
360
|
+
async inspectNode(session, node) {
|
|
361
|
+
const res = await this.request('GET', `/api/session/inspect/node?session=${encodeURIComponent(session)}&n=${node}`);
|
|
362
|
+
if (res.status !== 200)
|
|
363
|
+
throw await this.errorFor(res);
|
|
364
|
+
return (await res.json());
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Everything a person has marked on this session's screen.
|
|
368
|
+
*
|
|
369
|
+
* The viewer posts only an identity to your page; this is where the record lives. That is the
|
|
370
|
+
* separation on purpose: the data path is this SDK, and the browser is where the gesture happens.
|
|
371
|
+
*/
|
|
372
|
+
async picks(session) {
|
|
373
|
+
const res = await this.request('GET', `/api/session/inspect/picks?session=${encodeURIComponent(session)}`);
|
|
374
|
+
if (res.status !== 200)
|
|
375
|
+
throw await this.errorFor(res);
|
|
376
|
+
return (await res.json()).picks ?? [];
|
|
377
|
+
}
|
|
378
|
+
/** One of them, by the id the viewer gave your page. */
|
|
379
|
+
async pick(session, pickId) {
|
|
380
|
+
return (await this.picks(session)).find((p) => p.pickId === pickId);
|
|
381
|
+
}
|
|
382
|
+
/** Drop one mark, or every mark on this session, and the viewer's highlight goes with it. */
|
|
383
|
+
async clearPicks(session, pickId) {
|
|
384
|
+
const q = pickId ? `&pick=${encodeURIComponent(pickId)}` : '';
|
|
385
|
+
const res = await this.request('DELETE', `/api/session/inspect/picks?session=${encodeURIComponent(session)}${q}`);
|
|
386
|
+
if (res.status !== 200)
|
|
387
|
+
throw await this.errorFor(res);
|
|
388
|
+
}
|
|
209
389
|
/** What this customer has used, in total. A measurement, not an invoice. */
|
|
210
390
|
async usage() {
|
|
211
391
|
const res = await this.request('GET', '/api/usage');
|
|
@@ -224,6 +404,27 @@ class SimStream {
|
|
|
224
404
|
throw await this.errorFor(res);
|
|
225
405
|
return (await res.json());
|
|
226
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* The projects this credential can reach.
|
|
409
|
+
*
|
|
410
|
+
* Without it a caller holding one credential and several projects has to be told out of band
|
|
411
|
+
* which id to name — and an id somebody pasted from a chat is how a build lands in the wrong
|
|
412
|
+
* project.
|
|
413
|
+
*
|
|
414
|
+
* ‼️ A BARE ARRAY AND A `{"projects":…}` ENVELOPE READ THE SAME, ON PURPOSE. The coordinator
|
|
415
|
+
* writes both shapes today — `/api/usage` answers a bare array, `/api/me` puts projects under a
|
|
416
|
+
* key — and which one this route picks is not this SDK's decision to make. Tolerating both here
|
|
417
|
+
* costs one line; getting it wrong costs an empty list with nothing reporting an error.
|
|
418
|
+
*/
|
|
419
|
+
async listProjects() {
|
|
420
|
+
const res = await this.request('GET', '/api/projects');
|
|
421
|
+
if (res.status !== 200)
|
|
422
|
+
throw await this.errorFor(res);
|
|
423
|
+
const body = (await res.json());
|
|
424
|
+
if (Array.isArray(body))
|
|
425
|
+
return body;
|
|
426
|
+
return body?.projects ?? [];
|
|
427
|
+
}
|
|
227
428
|
sessionUrl(ttl) {
|
|
228
429
|
return ttl ? `/api/session?ttl=${encodeURIComponent(ttl)}` : '/api/session';
|
|
229
430
|
}
|
|
@@ -265,10 +466,18 @@ class SimStream {
|
|
|
265
466
|
: res.status === 429 ? 'quota'
|
|
266
467
|
: res.status === 503 ? 'capacity'
|
|
267
468
|
: res.status === 410 ? 'gone'
|
|
268
|
-
|
|
469
|
+
// ‼️ 501 IS THE FLEET BEING BEHIND, WHICH IS NOT A FAULT OF THE CALL. The coordinator and the
|
|
470
|
+
// Macs ship separately, so a method added here can reach a Mac that has never heard of it. The
|
|
471
|
+
// message names the Mac and what to do; the code exists so a caller can retry after an update
|
|
472
|
+
// instead of treating it like a bad request.
|
|
473
|
+
: res.status === 501 ? 'stale-fleet'
|
|
474
|
+
: 'http';
|
|
269
475
|
return new SimStreamError(text || `HTTP ${res.status}`, code, res.status);
|
|
270
476
|
}
|
|
271
477
|
}
|
|
272
478
|
exports.SimStream = SimStream;
|
|
479
|
+
var source_1 = require("./source");
|
|
480
|
+
Object.defineProperty(exports, "resolveSource", { enumerable: true, get: function () { return source_1.resolveSource; } });
|
|
481
|
+
Object.defineProperty(exports, "isCertain", { enumerable: true, get: function () { return source_1.isCertain; } });
|
|
273
482
|
var admin_1 = require("./admin");
|
|
274
483
|
Object.defineProperty(exports, "SimStreamAdmin", { enumerable: true, get: function () { return admin_1.SimStreamAdmin; } });
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a selected element was written, and how sure the answer is.
|
|
3
|
+
*
|
|
4
|
+
* ‼️ THIS IS ITS OWN FILE BECAUSE THE ANSWER IS GOING TO GET BETTER AND THE SHAPE MUST NOT.
|
|
5
|
+
* Today one mechanism fills it: the build's dSYM, which knows the line a view type's `body` is
|
|
6
|
+
* written on. Two more are designed and not built — a compile-time map of every expression, and a
|
|
7
|
+
* search of the caller's own checkout — and both answer the same question. Callers that read
|
|
8
|
+
* `file`, `startLine` and `confidence` keep working when they land.
|
|
9
|
+
*/
|
|
10
|
+
/** How much to trust the location. */
|
|
11
|
+
export type SourceConfidence =
|
|
12
|
+
/** One view type on this element's chain was written in the project, and it resolved. */
|
|
13
|
+
'high'
|
|
14
|
+
/** Several fit, and none of them is the answer — read `candidates`. */
|
|
15
|
+
| 'low'
|
|
16
|
+
/** No location: `note` says why, and it is a reason rather than a failure. */
|
|
17
|
+
| 'none';
|
|
18
|
+
export interface SourceAnswer {
|
|
19
|
+
/** Relative to the source root, with the build machine's own path removed. */
|
|
20
|
+
file?: string;
|
|
21
|
+
/** The line the view's `body` is written on. */
|
|
22
|
+
startLine?: number;
|
|
23
|
+
/** The last line of the deepest thing written inside that body. */
|
|
24
|
+
endLine?: number;
|
|
25
|
+
/** Which mechanism answered: `dsym` today, `none` when nothing could. */
|
|
26
|
+
via: string;
|
|
27
|
+
confidence: SourceConfidence;
|
|
28
|
+
/**
|
|
29
|
+
* Every location that fits, when more than one does.
|
|
30
|
+
*
|
|
31
|
+
* ‼️ READ THIS BEFORE ACTING ON A `low` ANSWER. A wrong line is worse than no line: an agent
|
|
32
|
+
* believes it and edits there. The service refuses to pick for you on purpose — the last system
|
|
33
|
+
* that picked recoloured the wrong element in front of somebody.
|
|
34
|
+
*/
|
|
35
|
+
candidates?: SourceAnswer[];
|
|
36
|
+
/** Why the answer is the size it is. Present exactly when it needs to be. */
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The source location for a selected element.
|
|
41
|
+
*
|
|
42
|
+
* The record the SDK already handed you carries this; the function exists so that the day a
|
|
43
|
+
* caller's own checkout can narrow it further, the call site does not change — only what it can
|
|
44
|
+
* reach does.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveSource(record: {
|
|
47
|
+
source?: SourceAnswer;
|
|
48
|
+
}): SourceAnswer;
|
|
49
|
+
/** True when this answer names one place and means it. */
|
|
50
|
+
export declare function isCertain(answer: SourceAnswer): boolean;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where a selected element was written, and how sure the answer is.
|
|
4
|
+
*
|
|
5
|
+
* ‼️ THIS IS ITS OWN FILE BECAUSE THE ANSWER IS GOING TO GET BETTER AND THE SHAPE MUST NOT.
|
|
6
|
+
* Today one mechanism fills it: the build's dSYM, which knows the line a view type's `body` is
|
|
7
|
+
* written on. Two more are designed and not built — a compile-time map of every expression, and a
|
|
8
|
+
* search of the caller's own checkout — and both answer the same question. Callers that read
|
|
9
|
+
* `file`, `startLine` and `confidence` keep working when they land.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.resolveSource = resolveSource;
|
|
13
|
+
exports.isCertain = isCertain;
|
|
14
|
+
/**
|
|
15
|
+
* The source location for a selected element.
|
|
16
|
+
*
|
|
17
|
+
* The record the SDK already handed you carries this; the function exists so that the day a
|
|
18
|
+
* caller's own checkout can narrow it further, the call site does not change — only what it can
|
|
19
|
+
* reach does.
|
|
20
|
+
*/
|
|
21
|
+
function resolveSource(record) {
|
|
22
|
+
return (record.source ?? {
|
|
23
|
+
via: 'none',
|
|
24
|
+
confidence: 'none',
|
|
25
|
+
note: 'this record carries no source answer at all, which means it was made by a version of ' +
|
|
26
|
+
'the service that predates them',
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** True when this answer names one place and means it. */
|
|
30
|
+
function isCertain(answer) {
|
|
31
|
+
return answer.confidence === 'high' && !!answer.file && !!answer.startLine;
|
|
32
|
+
}
|