@alivelabs/expo-orchestrator-react-client 0.1.0 → 0.2.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
@@ -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;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;IA6DlB,OAAO,CAAC,iBAAiB;IASzB,UAAU,IAAI,IAAI;CAYnB"}
package/dist/client.js CHANGED
@@ -97,6 +97,8 @@ export class ExpoCiClient extends TypedEventEmitter {
97
97
  if (this.destroyed)
98
98
  return;
99
99
  const socket = new WebSocket(this.wsUrl);
100
+ // Video arrives as binary frames; logs/status as JSON text.
101
+ socket.binaryType = "arraybuffer";
100
102
  this.ws = socket;
101
103
  socket.addEventListener("open", () => {
102
104
  if (this.destroyed) {
@@ -109,6 +111,11 @@ export class ExpoCiClient extends TypedEventEmitter {
109
111
  socket.addEventListener("message", (event) => {
110
112
  if (this.destroyed)
111
113
  return;
114
+ // Binary = an encoded H.264 chunk; hand it straight to the decoder.
115
+ if (event.data instanceof ArrayBuffer) {
116
+ this.emit("video-chunk", event.data);
117
+ return;
118
+ }
112
119
  let msg;
113
120
  try {
114
121
  msg = JSON.parse(event.data);
@@ -123,9 +130,6 @@ export class ExpoCiClient extends TypedEventEmitter {
123
130
  case "log":
124
131
  this.emit("log", msg);
125
132
  break;
126
- case "video-frame":
127
- this.emit("video-frame", msg);
128
- break;
129
133
  case "status":
130
134
  this.emit("status", msg);
131
135
  break;
@@ -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,2CAgJ5F"}
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,
@@ -153,11 +153,14 @@ export function SessionViewer({ sessionId, apiToken, baseUrl, className }) {
153
153
  const handleSwipe = useCallback((input) => {
154
154
  sendInput(input).catch(() => { });
155
155
  }, [sendInput]);
156
+ const handleKeyInput = useCallback((input) => {
157
+ sendInput(input).catch(() => { });
158
+ }, [sendInput]);
156
159
  return (_jsxs("div", { style: rootStyle, className: className, children: [_jsxs("div", { style: headerStyle, children: [_jsxs("h2", { style: titleStyle, children: ["Session ", sessionId] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }, children: [_jsx(StatusBadge, { status: status }), _jsx("span", { style: {
157
160
  fontSize: "11px",
158
161
  color: connected ? "#4ade80" : "#6b7280",
159
162
  fontWeight: 500,
160
- }, 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 }) }), _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: {
161
164
  fontSize: "11px",
162
165
  fontWeight: 600,
163
166
  color: "#8b949e",
@@ -1,17 +1,28 @@
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;
7
12
  onSwipe?: (input: Extract<SimulatorInput, {
8
13
  type: "swipe";
9
14
  }>) => void;
10
- /** Cap on the rendered simulator height; the image scales down proportionally
15
+ /** Forward the focused viewer's keystrokes to the simulator (text + key events). */
16
+ onKeyInput?: (input: Extract<SimulatorInput, {
17
+ type: "type";
18
+ } | {
19
+ type: "key";
20
+ }>) => void;
21
+ /** Cap on the rendered simulator height; the canvas scales down proportionally
11
22
  * to fit. Accepts any CSS length. Defaults to `80vh` so the device never
12
23
  * exceeds the viewport on a tall phone like iPhone 17 Pro Max. */
13
24
  maxHeight?: number | string;
14
25
  }
15
- export declare function SimulatorScreen({ frame, onTap, onSwipe, 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;
16
27
  export {};
17
28
  //# sourceMappingURL=SimulatorScreen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SimulatorScreen.d.ts","sourceRoot":"","sources":["../../src/components/SimulatorScreen.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,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;;uEAEmE;IACnE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAgFD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,OAAO,EACP,SAAkB,GACnB,EAAE,oBAAoB,2CA2GtB"}
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"}
@@ -1,6 +1,66 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useRef, useState } from "react";
2
+ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: the simulator screen is an application-role surface that forwards keyboard + pointer input and must be focusable.
3
+ import { useCallback, useRef, useState, } from "react";
3
4
  const SWIPE_THRESHOLD_PX = 5;
5
+ // Browser KeyboardEvent.code values baguette accepts (mirrors KeyCodeSchema in
6
+ // @alivelabs/expo-orchestrator-schemas). Built at module load to stay compact.
7
+ const SUPPORTED_KEY_CODES = (() => {
8
+ const set = new Set([
9
+ "Enter",
10
+ "Escape",
11
+ "Backspace",
12
+ "Tab",
13
+ "Space",
14
+ "ArrowUp",
15
+ "ArrowDown",
16
+ "ArrowLeft",
17
+ "ArrowRight",
18
+ "Minus",
19
+ "Equal",
20
+ "BracketLeft",
21
+ "BracketRight",
22
+ "Backslash",
23
+ "Semicolon",
24
+ "Quote",
25
+ "Backquote",
26
+ "Comma",
27
+ "Period",
28
+ "Slash",
29
+ ]);
30
+ for (let i = 0; i < 26; i += 1)
31
+ set.add(`Key${String.fromCharCode(65 + i)}`);
32
+ for (let i = 0; i < 10; i += 1)
33
+ set.add(`Digit${i}`);
34
+ return set;
35
+ })();
36
+ function isSupportedKeyCode(code) {
37
+ return SUPPORTED_KEY_CODES.has(code);
38
+ }
39
+ /**
40
+ * Translate a browser keydown into a simulator input. A printable character
41
+ * (no Ctrl/Cmd) is sent as `type` so layout/shift resolve correctly; everything
42
+ * else (Enter, Backspace, arrows, shortcuts like Cmd+A) goes as a `key` event
43
+ * with modifiers. Returns null for keys baguette can't take (e.g. F-keys).
44
+ */
45
+ function mapKeyboardEvent(e) {
46
+ const usesCommand = e.ctrlKey || e.metaKey;
47
+ if (e.key.length === 1 && !usesCommand) {
48
+ return { type: "type", text: e.key };
49
+ }
50
+ if (isSupportedKeyCode(e.code)) {
51
+ const modifiers = [];
52
+ if (e.shiftKey)
53
+ modifiers.push("shift");
54
+ if (e.ctrlKey)
55
+ modifiers.push("control");
56
+ if (e.altKey)
57
+ modifiers.push("option");
58
+ if (e.metaKey)
59
+ modifiers.push("command");
60
+ return { type: "key", code: e.code, modifiers: modifiers.length > 0 ? modifiers : undefined };
61
+ }
62
+ return null;
63
+ }
4
64
  // The outer titanium-style bezel that makes the screen read as a physical
5
65
  // device. The screen (wrapper) sits inside it with its own rounded corners.
6
66
  const deviceFrameStyle = {
@@ -11,6 +71,12 @@ const deviceFrameStyle = {
11
71
  boxShadow: "0 24px 60px -18px rgba(0,0,0,0.75), 0 4px 14px rgba(0,0,0,0.4)," +
12
72
  " inset 0 0 0 1px rgba(255,255,255,0.05), inset 0 1.5px 1px rgba(255,255,255,0.12)",
13
73
  lineHeight: 0,
74
+ transition: "box-shadow 0.18s ease",
75
+ };
76
+ // Extra ring + soft glow layered onto the bezel shadow while the screen is
77
+ // focused, so it's clear keystrokes are being forwarded to the simulator.
78
+ const focusedFrameStyle = {
79
+ boxShadow: `${deviceFrameStyle.boxShadow}, 0 0 0 2px rgba(59,130,246,0.7), 0 0 18px -4px rgba(59,130,246,0.5)`,
14
80
  };
15
81
  const wrapperStyle = {
16
82
  position: "relative",
@@ -22,6 +88,9 @@ const wrapperStyle = {
22
88
  userSelect: "none",
23
89
  lineHeight: 0,
24
90
  boxShadow: "inset 0 0 0 2px rgba(0,0,0,0.85)",
91
+ // The bezel renders the focus ring; suppress the default outline on the
92
+ // focusable screen itself.
93
+ outline: "none",
25
94
  };
26
95
  const basePlaceholderStyle = {
27
96
  // iPhone 17 logical resolution (402×874 pt). Using aspect-ratio keeps the
@@ -43,34 +112,44 @@ const basePlaceholderStyle = {
43
112
  // here or the stacked glyph + text collapse onto each other.
44
113
  lineHeight: 1.4,
45
114
  };
46
- const baseImgStyle = {
47
- display: "block",
115
+ const baseCanvasStyle = {
48
116
  maxWidth: "100%",
49
117
  width: "auto",
50
118
  height: "auto",
51
119
  pointerEvents: "none",
52
120
  };
53
- /** 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. */
54
122
  function toNatural(el, clientX, clientY) {
55
123
  const rect = el.getBoundingClientRect();
56
- const scaleX = el.naturalWidth / rect.width;
57
- const scaleY = el.naturalHeight / rect.height;
124
+ const scaleX = el.width / rect.width;
125
+ const scaleY = el.height / rect.height;
58
126
  return {
59
127
  x: Math.round((clientX - rect.left) * scaleX),
60
128
  y: Math.round((clientY - rect.top) * scaleY),
61
129
  };
62
130
  }
63
- export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", }) {
64
- const imgRef = useRef(null);
131
+ export function SimulatorScreen({ canvasRef, videoSupported, hasFrame, onTap, onSwipe, onKeyInput, maxHeight = "80vh", }) {
132
+ const canvasElRef = useRef(null);
65
133
  const dragRef = useRef(null);
66
- const imgStyle = { ...baseImgStyle, maxHeight };
67
- // Drive the placeholder by height so its width derives from the iPhone 17
68
- // aspect ratio (a block div would otherwise fill the column width and distort).
134
+ const [focused, setFocused] = useState(false);
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
+ };
69
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]);
70
149
  const handleMouseDown = useCallback((e) => {
71
- if (!imgRef.current)
150
+ if (!canvasElRef.current)
72
151
  return;
73
- const natural = toNatural(imgRef.current, e.clientX, e.clientY);
152
+ const natural = toNatural(canvasElRef.current, e.clientX, e.clientY);
74
153
  dragRef.current = {
75
154
  startX: e.clientX,
76
155
  startY: e.clientY,
@@ -81,7 +160,7 @@ export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", })
81
160
  const [ripple, setRipple] = useState(null);
82
161
  const rippleCounter = useRef(0);
83
162
  const handleMouseUp = useCallback((e) => {
84
- if (!dragRef.current || !imgRef.current)
163
+ if (!dragRef.current || !canvasElRef.current)
85
164
  return;
86
165
  const drag = dragRef.current;
87
166
  dragRef.current = null;
@@ -90,17 +169,17 @@ export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", })
90
169
  const dist = Math.sqrt(dx * dx + dy * dy);
91
170
  if (dist < SWIPE_THRESHOLD_PX) {
92
171
  // Tap
93
- const natural = toNatural(imgRef.current, e.clientX, e.clientY);
172
+ const natural = toNatural(canvasElRef.current, e.clientX, e.clientY);
94
173
  onTap?.({ type: "tap", x: natural.x, y: natural.y });
95
174
  // Show ripple at rendered coords
96
- const rect = imgRef.current.getBoundingClientRect();
175
+ const rect = canvasElRef.current.getBoundingClientRect();
97
176
  const id = ++rippleCounter.current;
98
177
  setRipple({ x: e.clientX - rect.left, y: e.clientY - rect.top, id });
99
178
  setTimeout(() => setRipple((r) => (r?.id === id ? null : r)), 500);
100
179
  }
101
180
  else {
102
181
  // Swipe
103
- const naturalEnd = toNatural(imgRef.current, e.clientX, e.clientY);
182
+ const naturalEnd = toNatural(canvasElRef.current, e.clientX, e.clientY);
104
183
  onSwipe?.({
105
184
  type: "swipe",
106
185
  startX: drag.naturalStartX,
@@ -110,13 +189,25 @@ export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", })
110
189
  });
111
190
  }
112
191
  }, [onTap, onSwipe]);
113
- return (_jsx("div", { style: deviceFrameStyle, children: _jsxs("div", { style: wrapperStyle, role: "application", "aria-label": "iOS simulator \u2014 click to tap, drag to swipe", onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, 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: {
192
+ const handleKeyDown = useCallback((e) => {
193
+ if (!onKeyInput)
194
+ return;
195
+ const input = mapKeyboardEvent(e);
196
+ if (!input)
197
+ return;
198
+ // Keep the keystroke off the page (no scrolling on space/arrows, no focus
199
+ // loss on Tab) — it goes to the simulator instead.
200
+ e.preventDefault();
201
+ onKeyInput(input);
202
+ }, [onKeyInput]);
203
+ const frameStyle = focused ? { ...deviceFrameStyle, ...focusedFrameStyle } : deviceFrameStyle;
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: {
114
205
  width: 6,
115
206
  height: 6,
116
207
  borderRadius: "50%",
117
208
  backgroundColor: "currentColor",
118
209
  animation: `expo-pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
119
- } }, i))) })] })), ripple && (_jsx("span", { style: {
210
+ } }, i))) })] }))), ripple && (_jsx("span", { style: {
120
211
  position: "absolute",
121
212
  left: ripple.x - 14,
122
213
  top: ripple.y - 14,
@@ -0,0 +1,34 @@
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 nextTimestamp;
19
+ private awaitingKeyframe;
20
+ private disposed;
21
+ setCanvas(canvas: HTMLCanvasElement | null): void;
22
+ onResize(cb: ((size: FrameSize) => void) | null): void;
23
+ get frameSize(): FrameSize | null;
24
+ push(chunk: ArrayBuffer): void;
25
+ dispose(): void;
26
+ private configure;
27
+ private decode;
28
+ private paintFrame;
29
+ private paintJpeg;
30
+ private updateSize;
31
+ private onDecoderError;
32
+ private closeDecoder;
33
+ }
34
+ //# 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;IAG9D,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,IAAI,SAAS,IAAI,SAAS,GAAG,IAAI,CAEhC;IAED,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAqB9B,OAAO,IAAI,IAAI;IAUf,OAAO,CAAC,SAAS;IAsBjB,OAAO,CAAC,MAAM;IAsBd,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,SAAS;IAiBjB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,cAAc;IAKtB,OAAO,CAAC,YAAY;CAWrB"}
@@ -0,0 +1,187 @@
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
+ // Monotonic, strictly-increasing microsecond timestamps. The values are
25
+ // arbitrary (we render immediately) but EncodedVideoChunk requires them.
26
+ this.nextTimestamp = 0;
27
+ // Until the first keyframe arrives after (re)configuring, deltas reference
28
+ // frames we never decoded — skip them to avoid decoder errors.
29
+ this.awaitingKeyframe = true;
30
+ this.disposed = false;
31
+ }
32
+ /** Whether this environment can decode the stream at all (needs WebCodecs). */
33
+ static isSupported() {
34
+ return typeof globalThis !== "undefined" && "VideoDecoder" in globalThis;
35
+ }
36
+ setCanvas(canvas) {
37
+ this.canvas = canvas;
38
+ this.ctx = canvas ? canvas.getContext("2d") : null;
39
+ if (canvas && this.size) {
40
+ canvas.width = this.size.width;
41
+ canvas.height = this.size.height;
42
+ }
43
+ }
44
+ onResize(cb) {
45
+ this.onResizeCb = cb;
46
+ }
47
+ get frameSize() {
48
+ return this.size;
49
+ }
50
+ push(chunk) {
51
+ if (this.disposed || chunk.byteLength === 0)
52
+ return;
53
+ const bytes = new Uint8Array(chunk);
54
+ const tag = bytes[0];
55
+ const payload = bytes.subarray(1);
56
+ switch (tag) {
57
+ case TAG_DESCRIPTION:
58
+ this.configure(payload);
59
+ break;
60
+ case TAG_JPEG_SEED:
61
+ this.paintJpeg(payload);
62
+ break;
63
+ case TAG_KEYFRAME:
64
+ this.decode(payload, "key");
65
+ break;
66
+ case TAG_DELTA:
67
+ this.decode(payload, "delta");
68
+ break;
69
+ }
70
+ }
71
+ dispose() {
72
+ this.disposed = true;
73
+ this.closeDecoder();
74
+ this.canvas = null;
75
+ this.ctx = null;
76
+ this.onResizeCb = null;
77
+ }
78
+ // ── internals ────────────────────────────────────────────────────────────
79
+ configure(description) {
80
+ if (!SimulatorDecoder.isSupported() || description.byteLength < 4)
81
+ return;
82
+ this.closeDecoder();
83
+ const decoder = new VideoDecoder({
84
+ output: (frame) => this.paintFrame(frame),
85
+ error: (err) => this.onDecoderError(err),
86
+ });
87
+ try {
88
+ decoder.configure({
89
+ codec: codecFromAvcC(description),
90
+ description,
91
+ optimizeForLatency: true,
92
+ });
93
+ }
94
+ catch {
95
+ // Unsupported profile/level on this platform — leave video unsupported.
96
+ decoder.close();
97
+ return;
98
+ }
99
+ this.decoder = decoder;
100
+ this.awaitingKeyframe = true;
101
+ }
102
+ decode(data, type) {
103
+ const decoder = this.decoder;
104
+ if (!decoder || decoder.state !== "configured")
105
+ return;
106
+ if (type === "key") {
107
+ this.awaitingKeyframe = false;
108
+ }
109
+ else if (this.awaitingKeyframe) {
110
+ return; // can't decode a delta before its keyframe
111
+ }
112
+ try {
113
+ decoder.decode(new EncodedVideoChunk({
114
+ type,
115
+ timestamp: this.nextTimestamp++,
116
+ data,
117
+ }));
118
+ }
119
+ catch {
120
+ // A bad/out-of-order chunk — wait for the next keyframe to resync.
121
+ this.awaitingKeyframe = true;
122
+ }
123
+ }
124
+ paintFrame(frame) {
125
+ const width = frame.displayWidth;
126
+ const height = frame.displayHeight;
127
+ this.updateSize(width, height);
128
+ if (this.canvas && this.ctx) {
129
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
130
+ }
131
+ frame.close();
132
+ }
133
+ paintJpeg(jpeg) {
134
+ if (jpeg.byteLength === 0 || typeof createImageBitmap !== "function")
135
+ return;
136
+ // Copy into a fresh buffer so the Blob owns its bytes.
137
+ const blob = new Blob([jpeg.slice()], { type: "image/jpeg" });
138
+ createImageBitmap(blob)
139
+ .then((bitmap) => {
140
+ this.updateSize(bitmap.width, bitmap.height);
141
+ if (this.canvas && this.ctx) {
142
+ this.ctx.drawImage(bitmap, 0, 0, this.canvas.width, this.canvas.height);
143
+ }
144
+ bitmap.close();
145
+ })
146
+ .catch(() => {
147
+ /* a stray/partial seed — ignore */
148
+ });
149
+ }
150
+ updateSize(width, height) {
151
+ if (width === 0 || height === 0)
152
+ return;
153
+ if (this.size && this.size.width === width && this.size.height === height)
154
+ return;
155
+ this.size = { width, height };
156
+ if (this.canvas) {
157
+ this.canvas.width = width;
158
+ this.canvas.height = height;
159
+ }
160
+ this.onResizeCb?.(this.size);
161
+ }
162
+ onDecoderError(_err) {
163
+ // Reset and wait for the next server-forced keyframe to recover.
164
+ this.awaitingKeyframe = true;
165
+ }
166
+ closeDecoder() {
167
+ const decoder = this.decoder;
168
+ this.decoder = null;
169
+ if (decoder && decoder.state !== "closed") {
170
+ try {
171
+ decoder.close();
172
+ }
173
+ catch {
174
+ /* already closed */
175
+ }
176
+ }
177
+ }
178
+ }
179
+ /**
180
+ * Derive the WebCodecs codec string from an avcC configuration record. Bytes
181
+ * 1–3 are AVCProfileIndication / profile_compatibility / AVCLevelIndication,
182
+ * which form the `avc1.PPCCLL` suffix VideoDecoder.configure expects.
183
+ */
184
+ function codecFromAvcC(avcc) {
185
+ const hex = (b) => b.toString(16).padStart(2, "0");
186
+ return `avc1.${hex(avcc[1])}${hex(avcc[2])}${hex(avcc[3])}`;
187
+ }
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,CA8HlD"}
@@ -1,15 +1,27 @@
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
+ // One decoder per hook instance; lives across reconnects within a session.
16
+ const decoderRef = useRef(null);
17
+ if (decoderRef.current === null) {
18
+ decoderRef.current = new SimulatorDecoder();
19
+ decoderRef.current.onResize(setFrameSize);
20
+ }
21
+ // Stable callback to wire the rendering <canvas> into the decoder.
22
+ const attachCanvas = useCallback((canvas) => {
23
+ decoderRef.current?.setCanvas(canvas);
24
+ }, []);
13
25
  // Stable reconnect callback
14
26
  const reconnect = useCallback(() => {
15
27
  clientRef.current?.connect();
@@ -22,6 +34,13 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
22
34
  }
23
35
  return client.sendInput(input);
24
36
  }, []);
37
+ // Tear the decoder down only on unmount — it is reused across reconnects.
38
+ useEffect(() => {
39
+ return () => {
40
+ decoderRef.current?.dispose();
41
+ decoderRef.current = null;
42
+ };
43
+ }, []);
25
44
  useEffect(() => {
26
45
  const client = new ExpoCiClient({ baseUrl, sessionId, apiToken });
27
46
  clientRef.current = client;
@@ -56,8 +75,8 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
56
75
  return next.length > maxLogs ? next.slice(next.length - maxLogs) : next;
57
76
  });
58
77
  });
59
- const offFrame = client.on("video-frame", (msg) => {
60
- setFrame(`data:image/jpeg;base64,${msg.data}`);
78
+ const offFrame = client.on("video-chunk", (chunk) => {
79
+ decoderRef.current?.push(chunk);
61
80
  });
62
81
  const offStatus = client.on("status", (msg) => {
63
82
  setStatus(msg.data.status);
@@ -80,5 +99,15 @@ export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = t
80
99
  };
81
100
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
101
  }, [baseUrl, sessionId, apiToken, autoConnect, maxLogs]);
83
- return { status, logs, frame, connected, error, sendInput, reconnect };
102
+ return {
103
+ status,
104
+ logs,
105
+ attachCanvas,
106
+ videoSupported,
107
+ frameSize,
108
+ connected,
109
+ error,
110
+ sendInput,
111
+ reconnect,
112
+ };
84
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alivelabs/expo-orchestrator-react-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": ">=18.0.0",