@alivelabs/expo-orchestrator-react-client 0.1.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 +123 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +172 -0
- package/dist/components/LogConsole.d.ts +8 -0
- package/dist/components/LogConsole.d.ts.map +1 -0
- package/dist/components/LogConsole.js +72 -0
- package/dist/components/SessionViewer.d.ts +8 -0
- package/dist/components/SessionViewer.d.ts.map +1 -0
- package/dist/components/SessionViewer.js +168 -0
- package/dist/components/SimulatorScreen.d.ts +17 -0
- package/dist/components/SimulatorScreen.d.ts.map +1 -0
- package/dist/components/SimulatorScreen.js +151 -0
- package/dist/components/StatusBadge.d.ts +7 -0
- package/dist/components/StatusBadge.d.ts.map +1 -0
- package/dist/components/StatusBadge.js +35 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/useExpoCiSession.d.ts +3 -0
- package/dist/useExpoCiSession.d.ts.map +1 -0
- package/dist/useExpoCiSession.js +84 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @alivelabs/expo-orchestrator-react-client
|
|
2
|
+
|
|
3
|
+
React client for the Expo CI Orchestrator. Renders a live build session in the browser: streaming logs, live iOS simulator video, session status, and interactive controls (tap, swipe, type, keypress).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @alivelabs/expo-orchestrator-react-client
|
|
9
|
+
# peer deps
|
|
10
|
+
npm install react react-dom
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { SessionViewer } from "@alivelabs/expo-orchestrator-react-client";
|
|
17
|
+
|
|
18
|
+
function App() {
|
|
19
|
+
return (
|
|
20
|
+
<SessionViewer
|
|
21
|
+
sessionId="abc-123"
|
|
22
|
+
apiToken="your-api-token"
|
|
23
|
+
baseUrl="https://your-orchestrator.example.com"
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Hook usage
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { useExpoCiSession } from "@alivelabs/expo-orchestrator-react-client";
|
|
33
|
+
|
|
34
|
+
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
|
+
});
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<p>Status: {status} — {connected ? "live" : "disconnected"}</p>
|
|
46
|
+
{frame && <img src={frame} alt="simulator" />}
|
|
47
|
+
<button onClick={() => sendInput({ type: "keypress", key: "home" })}>Home</button>
|
|
48
|
+
{!connected && <button onClick={reconnect}>Reconnect</button>}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Exports reference
|
|
55
|
+
|
|
56
|
+
| Export | Kind | Description |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `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 |
|
|
60
|
+
| `LogConsole` | Component | Scrollable, color-coded, auto-scrolling log viewer |
|
|
61
|
+
| `StatusBadge` | Component | Colored pill showing session status |
|
|
62
|
+
| `useExpoCiSession` | Hook | React hook wrapping `ExpoCiClient` with state management |
|
|
63
|
+
| `ExpoCiClient` | Class | Framework-agnostic client: REST + WebSocket with auto-reconnect |
|
|
64
|
+
|
|
65
|
+
### `<SessionViewer>` props
|
|
66
|
+
|
|
67
|
+
| Prop | Type | Required | Default | Description |
|
|
68
|
+
|---|---|---|---|---|
|
|
69
|
+
| `sessionId` | `string` | yes | — | The session to display |
|
|
70
|
+
| `apiToken` | `string` | yes | — | Bearer token for the API |
|
|
71
|
+
| `baseUrl` | `string` | no | `http://localhost:3000` | Orchestrator base URL |
|
|
72
|
+
| `className` | `string` | no | — | CSS class applied to the root element |
|
|
73
|
+
|
|
74
|
+
### `<SimulatorScreen>` props
|
|
75
|
+
|
|
76
|
+
| Prop | Type | Required | Description |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `frame` | `string \| null` | yes | Current frame as a `data:image/jpeg;base64,...` string |
|
|
79
|
+
| `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 |
|
|
81
|
+
|
|
82
|
+
### `<LogConsole>` props
|
|
83
|
+
|
|
84
|
+
| Prop | Type | Required | Default | Description |
|
|
85
|
+
|---|---|---|---|---|
|
|
86
|
+
| `logs` | `LogEntry[]` | yes | — | Array of log entries |
|
|
87
|
+
| `maxHeight` | `string \| number` | no | `400` | CSS max-height of the scroll area |
|
|
88
|
+
|
|
89
|
+
### `<StatusBadge>` props
|
|
90
|
+
|
|
91
|
+
| Prop | Type | Required | Description |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `status` | `SessionStatus \| null` | yes | Session status to display |
|
|
94
|
+
|
|
95
|
+
### `useExpoCiSession` options
|
|
96
|
+
|
|
97
|
+
| Option | Type | Default | Description |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| `sessionId` | `string` | — | Required |
|
|
100
|
+
| `apiToken` | `string` | — | Required |
|
|
101
|
+
| `baseUrl` | `string` | `http://localhost:3000` | Orchestrator base URL |
|
|
102
|
+
| `autoConnect` | `boolean` | `true` | Open WebSocket immediately on mount |
|
|
103
|
+
| `maxLogs` | `number` | `2000` | Max log lines retained in state |
|
|
104
|
+
|
|
105
|
+
### `ExpoCiClient` API
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const client = new ExpoCiClient({ sessionId, apiToken, baseUrl });
|
|
109
|
+
|
|
110
|
+
client.getSession() // GET /api/sessions/:id → SessionDetail
|
|
111
|
+
client.getLogs() // GET /api/sessions/:id/logs → SessionLogs
|
|
112
|
+
client.sendInput(input) // POST /api/sessions/:id/simulator/input
|
|
113
|
+
client.getScreenshotObjectUrl() // GET /api/sessions/:id/screenshot → object URL (fetch + blob)
|
|
114
|
+
|
|
115
|
+
client.connect() // Open WebSocket (auto-reconnect with exponential backoff)
|
|
116
|
+
client.disconnect() // Close WebSocket and remove all listeners
|
|
117
|
+
|
|
118
|
+
// Typed event emitter
|
|
119
|
+
const off = client.on("video-frame", (msg) => console.log(msg.data));
|
|
120
|
+
off(); // unsubscribe
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Events: `open`, `close`, `connected`, `log`, `video-frame`, `status`, `error`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ClientEventMap, SessionDetail, SessionLogs, SimulatorInput, SimulatorInputResponse } from "./types.js";
|
|
2
|
+
type Listener<T> = T extends undefined ? () => void : (data: T) => void;
|
|
3
|
+
type EventMap = Record<string, any>;
|
|
4
|
+
declare class TypedEventEmitter<Events extends EventMap> {
|
|
5
|
+
private readonly listeners;
|
|
6
|
+
on<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): () => void;
|
|
7
|
+
off<K extends keyof Events & string>(event: K, listener: Listener<Events[K]>): void;
|
|
8
|
+
emit<K extends keyof Events & string>(event: K, ...args: Events[K] extends undefined ? [] : [Events[K]]): void;
|
|
9
|
+
removeAllListeners(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface ExpoCiClientOptions {
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
apiToken: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class ExpoCiClient extends TypedEventEmitter<ClientEventMap> {
|
|
17
|
+
private readonly baseUrl;
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
private readonly apiToken;
|
|
20
|
+
private ws;
|
|
21
|
+
private reconnectTimer;
|
|
22
|
+
private attemptCount;
|
|
23
|
+
private destroyed;
|
|
24
|
+
constructor({ baseUrl, sessionId, apiToken }: ExpoCiClientOptions);
|
|
25
|
+
private restFetch;
|
|
26
|
+
getSession(): Promise<SessionDetail>;
|
|
27
|
+
getLogs(): Promise<SessionLogs>;
|
|
28
|
+
sendInput(input: SimulatorInput): Promise<SimulatorInputResponse>;
|
|
29
|
+
getScreenshotObjectUrl(): Promise<string>;
|
|
30
|
+
private get wsUrl();
|
|
31
|
+
connect(): void;
|
|
32
|
+
private openSocket;
|
|
33
|
+
private scheduleReconnect;
|
|
34
|
+
disconnect(): void;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
class TypedEventEmitter {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.listeners = new Map();
|
|
4
|
+
}
|
|
5
|
+
on(event, listener) {
|
|
6
|
+
if (!this.listeners.has(event)) {
|
|
7
|
+
this.listeners.set(event, new Set());
|
|
8
|
+
}
|
|
9
|
+
// biome-ignore lint/style/noNonNullAssertion: just set above
|
|
10
|
+
this.listeners.get(event).add(listener);
|
|
11
|
+
return () => this.off(event, listener);
|
|
12
|
+
}
|
|
13
|
+
off(event, listener) {
|
|
14
|
+
this.listeners.get(event)?.delete(listener);
|
|
15
|
+
}
|
|
16
|
+
emit(event, ...args) {
|
|
17
|
+
const set = this.listeners.get(event);
|
|
18
|
+
if (!set)
|
|
19
|
+
return;
|
|
20
|
+
for (const listener of set) {
|
|
21
|
+
listener(...args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
removeAllListeners() {
|
|
25
|
+
this.listeners.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ── Reconnect config ──────────────────────────────────────────────────────────
|
|
29
|
+
const INITIAL_DELAY_MS = 500;
|
|
30
|
+
const MAX_DELAY_MS = 30000;
|
|
31
|
+
const MAX_ATTEMPTS = 10;
|
|
32
|
+
export class ExpoCiClient extends TypedEventEmitter {
|
|
33
|
+
constructor({ baseUrl = "http://localhost:3000", sessionId, apiToken }) {
|
|
34
|
+
super();
|
|
35
|
+
this.ws = null;
|
|
36
|
+
this.reconnectTimer = null;
|
|
37
|
+
this.attemptCount = 0;
|
|
38
|
+
this.destroyed = false;
|
|
39
|
+
// Normalize: strip trailing slash
|
|
40
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
41
|
+
this.sessionId = sessionId;
|
|
42
|
+
this.apiToken = apiToken;
|
|
43
|
+
}
|
|
44
|
+
// ── REST helpers ────────────────────────────────────────────────────────────
|
|
45
|
+
async restFetch(path, init) {
|
|
46
|
+
const url = `${this.baseUrl}${path}`;
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
...init,
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${this.apiToken}`,
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
...(init?.headers ?? {}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
|
|
57
|
+
}
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
getSession() {
|
|
61
|
+
return this.restFetch(`/api/sessions/${this.sessionId}`);
|
|
62
|
+
}
|
|
63
|
+
getLogs() {
|
|
64
|
+
return this.restFetch(`/api/sessions/${this.sessionId}/logs`);
|
|
65
|
+
}
|
|
66
|
+
sendInput(input) {
|
|
67
|
+
return this.restFetch(`/api/sessions/${this.sessionId}/simulator/input`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
body: JSON.stringify(input),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async getScreenshotObjectUrl() {
|
|
73
|
+
const url = `${this.baseUrl}/api/sessions/${this.sessionId}/screenshot`;
|
|
74
|
+
const res = await fetch(url, {
|
|
75
|
+
headers: { Authorization: `Bearer ${this.apiToken}` },
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
|
|
79
|
+
}
|
|
80
|
+
const blob = await res.blob();
|
|
81
|
+
return URL.createObjectURL(blob);
|
|
82
|
+
}
|
|
83
|
+
// ── WebSocket ───────────────────────────────────────────────────────────────
|
|
84
|
+
get wsUrl() {
|
|
85
|
+
// Derive ws:// / wss:// from the baseUrl scheme
|
|
86
|
+
const httpUrl = this.baseUrl;
|
|
87
|
+
const wsBase = httpUrl.replace(/^https/, "wss").replace(/^http/, "ws");
|
|
88
|
+
return `${wsBase}/ws/sessions/${this.sessionId}?token=${encodeURIComponent(this.apiToken)}`;
|
|
89
|
+
}
|
|
90
|
+
connect() {
|
|
91
|
+
if (this.destroyed)
|
|
92
|
+
return;
|
|
93
|
+
this.attemptCount = 0;
|
|
94
|
+
this.openSocket();
|
|
95
|
+
}
|
|
96
|
+
openSocket() {
|
|
97
|
+
if (this.destroyed)
|
|
98
|
+
return;
|
|
99
|
+
const socket = new WebSocket(this.wsUrl);
|
|
100
|
+
this.ws = socket;
|
|
101
|
+
socket.addEventListener("open", () => {
|
|
102
|
+
if (this.destroyed) {
|
|
103
|
+
socket.close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.attemptCount = 0;
|
|
107
|
+
this.emit("open");
|
|
108
|
+
});
|
|
109
|
+
socket.addEventListener("message", (event) => {
|
|
110
|
+
if (this.destroyed)
|
|
111
|
+
return;
|
|
112
|
+
let msg;
|
|
113
|
+
try {
|
|
114
|
+
msg = JSON.parse(event.data);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
switch (msg.type) {
|
|
120
|
+
case "connected":
|
|
121
|
+
this.emit("connected", msg);
|
|
122
|
+
break;
|
|
123
|
+
case "log":
|
|
124
|
+
this.emit("log", msg);
|
|
125
|
+
break;
|
|
126
|
+
case "video-frame":
|
|
127
|
+
this.emit("video-frame", msg);
|
|
128
|
+
break;
|
|
129
|
+
case "status":
|
|
130
|
+
this.emit("status", msg);
|
|
131
|
+
break;
|
|
132
|
+
case "error":
|
|
133
|
+
this.emit("error", msg);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
socket.addEventListener("close", () => {
|
|
138
|
+
if (this.destroyed) {
|
|
139
|
+
this.emit("close");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.emit("close");
|
|
143
|
+
this.scheduleReconnect();
|
|
144
|
+
});
|
|
145
|
+
socket.addEventListener("error", () => {
|
|
146
|
+
// The "close" event always follows an error event on a WebSocket,
|
|
147
|
+
// so we let "close" drive reconnection.
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
scheduleReconnect() {
|
|
151
|
+
if (this.destroyed || this.attemptCount >= MAX_ATTEMPTS)
|
|
152
|
+
return;
|
|
153
|
+
this.attemptCount++;
|
|
154
|
+
const delay = Math.min(INITIAL_DELAY_MS * 2 ** (this.attemptCount - 1), MAX_DELAY_MS);
|
|
155
|
+
this.reconnectTimer = setTimeout(() => {
|
|
156
|
+
if (!this.destroyed)
|
|
157
|
+
this.openSocket();
|
|
158
|
+
}, delay);
|
|
159
|
+
}
|
|
160
|
+
disconnect() {
|
|
161
|
+
this.destroyed = true;
|
|
162
|
+
if (this.reconnectTimer !== null) {
|
|
163
|
+
clearTimeout(this.reconnectTimer);
|
|
164
|
+
this.reconnectTimer = null;
|
|
165
|
+
}
|
|
166
|
+
if (this.ws) {
|
|
167
|
+
this.ws.close();
|
|
168
|
+
this.ws = null;
|
|
169
|
+
}
|
|
170
|
+
this.removeAllListeners();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LogEntry } from "../types.js";
|
|
2
|
+
interface LogConsoleProps {
|
|
3
|
+
logs: LogEntry[];
|
|
4
|
+
maxHeight?: string | number;
|
|
5
|
+
}
|
|
6
|
+
export declare function LogConsole({ logs, maxHeight }: LogConsoleProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=LogConsole.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogConsole.d.ts","sourceRoot":"","sources":["../../src/components/LogConsole.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAY,MAAM,aAAa,CAAC;AAEtD,UAAU,eAAe;IACvB,IAAI,EAAE,QAAQ,EAAE,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7B;AAuDD,wBAAgB,UAAU,CAAC,EAAE,IAAI,EAAE,SAAe,EAAE,EAAE,eAAe,2CA4CpE"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
const LEVEL_COLORS = {
|
|
4
|
+
stdout: "#d1fae5",
|
|
5
|
+
stderr: "#fecaca",
|
|
6
|
+
system: "#bfdbfe",
|
|
7
|
+
};
|
|
8
|
+
const LEVEL_LABEL_COLORS = {
|
|
9
|
+
stdout: "#6ee7b7",
|
|
10
|
+
stderr: "#f87171",
|
|
11
|
+
system: "#93c5fd",
|
|
12
|
+
};
|
|
13
|
+
const containerStyle = (maxHeight) => ({
|
|
14
|
+
backgroundColor: "#0d1117",
|
|
15
|
+
borderRadius: "6px",
|
|
16
|
+
overflow: "hidden",
|
|
17
|
+
display: "flex",
|
|
18
|
+
flexDirection: "column",
|
|
19
|
+
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
|
|
20
|
+
fontSize: "12px",
|
|
21
|
+
lineHeight: "1.6",
|
|
22
|
+
border: "1px solid #21262d",
|
|
23
|
+
maxHeight,
|
|
24
|
+
});
|
|
25
|
+
const headerStyle = {
|
|
26
|
+
padding: "6px 12px",
|
|
27
|
+
backgroundColor: "#161b22",
|
|
28
|
+
color: "#8b949e",
|
|
29
|
+
fontSize: "11px",
|
|
30
|
+
fontWeight: 600,
|
|
31
|
+
letterSpacing: "0.05em",
|
|
32
|
+
textTransform: "uppercase",
|
|
33
|
+
borderBottom: "1px solid #21262d",
|
|
34
|
+
flexShrink: 0,
|
|
35
|
+
fontFamily: "system-ui, sans-serif",
|
|
36
|
+
};
|
|
37
|
+
const scrollAreaStyle = {
|
|
38
|
+
overflowY: "auto",
|
|
39
|
+
flex: 1,
|
|
40
|
+
padding: "8px 0",
|
|
41
|
+
};
|
|
42
|
+
const rowStyle = {
|
|
43
|
+
display: "flex",
|
|
44
|
+
alignItems: "flex-start",
|
|
45
|
+
gap: "8px",
|
|
46
|
+
padding: "1px 12px",
|
|
47
|
+
whiteSpace: "pre-wrap",
|
|
48
|
+
wordBreak: "break-all",
|
|
49
|
+
};
|
|
50
|
+
export function LogConsole({ logs, maxHeight = 400 }) {
|
|
51
|
+
const bottomRef = useRef(null);
|
|
52
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll to bottom whenever log count changes; bottomRef is a ref (stable, not reactive)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
55
|
+
}, [logs.length]);
|
|
56
|
+
return (_jsxs("div", { style: containerStyle(maxHeight), children: [_jsxs("div", { style: headerStyle, children: ["Build Logs \u2014 ", logs.length, " lines"] }), _jsxs("div", { style: scrollAreaStyle, children: [logs.length === 0 && (_jsx("div", { style: { ...rowStyle, color: "#6e7681", fontStyle: "italic" }, children: "Waiting for logs\u2026" })), logs.map((entry, idx) => (_jsxs("div", { style: { ...rowStyle, color: LEVEL_COLORS[entry.level] }, children: [_jsx("span", { style: {
|
|
57
|
+
color: LEVEL_LABEL_COLORS[entry.level],
|
|
58
|
+
flexShrink: 0,
|
|
59
|
+
minWidth: "44px",
|
|
60
|
+
fontWeight: 600,
|
|
61
|
+
fontSize: "11px",
|
|
62
|
+
}, children: entry.level }), _jsx("span", { style: { color: "#6e7681", flexShrink: 0, userSelect: "none" }, children: formatTime(entry.timestamp) }), _jsx("span", { style: { flex: 1 }, children: entry.message })] }, `${entry.timestamp}-${idx}`))), _jsx("div", { ref: bottomRef })] })] }));
|
|
63
|
+
}
|
|
64
|
+
function formatTime(iso) {
|
|
65
|
+
try {
|
|
66
|
+
const d = new Date(iso);
|
|
67
|
+
return d.toISOString().slice(11, 23); // HH:MM:SS.mmm
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return iso;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface SessionViewerProps {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
apiToken: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function SessionViewer({ sessionId, apiToken, baseUrl, className }: SessionViewerProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
//# sourceMappingURL=SessionViewer.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { useExpoCiSession } from "../useExpoCiSession.js";
|
|
4
|
+
import { LogConsole } from "./LogConsole.js";
|
|
5
|
+
import { SimulatorScreen } from "./SimulatorScreen.js";
|
|
6
|
+
import { StatusBadge } from "./StatusBadge.js";
|
|
7
|
+
const rootStyle = {
|
|
8
|
+
display: "flex",
|
|
9
|
+
flexDirection: "column",
|
|
10
|
+
gap: "16px",
|
|
11
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
12
|
+
color: "#e6edf3",
|
|
13
|
+
backgroundColor: "#0d1117",
|
|
14
|
+
padding: "20px",
|
|
15
|
+
borderRadius: "10px",
|
|
16
|
+
minWidth: "360px",
|
|
17
|
+
};
|
|
18
|
+
const headerStyle = {
|
|
19
|
+
display: "flex",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
justifyContent: "space-between",
|
|
22
|
+
gap: "12px",
|
|
23
|
+
flexWrap: "wrap",
|
|
24
|
+
};
|
|
25
|
+
const titleStyle = {
|
|
26
|
+
fontSize: "15px",
|
|
27
|
+
fontWeight: 600,
|
|
28
|
+
color: "#e6edf3",
|
|
29
|
+
margin: 0,
|
|
30
|
+
};
|
|
31
|
+
const bodyStyle = {
|
|
32
|
+
display: "flex",
|
|
33
|
+
gap: "20px",
|
|
34
|
+
flexWrap: "wrap",
|
|
35
|
+
alignItems: "flex-start",
|
|
36
|
+
};
|
|
37
|
+
const simulatorColStyle = {
|
|
38
|
+
flexShrink: 0,
|
|
39
|
+
};
|
|
40
|
+
const rightColStyle = {
|
|
41
|
+
flex: 1,
|
|
42
|
+
display: "flex",
|
|
43
|
+
flexDirection: "column",
|
|
44
|
+
gap: "16px",
|
|
45
|
+
minWidth: "260px",
|
|
46
|
+
};
|
|
47
|
+
const controlsStyle = {
|
|
48
|
+
display: "flex",
|
|
49
|
+
flexDirection: "column",
|
|
50
|
+
gap: "10px",
|
|
51
|
+
padding: "14px",
|
|
52
|
+
backgroundColor: "#161b22",
|
|
53
|
+
borderRadius: "6px",
|
|
54
|
+
border: "1px solid #21262d",
|
|
55
|
+
};
|
|
56
|
+
const inputRowStyle = {
|
|
57
|
+
display: "flex",
|
|
58
|
+
gap: "8px",
|
|
59
|
+
};
|
|
60
|
+
const textInputStyle = {
|
|
61
|
+
flex: 1,
|
|
62
|
+
padding: "6px 10px",
|
|
63
|
+
backgroundColor: "#0d1117",
|
|
64
|
+
border: "1px solid #30363d",
|
|
65
|
+
borderRadius: "6px",
|
|
66
|
+
color: "#e6edf3",
|
|
67
|
+
fontSize: "13px",
|
|
68
|
+
outline: "none",
|
|
69
|
+
};
|
|
70
|
+
const btnStyle = (variant) => {
|
|
71
|
+
const bg = {
|
|
72
|
+
primary: "#1f6feb",
|
|
73
|
+
secondary: "#21262d",
|
|
74
|
+
danger: "#b91c1c",
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
padding: "6px 12px",
|
|
78
|
+
backgroundColor: bg[variant],
|
|
79
|
+
color: "#e6edf3",
|
|
80
|
+
border: "1px solid rgba(255,255,255,0.1)",
|
|
81
|
+
borderRadius: "6px",
|
|
82
|
+
cursor: "pointer",
|
|
83
|
+
fontSize: "12px",
|
|
84
|
+
fontWeight: 500,
|
|
85
|
+
fontFamily: "system-ui, sans-serif",
|
|
86
|
+
whiteSpace: "nowrap",
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const keypressRowStyle = {
|
|
90
|
+
display: "flex",
|
|
91
|
+
gap: "8px",
|
|
92
|
+
flexWrap: "wrap",
|
|
93
|
+
};
|
|
94
|
+
const errorBannerStyle = {
|
|
95
|
+
padding: "8px 12px",
|
|
96
|
+
backgroundColor: "#450a0a",
|
|
97
|
+
border: "1px solid #7f1d1d",
|
|
98
|
+
borderRadius: "6px",
|
|
99
|
+
color: "#fca5a5",
|
|
100
|
+
fontSize: "13px",
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Preset hardware-button / keystroke shortcuts shown in the controls row.
|
|
104
|
+
* Each entry is a ready-to-send baguette payload — the orchestrator forwards
|
|
105
|
+
* it untouched, so the labels and shapes are decoupled from the server.
|
|
106
|
+
*/
|
|
107
|
+
const PRESETS = [
|
|
108
|
+
{ label: "Home", input: { type: "button", button: "home" } },
|
|
109
|
+
{ label: "App Switcher", input: { type: "button", button: "app-switcher" } },
|
|
110
|
+
{ label: "Esc", input: { type: "key", code: "Escape" } },
|
|
111
|
+
{ label: "Enter", input: { type: "key", code: "Enter" } },
|
|
112
|
+
];
|
|
113
|
+
export function SessionViewer({ sessionId, apiToken, baseUrl, className }) {
|
|
114
|
+
const { status, logs, frame, connected, error, sendInput, reconnect } = useExpoCiSession({
|
|
115
|
+
baseUrl,
|
|
116
|
+
sessionId,
|
|
117
|
+
apiToken,
|
|
118
|
+
});
|
|
119
|
+
const [textValue, setTextValue] = useState("");
|
|
120
|
+
const [sending, setSending] = useState(false);
|
|
121
|
+
const handleSendText = useCallback(async (e) => {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
const trimmed = textValue.trim();
|
|
124
|
+
if (!trimmed)
|
|
125
|
+
return;
|
|
126
|
+
setSending(true);
|
|
127
|
+
try {
|
|
128
|
+
await sendInput({ type: "type", text: trimmed });
|
|
129
|
+
setTextValue("");
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Errors surface through WS error events; silence here
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
setSending(false);
|
|
136
|
+
}
|
|
137
|
+
}, [textValue, sendInput]);
|
|
138
|
+
const handlePreset = useCallback(async (input) => {
|
|
139
|
+
setSending(true);
|
|
140
|
+
try {
|
|
141
|
+
await sendInput(input);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Same rationale as above
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
setSending(false);
|
|
148
|
+
}
|
|
149
|
+
}, [sendInput]);
|
|
150
|
+
const handleTap = useCallback((input) => {
|
|
151
|
+
sendInput(input).catch(() => { });
|
|
152
|
+
}, [sendInput]);
|
|
153
|
+
const handleSwipe = useCallback((input) => {
|
|
154
|
+
sendInput(input).catch(() => { });
|
|
155
|
+
}, [sendInput]);
|
|
156
|
+
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
|
+
fontSize: "11px",
|
|
158
|
+
color: connected ? "#4ade80" : "#6b7280",
|
|
159
|
+
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: {
|
|
161
|
+
fontSize: "11px",
|
|
162
|
+
fontWeight: 600,
|
|
163
|
+
color: "#8b949e",
|
|
164
|
+
marginBottom: "4px",
|
|
165
|
+
letterSpacing: "0.05em",
|
|
166
|
+
textTransform: "uppercase",
|
|
167
|
+
}, children: "Simulator Controls" }), _jsxs("form", { onSubmit: handleSendText, style: inputRowStyle, children: [_jsx("input", { type: "text", style: textInputStyle, placeholder: "Type text to send\u2026", value: textValue, onChange: (e) => setTextValue(e.currentTarget.value), disabled: sending }), _jsx("button", { type: "submit", style: btnStyle("primary"), disabled: sending || !textValue.trim(), children: "Send" })] }), _jsx("div", { style: keypressRowStyle, children: PRESETS.map((preset) => (_jsx("button", { type: "button", style: btnStyle("secondary"), disabled: sending, onClick: () => handlePreset(preset.input), children: preset.label }, preset.label))) })] }), _jsx(LogConsole, { logs: logs, maxHeight: 480 })] })] })] }));
|
|
168
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SimulatorInput } from "../types.js";
|
|
2
|
+
interface SimulatorScreenProps {
|
|
3
|
+
frame: string | null;
|
|
4
|
+
onTap?: (input: Extract<SimulatorInput, {
|
|
5
|
+
type: "tap";
|
|
6
|
+
}>) => void;
|
|
7
|
+
onSwipe?: (input: Extract<SimulatorInput, {
|
|
8
|
+
type: "swipe";
|
|
9
|
+
}>) => void;
|
|
10
|
+
/** Cap on the rendered simulator height; the image scales down proportionally
|
|
11
|
+
* to fit. Accepts any CSS length. Defaults to `80vh` so the device never
|
|
12
|
+
* exceeds the viewport on a tall phone like iPhone 17 Pro Max. */
|
|
13
|
+
maxHeight?: number | string;
|
|
14
|
+
}
|
|
15
|
+
export declare function SimulatorScreen({ frame, onTap, onSwipe, maxHeight, }: SimulatorScreenProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=SimulatorScreen.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
const SWIPE_THRESHOLD_PX = 5;
|
|
4
|
+
// The outer titanium-style bezel that makes the screen read as a physical
|
|
5
|
+
// device. The screen (wrapper) sits inside it with its own rounded corners.
|
|
6
|
+
const deviceFrameStyle = {
|
|
7
|
+
display: "inline-block",
|
|
8
|
+
padding: "11px",
|
|
9
|
+
borderRadius: "46px",
|
|
10
|
+
background: "linear-gradient(150deg, #3a3a3e 0%, #1b1b1d 48%, #0a0a0c 100%)",
|
|
11
|
+
boxShadow: "0 24px 60px -18px rgba(0,0,0,0.75), 0 4px 14px rgba(0,0,0,0.4)," +
|
|
12
|
+
" inset 0 0 0 1px rgba(255,255,255,0.05), inset 0 1.5px 1px rgba(255,255,255,0.12)",
|
|
13
|
+
lineHeight: 0,
|
|
14
|
+
};
|
|
15
|
+
const wrapperStyle = {
|
|
16
|
+
position: "relative",
|
|
17
|
+
display: "block",
|
|
18
|
+
backgroundColor: "#000",
|
|
19
|
+
borderRadius: "36px",
|
|
20
|
+
overflow: "hidden",
|
|
21
|
+
cursor: "crosshair",
|
|
22
|
+
userSelect: "none",
|
|
23
|
+
lineHeight: 0,
|
|
24
|
+
boxShadow: "inset 0 0 0 2px rgba(0,0,0,0.85)",
|
|
25
|
+
};
|
|
26
|
+
const basePlaceholderStyle = {
|
|
27
|
+
// iPhone 17 logical resolution (402×874 pt). Using aspect-ratio keeps the
|
|
28
|
+
// empty state proportionally correct at any column width — no distortion —
|
|
29
|
+
// and matches the device the orchestrator boots by default.
|
|
30
|
+
aspectRatio: "402 / 874",
|
|
31
|
+
maxWidth: "100%",
|
|
32
|
+
background: "radial-gradient(120% 80% at 50% 32%, #242428 0%, #161618 55%, #0f0f12 100%)",
|
|
33
|
+
display: "flex",
|
|
34
|
+
alignItems: "center",
|
|
35
|
+
justifyContent: "center",
|
|
36
|
+
color: "#7c7c85",
|
|
37
|
+
fontSize: "13px",
|
|
38
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
39
|
+
letterSpacing: "0.01em",
|
|
40
|
+
flexDirection: "column",
|
|
41
|
+
gap: "16px",
|
|
42
|
+
// The wrapper sets line-height:0 (so the <img> has no baseline gap); reset it
|
|
43
|
+
// here or the stacked glyph + text collapse onto each other.
|
|
44
|
+
lineHeight: 1.4,
|
|
45
|
+
};
|
|
46
|
+
const baseImgStyle = {
|
|
47
|
+
display: "block",
|
|
48
|
+
maxWidth: "100%",
|
|
49
|
+
width: "auto",
|
|
50
|
+
height: "auto",
|
|
51
|
+
pointerEvents: "none",
|
|
52
|
+
};
|
|
53
|
+
/** Convert a rendered-canvas pixel position to the image's natural pixel coords */
|
|
54
|
+
function toNatural(el, clientX, clientY) {
|
|
55
|
+
const rect = el.getBoundingClientRect();
|
|
56
|
+
const scaleX = el.naturalWidth / rect.width;
|
|
57
|
+
const scaleY = el.naturalHeight / rect.height;
|
|
58
|
+
return {
|
|
59
|
+
x: Math.round((clientX - rect.left) * scaleX),
|
|
60
|
+
y: Math.round((clientY - rect.top) * scaleY),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function SimulatorScreen({ frame, onTap, onSwipe, maxHeight = "80vh", }) {
|
|
64
|
+
const imgRef = useRef(null);
|
|
65
|
+
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).
|
|
69
|
+
const placeholderStyle = { ...basePlaceholderStyle, height: maxHeight };
|
|
70
|
+
const handleMouseDown = useCallback((e) => {
|
|
71
|
+
if (!imgRef.current)
|
|
72
|
+
return;
|
|
73
|
+
const natural = toNatural(imgRef.current, e.clientX, e.clientY);
|
|
74
|
+
dragRef.current = {
|
|
75
|
+
startX: e.clientX,
|
|
76
|
+
startY: e.clientY,
|
|
77
|
+
naturalStartX: natural.x,
|
|
78
|
+
naturalStartY: natural.y,
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
const [ripple, setRipple] = useState(null);
|
|
82
|
+
const rippleCounter = useRef(0);
|
|
83
|
+
const handleMouseUp = useCallback((e) => {
|
|
84
|
+
if (!dragRef.current || !imgRef.current)
|
|
85
|
+
return;
|
|
86
|
+
const drag = dragRef.current;
|
|
87
|
+
dragRef.current = null;
|
|
88
|
+
const dx = e.clientX - drag.startX;
|
|
89
|
+
const dy = e.clientY - drag.startY;
|
|
90
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
91
|
+
if (dist < SWIPE_THRESHOLD_PX) {
|
|
92
|
+
// Tap
|
|
93
|
+
const natural = toNatural(imgRef.current, e.clientX, e.clientY);
|
|
94
|
+
onTap?.({ type: "tap", x: natural.x, y: natural.y });
|
|
95
|
+
// Show ripple at rendered coords
|
|
96
|
+
const rect = imgRef.current.getBoundingClientRect();
|
|
97
|
+
const id = ++rippleCounter.current;
|
|
98
|
+
setRipple({ x: e.clientX - rect.left, y: e.clientY - rect.top, id });
|
|
99
|
+
setTimeout(() => setRipple((r) => (r?.id === id ? null : r)), 500);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Swipe
|
|
103
|
+
const naturalEnd = toNatural(imgRef.current, e.clientX, e.clientY);
|
|
104
|
+
onSwipe?.({
|
|
105
|
+
type: "swipe",
|
|
106
|
+
startX: drag.naturalStartX,
|
|
107
|
+
startY: drag.naturalStartY,
|
|
108
|
+
endX: naturalEnd.x,
|
|
109
|
+
endY: naturalEnd.y,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}, [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: {
|
|
114
|
+
width: 6,
|
|
115
|
+
height: 6,
|
|
116
|
+
borderRadius: "50%",
|
|
117
|
+
backgroundColor: "currentColor",
|
|
118
|
+
animation: `expo-pulse 1.2s ease-in-out ${i * 0.2}s infinite`,
|
|
119
|
+
} }, i))) })] })), ripple && (_jsx("span", { style: {
|
|
120
|
+
position: "absolute",
|
|
121
|
+
left: ripple.x - 14,
|
|
122
|
+
top: ripple.y - 14,
|
|
123
|
+
width: 28,
|
|
124
|
+
height: 28,
|
|
125
|
+
borderRadius: "50%",
|
|
126
|
+
border: "2px solid rgba(255,255,255,0.7)",
|
|
127
|
+
backgroundColor: "rgba(255,255,255,0.15)",
|
|
128
|
+
pointerEvents: "none",
|
|
129
|
+
animation: "expo-ripple 0.5s ease-out forwards",
|
|
130
|
+
} }, ripple.id)), _jsx(RippleStyles, {})] }) }));
|
|
131
|
+
}
|
|
132
|
+
// Inject keyframe once
|
|
133
|
+
let stylesInjected = false;
|
|
134
|
+
function RippleStyles() {
|
|
135
|
+
if (typeof document === "undefined" || stylesInjected)
|
|
136
|
+
return null;
|
|
137
|
+
stylesInjected = true;
|
|
138
|
+
const style = document.createElement("style");
|
|
139
|
+
style.textContent = `
|
|
140
|
+
@keyframes expo-ripple {
|
|
141
|
+
0% { transform: scale(0.5); opacity: 1; }
|
|
142
|
+
100% { transform: scale(2.5); opacity: 0; }
|
|
143
|
+
}
|
|
144
|
+
@keyframes expo-pulse {
|
|
145
|
+
0%, 100% { opacity: 0.35; transform: scale(0.94); }
|
|
146
|
+
50% { opacity: 1; transform: scale(1.05); }
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
document.head.appendChild(style);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SessionStatus } from "../types.js";
|
|
2
|
+
interface StatusBadgeProps {
|
|
3
|
+
status: SessionStatus | null;
|
|
4
|
+
}
|
|
5
|
+
export declare function StatusBadge({ status }: StatusBadgeProps): import("react/jsx-runtime").JSX.Element;
|
|
6
|
+
export {};
|
|
7
|
+
//# sourceMappingURL=StatusBadge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StatusBadge.d.ts","sourceRoot":"","sources":["../../src/components/StatusBadge.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,UAAU,gBAAgB;IACxB,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;CAC9B;AAgCD,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,EAAE,gBAAgB,2CAkBvD"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
const STATUS_COLORS = {
|
|
3
|
+
created: { bg: "#6b7280", text: "#fff" },
|
|
4
|
+
running: { bg: "#2563eb", text: "#fff" },
|
|
5
|
+
completed: { bg: "#16a34a", text: "#fff" },
|
|
6
|
+
failed: { bg: "#dc2626", text: "#fff" },
|
|
7
|
+
timeout: { bg: "#d97706", text: "#fff" },
|
|
8
|
+
deleted: { bg: "#374151", text: "#9ca3af" },
|
|
9
|
+
};
|
|
10
|
+
const pillStyle = {
|
|
11
|
+
display: "inline-flex",
|
|
12
|
+
alignItems: "center",
|
|
13
|
+
gap: "6px",
|
|
14
|
+
padding: "3px 10px",
|
|
15
|
+
borderRadius: "9999px",
|
|
16
|
+
fontSize: "12px",
|
|
17
|
+
fontWeight: 600,
|
|
18
|
+
letterSpacing: "0.04em",
|
|
19
|
+
textTransform: "uppercase",
|
|
20
|
+
fontFamily: "system-ui, sans-serif",
|
|
21
|
+
};
|
|
22
|
+
const dotStyle = (color) => ({
|
|
23
|
+
width: "7px",
|
|
24
|
+
height: "7px",
|
|
25
|
+
borderRadius: "50%",
|
|
26
|
+
backgroundColor: color,
|
|
27
|
+
flexShrink: 0,
|
|
28
|
+
});
|
|
29
|
+
export function StatusBadge({ status }) {
|
|
30
|
+
if (!status) {
|
|
31
|
+
return (_jsxs("span", { style: { ...pillStyle, backgroundColor: "#1f2937", color: "#6b7280" }, children: [_jsx("span", { style: dotStyle("#6b7280") }), "unknown"] }));
|
|
32
|
+
}
|
|
33
|
+
const { bg, text } = STATUS_COLORS[status];
|
|
34
|
+
return (_jsxs("span", { style: { ...pillStyle, backgroundColor: bg, color: text }, children: [_jsx("span", { style: dotStyle(text === "#fff" ? "rgba(255,255,255,0.6)" : text) }), status] }));
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { ExpoCiClientOptions } from "./client.js";
|
|
2
|
+
export { ExpoCiClient } from "./client.js";
|
|
3
|
+
export { LogConsole } from "./components/LogConsole.js";
|
|
4
|
+
export type { SessionViewerProps } from "./components/SessionViewer.js";
|
|
5
|
+
export { SessionViewer } from "./components/SessionViewer.js";
|
|
6
|
+
export { SimulatorScreen } from "./components/SimulatorScreen.js";
|
|
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";
|
|
9
|
+
export { useExpoCiSession } from "./useExpoCiSession.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Client
|
|
2
|
+
export { ExpoCiClient } from "./client.js";
|
|
3
|
+
export { LogConsole } from "./components/LogConsole.js";
|
|
4
|
+
// Components
|
|
5
|
+
export { SessionViewer } from "./components/SessionViewer.js";
|
|
6
|
+
export { SimulatorScreen } from "./components/SimulatorScreen.js";
|
|
7
|
+
export { StatusBadge } from "./components/StatusBadge.js";
|
|
8
|
+
// Hook
|
|
9
|
+
export { useExpoCiSession } from "./useExpoCiSession.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Log, SessionStatus, SimulatorInput } from "@alivelabs/expo-orchestrator-schemas";
|
|
2
|
+
export type { ListSimulatorsResponse, Log, LogLevel, Platform, SessionDetail, SessionStatus, SimulatorButton, SimulatorDevice, SimulatorInput, SimulatorInputResponse, SimulatorKeyCode, SimulatorKeyModifier, SourceType, } from "@alivelabs/expo-orchestrator-schemas";
|
|
3
|
+
/** Alias kept for the public hook API. */
|
|
4
|
+
export type LogEntry = Log;
|
|
5
|
+
export interface SessionLogs {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
logs: Log[];
|
|
8
|
+
}
|
|
9
|
+
export interface WsConnectedMessage {
|
|
10
|
+
type: "connected";
|
|
11
|
+
sessionId: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
export interface WsLogMessage {
|
|
15
|
+
type: "log";
|
|
16
|
+
sessionId: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
data: {
|
|
19
|
+
level: import("@alivelabs/expo-orchestrator-schemas").LogLevel;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
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
|
+
export interface WsStatusMessage {
|
|
31
|
+
type: "status";
|
|
32
|
+
sessionId: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
data: {
|
|
35
|
+
status: SessionStatus;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface WsErrorMessage {
|
|
39
|
+
type: "error";
|
|
40
|
+
sessionId: string;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
data: {
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export type WsMessage = WsConnectedMessage | WsLogMessage | WsVideoFrameMessage | WsStatusMessage | WsErrorMessage;
|
|
47
|
+
export interface ClientEventMap {
|
|
48
|
+
open: undefined;
|
|
49
|
+
close: undefined;
|
|
50
|
+
connected: WsConnectedMessage;
|
|
51
|
+
log: WsLogMessage;
|
|
52
|
+
"video-frame": WsVideoFrameMessage;
|
|
53
|
+
status: WsStatusMessage;
|
|
54
|
+
error: WsErrorMessage;
|
|
55
|
+
}
|
|
56
|
+
export interface UseExpoCiSessionOptions {
|
|
57
|
+
baseUrl?: string;
|
|
58
|
+
sessionId: string;
|
|
59
|
+
apiToken: string;
|
|
60
|
+
/** @default true */
|
|
61
|
+
autoConnect?: boolean;
|
|
62
|
+
/** Max number of log lines to retain */
|
|
63
|
+
maxLogs?: number;
|
|
64
|
+
}
|
|
65
|
+
export interface UseExpoCiSessionResult {
|
|
66
|
+
status: SessionStatus | null;
|
|
67
|
+
logs: LogEntry[];
|
|
68
|
+
/** Current frame as a data:image/jpeg;base64,... string, or null */
|
|
69
|
+
frame: string | null;
|
|
70
|
+
connected: boolean;
|
|
71
|
+
error: string | null;
|
|
72
|
+
sendInput: (input: SimulatorInput) => Promise<import("@alivelabs/expo-orchestrator-schemas").SimulatorInputResponse>;
|
|
73
|
+
reconnect: () => void;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// ── Domain & wire types ──────────────────────────────────────────────────────
|
|
2
|
+
// Re-exported from @alivelabs/expo-orchestrator-schemas so the client and the orchestrator share a
|
|
3
|
+
// single, inferred-from-Zod source of truth. The Ws* message and hook types
|
|
4
|
+
// below are React-client-specific and have no schema counterpart.
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { UseExpoCiSessionOptions, UseExpoCiSessionResult } from "./types.js";
|
|
2
|
+
export declare function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect, maxLogs, }: UseExpoCiSessionOptions): UseExpoCiSessionResult;
|
|
3
|
+
//# sourceMappingURL=useExpoCiSession.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ExpoCiClient } from "./client.js";
|
|
3
|
+
const DEFAULT_MAX_LOGS = 2000;
|
|
4
|
+
export function useExpoCiSession({ baseUrl, sessionId, apiToken, autoConnect = true, maxLogs = DEFAULT_MAX_LOGS, }) {
|
|
5
|
+
const [status, setStatus] = useState(null);
|
|
6
|
+
const [logs, setLogs] = useState([]);
|
|
7
|
+
const [frame, setFrame] = useState(null);
|
|
8
|
+
const [connected, setConnected] = useState(false);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
// We keep the client in a ref so reconnect() can call client.connect()
|
|
11
|
+
// without causing a re-render or stale closure issues.
|
|
12
|
+
const clientRef = useRef(null);
|
|
13
|
+
// Stable reconnect callback
|
|
14
|
+
const reconnect = useCallback(() => {
|
|
15
|
+
clientRef.current?.connect();
|
|
16
|
+
}, []);
|
|
17
|
+
// Stable sendInput callback
|
|
18
|
+
const sendInput = useCallback((input) => {
|
|
19
|
+
const client = clientRef.current;
|
|
20
|
+
if (!client) {
|
|
21
|
+
return Promise.reject(new Error("Client not initialized"));
|
|
22
|
+
}
|
|
23
|
+
return client.sendInput(input);
|
|
24
|
+
}, []);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const client = new ExpoCiClient({ baseUrl, sessionId, apiToken });
|
|
27
|
+
clientRef.current = client;
|
|
28
|
+
// Fetch initial session status
|
|
29
|
+
client
|
|
30
|
+
.getSession()
|
|
31
|
+
.then((session) => {
|
|
32
|
+
setStatus(session.status);
|
|
33
|
+
})
|
|
34
|
+
.catch(() => {
|
|
35
|
+
// Non-fatal: WS status messages will keep state up to date
|
|
36
|
+
});
|
|
37
|
+
// Fetch initial logs
|
|
38
|
+
client
|
|
39
|
+
.getLogs()
|
|
40
|
+
.then((sessionLogs) => {
|
|
41
|
+
setLogs(sessionLogs.logs.slice(-maxLogs));
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
// Non-fatal
|
|
45
|
+
});
|
|
46
|
+
const offOpen = client.on("open", () => {
|
|
47
|
+
setConnected(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
});
|
|
50
|
+
const offClose = client.on("close", () => {
|
|
51
|
+
setConnected(false);
|
|
52
|
+
});
|
|
53
|
+
const offLog = client.on("log", (msg) => {
|
|
54
|
+
setLogs((prev) => {
|
|
55
|
+
const next = [...prev, { timestamp: msg.timestamp, ...msg.data }];
|
|
56
|
+
return next.length > maxLogs ? next.slice(next.length - maxLogs) : next;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
const offFrame = client.on("video-frame", (msg) => {
|
|
60
|
+
setFrame(`data:image/jpeg;base64,${msg.data}`);
|
|
61
|
+
});
|
|
62
|
+
const offStatus = client.on("status", (msg) => {
|
|
63
|
+
setStatus(msg.data.status);
|
|
64
|
+
});
|
|
65
|
+
const offError = client.on("error", (msg) => {
|
|
66
|
+
setError(msg.data.message);
|
|
67
|
+
});
|
|
68
|
+
if (autoConnect) {
|
|
69
|
+
client.connect();
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
offOpen();
|
|
73
|
+
offClose();
|
|
74
|
+
offLog();
|
|
75
|
+
offFrame();
|
|
76
|
+
offStatus();
|
|
77
|
+
offError();
|
|
78
|
+
client.disconnect();
|
|
79
|
+
clientRef.current = null;
|
|
80
|
+
};
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [baseUrl, sessionId, apiToken, autoConnect, maxLogs]);
|
|
83
|
+
return { status, logs, frame, connected, error, sendInput, reconnect };
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alivelabs/expo-orchestrator-react-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React client for Expo CI Orchestrator — streaming logs, live simulator video, and interactive controls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alive-home/alive-expo-orchestrator.git",
|
|
10
|
+
"directory": "packages/react-client"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"prepublishOnly": "tsc",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@alivelabs/expo-orchestrator-schemas": "^0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": ">=18.0.0",
|
|
37
|
+
"react-dom": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/react": "^19.2.15",
|
|
41
|
+
"@types/react-dom": "^19.2.3",
|
|
42
|
+
"react": "^19.2.6",
|
|
43
|
+
"react-dom": "^19.2.6",
|
|
44
|
+
"typescript": "^6.0.3"
|
|
45
|
+
}
|
|
46
|
+
}
|