@alivelabs/expo-orchestrator-react-client 0.1.1 → 0.2.1

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
@@ -28,39 +28,48 @@ function App() {
28
28
 
29
29
  ## Hook usage
30
30
 
31
+ The live screen is an **H.264 stream decoded with WebCodecs** onto a `<canvas>`.
32
+ The hook owns the decoder — pass `attachCanvas` to a `<canvas ref>`:
33
+
31
34
  ```tsx
32
35
  import { useExpoCiSession } from "@alivelabs/expo-orchestrator-react-client";
33
36
 
34
37
  function BuildPage() {
35
- const { status, logs, frame, connected, error, sendInput, reconnect } = useExpoCiSession({
36
- sessionId: "abc-123",
37
- apiToken: "your-api-token",
38
- baseUrl: "https://your-orchestrator.example.com",
39
- autoConnect: true, // default
40
- maxLogs: 2000, // default
41
- });
38
+ const { status, logs, attachCanvas, videoSupported, connected, sendInput, reconnect } =
39
+ useExpoCiSession({
40
+ sessionId: "abc-123",
41
+ apiToken: "your-api-token",
42
+ baseUrl: "https://your-orchestrator.example.com",
43
+ autoConnect: true, // default
44
+ maxLogs: 2000, // default
45
+ });
42
46
 
43
47
  return (
44
48
  <div>
45
49
  <p>Status: {status} — {connected ? "live" : "disconnected"}</p>
46
- {frame && <img src={frame} alt="simulator" />}
47
- <button onClick={() => sendInput({ type: "keypress", key: "home" })}>Home</button>
50
+ {videoSupported ? <canvas ref={attachCanvas} /> : <p>Video not supported in this browser.</p>}
51
+ <button onClick={() => sendInput({ type: "button", button: "home" })}>Home</button>
48
52
  {!connected && <button onClick={reconnect}>Reconnect</button>}
49
53
  </div>
50
54
  );
51
55
  }
52
56
  ```
53
57
 
58
+ WebCodecs needs a modern browser (Chrome/Edge, Safari 16.4+, recent Firefox) and
59
+ a secure context (HTTPS or localhost); `videoSupported` is `false` otherwise. For
60
+ full control you can import `SimulatorDecoder` and drive your own canvas.
61
+
54
62
  ## Exports reference
55
63
 
56
64
  | Export | Kind | Description |
57
65
  |---|---|---|
58
66
  | `SessionViewer` | Component | All-in-one: status badge, simulator screen, log console, interaction controls |
59
- | `SimulatorScreen` | Component | Renders the current frame; click = tap, click-drag = swipe |
67
+ | `SimulatorScreen` | Component | Renders the live H.264 stream to a canvas; click = tap, click-drag = swipe |
60
68
  | `LogConsole` | Component | Scrollable, color-coded, auto-scrolling log viewer |
61
69
  | `StatusBadge` | Component | Colored pill showing session status |
62
- | `useExpoCiSession` | Hook | React hook wrapping `ExpoCiClient` with state management |
70
+ | `useExpoCiSession` | Hook | React hook wrapping `ExpoCiClient` + the WebCodecs decoder |
63
71
  | `ExpoCiClient` | Class | Framework-agnostic client: REST + WebSocket with auto-reconnect |
72
+ | `SimulatorDecoder` | Class | WebCodecs H.264 decoder painting to a `<canvas>` (advanced) |
64
73
 
65
74
  ### `<SessionViewer>` props
66
75
 
@@ -75,9 +84,13 @@ function BuildPage() {
75
84
 
76
85
  | Prop | Type | Required | Description |
77
86
  |---|---|---|---|
78
- | `frame` | `string \| null` | yes | Current frame as a `data:image/jpeg;base64,...` string |
87
+ | `canvasRef` | `(canvas: HTMLCanvasElement \| null) => void` | yes | Ref callback wiring the canvas to the decoder (pass `attachCanvas`) |
88
+ | `videoSupported` | `boolean` | yes | Whether WebCodecs is available; shows a notice when `false` |
89
+ | `hasFrame` | `boolean` | yes | Whether a frame has painted yet (drives the placeholder) |
79
90
  | `onTap` | `(input: { type: "tap"; x: number; y: number }) => void` | no | Called on click |
80
- | `onSwipe` | `(input: { type: "swipe"; fromX: number; fromY: number; toX: number; toY: number }) => void` | no | Called on drag |
91
+ | `onSwipe` | `(input: { type: "swipe"; startX; startY; endX; endY }) => void` | no | Called on drag |
92
+ | `onKeyInput` | `(input: { type: "type" \| "key"; … }) => void` | no | Called for keystrokes while focused |
93
+ | `maxHeight` | `number \| string` | no | Cap on rendered height (default `80vh`) |
81
94
 
82
95
  ### `<LogConsole>` props
83
96
 
@@ -115,9 +128,10 @@ client.getScreenshotObjectUrl() // GET /api/sessions/:id/screenshot → object
115
128
  client.connect() // Open WebSocket (auto-reconnect with exponential backoff)
116
129
  client.disconnect() // Close WebSocket and remove all listeners
117
130
 
118
- // Typed event emitter
119
- const off = client.on("video-frame", (msg) => console.log(msg.data));
131
+ // Typed event emitter. Video arrives as raw encoded chunks (ArrayBuffer) —
132
+ // feed them to a SimulatorDecoder, or let the hook do it for you.
133
+ const off = client.on("video-chunk", (chunk) => decoder.push(chunk));
120
134
  off(); // unsubscribe
121
135
  ```
122
136
 
123
- Events: `open`, `close`, `connected`, `log`, `video-frame`, `status`, `error`.
137
+ Events: `open`, `close`, `connected`, `log`, `video-chunk`, `status`, `error`.
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,sBAAsB,EAEvB,MAAM,YAAY,CAAC;AAIpB,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,SAAS,GAAG,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;AAGxE,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAEpC,cAAM,iBAAiB,CAAC,MAAM,SAAS,QAAQ;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwD;IAElF,EAAE,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IASxF,GAAG,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAInF,IAAI,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAClC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,SAAS,SAAS,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GACtD,IAAI;IAQP,kBAAkB,IAAI,IAAI;CAG3B;AAUD,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAa,SAAQ,iBAAiB,CAAC,cAAc,CAAC;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,SAAS,CAAS;gBAEd,EAAE,OAAiC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,mBAAmB;YAU7E,SAAS;IAgBvB,UAAU,IAAI,OAAO,CAAC,aAAa,CAAC;IAIpC,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC;IAI/B,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAU3D,sBAAsB,IAAI,OAAO,CAAC,MAAM,CAAC;IAc/C,OAAO,KAAK,KAAK,GAKhB;IAED,OAAO,IAAI,IAAI;IAMf,OAAO,CAAC,UAAU;IAyDlB,OAAO,CAAC,iBAAiB;IASzB,UAAU,IAAI,IAAI;CAYnB"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,WAAW,EACX,cAAc,EACd,sBAAsB,EAEvB,MAAM,YAAY,CAAC;AAIpB,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,SAAS,GAAG,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;AAGxE,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAEpC,cAAM,iBAAiB,CAAC,MAAM,SAAS,QAAQ;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwD;IAElF,EAAE,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IASxF,GAAG,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAInF,IAAI,CAAC,CAAC,SAAS,MAAM,MAAM,GAAG,MAAM,EAClC,KAAK,EAAE,CAAC,EACR,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,SAAS,SAAS,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GACtD,IAAI;IAQP,kBAAkB,IAAI,IAAI;CAG3B;AAkBD,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAa,SAAQ,iBAAiB,CAAC,cAAc,CAAC;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,SAAS,CAAS;gBAEd,EAAE,OAAiC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,mBAAmB;YAU7E,SAAS;IAgBvB,UAAU,IAAI,OAAO,CAAC,aAAa,CAAC;IAIpC,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC;IAI/B,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAU3D,sBAAsB,IAAI,OAAO,CAAC,MAAM,CAAC;IAc/C,OAAO,KAAK,KAAK,GAKhB;IAED,OAAO,IAAI,IAAI;IAMf,OAAO,CAAC,UAAU;IAwElB,OAAO,CAAC,iBAAiB;IASzB,UAAU,IAAI,IAAI;CAYnB"}
package/dist/client.js CHANGED
@@ -29,6 +29,13 @@ class TypedEventEmitter {
29
29
  const INITIAL_DELAY_MS = 500;
30
30
  const MAX_DELAY_MS = 30000;
31
31
  const MAX_ATTEMPTS = 10;
32
+ // Close codes the server sends on purpose — reconnecting can't recover these,
33
+ // so we stop and (where useful) surface why. A `null` message closes quietly.
34
+ const TERMINAL_CLOSE_CODES = {
35
+ 1000: null, // normal closure — the session ended
36
+ 1008: "Unauthorized — the token was rejected for this session.",
37
+ 4404: "This session isn't live. Live video and input are only available while a build is running.",
38
+ };
32
39
  export class ExpoCiClient extends TypedEventEmitter {
33
40
  constructor({ baseUrl = "http://localhost:3000", sessionId, apiToken }) {
34
41
  super();
@@ -97,6 +104,8 @@ export class ExpoCiClient extends TypedEventEmitter {
97
104
  if (this.destroyed)
98
105
  return;
99
106
  const socket = new WebSocket(this.wsUrl);
107
+ // Video arrives as binary frames; logs/status as JSON text.
108
+ socket.binaryType = "arraybuffer";
100
109
  this.ws = socket;
101
110
  socket.addEventListener("open", () => {
102
111
  if (this.destroyed) {
@@ -109,6 +118,11 @@ export class ExpoCiClient extends TypedEventEmitter {
109
118
  socket.addEventListener("message", (event) => {
110
119
  if (this.destroyed)
111
120
  return;
121
+ // Binary = an encoded H.264 chunk; hand it straight to the decoder.
122
+ if (event.data instanceof ArrayBuffer) {
123
+ this.emit("video-chunk", event.data);
124
+ return;
125
+ }
112
126
  let msg;
113
127
  try {
114
128
  msg = JSON.parse(event.data);
@@ -123,9 +137,6 @@ export class ExpoCiClient extends TypedEventEmitter {
123
137
  case "log":
124
138
  this.emit("log", msg);
125
139
  break;
126
- case "video-frame":
127
- this.emit("video-frame", msg);
128
- break;
129
140
  case "status":
130
141
  this.emit("status", msg);
131
142
  break;
@@ -134,12 +145,24 @@ export class ExpoCiClient extends TypedEventEmitter {
134
145
  break;
135
146
  }
136
147
  });
137
- socket.addEventListener("close", () => {
138
- if (this.destroyed) {
139
- this.emit("close");
148
+ socket.addEventListener("close", (event) => {
149
+ this.emit("close");
150
+ if (this.destroyed)
151
+ return;
152
+ // A deliberate server close (unauthorized, session not live, ended) won't
153
+ // recover on retry — stop, and surface the reason to the UI.
154
+ if (Object.hasOwn(TERMINAL_CLOSE_CODES, event.code)) {
155
+ const message = TERMINAL_CLOSE_CODES[event.code];
156
+ if (message) {
157
+ this.emit("error", {
158
+ type: "error",
159
+ sessionId: this.sessionId,
160
+ timestamp: new Date().toISOString(),
161
+ data: { message },
162
+ });
163
+ }
140
164
  return;
141
165
  }
142
- this.emit("close");
143
166
  this.scheduleReconnect();
144
167
  });
145
168
  socket.addEventListener("error", () => {
@@ -1 +1 @@
1
- {"version":3,"file":"SessionViewer.d.ts","sourceRoot":"","sources":["../../src/components/SessionViewer.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAyHD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,kBAAkB,2CA4J5F"}
1
+ {"version":3,"file":"SessionViewer.d.ts","sourceRoot":"","sources":["../../src/components/SessionViewer.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAyHD,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,kBAAkB,2CAwK5F"}
@@ -111,7 +111,7 @@ const PRESETS = [
111
111
  { label: "Enter", input: { type: "key", code: "Enter" } },
112
112
  ];
113
113
  export function SessionViewer({ sessionId, apiToken, baseUrl, className }) {
114
- const { status, logs, frame, connected, error, sendInput, reconnect } = useExpoCiSession({
114
+ const { status, logs, attachCanvas, videoSupported, frameSize, connected, error, sendInput, reconnect, } = useExpoCiSession({
115
115
  baseUrl,
116
116
  sessionId,
117
117
  apiToken,
@@ -160,7 +160,7 @@ export function SessionViewer({ sessionId, apiToken, baseUrl, className }) {
160
160
  fontSize: "11px",
161
161
  color: connected ? "#4ade80" : "#6b7280",
162
162
  fontWeight: 500,
163
- }, children: connected ? "● live" : "○ disconnected" }), !connected && (_jsx("button", { type: "button", style: btnStyle("secondary"), onClick: reconnect, children: "Reconnect" }))] })] }), error && _jsxs("div", { style: errorBannerStyle, children: ["\u26A0 ", error] }), _jsxs("div", { style: bodyStyle, children: [_jsx("div", { style: simulatorColStyle, children: _jsx(SimulatorScreen, { frame: frame, onTap: handleTap, onSwipe: handleSwipe, onKeyInput: handleKeyInput }) }), _jsxs("div", { style: rightColStyle, children: [_jsxs("div", { style: controlsStyle, children: [_jsx("div", { style: {
163
+ }, children: connected ? "● live" : "○ disconnected" }), !connected && (_jsx("button", { type: "button", style: btnStyle("secondary"), onClick: reconnect, children: "Reconnect" }))] })] }), error && _jsxs("div", { style: errorBannerStyle, children: ["\u26A0 ", error] }), _jsxs("div", { style: bodyStyle, children: [_jsx("div", { style: simulatorColStyle, children: _jsx(SimulatorScreen, { canvasRef: attachCanvas, videoSupported: videoSupported, hasFrame: frameSize !== null, onTap: handleTap, onSwipe: handleSwipe, onKeyInput: handleKeyInput }) }), _jsxs("div", { style: rightColStyle, children: [_jsxs("div", { style: controlsStyle, children: [_jsx("div", { style: {
164
164
  fontSize: "11px",
165
165
  fontWeight: 600,
166
166
  color: "#8b949e",
@@ -1,6 +1,11 @@
1
1
  import type { SimulatorInput } from "../types.js";
2
2
  interface SimulatorScreenProps {
3
- frame: string | null;
3
+ /** Ref callback wiring the rendering `<canvas>` to the decoder (from the hook). */
4
+ canvasRef: (canvas: HTMLCanvasElement | null) => void;
5
+ /** Whether this browser can decode the live H.264 stream (WebCodecs present). */
6
+ videoSupported: boolean;
7
+ /** True once the first frame has painted — drives the placeholder/canvas swap. */
8
+ hasFrame: boolean;
4
9
  onTap?: (input: Extract<SimulatorInput, {
5
10
  type: "tap";
6
11
  }>) => void;
@@ -13,11 +18,11 @@ interface SimulatorScreenProps {
13
18
  } | {
14
19
  type: "key";
15
20
  }>) => void;
16
- /** Cap on the rendered simulator height; the image scales down proportionally
21
+ /** Cap on the rendered simulator height; the canvas scales down proportionally
17
22
  * to fit. Accepts any CSS length. Defaults to `80vh` so the device never
18
23
  * exceeds the viewport on a tall phone like iPhone 17 Pro Max. */
19
24
  maxHeight?: number | string;
20
25
  }
21
- export declare function SimulatorScreen({ frame, onTap, onSwipe, onKeyInput, maxHeight, }: SimulatorScreenProps): import("react/jsx-runtime").JSX.Element;
26
+ export declare function SimulatorScreen({ canvasRef, videoSupported, hasFrame, onTap, onSwipe, onKeyInput, maxHeight, }: SimulatorScreenProps): import("react/jsx-runtime").JSX.Element;
22
27
  export {};
23
28
  //# sourceMappingURL=SimulatorScreen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SimulatorScreen.d.ts","sourceRoot":"","sources":["../../src/components/SimulatorScreen.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAA0C,MAAM,aAAa,CAAC;AAE1F,UAAU,oBAAoB;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IAClE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IACtE,oFAAoF;IACpF,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IAC1F;;uEAEmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAoJD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,OAAO,EACP,UAAU,EACV,SAAkB,GACnB,EAAE,oBAAoB,2CA+HtB"}
1
+ {"version":3,"file":"SimulatorScreen.d.ts","sourceRoot":"","sources":["../../src/components/SimulatorScreen.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,cAAc,EAA0C,MAAM,aAAa,CAAC;AAE1F,UAAU,oBAAoB;IAC5B,mFAAmF;IACnF,SAAS,EAAE,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,KAAK,IAAI,CAAC;IACtD,iFAAiF;IACjF,cAAc,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IAClE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IACtE,oFAAoF;IACpF,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,cAAc,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE,CAAC,KAAK,IAAI,CAAC;IAC1F;;uEAEmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAmJD,wBAAgB,eAAe,CAAC,EAC9B,SAAS,EACT,cAAc,EACd,QAAQ,EACR,KAAK,EACL,OAAO,EACP,UAAU,EACV,SAAkB,GACnB,EAAE,oBAAoB,2CA2JtB"}
@@ -112,35 +112,44 @@ const basePlaceholderStyle = {
112
112
  // here or the stacked glyph + text collapse onto each other.
113
113
  lineHeight: 1.4,
114
114
  };
115
- const baseImgStyle = {
116
- display: "block",
115
+ const baseCanvasStyle = {
117
116
  maxWidth: "100%",
118
117
  width: "auto",
119
118
  height: "auto",
120
119
  pointerEvents: "none",
121
120
  };
122
- /** Convert a rendered-canvas pixel position to the image's natural pixel coords */
121
+ /** Convert a rendered pixel position to the canvas's natural (device) pixel coords. */
123
122
  function toNatural(el, clientX, clientY) {
124
123
  const rect = el.getBoundingClientRect();
125
- const scaleX = el.naturalWidth / rect.width;
126
- const scaleY = el.naturalHeight / rect.height;
124
+ const scaleX = el.width / rect.width;
125
+ const scaleY = el.height / rect.height;
127
126
  return {
128
127
  x: Math.round((clientX - rect.left) * scaleX),
129
128
  y: Math.round((clientY - rect.top) * scaleY),
130
129
  };
131
130
  }
132
- export function SimulatorScreen({ frame, onTap, onSwipe, onKeyInput, maxHeight = "80vh", }) {
133
- const imgRef = useRef(null);
131
+ export function SimulatorScreen({ canvasRef, videoSupported, hasFrame, onTap, onSwipe, onKeyInput, maxHeight = "80vh", }) {
132
+ const canvasElRef = useRef(null);
134
133
  const dragRef = useRef(null);
135
134
  const [focused, setFocused] = useState(false);
136
- const imgStyle = { ...baseImgStyle, maxHeight };
137
- // Drive the placeholder by height so its width derives from the iPhone 17
138
- // aspect ratio (a block div would otherwise fill the column width and distort).
135
+ // The canvas only drives layout once a frame has painted (it's 0×0 before);
136
+ // until then the placeholder gives the device its iPhone-aspect footprint.
137
+ const canvasStyle = {
138
+ ...baseCanvasStyle,
139
+ maxHeight,
140
+ display: hasFrame ? "block" : "none",
141
+ };
139
142
  const placeholderStyle = { ...basePlaceholderStyle, height: maxHeight };
143
+ // Wire the canvas into both the decoder (parent callback) and our local ref
144
+ // for pointer-coordinate math.
145
+ const setCanvas = useCallback((el) => {
146
+ canvasElRef.current = el;
147
+ canvasRef(el);
148
+ }, [canvasRef]);
140
149
  const handleMouseDown = useCallback((e) => {
141
- if (!imgRef.current)
150
+ if (!canvasElRef.current)
142
151
  return;
143
- const natural = toNatural(imgRef.current, e.clientX, e.clientY);
152
+ const natural = toNatural(canvasElRef.current, e.clientX, e.clientY);
144
153
  dragRef.current = {
145
154
  startX: e.clientX,
146
155
  startY: e.clientY,
@@ -151,7 +160,7 @@ export function SimulatorScreen({ frame, onTap, onSwipe, onKeyInput, maxHeight =
151
160
  const [ripple, setRipple] = useState(null);
152
161
  const rippleCounter = useRef(0);
153
162
  const handleMouseUp = useCallback((e) => {
154
- if (!dragRef.current || !imgRef.current)
163
+ if (!dragRef.current || !canvasElRef.current)
155
164
  return;
156
165
  const drag = dragRef.current;
157
166
  dragRef.current = null;
@@ -160,17 +169,17 @@ export function SimulatorScreen({ frame, onTap, onSwipe, onKeyInput, maxHeight =
160
169
  const dist = Math.sqrt(dx * dx + dy * dy);
161
170
  if (dist < SWIPE_THRESHOLD_PX) {
162
171
  // Tap
163
- const natural = toNatural(imgRef.current, e.clientX, e.clientY);
172
+ const natural = toNatural(canvasElRef.current, e.clientX, e.clientY);
164
173
  onTap?.({ type: "tap", x: natural.x, y: natural.y });
165
174
  // Show ripple at rendered coords
166
- const rect = imgRef.current.getBoundingClientRect();
175
+ const rect = canvasElRef.current.getBoundingClientRect();
167
176
  const id = ++rippleCounter.current;
168
177
  setRipple({ x: e.clientX - rect.left, y: e.clientY - rect.top, id });
169
178
  setTimeout(() => setRipple((r) => (r?.id === id ? null : r)), 500);
170
179
  }
171
180
  else {
172
181
  // Swipe
173
- const naturalEnd = toNatural(imgRef.current, e.clientX, e.clientY);
182
+ const naturalEnd = toNatural(canvasElRef.current, e.clientX, e.clientY);
174
183
  onSwipe?.({
175
184
  type: "swipe",
176
185
  startX: drag.naturalStartX,
@@ -192,13 +201,13 @@ export function SimulatorScreen({ frame, onTap, onSwipe, onKeyInput, maxHeight =
192
201
  onKeyInput(input);
193
202
  }, [onKeyInput]);
194
203
  const frameStyle = focused ? { ...deviceFrameStyle, ...focusedFrameStyle } : deviceFrameStyle;
195
- return (_jsx("div", { style: frameStyle, children: _jsxs("div", { style: wrapperStyle, role: "application", "aria-label": "iOS simulator \u2014 click to focus; tap, drag to swipe, type to send keys", tabIndex: 0, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, onKeyDown: handleKeyDown, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: [frame ? (_jsx("img", { ref: imgRef, src: frame, alt: "iOS Simulator", style: imgStyle, draggable: false })) : (_jsxs("div", { style: placeholderStyle, children: [_jsx("span", { style: { fontWeight: 500 }, children: "Waiting for the simulator" }), _jsx("div", { style: { display: "flex", gap: "6px" }, children: [0, 1, 2].map((i) => (_jsx("span", { style: {
204
+ return (_jsx("div", { style: frameStyle, children: _jsxs("div", { style: wrapperStyle, role: "application", "aria-label": "iOS simulator \u2014 click to focus; tap, drag to swipe, type to send keys", tabIndex: 0, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, onKeyDown: handleKeyDown, onFocus: () => setFocused(true), onBlur: () => setFocused(false), children: [videoSupported && (_jsx("canvas", { ref: setCanvas, style: canvasStyle, "aria-label": "iOS Simulator" })), !videoSupported ? (_jsxs("div", { style: placeholderStyle, children: [_jsx("span", { style: { fontWeight: 500 }, children: "Live video isn't supported here" }), _jsx("span", { style: { fontSize: "11px", textAlign: "center", maxWidth: "80%" }, children: "This browser can't decode the stream (WebCodecs unavailable). Try a recent Chrome, Edge, or Safari 16.4+." })] })) : (!hasFrame && (_jsxs("div", { style: placeholderStyle, children: [_jsx("span", { style: { fontWeight: 500 }, children: "Waiting for the simulator" }), _jsx("div", { style: { display: "flex", gap: "6px" }, children: [0, 1, 2].map((i) => (_jsx("span", { style: {
196
205
  width: 6,
197
206
  height: 6,
198
207
  borderRadius: "50%",
199
208
  backgroundColor: "currentColor",
200
209
  animation: `expo-pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
201
- } }, i))) })] })), ripple && (_jsx("span", { style: {
210
+ } }, i))) })] }))), ripple && (_jsx("span", { style: {
202
211
  position: "absolute",
203
212
  left: ripple.x - 14,
204
213
  top: ripple.y - 14,
@@ -0,0 +1,37 @@
1
+ export interface FrameSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ /**
6
+ * Owns a `VideoDecoder` and paints decoded frames onto a caller-provided
7
+ * `<canvas>`. Feed it raw chunks via {@link push}; attach the canvas with
8
+ * {@link setCanvas}. One instance per session; `dispose()` when done.
9
+ */
10
+ export declare class SimulatorDecoder {
11
+ /** Whether this environment can decode the stream at all (needs WebCodecs). */
12
+ static isSupported(): boolean;
13
+ private decoder;
14
+ private canvas;
15
+ private ctx;
16
+ private size;
17
+ private onResizeCb;
18
+ private onErrorCb;
19
+ private nextTimestamp;
20
+ private awaitingKeyframe;
21
+ private disposed;
22
+ setCanvas(canvas: HTMLCanvasElement | null): void;
23
+ onResize(cb: ((size: FrameSize) => void) | null): void;
24
+ onError(cb: ((message: string) => void) | null): void;
25
+ private reportError;
26
+ get frameSize(): FrameSize | null;
27
+ push(chunk: ArrayBuffer): void;
28
+ dispose(): void;
29
+ private configure;
30
+ private decode;
31
+ private paintFrame;
32
+ private paintJpeg;
33
+ private updateSize;
34
+ private onDecoderError;
35
+ private closeDecoder;
36
+ }
37
+ //# sourceMappingURL=decoder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decoder.d.ts","sourceRoot":"","sources":["../src/decoder.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,qBAAa,gBAAgB;IAC3B,+EAA+E;IAC/E,MAAM,CAAC,WAAW,IAAI,OAAO;IAI7B,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,GAAG,CAAyC;IACpD,OAAO,CAAC,IAAI,CAA0B;IACtC,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,SAAS,CAA4C;IAG7D,OAAO,CAAC,aAAa,CAAK;IAG1B,OAAO,CAAC,gBAAgB,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAS;IAEzB,SAAS,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,GAAG,IAAI;IASjD,QAAQ,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI;IAItD,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI;IAIrD,OAAO,CAAC,WAAW;IAMnB,IAAI,SAAS,IAAI,SAAS,GAAG,IAAI,CAEhC;IAED,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAqB9B,OAAO,IAAI,IAAI;IAWf,OAAO,CAAC,SAAS;IAmBjB,OAAO,CAAC,MAAM;IAuBd,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,SAAS;IAiBjB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,YAAY;CAWrB"}
@@ -0,0 +1,196 @@
1
+ // WebCodecs H.264 decoder for the simulator stream. The server forwards
2
+ // `baguette serve`'s AVCC chunks verbatim: each is `[tag byte][payload]` with
3
+ // 0x01 avcC description — feed to VideoDecoder.configure
4
+ // 0x02 keyframe (IDR)
5
+ // 0x03 delta frame
6
+ // 0x04 standalone JPEG — painted immediately for an instant first frame
7
+ // We decode H.264 with a VideoDecoder and paint each frame onto a <canvas>.
8
+ const TAG_DESCRIPTION = 0x01;
9
+ const TAG_KEYFRAME = 0x02;
10
+ const TAG_DELTA = 0x03;
11
+ const TAG_JPEG_SEED = 0x04;
12
+ /**
13
+ * Owns a `VideoDecoder` and paints decoded frames onto a caller-provided
14
+ * `<canvas>`. Feed it raw chunks via {@link push}; attach the canvas with
15
+ * {@link setCanvas}. One instance per session; `dispose()` when done.
16
+ */
17
+ export class SimulatorDecoder {
18
+ constructor() {
19
+ this.decoder = null;
20
+ this.canvas = null;
21
+ this.ctx = null;
22
+ this.size = null;
23
+ this.onResizeCb = null;
24
+ this.onErrorCb = null;
25
+ // Monotonic, strictly-increasing microsecond timestamps. The values are
26
+ // arbitrary (we render immediately) but EncodedVideoChunk requires them.
27
+ this.nextTimestamp = 0;
28
+ // Until the first keyframe arrives after (re)configuring, deltas reference
29
+ // frames we never decoded — skip them to avoid decoder errors.
30
+ this.awaitingKeyframe = true;
31
+ this.disposed = false;
32
+ }
33
+ /** Whether this environment can decode the stream at all (needs WebCodecs). */
34
+ static isSupported() {
35
+ return typeof globalThis !== "undefined" && "VideoDecoder" in globalThis;
36
+ }
37
+ setCanvas(canvas) {
38
+ this.canvas = canvas;
39
+ this.ctx = canvas ? canvas.getContext("2d") : null;
40
+ if (canvas && this.size) {
41
+ canvas.width = this.size.width;
42
+ canvas.height = this.size.height;
43
+ }
44
+ }
45
+ onResize(cb) {
46
+ this.onResizeCb = cb;
47
+ }
48
+ onError(cb) {
49
+ this.onErrorCb = cb;
50
+ }
51
+ reportError(message, err) {
52
+ // Surface decoder problems instead of silently showing a black canvas.
53
+ console.warn(`[expo-orchestrator] ${message}`, err ?? "");
54
+ this.onErrorCb?.(message);
55
+ }
56
+ get frameSize() {
57
+ return this.size;
58
+ }
59
+ push(chunk) {
60
+ if (this.disposed || chunk.byteLength === 0)
61
+ return;
62
+ const bytes = new Uint8Array(chunk);
63
+ const tag = bytes[0];
64
+ const payload = bytes.subarray(1);
65
+ switch (tag) {
66
+ case TAG_DESCRIPTION:
67
+ this.configure(payload);
68
+ break;
69
+ case TAG_JPEG_SEED:
70
+ this.paintJpeg(payload);
71
+ break;
72
+ case TAG_KEYFRAME:
73
+ this.decode(payload, "key");
74
+ break;
75
+ case TAG_DELTA:
76
+ this.decode(payload, "delta");
77
+ break;
78
+ }
79
+ }
80
+ dispose() {
81
+ this.disposed = true;
82
+ this.closeDecoder();
83
+ this.canvas = null;
84
+ this.ctx = null;
85
+ this.onResizeCb = null;
86
+ this.onErrorCb = null;
87
+ }
88
+ // ── internals ────────────────────────────────────────────────────────────
89
+ configure(description) {
90
+ if (!SimulatorDecoder.isSupported() || description.byteLength < 4)
91
+ return;
92
+ this.closeDecoder();
93
+ const decoder = new VideoDecoder({
94
+ output: (frame) => this.paintFrame(frame),
95
+ error: (err) => this.onDecoderError(err),
96
+ });
97
+ const codec = codecFromAvcC(description);
98
+ try {
99
+ decoder.configure({ codec, description, optimizeForLatency: true });
100
+ }
101
+ catch (err) {
102
+ decoder.close();
103
+ this.reportError(`failed to configure the video decoder for codec ${codec}`, err);
104
+ return;
105
+ }
106
+ this.decoder = decoder;
107
+ this.awaitingKeyframe = true;
108
+ }
109
+ decode(data, type) {
110
+ const decoder = this.decoder;
111
+ if (!decoder || decoder.state !== "configured")
112
+ return;
113
+ if (type === "key") {
114
+ this.awaitingKeyframe = false;
115
+ }
116
+ else if (this.awaitingKeyframe) {
117
+ return; // can't decode a delta before its keyframe
118
+ }
119
+ try {
120
+ decoder.decode(new EncodedVideoChunk({
121
+ type,
122
+ timestamp: this.nextTimestamp++,
123
+ data,
124
+ }));
125
+ }
126
+ catch (err) {
127
+ // A bad/out-of-order chunk — wait for the next keyframe to resync.
128
+ this.awaitingKeyframe = true;
129
+ this.reportError(`failed to decode a ${type} frame`, err);
130
+ }
131
+ }
132
+ paintFrame(frame) {
133
+ const width = frame.displayWidth;
134
+ const height = frame.displayHeight;
135
+ this.updateSize(width, height);
136
+ if (this.canvas && this.ctx) {
137
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
138
+ }
139
+ frame.close();
140
+ }
141
+ paintJpeg(jpeg) {
142
+ if (jpeg.byteLength === 0 || typeof createImageBitmap !== "function")
143
+ return;
144
+ // Copy into a fresh buffer so the Blob owns its bytes.
145
+ const blob = new Blob([jpeg.slice()], { type: "image/jpeg" });
146
+ createImageBitmap(blob)
147
+ .then((bitmap) => {
148
+ this.updateSize(bitmap.width, bitmap.height);
149
+ if (this.canvas && this.ctx) {
150
+ this.ctx.drawImage(bitmap, 0, 0, this.canvas.width, this.canvas.height);
151
+ }
152
+ bitmap.close();
153
+ })
154
+ .catch(() => {
155
+ /* a stray/partial seed — ignore */
156
+ });
157
+ }
158
+ updateSize(width, height) {
159
+ if (width === 0 || height === 0)
160
+ return;
161
+ if (this.size && this.size.width === width && this.size.height === height)
162
+ return;
163
+ this.size = { width, height };
164
+ if (this.canvas) {
165
+ this.canvas.width = width;
166
+ this.canvas.height = height;
167
+ }
168
+ this.onResizeCb?.(this.size);
169
+ }
170
+ onDecoderError(err) {
171
+ // Reset and wait for the next server-forced keyframe to recover.
172
+ this.awaitingKeyframe = true;
173
+ this.reportError("video decoder error", err);
174
+ }
175
+ closeDecoder() {
176
+ const decoder = this.decoder;
177
+ this.decoder = null;
178
+ if (decoder && decoder.state !== "closed") {
179
+ try {
180
+ decoder.close();
181
+ }
182
+ catch {
183
+ /* already closed */
184
+ }
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Derive the WebCodecs codec string from an avcC configuration record. Bytes
190
+ * 1–3 are AVCProfileIndication / profile_compatibility / AVCLevelIndication,
191
+ * which form the `avc1.PPCCLL` suffix VideoDecoder.configure expects.
192
+ */
193
+ function codecFromAvcC(avcc) {
194
+ const hex = (b) => b.toString(16).padStart(2, "0");
195
+ return `avc1.${hex(avcc[1])}${hex(avcc[2])}${hex(avcc[3])}`;
196
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,8 @@ export type { SessionViewerProps } from "./components/SessionViewer.js";
5
5
  export { SessionViewer } from "./components/SessionViewer.js";
6
6
  export { SimulatorScreen } from "./components/SimulatorScreen.js";
7
7
  export { StatusBadge } from "./components/StatusBadge.js";
8
- export type { ClientEventMap, ListSimulatorsResponse, LogEntry, LogLevel, SessionDetail, SessionLogs, SessionStatus, SimulatorButton, SimulatorDevice, SimulatorInput, SimulatorInputResponse, SimulatorKeyCode, SimulatorKeyModifier, UseExpoCiSessionOptions, UseExpoCiSessionResult, WsConnectedMessage, WsErrorMessage, WsLogMessage, WsMessage, WsStatusMessage, WsVideoFrameMessage, } from "./types.js";
8
+ export type { FrameSize } from "./decoder.js";
9
+ export { SimulatorDecoder } from "./decoder.js";
10
+ export type { ClientEventMap, ListSimulatorsResponse, LogEntry, LogLevel, SessionDetail, SessionLogs, SessionStatus, SimulatorButton, SimulatorDevice, SimulatorInput, SimulatorInputResponse, SimulatorKeyCode, SimulatorKeyModifier, UseExpoCiSessionOptions, UseExpoCiSessionResult, WsConnectedMessage, WsErrorMessage, WsLogMessage, WsMessage, WsStatusMessage, } from "./types.js";
9
11
  export { useExpoCiSession } from "./useExpoCiSession.js";
10
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAExE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAE1D,YAAY,EACV,cAAc,EACd,sBAAsB,EACtB,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,WAAW,EACX,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,SAAS,EACT,eAAe,EACf,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAExE,OAAO,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAClE,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC1D,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,sBAAsB,EACtB,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,WAAW,EACX,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,SAAS,EACT,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -5,5 +5,7 @@ export { LogConsole } from "./components/LogConsole.js";
5
5
  export { SessionViewer } from "./components/SessionViewer.js";
6
6
  export { SimulatorScreen } from "./components/SimulatorScreen.js";
7
7
  export { StatusBadge } from "./components/StatusBadge.js";
8
+ // Decoder (advanced: drive your own canvas)
9
+ export { SimulatorDecoder } from "./decoder.js";
8
10
  // Hook
9
11
  export { useExpoCiSession } from "./useExpoCiSession.js";
package/dist/types.d.ts CHANGED
@@ -20,13 +20,6 @@ export interface WsLogMessage {
20
20
  message: string;
21
21
  };
22
22
  }
23
- export interface WsVideoFrameMessage {
24
- type: "video-frame";
25
- sessionId: string;
26
- timestamp: string;
27
- /** base64 JPEG with NO data-URI prefix */
28
- data: string;
29
- }
30
23
  export interface WsStatusMessage {
31
24
  type: "status";
32
25
  sessionId: string;
@@ -43,13 +36,16 @@ export interface WsErrorMessage {
43
36
  message: string;
44
37
  };
45
38
  }
46
- export type WsMessage = WsConnectedMessage | WsLogMessage | WsVideoFrameMessage | WsStatusMessage | WsErrorMessage;
39
+ /** JSON messages pushed down the WebSocket. Live video is sent as binary
40
+ * frames instead — see the `video-chunk` client event. */
41
+ export type WsMessage = WsConnectedMessage | WsLogMessage | WsStatusMessage | WsErrorMessage;
47
42
  export interface ClientEventMap {
48
43
  open: undefined;
49
44
  close: undefined;
50
45
  connected: WsConnectedMessage;
51
46
  log: WsLogMessage;
52
- "video-frame": WsVideoFrameMessage;
47
+ /** One raw encoded H.264/AVCC chunk (`[tag][payload]`) for the decoder. */
48
+ "video-chunk": ArrayBuffer;
53
49
  status: WsStatusMessage;
54
50
  error: WsErrorMessage;
55
51
  }
@@ -65,8 +61,16 @@ export interface UseExpoCiSessionOptions {
65
61
  export interface UseExpoCiSessionResult {
66
62
  status: SessionStatus | null;
67
63
  logs: LogEntry[];
68
- /** Current frame as a data:image/jpeg;base64,... string, or null */
69
- frame: string | null;
64
+ /** Attach the `<canvas>` the decoder paints the live H.264 stream onto.
65
+ * Pass via a ref callback; pass `null` on unmount. */
66
+ attachCanvas: (canvas: HTMLCanvasElement | null) => void;
67
+ /** Whether this browser can decode the stream (WebCodecs available). */
68
+ videoSupported: boolean;
69
+ /** Natural pixel size of the decoded video, once the first frame paints. */
70
+ frameSize: {
71
+ width: number;
72
+ height: number;
73
+ } | null;
70
74
  connected: boolean;
71
75
  error: string | null;
72
76
  sendInput: (input: SimulatorInput) => Promise<import("@alivelabs/expo-orchestrator-schemas").SimulatorInputResponse>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,GAAG,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAE/F,YAAY,EACV,sBAAsB,EACtB,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAE9C,0CAA0C;AAC1C,MAAM,MAAM,QAAQ,GAAG,GAAG,CAAC;AAE3B,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,GAAG,EAAE,CAAC;CACb;AAMD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,KAAK,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,KAAK,EAAE,OAAO,sCAAsC,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3F;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,aAAa,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,MAAM,EAAE,aAAa,CAAA;KAAE,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3B;AAED,MAAM,MAAM,SAAS,GACjB,kBAAkB,GAClB,YAAY,GACZ,mBAAmB,GACnB,eAAe,GACf,cAAc,CAAC;AAInB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,GAAG,EAAE,YAAY,CAAC;IAClB,aAAa,EAAE,mBAAmB,CAAC;IACnC,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;CACvB;AAID,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,oEAAoE;IACpE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,CACT,KAAK,EAAE,cAAc,KAClB,OAAO,CAAC,OAAO,sCAAsC,EAAE,sBAAsB,CAAC,CAAC;IACpF,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,GAAG,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAE/F,YAAY,EACV,sBAAsB,EACtB,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,aAAa,EACb,aAAa,EACb,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,oBAAoB,EACpB,UAAU,GACX,MAAM,sCAAsC,CAAC;AAE9C,0CAA0C;AAC1C,MAAM,MAAM,QAAQ,GAAG,GAAG,CAAC;AAE3B,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,GAAG,EAAE,CAAC;CACb;AAMD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,KAAK,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,KAAK,EAAE,OAAO,sCAAsC,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3F;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,MAAM,EAAE,aAAa,CAAA;KAAE,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3B;AAED;2DAC2D;AAC3D,MAAM,MAAM,SAAS,GAAG,kBAAkB,GAAG,YAAY,GAAG,eAAe,GAAG,cAAc,CAAC;AAI7F,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,GAAG,EAAE,YAAY,CAAC;IAClB,2EAA2E;IAC3E,aAAa,EAAE,WAAW,CAAC;IAC3B,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;CACvB;AAID,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB;2DACuD;IACvD,YAAY,EAAE,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,KAAK,IAAI,CAAC;IACzD,wEAAwE;IACxE,cAAc,EAAE,OAAO,CAAC;IACxB,4EAA4E;IAC5E,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACpD,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,CACT,KAAK,EAAE,cAAc,KAClB,OAAO,CAAC,OAAO,sCAAsC,EAAE,sBAAsB,CAAC,CAAC;IACpF,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB"}
@@ -1 +1 @@
1
- {"version":3,"file":"useExpoCiSession.d.ts","sourceRoot":"","sources":["../src/useExpoCiSession.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAKV,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,YAAY,CAAC;AAIpB,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,SAAS,EACT,QAAQ,EACR,WAAkB,EAClB,OAA0B,GAC3B,EAAE,uBAAuB,GAAG,sBAAsB,CA+FlD"}
1
+ {"version":3,"file":"useExpoCiSession.d.ts","sourceRoot":"","sources":["../src/useExpoCiSession.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAKV,uBAAuB,EACvB,sBAAsB,EACvB,MAAM,YAAY,CAAC;AAIpB,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,SAAS,EACT,QAAQ,EACR,WAAkB,EAClB,OAA0B,GAC3B,EAAE,uBAAuB,GAAG,sBAAsB,CAmIlD"}
@@ -1,15 +1,29 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { ExpoCiClient } from "./client.js";
3
+ import { SimulatorDecoder } from "./decoder.js";
3
4
  const DEFAULT_MAX_LOGS = 2000;
4
5
  export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = true, maxLogs = DEFAULT_MAX_LOGS, }) {
5
6
  const [status, setStatus] = useState(null);
6
7
  const [logs, setLogs] = useState([]);
7
- const [frame, setFrame] = useState(null);
8
+ const [frameSize, setFrameSize] = useState(null);
8
9
  const [connected, setConnected] = useState(false);
9
10
  const [error, setError] = useState(null);
11
+ const videoSupported = useMemo(() => SimulatorDecoder.isSupported(), []);
10
12
  // We keep the client in a ref so reconnect() can call client.connect()
11
13
  // without causing a re-render or stale closure issues.
12
14
  const clientRef = useRef(null);
15
+ // The decoder is created/torn down inside the connect effect so its lifecycle
16
+ // is paired with the socket (StrictMode-safe). The canvas node is held in its
17
+ // own ref so a freshly-created decoder can be (re)wired to it immediately.
18
+ const decoderRef = useRef(null);
19
+ const canvasNodeRef = useRef(null);
20
+ // Stable callback to wire the rendering <canvas> into the decoder. The ref
21
+ // callback fires during commit (before the effect creates the decoder), so we
22
+ // stash the node and also push it to the decoder if one already exists.
23
+ const attachCanvas = useCallback((canvas) => {
24
+ canvasNodeRef.current = canvas;
25
+ decoderRef.current?.setCanvas(canvas);
26
+ }, []);
13
27
  // Stable reconnect callback
14
28
  const reconnect = useCallback(() => {
15
29
  clientRef.current?.connect();
@@ -23,6 +37,14 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
23
37
  return client.sendInput(input);
24
38
  }, []);
25
39
  useEffect(() => {
40
+ // Create the decoder here (not in render) so create + dispose are paired
41
+ // with this effect — StrictMode's mount/unmount/mount can't leave the active
42
+ // decoder canvas-less. Wire the current canvas immediately.
43
+ const decoder = new SimulatorDecoder();
44
+ decoder.onResize(setFrameSize);
45
+ decoder.onError(setError);
46
+ decoder.setCanvas(canvasNodeRef.current);
47
+ decoderRef.current = decoder;
26
48
  const client = new ExpoCiClient({ baseUrl, sessionId, apiToken });
27
49
  clientRef.current = client;
28
50
  // Fetch initial session status
@@ -56,8 +78,8 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
56
78
  return next.length > maxLogs ? next.slice(next.length - maxLogs) : next;
57
79
  });
58
80
  });
59
- const offFrame = client.on("video-frame", (msg) => {
60
- setFrame(`data:image/jpeg;base64,${msg.data}`);
81
+ const offFrame = client.on("video-chunk", (chunk) => {
82
+ decoder.push(chunk);
61
83
  });
62
84
  const offStatus = client.on("status", (msg) => {
63
85
  setStatus(msg.data.status);
@@ -77,8 +99,20 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
77
99
  offError();
78
100
  client.disconnect();
79
101
  clientRef.current = null;
102
+ decoder.dispose();
103
+ decoderRef.current = null;
80
104
  };
81
105
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
106
  }, [baseUrl, sessionId, apiToken, autoConnect, maxLogs]);
83
- return { status, logs, frame, connected, error, sendInput, reconnect };
107
+ return {
108
+ status,
109
+ logs,
110
+ attachCanvas,
111
+ videoSupported,
112
+ frameSize,
113
+ connected,
114
+ error,
115
+ sendInput,
116
+ reconnect,
117
+ };
84
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alivelabs/expo-orchestrator-react-client",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "React client for Expo CI Orchestrator — streaming logs, live simulator video, and interactive controls.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@alivelabs/expo-orchestrator-schemas": "^0.1.0"
33
+ "@alivelabs/expo-orchestrator-schemas": "^0.2.1"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": ">=18.0.0",