runcloud 0.1.2 → 0.1.4
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/dist/api.js +78 -37
- package/dist/commands/build.js +244 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +46 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/login.js +4 -2
- package/dist/commands/run-cloud.js +297 -77
- package/dist/commands/sandbox.js +136 -0
- package/dist/config.js +50 -9
- package/dist/environmentShell.js +70 -0
- package/dist/index.js +4 -0
- package/dist/run-cloud.js +6 -2
- package/dist/services-manifest.js +30 -1
- package/examples/eight-device-mosaic/README.md +30 -0
- package/examples/eight-device-mosaic/demo.mjs +329 -0
- package/examples/eight-device-mosaic/package.json +13 -0
- package/examples/eight-device-mosaic/verify.mjs +115 -0
- package/examples/live-camera-relay/App.tsx +516 -0
- package/examples/live-camera-relay/README.md +57 -0
- package/examples/live-camera-relay/app.json +17 -0
- package/examples/live-camera-relay/demo.mjs +472 -0
- package/examples/live-camera-relay/index.ts +4 -0
- package/examples/live-camera-relay/package.json +31 -0
- package/examples/live-camera-relay/tsconfig.json +6 -0
- package/examples/live-camera-relay/verify.mjs +151 -0
- package/examples/parallel-simulators/README.md +33 -0
- package/examples/parallel-simulators/demo.mjs +185 -0
- package/examples/parallel-simulators/package.json +13 -0
- package/examples/parallel-simulators/verify.mjs +78 -0
- package/package.json +5 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expo": {
|
|
3
|
+
"name": "run.cloud live camera relay",
|
|
4
|
+
"slug": "run-cloud-live-camera-relay",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"orientation": "portrait",
|
|
7
|
+
"userInterfaceStyle": "dark",
|
|
8
|
+
"web": {
|
|
9
|
+
"output": "single",
|
|
10
|
+
"name": "run.cloud live camera relay",
|
|
11
|
+
"shortName": "Camera relay"
|
|
12
|
+
},
|
|
13
|
+
"experiments": {
|
|
14
|
+
"baseUrl": "/run-cloud-examples/live-camera-relay"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const DEVICE_COUNT = 3;
|
|
6
|
+
const DEFAULT_APP_URL = 'https://newly-app.github.io/run-cloud-examples/live-camera-relay/';
|
|
7
|
+
|
|
8
|
+
export function parseOptions(args) {
|
|
9
|
+
const options = { duration: 120, open: false };
|
|
10
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
11
|
+
const argument = args[index];
|
|
12
|
+
if (argument === '--open') {
|
|
13
|
+
options.open = true;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (argument === '--duration') {
|
|
17
|
+
const raw = args[index + 1];
|
|
18
|
+
if (!raw) throw new Error('--duration requires a value.');
|
|
19
|
+
options.duration = Number(raw);
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (argument === '--help' || argument === '-h') {
|
|
24
|
+
options.help = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!Number.isInteger(options.duration) || options.duration < 1 || options.duration > 600) {
|
|
31
|
+
throw new Error('--duration must be between 1 and 600 seconds.');
|
|
32
|
+
}
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runCloud(args) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(process.env.RUNCLOUD_BIN || 'runcloud', args, {
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
let stdout = '';
|
|
42
|
+
let stderr = '';
|
|
43
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
44
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
45
|
+
child.on('error', reject);
|
|
46
|
+
child.on('close', (code) => {
|
|
47
|
+
if (code !== 0) {
|
|
48
|
+
reject(new Error(stderr.trim() || stdout.trim() || `runcloud exited with code ${code}`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
resolve(JSON.parse(stdout));
|
|
53
|
+
} catch {
|
|
54
|
+
reject(new Error(`runcloud returned invalid JSON: ${stdout.trim()}`));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function browserCommand(url, platform = process.platform) {
|
|
61
|
+
return platform === 'darwin'
|
|
62
|
+
? ['open', [url]]
|
|
63
|
+
: platform === 'win32'
|
|
64
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
65
|
+
: ['xdg-open', [url]];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function openBrowser(url) {
|
|
69
|
+
const command = browserCommand(url);
|
|
70
|
+
const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore' });
|
|
71
|
+
child.on('error', () => undefined);
|
|
72
|
+
child.unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escapeHtml(value) {
|
|
76
|
+
return String(value)
|
|
77
|
+
.replaceAll('&', '&')
|
|
78
|
+
.replaceAll('"', '"')
|
|
79
|
+
.replaceAll('<', '<')
|
|
80
|
+
.replaceAll('>', '>');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function scriptData(value) {
|
|
84
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function embedUrl(value) {
|
|
88
|
+
const url = new URL(value);
|
|
89
|
+
url.searchParams.set('loadingGuard', '1');
|
|
90
|
+
return url.toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function absoluteHttpUrl(value, name) {
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(value);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`${name} must be a valid absolute URL.`);
|
|
99
|
+
}
|
|
100
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
101
|
+
throw new Error(`${name} must use HTTP or HTTPS.`);
|
|
102
|
+
}
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function cameraConfig(session) {
|
|
107
|
+
const signedUrl = absoluteHttpUrl(session.url, 'The signed simulator URL');
|
|
108
|
+
const baseUrl = absoluteHttpUrl(session.baseUrl || signedUrl.origin, 'The simulator base URL');
|
|
109
|
+
const token = signedUrl.searchParams.get('token');
|
|
110
|
+
const device = session.device || signedUrl.searchParams.get('device');
|
|
111
|
+
if (!token) throw new Error('The broadcaster session URL does not contain a camera token.');
|
|
112
|
+
if (!device) throw new Error('The broadcaster session does not identify its simulator device.');
|
|
113
|
+
return { baseUrl: baseUrl.toString().replace(/\/$/, ''), token, device };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function appRoleUrl(appUrl, roomId, role, receiver) {
|
|
117
|
+
const url = absoluteHttpUrl(appUrl, 'RUN_CLOUD_CAMERA_DEMO_URL');
|
|
118
|
+
url.searchParams.set('room', roomId);
|
|
119
|
+
url.searchParams.set('role', role);
|
|
120
|
+
if (receiver) url.searchParams.set('receiver', String(receiver));
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function cameraViewerHtml(sessions, duration, roomId, now = Date.now()) {
|
|
125
|
+
if (sessions.length !== DEVICE_COUNT) throw new Error('The camera viewer requires three sessions.');
|
|
126
|
+
const source = cameraConfig(sessions[0]);
|
|
127
|
+
const roles = ['Broadcaster', 'Receiver 01', 'Receiver 02'];
|
|
128
|
+
const frames = sessions.map((session, index) => `
|
|
129
|
+
<section class="device" data-device="${index}">
|
|
130
|
+
<div class="device-heading"><span>${roles[index]}</span><b data-state>CONNECTING</b></div>
|
|
131
|
+
<iframe title="${roles[index]} simulator" src="${escapeHtml(embedUrl(session.url))}" allow="clipboard-read; clipboard-write"></iframe>
|
|
132
|
+
</section>`).join('');
|
|
133
|
+
const deadline = now + duration * 1000;
|
|
134
|
+
|
|
135
|
+
return `<!doctype html>
|
|
136
|
+
<html lang="en">
|
|
137
|
+
<head>
|
|
138
|
+
<meta charset="utf-8">
|
|
139
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
140
|
+
<title>run.cloud live camera relay</title>
|
|
141
|
+
<style>
|
|
142
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #030404; color: #f8fafc; }
|
|
143
|
+
* { box-sizing: border-box; }
|
|
144
|
+
html, body { width: 100%; min-height: 100%; margin: 0; }
|
|
145
|
+
body { display: grid; grid-template-rows: 64px minmax(0, 1fr); min-height: 100vh; background: #030404; }
|
|
146
|
+
header { display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid rgba(255,255,255,.12); padding: 0 18px; background: #070909; }
|
|
147
|
+
.brand, .camera-controls, .session-status { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
148
|
+
.brand-mark { width: 22px; height: 14px; border: 2px solid #67e8f9; transform: skewX(-18deg); }
|
|
149
|
+
.brand strong { font-size: 14px; font-weight: 700; }
|
|
150
|
+
.brand span, .session-status { color: #94a3b8; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
151
|
+
.session-status b { color: #bef264; font-weight: 600; }
|
|
152
|
+
#timer { width: 42px; color: #e2e8f0; text-align: right; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
153
|
+
#local-preview { display: none; width: 72px; height: 42px; border: 1px solid #334155; background: #020303; object-fit: cover; }
|
|
154
|
+
#local-preview.active { display: block; }
|
|
155
|
+
button { height: 36px; border-radius: 4px; padding: 0 13px; cursor: pointer; font: 600 12px ui-sans-serif, system-ui, sans-serif; }
|
|
156
|
+
#connect-camera { border: 0; background: #f8fafc; color: #020617; }
|
|
157
|
+
#connect-camera:hover { background: #dbeafe; }
|
|
158
|
+
#connect-camera[data-live="true"] { background: #bef264; }
|
|
159
|
+
#finish { width: 36px; border: 1px solid rgba(255,255,255,.18); padding: 0; background: transparent; color: #f8fafc; font-size: 18px; }
|
|
160
|
+
button:disabled { cursor: wait; opacity: .55; }
|
|
161
|
+
main { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 8px; }
|
|
162
|
+
.device { min-width: 0; min-height: 0; display: grid; grid-template-rows: 34px minmax(0, 1fr); border: 1px solid #1e293b; background: #080b0b; }
|
|
163
|
+
.device-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid #1e293b; padding: 0 10px; color: #cbd5e1; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; }
|
|
164
|
+
.device-heading b { color: #64748b; font-weight: 600; }
|
|
165
|
+
.device[data-ready="true"] .device-heading b { color: #bef264; }
|
|
166
|
+
.device[data-ended="true"] .device-heading b { color: #fda4af; }
|
|
167
|
+
iframe { display: block; width: 100%; height: 100%; min-height: 540px; border: 0; }
|
|
168
|
+
#message { position: fixed; right: 12px; bottom: 12px; z-index: 5; max-width: min(440px, calc(100vw - 24px)); border: 1px solid #7f1d1d; background: #2b0b0d; color: #fecdd3; padding: 10px 12px; font-size: 12px; line-height: 18px; }
|
|
169
|
+
#message:empty { display: none; }
|
|
170
|
+
@media (max-width: 920px) {
|
|
171
|
+
body { display: block; }
|
|
172
|
+
header { position: sticky; top: 0; z-index: 4; height: auto; min-height: 64px; flex-wrap: wrap; padding: 10px; }
|
|
173
|
+
.brand span, .session-status span { display: none; }
|
|
174
|
+
main { grid-template-columns: 1fr; }
|
|
175
|
+
.device { min-height: 680px; }
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
178
|
+
</head>
|
|
179
|
+
<body>
|
|
180
|
+
<header>
|
|
181
|
+
<div class="brand"><i class="brand-mark"></i><strong>run.cloud</strong><span>LIVE CAMERA RELAY</span></div>
|
|
182
|
+
<div class="session-status"><b id="ready-count">0/${DEVICE_COUNT} LIVE</b><span>ROOM ${escapeHtml(roomId)}</span><span id="timer">--:--</span></div>
|
|
183
|
+
<div class="camera-controls">
|
|
184
|
+
<video id="local-preview" muted playsinline aria-label="Local webcam preview"></video>
|
|
185
|
+
<button id="connect-camera" type="button">Connect webcam</button>
|
|
186
|
+
<button id="finish" type="button" title="End demo" aria-label="End demo">×</button>
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
<main>${frames}</main>
|
|
190
|
+
<div id="message" role="alert"></div>
|
|
191
|
+
<script>
|
|
192
|
+
const camera = ${scriptData(source)};
|
|
193
|
+
const deadline = ${deadline};
|
|
194
|
+
const devices = [...document.querySelectorAll('.device')];
|
|
195
|
+
const frames = [...document.querySelectorAll('iframe')];
|
|
196
|
+
const ready = new Set();
|
|
197
|
+
const count = document.querySelector('#ready-count');
|
|
198
|
+
const timer = document.querySelector('#timer');
|
|
199
|
+
const connectButton = document.querySelector('#connect-camera');
|
|
200
|
+
const preview = document.querySelector('#local-preview');
|
|
201
|
+
const message = document.querySelector('#message');
|
|
202
|
+
let webcamStream = null;
|
|
203
|
+
let frameTimer = null;
|
|
204
|
+
let stopped = false;
|
|
205
|
+
|
|
206
|
+
const integrationUrl = (path) => {
|
|
207
|
+
const url = new URL('/api/integration/camera/' + path, camera.baseUrl);
|
|
208
|
+
url.searchParams.set('device', camera.device);
|
|
209
|
+
return url;
|
|
210
|
+
};
|
|
211
|
+
const postCamera = async (path, body) => {
|
|
212
|
+
const response = await fetch(integrationUrl(path), {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: 'Bearer ' + camera.token,
|
|
216
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
217
|
+
},
|
|
218
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const payload = await response.json().catch(() => ({}));
|
|
222
|
+
throw new Error(payload.error || 'Camera request failed with HTTP ' + response.status);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const stopWebcam = async () => {
|
|
226
|
+
stopped = true;
|
|
227
|
+
if (frameTimer) clearTimeout(frameTimer);
|
|
228
|
+
webcamStream?.getTracks().forEach((track) => track.stop());
|
|
229
|
+
webcamStream = null;
|
|
230
|
+
preview.srcObject = null;
|
|
231
|
+
await postCamera('stop').catch(() => undefined);
|
|
232
|
+
};
|
|
233
|
+
const connectWebcam = async () => {
|
|
234
|
+
connectButton.disabled = true;
|
|
235
|
+
message.textContent = '';
|
|
236
|
+
stopped = false;
|
|
237
|
+
try {
|
|
238
|
+
await postCamera('attach', {
|
|
239
|
+
bundleId: 'com.apple.mobilesafari',
|
|
240
|
+
mirror: 'on',
|
|
241
|
+
source: { type: 'client' },
|
|
242
|
+
});
|
|
243
|
+
webcamStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
244
|
+
preview.srcObject = webcamStream;
|
|
245
|
+
await preview.play();
|
|
246
|
+
preview.classList.add('active');
|
|
247
|
+
connectButton.dataset.live = 'true';
|
|
248
|
+
connectButton.textContent = 'Webcam connected';
|
|
249
|
+
|
|
250
|
+
const canvas = document.createElement('canvas');
|
|
251
|
+
const context = canvas.getContext('2d');
|
|
252
|
+
if (!context) throw new Error('Canvas rendering is unavailable.');
|
|
253
|
+
const sendFrame = async () => {
|
|
254
|
+
if (stopped || !webcamStream) return;
|
|
255
|
+
try {
|
|
256
|
+
const sourceWidth = preview.videoWidth || 640;
|
|
257
|
+
const sourceHeight = preview.videoHeight || 480;
|
|
258
|
+
const width = Math.min(640, sourceWidth);
|
|
259
|
+
const height = Math.max(1, Math.round(sourceHeight * width / sourceWidth));
|
|
260
|
+
if (canvas.width !== width) canvas.width = width;
|
|
261
|
+
if (canvas.height !== height) canvas.height = height;
|
|
262
|
+
context.drawImage(preview, 0, 0, width, height);
|
|
263
|
+
await postCamera('client-frame', { dataUrl: canvas.toDataURL('image/jpeg', 0.68) });
|
|
264
|
+
if (!stopped) frameTimer = setTimeout(sendFrame, 125);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
message.textContent = error instanceof Error ? error.message : String(error);
|
|
267
|
+
await stopWebcam();
|
|
268
|
+
connectButton.dataset.live = 'false';
|
|
269
|
+
connectButton.textContent = 'Reconnect webcam';
|
|
270
|
+
connectButton.disabled = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
frameTimer = setTimeout(sendFrame, 0);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
message.textContent = error instanceof Error ? error.message : String(error);
|
|
276
|
+
await stopWebcam();
|
|
277
|
+
connectButton.textContent = 'Retry webcam';
|
|
278
|
+
connectButton.disabled = false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const tick = () => {
|
|
283
|
+
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
284
|
+
timer.textContent = String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
|
|
285
|
+
};
|
|
286
|
+
tick();
|
|
287
|
+
setInterval(tick, 250);
|
|
288
|
+
window.addEventListener('message', (event) => {
|
|
289
|
+
const index = frames.findIndex((frame) => frame.contentWindow === event.source);
|
|
290
|
+
if (index < 0 || !event.data || typeof event.data !== 'object') return;
|
|
291
|
+
if (event.data.type === 'ios-simulator:status' && event.data.streaming === true) {
|
|
292
|
+
ready.add(index);
|
|
293
|
+
devices[index].dataset.ready = 'true';
|
|
294
|
+
devices[index].querySelector('[data-state]').textContent = 'LIVE';
|
|
295
|
+
count.textContent = ready.size + '/${DEVICE_COUNT} LIVE';
|
|
296
|
+
}
|
|
297
|
+
if (event.data.type === 'ios-simulator:session-ended') {
|
|
298
|
+
devices[index].dataset.ended = 'true';
|
|
299
|
+
devices[index].querySelector('[data-state]').textContent = 'ENDED';
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
connectButton.addEventListener('click', connectWebcam);
|
|
303
|
+
document.querySelector('#finish').addEventListener('click', async () => {
|
|
304
|
+
document.querySelector('#finish').disabled = true;
|
|
305
|
+
await stopWebcam();
|
|
306
|
+
await fetch('/finish', { method: 'POST' }).catch(() => undefined);
|
|
307
|
+
});
|
|
308
|
+
window.addEventListener('pagehide', () => {
|
|
309
|
+
webcamStream?.getTracks().forEach((track) => track.stop());
|
|
310
|
+
});
|
|
311
|
+
</script>
|
|
312
|
+
</body>
|
|
313
|
+
</html>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function startCameraViewer(sessions, duration, roomId) {
|
|
317
|
+
let finish;
|
|
318
|
+
const finished = new Promise((resolve) => { finish = resolve; });
|
|
319
|
+
const page = cameraViewerHtml(sessions, duration, roomId);
|
|
320
|
+
const server = createServer((request, response) => {
|
|
321
|
+
if (request.method === 'GET' && request.url === '/') {
|
|
322
|
+
response.writeHead(200, {
|
|
323
|
+
'Cache-Control': 'no-store',
|
|
324
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
325
|
+
});
|
|
326
|
+
response.end(page);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (request.method === 'POST' && request.url === '/finish') {
|
|
330
|
+
response.writeHead(204);
|
|
331
|
+
response.end();
|
|
332
|
+
finish('viewer');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
response.writeHead(404);
|
|
336
|
+
response.end('Not found');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await new Promise((resolve, reject) => {
|
|
340
|
+
server.once('error', reject);
|
|
341
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
342
|
+
});
|
|
343
|
+
const address = server.address();
|
|
344
|
+
if (!address || typeof address === 'string') throw new Error('Could not start the local camera viewer.');
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
url: `http://127.0.0.1:${address.port}/`,
|
|
348
|
+
finished,
|
|
349
|
+
close: () => new Promise((resolve, reject) => {
|
|
350
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function stopSignal() {
|
|
356
|
+
let stop;
|
|
357
|
+
let stopped = false;
|
|
358
|
+
const promise = new Promise((resolve) => { stop = resolve; });
|
|
359
|
+
const interrupt = () => {
|
|
360
|
+
stopped = true;
|
|
361
|
+
stop('interrupt');
|
|
362
|
+
};
|
|
363
|
+
process.once('SIGINT', interrupt);
|
|
364
|
+
process.once('SIGTERM', interrupt);
|
|
365
|
+
return {
|
|
366
|
+
promise,
|
|
367
|
+
get stopped() { return stopped; },
|
|
368
|
+
dispose() {
|
|
369
|
+
process.removeListener('SIGINT', interrupt);
|
|
370
|
+
process.removeListener('SIGTERM', interrupt);
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function waitForDemo(seconds, signals) {
|
|
376
|
+
let timer;
|
|
377
|
+
const timeout = new Promise((resolve) => {
|
|
378
|
+
timer = setTimeout(() => resolve('timer'), seconds * 1000);
|
|
379
|
+
});
|
|
380
|
+
return Promise.race([timeout, ...signals]).finally(() => clearTimeout(timer));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function releaseSessions(sessions) {
|
|
384
|
+
if (sessions.length === 0) return;
|
|
385
|
+
console.log('\nReleasing live camera relay sessions...');
|
|
386
|
+
const results = await Promise.allSettled(
|
|
387
|
+
sessions.map((session) => runCloud(['ios', 'delete', session.id, '--json'])),
|
|
388
|
+
);
|
|
389
|
+
results.forEach((result, index) => {
|
|
390
|
+
const session = sessions[index];
|
|
391
|
+
if (result.status === 'fulfilled') console.log(` released ${session.id}`);
|
|
392
|
+
else console.error(` failed to release ${session.id}: ${result.reason.message}`);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function runDemo(args = process.argv.slice(2)) {
|
|
397
|
+
const options = parseOptions(args);
|
|
398
|
+
if (options.help) {
|
|
399
|
+
console.log('Usage: npm run demo -- [--duration seconds] [--open]');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const appUrl = process.env.RUN_CLOUD_CAMERA_DEMO_URL || DEFAULT_APP_URL;
|
|
404
|
+
const roomId = randomBytes(12).toString('hex');
|
|
405
|
+
const assignments = [
|
|
406
|
+
{ name: 'camera-broadcaster', role: 'broadcaster', receiver: undefined },
|
|
407
|
+
{ name: 'camera-receiver-01', role: 'receiver', receiver: 1 },
|
|
408
|
+
{ name: 'camera-receiver-02', role: 'receiver', receiver: 2 },
|
|
409
|
+
];
|
|
410
|
+
const sessions = [];
|
|
411
|
+
const stop = stopSignal();
|
|
412
|
+
let viewer;
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
console.log('Starting one broadcaster and two receiver simulators...');
|
|
416
|
+
const starts = await Promise.allSettled(assignments.map(async (assignment, index) => {
|
|
417
|
+
const session = await runCloud([
|
|
418
|
+
'ios',
|
|
419
|
+
'create',
|
|
420
|
+
'--display-name',
|
|
421
|
+
assignment.name,
|
|
422
|
+
'--label',
|
|
423
|
+
'demo=live-camera-relay',
|
|
424
|
+
'--label',
|
|
425
|
+
`role=${assignment.role}`,
|
|
426
|
+
'--hard-timeout',
|
|
427
|
+
'10m',
|
|
428
|
+
'--json',
|
|
429
|
+
]);
|
|
430
|
+
sessions[index] = session;
|
|
431
|
+
await runCloud([
|
|
432
|
+
'ios',
|
|
433
|
+
'open-url',
|
|
434
|
+
appRoleUrl(appUrl, roomId, assignment.role, assignment.receiver),
|
|
435
|
+
'--id',
|
|
436
|
+
session.id,
|
|
437
|
+
'--json',
|
|
438
|
+
]);
|
|
439
|
+
return session;
|
|
440
|
+
}));
|
|
441
|
+
|
|
442
|
+
const failures = starts
|
|
443
|
+
.map((result, index) => ({ result, index }))
|
|
444
|
+
.filter(({ result }) => result.status === 'rejected');
|
|
445
|
+
failures.forEach(({ result, index }) => {
|
|
446
|
+
console.error(` ${assignments[index].name} failed: ${result.reason.message}`);
|
|
447
|
+
});
|
|
448
|
+
if (failures.length > 0 || stop.stopped) {
|
|
449
|
+
throw new Error(failures.length > 0
|
|
450
|
+
? `The camera relay requires all ${DEVICE_COUNT} devices; ${failures.length} did not start.`
|
|
451
|
+
: 'The camera relay was interrupted during setup.');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
viewer = await startCameraViewer(sessions, options.duration, roomId);
|
|
455
|
+
console.log(`\nLive camera relay: ${viewer.url}`);
|
|
456
|
+
if (options.open) openBrowser(viewer.url);
|
|
457
|
+
console.log('Connect the webcam, then start the camera inside the broadcaster simulator.');
|
|
458
|
+
console.log(`Keeping the demo live for ${options.duration} seconds. Press Ctrl+C to finish early.`);
|
|
459
|
+
await waitForDemo(options.duration, [stop.promise, viewer.finished]);
|
|
460
|
+
} finally {
|
|
461
|
+
stop.dispose();
|
|
462
|
+
if (viewer) await viewer.close();
|
|
463
|
+
await releaseSessions(sessions.filter(Boolean));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (import.meta.url === new URL(process.argv[1], 'file:').href) {
|
|
468
|
+
runDemo().catch((error) => {
|
|
469
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
});
|
|
472
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "run-cloud-live-camera-relay-demo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "expo start",
|
|
9
|
+
"web": "expo start --web",
|
|
10
|
+
"export:web": "expo export --platform web --output-dir dist",
|
|
11
|
+
"demo": "node demo.mjs",
|
|
12
|
+
"test": "node --test verify.mjs",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@expo/metro-runtime": "~57.0.5",
|
|
17
|
+
"expo": "~57.0.6",
|
|
18
|
+
"react": "19.2.3",
|
|
19
|
+
"react-dom": "19.2.3",
|
|
20
|
+
"react-native": "0.86.0",
|
|
21
|
+
"react-native-web": "^0.21.2",
|
|
22
|
+
"trystero": "^0.25.3"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "~19.2.2",
|
|
26
|
+
"typescript": "~6.0.3"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
appRoleUrl,
|
|
10
|
+
browserCommand,
|
|
11
|
+
cameraConfig,
|
|
12
|
+
cameraViewerHtml,
|
|
13
|
+
parseOptions,
|
|
14
|
+
startCameraViewer,
|
|
15
|
+
} from './demo.mjs';
|
|
16
|
+
|
|
17
|
+
function sessions() {
|
|
18
|
+
return Array.from({ length: 3 }, (_, index) => ({
|
|
19
|
+
id: `camera-${index + 1}`,
|
|
20
|
+
url: `https://sim.example/session?token=secret-${index + 1}&device=device-${index + 1}`,
|
|
21
|
+
baseUrl: 'https://sim.example',
|
|
22
|
+
device: `device-${index + 1}`,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('defaults to a three-device two-minute camera relay', () => {
|
|
27
|
+
assert.deepEqual(parseOptions([]), { duration: 120, open: false });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('accepts a shorter auto-open demo and rejects unsupported counts', () => {
|
|
31
|
+
assert.deepEqual(parseOptions(['--duration', '30', '--open']), {
|
|
32
|
+
duration: 30,
|
|
33
|
+
open: true,
|
|
34
|
+
});
|
|
35
|
+
assert.throws(() => parseOptions(['--count', '4']), /Unknown option/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('opens the local viewer on Windows with the required empty title', () => {
|
|
39
|
+
assert.deepEqual(browserCommand('http://127.0.0.1:4321', 'win32'), [
|
|
40
|
+
'cmd',
|
|
41
|
+
['/c', 'start', '', 'http://127.0.0.1:4321'],
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('builds role URLs with one private room', () => {
|
|
46
|
+
const source = new URL(appRoleUrl('https://demo.example/live/', 'room-12345678', 'broadcaster'));
|
|
47
|
+
const receiver = new URL(appRoleUrl('https://demo.example/live/', 'room-12345678', 'receiver', 2));
|
|
48
|
+
assert.equal(source.searchParams.get('role'), 'broadcaster');
|
|
49
|
+
assert.equal(receiver.searchParams.get('role'), 'receiver');
|
|
50
|
+
assert.equal(receiver.searchParams.get('receiver'), '2');
|
|
51
|
+
assert.equal(source.searchParams.get('room'), receiver.searchParams.get('room'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('extracts scoped camera credentials without accepting incomplete sessions', () => {
|
|
55
|
+
assert.deepEqual(cameraConfig(sessions()[0]), {
|
|
56
|
+
baseUrl: 'https://sim.example',
|
|
57
|
+
token: 'secret-1',
|
|
58
|
+
device: 'device-1',
|
|
59
|
+
});
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => cameraConfig({ url: 'https://sim.example/?device=device-1' }),
|
|
62
|
+
/does not contain a camera token/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('renders three signed embeds while keeping credentials out of the local URL', async () => {
|
|
67
|
+
const demoSessions = sessions();
|
|
68
|
+
const html = cameraViewerHtml(demoSessions, 60, 'room-12345678', 1_000);
|
|
69
|
+
assert.equal((html.match(/<iframe /g) || []).length, 3);
|
|
70
|
+
assert.match(html, /Connect webcam/);
|
|
71
|
+
assert.match(html, /com\.apple\.mobilesafari/);
|
|
72
|
+
assert.match(html, /audio: false/);
|
|
73
|
+
assert.match(html, /const deadline = 61000/);
|
|
74
|
+
|
|
75
|
+
const viewer = await startCameraViewer(demoSessions, 60, 'room-12345678');
|
|
76
|
+
try {
|
|
77
|
+
assert.doesNotMatch(viewer.url, /secret/);
|
|
78
|
+
const response = await fetch(viewer.url);
|
|
79
|
+
assert.equal(response.status, 200);
|
|
80
|
+
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
81
|
+
const finish = await fetch(new URL('/finish', viewer.url), { method: 'POST' });
|
|
82
|
+
assert.equal(finish.status, 204);
|
|
83
|
+
assert.equal(await viewer.finished, 'viewer');
|
|
84
|
+
} finally {
|
|
85
|
+
await viewer.close();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('starts, assigns, and releases the broadcaster and two receivers', () => {
|
|
90
|
+
const root = mkdtempSync(join(tmpdir(), 'run-cloud-camera-demo-'));
|
|
91
|
+
const fakeCli = join(root, 'runcloud');
|
|
92
|
+
const operationLog = join(root, 'operations.jsonl');
|
|
93
|
+
writeFileSync(
|
|
94
|
+
fakeCli,
|
|
95
|
+
`#!/usr/bin/env node
|
|
96
|
+
import { appendFileSync } from 'node:fs';
|
|
97
|
+
const args = process.argv.slice(2);
|
|
98
|
+
appendFileSync(process.env.RUN_CLOUD_DEMO_LOG, JSON.stringify(args) + '\\n');
|
|
99
|
+
if (args[0] === 'ios' && args[1] === 'create') {
|
|
100
|
+
const name = args[args.indexOf('--display-name') + 1];
|
|
101
|
+
process.stdout.write(JSON.stringify({
|
|
102
|
+
id: name,
|
|
103
|
+
url: 'https://sim.example/session?token=secret&device=' + name,
|
|
104
|
+
baseUrl: 'https://sim.example',
|
|
105
|
+
device: name,
|
|
106
|
+
}));
|
|
107
|
+
} else {
|
|
108
|
+
process.stdout.write(JSON.stringify({ ok: true }));
|
|
109
|
+
}
|
|
110
|
+
`,
|
|
111
|
+
);
|
|
112
|
+
chmodSync(fakeCli, 0o755);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = spawnSync(
|
|
116
|
+
process.execPath,
|
|
117
|
+
['demo.mjs', '--duration', '1'],
|
|
118
|
+
{
|
|
119
|
+
cwd: dirname(fileURLToPath(import.meta.url)),
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
env: {
|
|
122
|
+
...process.env,
|
|
123
|
+
RUNCLOUD_BIN: fakeCli,
|
|
124
|
+
RUN_CLOUD_DEMO_LOG: operationLog,
|
|
125
|
+
RUN_CLOUD_CAMERA_DEMO_URL: 'https://preview.example/live-camera-relay/',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
assert.equal(result.status, 0, result.stderr);
|
|
130
|
+
assert.doesNotMatch(result.stdout, /token=secret/);
|
|
131
|
+
const operations = readFileSync(operationLog, 'utf8')
|
|
132
|
+
.trim()
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => JSON.parse(line));
|
|
135
|
+
const creates = operations.filter((args) => args[1] === 'create');
|
|
136
|
+
const opens = operations.filter((args) => args[1] === 'open-url');
|
|
137
|
+
const deletes = operations.filter((args) => args[1] === 'delete');
|
|
138
|
+
assert.equal(creates.length, 3);
|
|
139
|
+
assert.equal(opens.length, 3);
|
|
140
|
+
assert.equal(deletes.length, 3);
|
|
141
|
+
assert.deepEqual(opens.map((args) => new URL(args[2]).searchParams.get('role')).sort(), [
|
|
142
|
+
'broadcaster',
|
|
143
|
+
'receiver',
|
|
144
|
+
'receiver',
|
|
145
|
+
]);
|
|
146
|
+
assert.equal(new Set(opens.map((args) => new URL(args[2]).searchParams.get('room'))).size, 1);
|
|
147
|
+
assert.ok(opens.every((args) => new URL(args[2]).origin === 'https://preview.example'));
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(root, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|