@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 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
- export type SimStreamErrorCode = 'unauthorized' | 'quota' | 'capacity' | 'timeout' | 'gone' | 'http';
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
- : 'http';
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; } });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palgroup/simstream",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Start and drive remote iOS simulator sessions.",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",