@limrun/ui 0.16.0 → 0.17.0-rc.2

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.
Files changed (73) hide show
  1. package/README.md +64 -0
  2. package/dist/GLTFLoader-Cami_vza.mjs +1679 -0
  3. package/dist/GLTFLoader-DeCLxW_q.js +1 -0
  4. package/dist/components/duo-button-hinge.d.ts +16 -0
  5. package/dist/components/duo-controls.d.ts +21 -0
  6. package/dist/components/duo-flat-frame.d.ts +7 -0
  7. package/dist/components/duo-flat-geometry.d.ts +52 -0
  8. package/dist/components/duo-fold-motion.d.ts +27 -0
  9. package/dist/components/duo-fold-renderer.d.ts +19 -0
  10. package/dist/components/duo-fold-timing.d.ts +1 -0
  11. package/dist/components/duo-frame.d.ts +16 -0
  12. package/dist/components/duo-hardware.d.ts +20 -0
  13. package/dist/components/duo-loading.d.ts +8 -0
  14. package/dist/components/duo-model.d.ts +19 -0
  15. package/dist/components/duo-screen-handoff.d.ts +4 -0
  16. package/dist/components/duo-view.d.ts +9 -0
  17. package/dist/components/remote-control.d.ts +5 -1
  18. package/dist/core/duo.d.ts +63 -0
  19. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  20. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  21. package/dist/duo-frame-Cmw4p2uN.mjs +26767 -0
  22. package/dist/duo-frame-iMtrvx_a.js +4059 -0
  23. package/dist/duo-view-BAh-w2Kz.js +1 -0
  24. package/dist/duo-view-D9Ae00rj.mjs +512 -0
  25. package/dist/index.cjs +1 -1
  26. package/dist/index.css +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/lite.cjs +1 -1
  29. package/dist/lite.js +1 -1
  30. package/dist/remote-control-B2jZpf3c.js +1 -0
  31. package/dist/remote-control-DiddxS8l.mjs +2883 -0
  32. package/package.json +8 -2
  33. package/dist/demo.d.ts +0 -1
  34. package/dist/remote-control-BRgbD7yo.mjs +0 -2516
  35. package/dist/remote-control-VISrP1zI.js +0 -1
  36. package/index.html +0 -199
  37. package/src/assets/Apple_logo_white.svg +0 -4
  38. package/src/assets/android_boot.gif +0 -0
  39. package/src/assets/android_boot.webp +0 -0
  40. package/src/assets/iphone16pro_black.webp +0 -0
  41. package/src/assets/iphone16pro_black_bg.webp +0 -0
  42. package/src/assets/iphone16pro_black_landscape.webp +0 -0
  43. package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
  44. package/src/assets/pixel9_black.webp +0 -0
  45. package/src/assets/pixel9_black_landscape.webp +0 -0
  46. package/src/assets/pixel_tablet_landscape.webp +0 -0
  47. package/src/assets/pixel_tablet_portrait.webp +0 -0
  48. package/src/components/device-install/index.ts +0 -1
  49. package/src/components/inspect-overlay.css +0 -223
  50. package/src/components/inspect-overlay.tsx +0 -437
  51. package/src/components/remote-control-framed.tsx +0 -14
  52. package/src/components/remote-control.css +0 -131
  53. package/src/components/remote-control.test.ts +0 -34
  54. package/src/components/remote-control.tsx +0 -3853
  55. package/src/core/ax-fetcher.test.ts +0 -418
  56. package/src/core/ax-fetcher.ts +0 -387
  57. package/src/core/ax-tree.test.ts +0 -491
  58. package/src/core/ax-tree.ts +0 -416
  59. package/src/core/camera.test.ts +0 -155
  60. package/src/core/camera.ts +0 -133
  61. package/src/core/constants.ts +0 -344
  62. package/src/core/webrtc-messages.ts +0 -148
  63. package/src/demo.tsx +0 -269
  64. package/src/frame-assets.ts +0 -22
  65. package/src/hooks/index.ts +0 -1
  66. package/src/index.ts +0 -29
  67. package/src/lite.test.ts +0 -30
  68. package/src/lite.ts +0 -30
  69. package/src/vite-env.d.ts +0 -1
  70. package/tsconfig.json +0 -26
  71. package/tsconfig.node.json +0 -26
  72. package/vite.config.ts +0 -35
  73. package/vitest.config.ts +0 -24
@@ -1,416 +0,0 @@
1
- // Accessibility tree types, normalizers, and helpers shared by the inspect
2
- // overlay and exported for customers building their own panels.
3
- //
4
- // We unify two server response shapes into a single AxSnapshot:
5
- //
6
- // iOS (limulator): {type:'elementTreeResult', id, json: '<nested-tree-json>'}
7
- // Android (scrcpy): {type:'getElementTreeResult', id, payload:{nodes:[flat]}}
8
- // (also emits legacy {type:'elementTreeResult', id, json:'<xml>'})
9
- //
10
- // Both are flattened into a single list of AxElement; positions are expressed
11
- // in a normalized screen coordinate space derived from the root rect so
12
- // rendering can use plain percentages.
13
-
14
- export interface AxRect {
15
- x: number;
16
- y: number;
17
- width: number;
18
- height: number;
19
- }
20
-
21
- export interface AxSelectors {
22
- AXUniqueId?: string;
23
- AXLabel?: string;
24
- resourceId?: string;
25
- contentDesc?: string;
26
- text?: string;
27
- className?: string;
28
- }
29
-
30
- export interface AxElement {
31
- // Stable identity for React keys / selection persistence across snapshots.
32
- // Prefers AXUniqueId / resourceId, falls back to a hierarchical path.
33
- id: string;
34
- // Hierarchical path within the source tree (e.g. "0.1.2"). Useful as a
35
- // fallback identity and for debugging.
36
- path: string;
37
- // Human label (AXLabel on iOS, content-desc/text on Android).
38
- label: string;
39
- // Value (AXValue on iOS, text on Android inputs).
40
- value: string;
41
- // Semantic role (role_description on iOS, className on Android).
42
- role: string;
43
- // Element type / class name.
44
- type: string;
45
- // Whether the element is interactive.
46
- enabled: boolean;
47
- // Whether the element currently has focus.
48
- focused: boolean;
49
- // Bounds in the screen coordinate space of AxSnapshot.screen.
50
- frame: AxRect;
51
- // Selectors that map back to SDK tapElement / tap calls.
52
- selectors: AxSelectors;
53
- // Raw platform-specific node (without children/parsedBounds extras).
54
- // Exposed so advanced customers can read fields we didn't surface.
55
- raw: Record<string, unknown>;
56
- }
57
-
58
- export type AxPlatform = 'ios' | 'android';
59
-
60
- export interface AxSnapshot {
61
- platform: AxPlatform;
62
- screen: { width: number; height: number };
63
- elements: AxElement[];
64
- // Unix epoch ms when the response was decoded on the client.
65
- capturedAt: number;
66
- errors?: string[];
67
- }
68
-
69
- export const AX_UNAVAILABLE_ERROR = 'Accessibility unavailable on this device.';
70
-
71
- // Hard cap to keep React render time bounded on enormous trees.
72
- const MAX_ELEMENTS = 500;
73
-
74
- const rectsApproxEqual = (a: AxRect, b: AxRect): boolean =>
75
- Math.abs(a.x - b.x) < 0.5 &&
76
- Math.abs(a.y - b.y) < 0.5 &&
77
- Math.abs(a.width - b.width) < 0.5 &&
78
- Math.abs(a.height - b.height) < 0.5;
79
-
80
- const rectArea = (r: AxRect): number => Math.max(0, r.width) * Math.max(0, r.height);
81
-
82
- // ────────────────────────────────────────────────────────────────────────────
83
- // iOS normalization
84
- // ────────────────────────────────────────────────────────────────────────────
85
-
86
- interface RawIosNode {
87
- AXLabel?: string | null;
88
- AXValue?: string | null;
89
- AXUniqueId?: string | null;
90
- // `frame` is the canonical bounds; the legacy `AXFrame` string field is
91
- // not consumed.
92
- frame?: { x: number; y: number; width: number; height: number };
93
- role?: string;
94
- role_description?: string;
95
- type?: string;
96
- subrole?: string | null;
97
- title?: string | null;
98
- enabled?: boolean;
99
- focused?: boolean;
100
- pid?: number;
101
- traits?: string[];
102
- children?: RawIosNode[];
103
- // Some serializations carry extras we'll preserve in `raw`.
104
- [key: string]: unknown;
105
- }
106
-
107
- // Picks the "screen rectangle" used as the denominator when expressing
108
- // element positions as percentages.
109
- //
110
- // Apple's `accessibilityElementForFrontmostApplication()` returns the
111
- // foreground Application element as the (only) root; its `frame` is the
112
- // device's logical screen — `{x: 0, y: 0, width, height}` in points. The
113
- // element frames inside the tree are in this same coordinate space, so a
114
- // child at `(16, 64)` is 16pt from the device's left edge and 64pt from
115
- // the top edge.
116
- //
117
- // We accept any root whose frame has positive dimensions to be robust
118
- // against the edge case where the foreground app reports a non-zero
119
- // origin (e.g. status bar excluded). In practice that never happens; if
120
- // it does, percentages will be slightly off but the overlay will still
121
- // roughly line up. The defensive console.warn helps us catch this in
122
- // production telemetry without breaking the feature.
123
- const iosScreenFrame = (roots: RawIosNode[]): AxRect => {
124
- const root = roots.find((n) => n.frame && n.frame.width > 0 && n.frame.height > 0);
125
- if (root?.frame) {
126
- if (Math.abs(root.frame.x) > 0.5 || Math.abs(root.frame.y) > 0.5) {
127
- console.warn(
128
- `[ax-tree] iOS root frame is not anchored at (0,0): ` +
129
- `(${root.frame.x},${root.frame.y}). Element positions may be ` +
130
- `slightly off from the rendered video.`,
131
- );
132
- }
133
- return { x: 0, y: 0, width: root.frame.width, height: root.frame.height };
134
- }
135
- return { x: 0, y: 0, width: 1, height: 1 };
136
- };
137
-
138
- const buildIosSelectors = (node: RawIosNode): AxSelectors => {
139
- const sel: AxSelectors = {};
140
- if (typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0) sel.AXUniqueId = node.AXUniqueId;
141
- if (typeof node.AXLabel === 'string' && node.AXLabel.length > 0) sel.AXLabel = node.AXLabel;
142
- if (typeof node.type === 'string' && node.type.length > 0) sel.className = node.type;
143
- return sel;
144
- };
145
-
146
- const stripIosChildren = (node: RawIosNode): Record<string, unknown> => {
147
- const out: Record<string, unknown> = {};
148
- for (const [k, v] of Object.entries(node)) {
149
- if (k === 'children') continue;
150
- out[k] = v;
151
- }
152
- return out;
153
- };
154
-
155
- export function normalizeIosTree(roots: RawIosNode[] | RawIosNode): AxSnapshot {
156
- const rootArray = Array.isArray(roots) ? roots : [roots];
157
- const screen = iosScreenFrame(rootArray);
158
- const elements: AxElement[] = [];
159
-
160
- const visit = (node: RawIosNode, path: string) => {
161
- if (elements.length >= MAX_ELEMENTS) return;
162
- const frame = node.frame;
163
- if (frame && frame.width > 0 && frame.height > 0 && !rectsApproxEqual(frame, screen)) {
164
- const role = (node.role_description as string | undefined) || (node.role as string | undefined) || '';
165
- const label =
166
- (typeof node.AXLabel === 'string' ? node.AXLabel : '') ||
167
- (typeof node.title === 'string' ? (node.title as string) : '') ||
168
- '';
169
- elements.push({
170
- id: typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0 ? node.AXUniqueId : path,
171
- path,
172
- label,
173
- value: typeof node.AXValue === 'string' ? node.AXValue : '',
174
- role,
175
- type: typeof node.type === 'string' ? node.type : '',
176
- enabled: node.enabled !== false,
177
- focused: node.focused === true,
178
- frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height },
179
- selectors: buildIosSelectors(node),
180
- raw: stripIosChildren(node),
181
- });
182
- }
183
- const children = Array.isArray(node.children) ? node.children : [];
184
- for (let i = 0; i < children.length && elements.length < MAX_ELEMENTS; i++) {
185
- visit(children[i]!, `${path}.${i}`);
186
- }
187
- };
188
-
189
- for (let i = 0; i < rootArray.length && elements.length < MAX_ELEMENTS; i++) {
190
- visit(rootArray[i]!, String(i));
191
- }
192
-
193
- return {
194
- platform: 'ios',
195
- screen: { width: screen.width, height: screen.height },
196
- elements,
197
- capturedAt: Date.now(),
198
- };
199
- }
200
-
201
- // ────────────────────────────────────────────────────────────────────────────
202
- // Android normalization
203
- // ────────────────────────────────────────────────────────────────────────────
204
-
205
- interface RawAndroidParsedBounds {
206
- left: number;
207
- top: number;
208
- right: number;
209
- bottom: number;
210
- centerX: number;
211
- centerY: number;
212
- }
213
-
214
- interface RawAndroidNode {
215
- index?: string;
216
- text?: string;
217
- resourceId?: string;
218
- className?: string;
219
- packageName?: string;
220
- contentDesc?: string;
221
- clickable?: boolean;
222
- enabled?: boolean;
223
- focusable?: boolean;
224
- focused?: boolean;
225
- scrollable?: boolean;
226
- selected?: boolean;
227
- bounds?: string;
228
- parsedBounds?: RawAndroidParsedBounds;
229
- [key: string]: unknown;
230
- }
231
-
232
- const androidScreenFrame = (nodes: RawAndroidNode[]): AxRect => {
233
- // Take the largest rect — uiautomator's first node is typically the
234
- // screen-spanning FrameLayout, but tolerate weirdness by scanning.
235
- let best: AxRect = { x: 0, y: 0, width: 1, height: 1 };
236
- let bestArea = 0;
237
- for (const n of nodes) {
238
- const pb = n.parsedBounds;
239
- if (!pb) continue;
240
- const w = pb.right - pb.left;
241
- const h = pb.bottom - pb.top;
242
- const area = Math.max(0, w) * Math.max(0, h);
243
- if (area > bestArea) {
244
- bestArea = area;
245
- best = { x: 0, y: 0, width: w, height: h };
246
- }
247
- }
248
- return best;
249
- };
250
-
251
- const buildAndroidSelectors = (node: RawAndroidNode): AxSelectors => {
252
- const sel: AxSelectors = {};
253
- if (typeof node.resourceId === 'string' && node.resourceId.length > 0) sel.resourceId = node.resourceId;
254
- if (typeof node.contentDesc === 'string' && node.contentDesc.length > 0) sel.contentDesc = node.contentDesc;
255
- if (typeof node.text === 'string' && node.text.length > 0) sel.text = node.text;
256
- if (typeof node.className === 'string' && node.className.length > 0) sel.className = node.className;
257
- return sel;
258
- };
259
-
260
- export function normalizeAndroidTree(nodes: RawAndroidNode[]): AxSnapshot {
261
- const screen = androidScreenFrame(nodes);
262
- const elements: AxElement[] = [];
263
-
264
- for (let i = 0; i < nodes.length && elements.length < MAX_ELEMENTS; i++) {
265
- const node = nodes[i]!;
266
- const pb = node.parsedBounds;
267
- if (!pb) continue;
268
- const width = Math.max(0, pb.right - pb.left);
269
- const height = Math.max(0, pb.bottom - pb.top);
270
- if (width <= 0 || height <= 0) continue;
271
- const frame: AxRect = { x: pb.left, y: pb.top, width, height };
272
- if (rectsApproxEqual(frame, { x: 0, y: 0, width: screen.width, height: screen.height })) continue;
273
-
274
- const label = node.contentDesc || node.text || '';
275
- const role = node.className || '';
276
- elements.push({
277
- id:
278
- node.resourceId && node.resourceId.length > 0 ? node.resourceId
279
- : node.contentDesc && node.contentDesc.length > 0 ? `cd:${node.contentDesc}`
280
- : String(i),
281
- path: String(i),
282
- label,
283
- value: typeof node.text === 'string' ? node.text : '',
284
- role,
285
- type: role,
286
- enabled: node.enabled !== false,
287
- focused: node.focused === true,
288
- frame,
289
- selectors: buildAndroidSelectors(node),
290
- raw: { ...node },
291
- });
292
- }
293
-
294
- return {
295
- platform: 'android',
296
- screen: { width: screen.width, height: screen.height },
297
- elements,
298
- capturedAt: Date.now(),
299
- };
300
- }
301
-
302
- // ────────────────────────────────────────────────────────────────────────────
303
- // Generic helpers (exported for customers building their own panels)
304
- // ────────────────────────────────────────────────────────────────────────────
305
-
306
- export function clampAxFrameForScreen(
307
- frame: AxRect,
308
- screen: { width: number; height: number },
309
- ): AxRect | null {
310
- const x = Math.max(0, frame.x);
311
- const y = Math.max(0, frame.y);
312
- const right = Math.min(screen.width, frame.x + frame.width);
313
- const bottom = Math.min(screen.height, frame.y + frame.height);
314
- const width = Math.max(0, right - x);
315
- const height = Math.max(0, bottom - y);
316
- return width > 0 && height > 0 ? { x, y, width, height } : null;
317
- }
318
-
319
- export function axElementsEqual(a: AxElement, b: AxElement): boolean {
320
- if (a === b) return true;
321
- if (a.id !== b.id || a.path !== b.path) return false;
322
- if (a.label !== b.label || a.value !== b.value) return false;
323
- if (a.role !== b.role || a.type !== b.type) return false;
324
- if (a.enabled !== b.enabled || a.focused !== b.focused) return false;
325
- const fa = a.frame;
326
- const fb = b.frame;
327
- return fa.x === fb.x && fa.y === fb.y && fa.width === fb.width && fa.height === fb.height;
328
- }
329
-
330
- export function axSnapshotsEqual(a: AxSnapshot | null, b: AxSnapshot | null): boolean {
331
- if (a === b) return true;
332
- if (!a || !b) return false;
333
- if (a.platform !== b.platform) return false;
334
- if (a.screen.width !== b.screen.width || a.screen.height !== b.screen.height) return false;
335
- if (a.elements.length !== b.elements.length) return false;
336
- for (let i = 0; i < a.elements.length; i++) {
337
- if (!axElementsEqual(a.elements[i]!, b.elements[i]!)) return false;
338
- }
339
- return true;
340
- }
341
-
342
- // Returns the smallest matching element under the given point (in screen
343
- // coordinate space). Used by the overlay for hit-testing when boxes overlap.
344
- export function axElementAtPoint(snapshot: AxSnapshot, x: number, y: number): AxElement | null {
345
- let best: AxElement | null = null;
346
- let bestArea = Infinity;
347
- for (const el of snapshot.elements) {
348
- const f = el.frame;
349
- if (x < f.x || y < f.y || x > f.x + f.width || y > f.y + f.height) continue;
350
- const area = rectArea(f);
351
- if (area < bestArea) {
352
- bestArea = area;
353
- best = el;
354
- }
355
- }
356
- return best;
357
- }
358
-
359
- // Produces a one-line SDK selector expression that customers can paste into
360
- // code. Returns null when no usable selector exists.
361
- export function axElementSelectorExpression(el: AxElement, platform: AxPlatform): string | null {
362
- if (platform === 'ios') {
363
- if (el.selectors.AXUniqueId) {
364
- return `client.tapElement({ AXUniqueId: ${JSON.stringify(el.selectors.AXUniqueId)} })`;
365
- }
366
- if (el.selectors.AXLabel) {
367
- const typeHint = el.selectors.className ? `, type: ${JSON.stringify(el.selectors.className)}` : '';
368
- return `client.tapElement({ AXLabel: ${JSON.stringify(el.selectors.AXLabel)}${typeHint} })`;
369
- }
370
- return null;
371
- }
372
- // android
373
- if (el.selectors.resourceId) {
374
- return `client.tap({ selector: { resourceId: ${JSON.stringify(el.selectors.resourceId)} } })`;
375
- }
376
- if (el.selectors.contentDesc) {
377
- return `client.tap({ selector: { contentDesc: ${JSON.stringify(el.selectors.contentDesc)} } })`;
378
- }
379
- if (el.selectors.text) {
380
- return `client.tap({ selector: { text: ${JSON.stringify(el.selectors.text)} } })`;
381
- }
382
- return null;
383
- }
384
-
385
- // Cleans up internal-looking role tokens. iOS's `role_description` can be
386
- // raw strings like "AXGenericElement" or empty when AppKit doesn't have a
387
- // description for the role. Android sets role to the className which is
388
- // often fully-qualified (`android.widget.TextView`); strip the package.
389
- export function axElementRoleLabel(el: AxElement): string {
390
- const raw = el.role || el.type || '';
391
- if (!raw) return 'element';
392
- // Drop "AX" prefix and split CamelCase into spaced words ("AXTextField" → "Text Field").
393
- let cleaned = raw.replace(/^AX/, '');
394
- // For Android fully-qualified names, keep just the last segment.
395
- if (cleaned.includes('.')) {
396
- cleaned = cleaned.split('.').pop()!;
397
- }
398
- // Reject the generic catch-all bucket; callers can decide to hide it.
399
- if (cleaned === 'GenericElement') return 'Element';
400
- // Lightly humanize CamelCase.
401
- cleaned = cleaned.replace(/([a-z])([A-Z])/g, '$1 $2');
402
- return cleaned;
403
- }
404
-
405
- // A short human-readable summary used in tooltips and the info card title.
406
- // Truncated to avoid blowing up tooltips on elements with paragraph-length
407
- // AXLabels.
408
- const SUMMARY_MAX_LABEL_LEN = 80;
409
- export function axElementSummary(el: AxElement): string {
410
- const role = axElementRoleLabel(el);
411
- const text = el.label || el.value || '';
412
- if (!text) return role;
413
- const trimmed =
414
- text.length > SUMMARY_MAX_LABEL_LEN ? text.slice(0, SUMMARY_MAX_LABEL_LEN).trimEnd() + '…' : text;
415
- return `${role} · ${trimmed}`;
416
- }
@@ -1,155 +0,0 @@
1
- import { describe, expect, test, vi } from 'vitest';
2
- import {
3
- CameraOperationCoordinator,
4
- cameraCaptureConstraints,
5
- createGrantedCameraResult,
6
- parseCameraRequest,
7
- shouldReacquireCamera,
8
- } from './camera';
9
-
10
- describe('parseCameraRequest', () => {
11
- test('parses active camera facing', () => {
12
- expect(
13
- parseCameraRequest({
14
- type: 'cameraRequest',
15
- active: true,
16
- facingMode: 'environment',
17
- }),
18
- ).toEqual({
19
- active: true,
20
- facingMode: 'environment',
21
- });
22
- });
23
-
24
- test('rejects messages without a boolean active field', () => {
25
- expect(parseCameraRequest({ type: 'cameraRequest', active: 'true' })).toBeNull();
26
- expect(parseCameraRequest({ type: 'other', active: true })).toBeNull();
27
- });
28
- });
29
-
30
- describe('cameraCaptureConstraints', () => {
31
- test('uses ideal facing and frame-rate preferences without geometry', () => {
32
- const constraints = cameraCaptureConstraints({
33
- active: true,
34
- facingMode: 'environment',
35
- });
36
-
37
- expect(constraints).toEqual({
38
- frameRate: { ideal: 30, max: 30 },
39
- facingMode: { ideal: 'environment' },
40
- });
41
- });
42
- });
43
-
44
- describe('shouldReacquireCamera', () => {
45
- test('reuses an existing track only for the same facing', () => {
46
- expect(shouldReacquireCamera({ facingMode: 'user' }, { facingMode: 'user' })).toBe(false);
47
- expect(shouldReacquireCamera({}, {})).toBe(false);
48
- });
49
-
50
- test('reacquires when facing changes', () => {
51
- expect(shouldReacquireCamera({ facingMode: 'user' }, { facingMode: 'environment' })).toBe(true);
52
- expect(shouldReacquireCamera(undefined, { facingMode: 'user' })).toBe(true);
53
- });
54
- });
55
-
56
- describe('CameraOperationCoordinator', () => {
57
- test('invalidates suspended work before serially starting the next operation', async () => {
58
- const coordinator = new CameraOperationCoordinator();
59
- const events: string[] = [];
60
- let releaseFirst!: () => void;
61
- let markFirstStarted!: () => void;
62
- const firstStarted = new Promise<void>((resolve) => {
63
- markFirstStarted = resolve;
64
- });
65
- const firstDeferred = new Promise<void>((resolve) => {
66
- releaseFirst = resolve;
67
- });
68
-
69
- const first = coordinator.enqueue(async ({ isCurrent }) => {
70
- events.push('first:start');
71
- markFirstStarted();
72
- await firstDeferred;
73
- events.push(isCurrent() ? 'first:commit' : 'first:stale');
74
- });
75
- await firstStarted;
76
-
77
- const second = coordinator.enqueue(async ({ isCurrent }) => {
78
- events.push('second:start');
79
- events.push(isCurrent() ? 'second:commit' : 'second:stale');
80
- });
81
- const endedCleanup = coordinator.enqueueCurrent(async ({ isCurrent }) => {
82
- events.push('ended:start');
83
- events.push(isCurrent() ? 'ended:current' : 'ended:stale');
84
- });
85
-
86
- expect(events).toEqual(['first:start']);
87
- expect(coordinator.currentGeneration).toBe(2);
88
- releaseFirst();
89
- await Promise.all([first, second, endedCleanup]);
90
- expect(events).toEqual([
91
- 'first:start',
92
- 'first:stale',
93
- 'second:start',
94
- 'second:commit',
95
- 'ended:start',
96
- 'ended:current',
97
- ]);
98
- });
99
-
100
- test('continues serial processing after an operation rejects', async () => {
101
- const coordinator = new CameraOperationCoordinator();
102
- await expect(
103
- coordinator.enqueue(async () => {
104
- throw new Error('capture failed');
105
- }),
106
- ).rejects.toThrow('capture failed');
107
-
108
- const operation = vi.fn();
109
- await coordinator.enqueue(async () => operation());
110
- expect(operation).toHaveBeenCalledOnce();
111
- });
112
-
113
- test('marks non-invalidating work stale when a later request arrives', async () => {
114
- const coordinator = new CameraOperationCoordinator();
115
- let release!: () => void;
116
- const deferred = new Promise<void>((resolve) => {
117
- release = resolve;
118
- });
119
- const first = coordinator.enqueue(async () => deferred);
120
- const cleanup = coordinator.enqueueCurrent(async ({ isCurrent }) => {
121
- expect(isCurrent()).toBe(false);
122
- });
123
- const newerRequest = coordinator.enqueue(async ({ isCurrent }) => {
124
- expect(isCurrent()).toBe(true);
125
- });
126
-
127
- release();
128
- await Promise.all([first, cleanup, newerRequest]);
129
- });
130
- });
131
-
132
- describe('camera result metadata', () => {
133
- test('reads settings afresh for every acknowledgement', () => {
134
- const getSettings = vi
135
- .fn()
136
- .mockReturnValueOnce({ width: 1280, height: 720, deviceId: 'front' })
137
- .mockReturnValueOnce({ width: 720, height: 1280, deviceId: 'front' });
138
- const track = {
139
- getSettings,
140
- label: 'Built-in Camera',
141
- } as unknown as MediaStreamTrack;
142
-
143
- expect(createGrantedCameraResult(track).camera).toMatchObject({
144
- width: 1280,
145
- height: 720,
146
- label: 'Built-in Camera',
147
- });
148
- expect(createGrantedCameraResult(track).camera).toMatchObject({
149
- width: 720,
150
- height: 1280,
151
- label: 'Built-in Camera',
152
- });
153
- expect(getSettings).toHaveBeenCalledTimes(2);
154
- });
155
- });
@@ -1,133 +0,0 @@
1
- export type CameraFacingMode = 'user' | 'environment';
2
-
3
- /** Camera selection requested by the instance host. */
4
- export interface CameraRequest {
5
- active: boolean;
6
- facingMode?: CameraFacingMode;
7
- }
8
-
9
- /** Browser capture metadata returned to the instance host. */
10
- export interface CameraCaptureInfo {
11
- width?: number;
12
- height?: number;
13
- frameRate?: number;
14
- deviceId?: string;
15
- label?: string;
16
- facingMode?: string;
17
- }
18
-
19
- /** Acknowledgement sent after camera capture changes or is reused. */
20
- export interface CameraResult {
21
- type: 'cameraResult';
22
- granted: boolean;
23
- camera?: CameraCaptureInfo;
24
- }
25
-
26
- export interface CameraOperationContext {
27
- generation: number;
28
- isCurrent: () => boolean;
29
- }
30
-
31
- /**
32
- * Serializes camera mutations while invalidating queued or suspended work.
33
- *
34
- * `enqueue` increments the generation before returning, so an operation
35
- * waiting on browser permission becomes stale as soon as a newer request
36
- * arrives, even though the newer operation has not started yet.
37
- */
38
- export class CameraOperationCoordinator {
39
- private generation = 0;
40
- private tail: Promise<void> = Promise.resolve();
41
-
42
- get currentGeneration(): number {
43
- return this.generation;
44
- }
45
-
46
- enqueue(operation: (context: CameraOperationContext) => Promise<void>): Promise<void> {
47
- const generation = ++this.generation;
48
- return this.append(generation, operation);
49
- }
50
-
51
- /**
52
- * Queues related work without superseding an already queued request.
53
- *
54
- * The operation retains the generation current at enqueue time and becomes
55
- * stale if a later camera request increments it before this work starts.
56
- */
57
- enqueueCurrent(operation: (context: CameraOperationContext) => Promise<void>): Promise<void> {
58
- return this.append(this.generation, operation);
59
- }
60
-
61
- private append(
62
- generation: number,
63
- operation: (context: CameraOperationContext) => Promise<void>,
64
- ): Promise<void> {
65
- const context: CameraOperationContext = {
66
- generation,
67
- isCurrent: () => generation === this.generation,
68
- };
69
- const next = this.tail.then(() => operation(context));
70
- this.tail = next.catch(() => undefined);
71
- return next;
72
- }
73
-
74
- invalidate(): void {
75
- ++this.generation;
76
- }
77
- }
78
-
79
- export function parseCameraRequest(message: unknown): CameraRequest | null {
80
- if (!message || typeof message !== 'object') return null;
81
- const value = message as Record<string, unknown>;
82
- if (value.type !== 'cameraRequest' || typeof value.active !== 'boolean') return null;
83
-
84
- const request: CameraRequest = { active: value.active };
85
- if (value.facingMode === 'user' || value.facingMode === 'environment') {
86
- request.facingMode = value.facingMode;
87
- }
88
- return request;
89
- }
90
-
91
- /**
92
- * Builds best-effort capture constraints.
93
- *
94
- * Facing is a preference so browsers can fall back when a phone-facing label
95
- * or exact mode is unavailable. The browser chooses capture dimensions.
96
- */
97
- export function cameraCaptureConstraints(request: CameraRequest): MediaTrackConstraints {
98
- const constraints: MediaTrackConstraints = {
99
- frameRate: { ideal: 30, max: 30 },
100
- };
101
-
102
- if (request?.facingMode) {
103
- constraints.facingMode = { ideal: request.facingMode };
104
- }
105
- return constraints;
106
- }
107
-
108
- export function shouldReacquireCamera(
109
- current: Pick<CameraRequest, 'facingMode'> | undefined,
110
- requested: Pick<CameraRequest, 'facingMode'>,
111
- ): boolean {
112
- return !current || current.facingMode !== requested.facingMode;
113
- }
114
-
115
- export function cameraCaptureInfoFromTrack(track: MediaStreamTrack): CameraCaptureInfo {
116
- const settings = track.getSettings();
117
- return {
118
- width: settings.width,
119
- height: settings.height,
120
- frameRate: settings.frameRate,
121
- deviceId: settings.deviceId,
122
- label: track.label || undefined,
123
- facingMode: settings.facingMode,
124
- };
125
- }
126
-
127
- export function createGrantedCameraResult(track: MediaStreamTrack): CameraResult {
128
- return {
129
- type: 'cameraResult',
130
- granted: true,
131
- camera: cameraCaptureInfoFromTrack(track),
132
- };
133
- }