specrails-desktop 2.63.2 → 2.64.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.
Files changed (72) hide show
  1. package/client/dist/assets/{ActivityFeedPage-B1bpTy7O.js → ActivityFeedPage-D23wXKgy.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-BussBbN3.js → AgentBrowserCapture-DQzS4XcK.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-BiT1n3BU.js → AgentModeAnalyticsPane-CFTiSl8T.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-CUjNfeKa.js → AgentModeCodePane-DAaSzIPA.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-RVEwyS59.js → AgentModeJobsPane-C29YpixV.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-DKE-eMC5.js → AgentsPage-B-sSTP1I.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-Bser2Ly3.js → AnalyticsPage-81fI80nA.js} +1 -1
  8. package/client/dist/assets/{CodePage-BK0grm6e.js → CodePage-Ct4XEPdx.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-DEBidZbs.js → DesktopAnalyticsPage-Bt37XOLF.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-Dhyo57n9.js → DocsDialog-_IEr8umI.js} +1 -1
  11. package/client/dist/assets/{DocsPage-BcEkPXd6.js → DocsPage-DX9WOqt2.js} +1 -1
  12. package/client/dist/assets/{ExportDropdown-mRmqdkjr.js → ExportDropdown-Bb1lHx3r.js} +1 -1
  13. package/client/dist/assets/{InteractiveJobComposer-BlCe3vrU.js → InteractiveJobComposer-DDI6TdsS.js} +1 -1
  14. package/client/dist/assets/{JobDetailModal-CkAejD1F.js → JobDetailModal-CkD0PBaZ.js} +1 -1
  15. package/client/dist/assets/{JobDetailPage-P-FS_lKz.js → JobDetailPage-B8Rd4U5J.js} +1 -1
  16. package/client/dist/assets/{JobsPage-CoUXgYse.js → JobsPage-ChOiSbJn.js} +1 -1
  17. package/client/dist/assets/{LoopBuilderPage-iFylNk68.js → LoopBuilderPage-6Xwt2G6G.js} +1 -1
  18. package/client/dist/assets/{LoopPreviewModal-4obS_Ptg.js → LoopPreviewModal-CyDapSvB.js} +1 -1
  19. package/client/dist/assets/{LoopWindowSurface-CLr5OPqj.js → LoopWindowSurface-gRdx4nmg.js} +1 -1
  20. package/client/dist/assets/{LoopsPage-eKpWFtCp.js → LoopsPage-BPGVFsH-.js} +1 -1
  21. package/client/dist/assets/{MinimizedChatsContext-BHIUPVmC.js → MinimizedChatsContext-DHfigjHS.js} +1 -1
  22. package/client/dist/assets/{PluginsPage-BgTC2CQx.js → PluginsPage-DQRRdCUi.js} +1 -1
  23. package/client/dist/assets/{ProjectSettingsDialog-B4chTfU8.js → ProjectSettingsDialog-Ce8DSG6H.js} +1 -1
  24. package/client/dist/assets/{RepositoryDeliveries-DAIxIYsl.js → RepositoryDeliveries-B4ybHOyB.js} +1 -1
  25. package/client/dist/assets/{RepositoryScopeSelector-DeCoUsRN.js → RepositoryScopeSelector-2eSqR9WK.js} +1 -1
  26. package/client/dist/assets/{ReviewPacketPage-Cktv0-9b.js → ReviewPacketPage-WVoio8GJ.js} +1 -1
  27. package/client/dist/assets/{TemplatePreviewModal-BZTV1TM1.js → TemplatePreviewModal-CTBZUXAN.js} +1 -1
  28. package/client/dist/assets/{TicketDetailModalContext-DiPngt2g.js → TicketDetailModalContext-Dkwo2cDs.js} +1 -1
  29. package/client/dist/assets/{Trans-ZiXIfj50.js → Trans-BbFxRFtR.js} +1 -1
  30. package/client/dist/assets/{formatDistanceToNow-CdN5lTyD.js → formatDistanceToNow-NjPHBLfB.js} +1 -1
  31. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-62h40I4t.js → getTimezoneOffsetInMilliseconds-vJ7qUoqN.js} +1 -1
  32. package/client/dist/assets/{index-CECj3joE.js → index-IdGpV21X.js} +36 -36
  33. package/client/dist/assets/{jira-api-C_D_RDhl.js → jira-api-Cabj-H0J.js} +1 -1
  34. package/client/dist/assets/{project-repositories-Be0CWklo.js → project-repositories-Cnnuj9Ia.js} +1 -1
  35. package/client/dist/assets/{settings-Hw7av-B1.js → settings-B1NV8Ow9.js} +1 -1
  36. package/client/dist/assets/settings-BQ8XFZeE.js +1 -0
  37. package/client/dist/assets/settings-BRkxfBbO.js +1 -0
  38. package/client/dist/assets/settings-C_CcU1dP.js +1 -0
  39. package/client/dist/assets/settings-Cto9n0eL.js +1 -0
  40. package/client/dist/assets/{settings-nryN6wuS.js → settings-Pyw8uYtL.js} +1 -1
  41. package/client/dist/assets/settings-b3HVCNNd.js +1 -0
  42. package/client/dist/assets/settings-hdive4u6.js +1 -0
  43. package/client/dist/assets/{spending-eWiZGhC_.js → spending-CQnrWiR3.js} +1 -1
  44. package/client/dist/assets/{useDesktop-C0mRIlHT.js → useDesktop-BjLT0_EE.js} +1 -1
  45. package/client/dist/assets/{useRuntimeRuns-B1a6GChu.js → useRuntimeRuns-C97qEqMg.js} +1 -1
  46. package/client/dist/assets/{useSharedWebSocket-CcsE5986.js → useSharedWebSocket-Bj4LMp7b.js} +2 -2
  47. package/client/dist/index.html +15 -15
  48. package/docs/internals/api-reference.md +26 -0
  49. package/docs/internals/source-map.md +9 -2
  50. package/package.json +1 -1
  51. package/server/dist/index.js +14 -1
  52. package/server/dist/mobile/mobile-admin-router.js +16 -0
  53. package/server/dist/mobile/mobile-gateway.js +18 -0
  54. package/server/dist/mobile/mobile-missions.js +6 -4
  55. package/server/dist/mobile/mobile-preview-channel.js +179 -0
  56. package/server/dist/mobile/mobile-preview.js +366 -0
  57. package/server/dist/mobile/mobile-router.js +9 -1
  58. package/server/dist/mobile/mobile-webrtc-peer.js +54 -4
  59. package/server/dist/mobile/mobile-ws.js +4 -2
  60. package/server/dist/modules/browser/domain/preview-origins.js +117 -0
  61. package/server/dist/modules/browser/runtime/browser-capture-manager.js +141 -18
  62. package/server/dist/modules/browser/runtime/browser-capture-types.js +10 -1
  63. package/server/dist/modules/browser/runtime/browser-playwright.js +19 -0
  64. package/server/dist/modules/browser/runtime/preview-targets.js +80 -0
  65. package/server/dist/modules/terminals/runtime/background-process-service.js +24 -1
  66. package/server/dist/project-registry.js +10 -0
  67. package/client/dist/assets/settings-BO2N-oPf.js +0 -1
  68. package/client/dist/assets/settings-BQChAs_w.js +0 -1
  69. package/client/dist/assets/settings-BzmYlcQB.js +0 -1
  70. package/client/dist/assets/settings-CGjXb6TX.js +0 -1
  71. package/client/dist/assets/settings-CMxEWY46.js +0 -1
  72. package/client/dist/assets/settings-C_dRSgI2.js +0 -1
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MobilePreviewService = exports.PreviewError = void 0;
4
+ exports.createMobilePreviewRouter = createMobilePreviewRouter;
5
+ const express_1 = require("express");
6
+ const browser_capture_types_1 = require("../modules/browser/runtime/browser-capture-types");
7
+ const preview_origins_1 = require("../modules/browser/domain/preview-origins");
8
+ const agent_store_1 = require("../modules/agents/runtime/agent-store");
9
+ const mobile_missions_1 = require("./mobile-missions");
10
+ const mobile_preview_channel_1 = require("./mobile-preview-channel");
11
+ class PreviewError extends Error {
12
+ status;
13
+ code;
14
+ constructor(status, code, message) {
15
+ super(message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.name = 'PreviewError';
19
+ }
20
+ }
21
+ exports.PreviewError = PreviewError;
22
+ /**
23
+ * Companion app-preview use cases. Owns only the channel bindings; sessions,
24
+ * budgets and the origin policy live in BrowserCaptureManager, targets in the
25
+ * process-log source. A binding is replaced on re-attach (new peer after a
26
+ * Wi-Fi drop) and detached — never killed — when its channel closes, so the
27
+ * idle sweep reclaims abandoned previews.
28
+ */
29
+ class MobilePreviewService {
30
+ deps;
31
+ bindings = new Map();
32
+ now;
33
+ constructor(deps) {
34
+ this.deps = deps;
35
+ this.now = deps.now ?? Date.now;
36
+ }
37
+ available() {
38
+ return this.deps.enabled();
39
+ }
40
+ /** Targets of a mission's running processes. */
41
+ targetsForMission(projectId, chatId) {
42
+ if (!this.available())
43
+ return [];
44
+ return this.deps.targets.list(projectId).filter((target) => target.chatId === chatId);
45
+ }
46
+ async open(device, projectId, input) {
47
+ const browser = this.requireBrowser(projectId);
48
+ const target = this.deps.targets.forProcess(projectId, input.processId);
49
+ if (!target || target.chatId !== input.chatId)
50
+ throw new PreviewError(404, 'preview_target_gone', 'This process is no longer running or announced no URL.');
51
+ const origin = (0, preview_origins_1.previewOrigin)(input.url);
52
+ if (!origin || !target.urls.some((url) => (0, preview_origins_1.previewOrigin)(url) === origin)) {
53
+ throw new PreviewError(400, 'preview_url_not_announced', 'Only URLs announced by this process can be previewed.');
54
+ }
55
+ let meta;
56
+ try {
57
+ meta = await browser.create({ initialUrl: input.url, owner: { kind: 'device', deviceId: device.id, deviceName: device.name }, viewport: input.viewport });
58
+ }
59
+ catch (err) {
60
+ throw translateBrowserError(err);
61
+ }
62
+ try {
63
+ const opened = { sessionId: meta.id, channel: this.bind(projectId, device.id, browser, meta.id), url: input.url };
64
+ this.changed();
65
+ return opened;
66
+ }
67
+ catch (err) {
68
+ await browser.kill(meta.id);
69
+ throw err;
70
+ }
71
+ }
72
+ /** Re-attach a live session after the peer reconnected. */
73
+ attach(device, projectId, sessionId) {
74
+ const browser = this.requireBrowser(projectId);
75
+ this.requireOwner(browser, device.id, sessionId);
76
+ return { sessionId, channel: this.bind(projectId, device.id, browser, sessionId) };
77
+ }
78
+ async navigate(device, projectId, sessionId, action, url) {
79
+ const browser = this.requireBrowser(projectId);
80
+ this.requireOwner(browser, device.id, sessionId);
81
+ try {
82
+ const result = await browser.navigateAsDevice(sessionId, device.id, action, url);
83
+ if (!result)
84
+ throw new PreviewError(404, 'preview_session_closed', 'This preview session has ended.');
85
+ return { url: result.url, title: result.title };
86
+ }
87
+ catch (err) {
88
+ throw translateBrowserError(err);
89
+ }
90
+ }
91
+ async close(device, projectId, sessionId) {
92
+ const browser = this.requireBrowser(projectId);
93
+ this.requireOwner(browser, device.id, sessionId);
94
+ this.unbind(sessionId);
95
+ await browser.kill(sessionId);
96
+ this.changed();
97
+ }
98
+ /** Live device-owned sessions across projects (Desktop indicator). */
99
+ listDeviceSessions() {
100
+ if (!this.available())
101
+ return [];
102
+ const sessions = [];
103
+ for (const browser of this.deps.allBrowsers()) {
104
+ for (const meta of browser.listSessions()) {
105
+ if (meta.owner.kind !== 'device')
106
+ continue;
107
+ sessions.push({ projectId: meta.projectId, sessionId: meta.id, deviceId: meta.owner.deviceId, deviceName: meta.owner.deviceName,
108
+ url: meta.url, title: meta.title, createdAt: meta.createdAt });
109
+ }
110
+ }
111
+ return sessions.sort((a, b) => b.createdAt - a.createdAt);
112
+ }
113
+ /** The Desktop user ends a phone's preview; the viewer shows "session closed". */
114
+ async endFromDesktop(projectId, sessionId) {
115
+ const browser = this.deps.browserFor(projectId);
116
+ if (!browser || browser.sessionOwner(sessionId)?.kind !== 'device')
117
+ return false;
118
+ // Kill first so the attached channel client receives `closed` before the
119
+ // binding is dropped.
120
+ const killed = await browser.kill(sessionId);
121
+ this.unbind(sessionId);
122
+ if (killed)
123
+ this.changed();
124
+ return killed;
125
+ }
126
+ /** Device revoked/unpaired: end every preview it owns, in every project. */
127
+ async closeForDevice(deviceId) {
128
+ for (const [sessionId, binding] of [...this.bindings])
129
+ if (binding.deviceId === deviceId)
130
+ this.unbind(sessionId);
131
+ for (const browser of this.deps.allBrowsers()) {
132
+ try {
133
+ await browser.killDeviceSessions(deviceId);
134
+ }
135
+ catch { /* best-effort per project */ }
136
+ }
137
+ this.changed();
138
+ }
139
+ changed() {
140
+ try {
141
+ this.deps.onChange?.();
142
+ }
143
+ catch { /* a notification failure never fails the use case */ }
144
+ }
145
+ requireBrowser(projectId) {
146
+ if (!this.available())
147
+ throw new PreviewError(404, 'preview_unavailable', 'App preview requires browser capture to be enabled on Desktop.');
148
+ const browser = this.deps.browserFor(projectId);
149
+ if (!browser)
150
+ throw new PreviewError(404, 'unknown_project', 'Unknown project');
151
+ return browser;
152
+ }
153
+ requireOwner(browser, deviceId, sessionId) {
154
+ const owner = browser.sessionOwner(sessionId);
155
+ // A session of another device or of Desktop is indistinguishable from a
156
+ // missing one: never confirm its existence to this device.
157
+ if (!owner || owner.kind !== 'device' || owner.deviceId !== deviceId) {
158
+ throw new PreviewError(404, 'preview_session_closed', 'This preview session has ended.');
159
+ }
160
+ }
161
+ bind(projectId, deviceId, browser, sessionId) {
162
+ const label = `${mobile_preview_channel_1.PREVIEW_CHANNEL_PREFIX}${sessionId}`;
163
+ const channel = this.deps.openChannel(deviceId, label);
164
+ if (!channel)
165
+ throw new PreviewError(409, 'preview_requires_webrtc', 'Reconnect Companion to Desktop to preview apps.');
166
+ this.unbind(sessionId);
167
+ const client = new mobile_preview_channel_1.PreviewChannelClient(channel);
168
+ const binding = { projectId, deviceId, channel, client };
169
+ this.bindings.set(sessionId, binding);
170
+ const limiter = new mobile_preview_channel_1.PreviewInboundLimiter(this.now);
171
+ const current = () => this.bindings.get(sessionId) === binding;
172
+ channel.onOpen(() => {
173
+ if (!current())
174
+ return;
175
+ void browser.attach(sessionId, client).then((meta) => {
176
+ if (!meta)
177
+ client.close(1000, 'session_closed');
178
+ });
179
+ });
180
+ channel.onMessage((data) => {
181
+ if (!current() || !limiter.take())
182
+ return;
183
+ const msg = (0, mobile_preview_channel_1.parsePreviewInbound)(typeof data === 'string' ? data : data.toString('utf8'));
184
+ if (!msg)
185
+ return;
186
+ if (msg.type === 'input') {
187
+ void browser.handleInput(sessionId, msg.event).catch(() => { });
188
+ }
189
+ else {
190
+ void browser.navigateAsDevice(sessionId, deviceId, msg.action, msg.url).catch(() => {
191
+ // A rejected or failed navigation keeps the current page; tell the
192
+ // viewer so it can restore its address field.
193
+ try {
194
+ channel.send(JSON.stringify({ type: 'nav_rejected' }));
195
+ }
196
+ catch { /* ignore */ }
197
+ });
198
+ }
199
+ });
200
+ channel.onClose(() => {
201
+ browser.detach(sessionId, client);
202
+ if (current())
203
+ this.bindings.delete(sessionId);
204
+ });
205
+ return label;
206
+ }
207
+ unbind(sessionId) {
208
+ const binding = this.bindings.get(sessionId);
209
+ if (!binding)
210
+ return;
211
+ this.bindings.delete(sessionId);
212
+ try {
213
+ binding.channel.close();
214
+ }
215
+ catch { /* ignore */ }
216
+ }
217
+ }
218
+ exports.MobilePreviewService = MobilePreviewService;
219
+ function translateBrowserError(err) {
220
+ if (err instanceof PreviewError)
221
+ return err;
222
+ if (err instanceof browser_capture_types_1.BrowserNavigationRejectedError)
223
+ return new PreviewError(400, 'preview_url_not_announced', 'Only URLs announced by this project can be opened in a preview.');
224
+ if (err instanceof browser_capture_types_1.BrowserLimitExceededError)
225
+ return new PreviewError(429, 'preview_limit', `Up to ${err.limit} phones can preview this project at once. Close another preview and retry.`);
226
+ return new PreviewError(502, 'preview_failed', 'Desktop could not open the preview. Retry in a moment.');
227
+ }
228
+ const ID = /^[A-Za-z0-9_-]{1,160}$/;
229
+ const ACTIONS = new Set(['goto', 'back', 'forward', 'reload']);
230
+ function viewportFrom(raw) {
231
+ if (!raw || typeof raw !== 'object')
232
+ return undefined;
233
+ const v = raw;
234
+ const ok = (n) => typeof n === 'number' && Number.isFinite(n) && n > 0;
235
+ if (!ok(v.width) || !ok(v.height))
236
+ return undefined;
237
+ return { width: v.width, height: v.height, ...(ok(v.deviceScaleFactor) ? { deviceScaleFactor: v.deviceScaleFactor } : {}) };
238
+ }
239
+ /**
240
+ * Gateway routes. Preview targets come from mission processes, so they follow
241
+ * the mission rule: an all-projects device grant (`mobile-mission-control`).
242
+ */
243
+ function createMobilePreviewRouter({ db, preview }) {
244
+ const router = (0, express_1.Router)();
245
+ const segment = (value) => typeof value === 'string' ? value : '';
246
+ const device = (req) => {
247
+ const row = req.mobileDevice;
248
+ return { id: row.id, name: row.name };
249
+ };
250
+ const fail = (res, err) => {
251
+ if (err instanceof PreviewError) {
252
+ res.status(err.status).json({ error: err.message, code: err.code });
253
+ return;
254
+ }
255
+ res.status(500).json({ error: 'Preview failed', code: 'preview_failed' });
256
+ };
257
+ const gate = (req, res) => {
258
+ const pid = segment(req.params.pid);
259
+ if (!ID.test(pid)) {
260
+ res.status(400).json({ error: 'Invalid parameter' });
261
+ return null;
262
+ }
263
+ if (!(0, mobile_missions_1.mobileCapabilities)(db, device(req).id).features.missions) {
264
+ res.status(403).json({ error: mobile_missions_1.MISSION_ACCESS_REASON });
265
+ return null;
266
+ }
267
+ if (!preview.available()) {
268
+ res.status(404).json({ error: 'App preview is unavailable on Desktop.', code: 'preview_unavailable' });
269
+ return null;
270
+ }
271
+ return pid;
272
+ };
273
+ const mission = (res, pid, cid) => {
274
+ if (!ID.test(cid)) {
275
+ res.status(400).json({ error: 'Invalid mission identity' });
276
+ return false;
277
+ }
278
+ const conversation = (0, agent_store_1.getAgentConversation)(db, cid);
279
+ if (!conversation || conversation.pinned_project_id !== pid) {
280
+ res.status(404).json({ error: 'Mission not found in this project' });
281
+ return false;
282
+ }
283
+ return true;
284
+ };
285
+ router.get('/projects/:pid/missions/:cid/preview-targets', (req, res) => {
286
+ const pid = gate(req, res);
287
+ if (!pid)
288
+ return;
289
+ const cid = segment(req.params.cid);
290
+ if (!mission(res, pid, cid))
291
+ return;
292
+ res.json({ targets: preview.targetsForMission(pid, cid).map(({ processId, urls }) => ({ processId, urls })) });
293
+ });
294
+ router.post('/projects/:pid/preview-sessions', async (req, res) => {
295
+ const pid = gate(req, res);
296
+ if (!pid)
297
+ return;
298
+ const body = (req.body ?? {});
299
+ const cid = segment(body.missionId), processId = segment(body.processId), url = segment(body.url);
300
+ if (!mission(res, pid, cid))
301
+ return;
302
+ if (!ID.test(processId) || !url || url.length > 2048) {
303
+ res.status(400).json({ error: 'processId and url are required' });
304
+ return;
305
+ }
306
+ try {
307
+ res.status(201).json(await preview.open(device(req), pid, { chatId: cid, processId, url, viewport: viewportFrom(body.viewport) }));
308
+ }
309
+ catch (err) {
310
+ fail(res, err);
311
+ }
312
+ });
313
+ router.post('/projects/:pid/preview-sessions/:sid/attach', (req, res) => {
314
+ const pid = gate(req, res);
315
+ if (!pid)
316
+ return;
317
+ const sid = segment(req.params.sid);
318
+ if (!ID.test(sid)) {
319
+ res.status(400).json({ error: 'Invalid parameter' });
320
+ return;
321
+ }
322
+ try {
323
+ res.json(preview.attach(device(req), pid, sid));
324
+ }
325
+ catch (err) {
326
+ fail(res, err);
327
+ }
328
+ });
329
+ router.post('/projects/:pid/preview-sessions/:sid/navigate', async (req, res) => {
330
+ const pid = gate(req, res);
331
+ if (!pid)
332
+ return;
333
+ const sid = segment(req.params.sid);
334
+ const body = (req.body ?? {});
335
+ const action = segment(body.action) || 'goto';
336
+ const url = typeof body.url === 'string' ? body.url : undefined;
337
+ if (!ID.test(sid) || !ACTIONS.has(action) || (action === 'goto' && (!url || url.length > 2048))) {
338
+ res.status(400).json({ error: 'Invalid navigation' });
339
+ return;
340
+ }
341
+ try {
342
+ res.json(await preview.navigate(device(req), pid, sid, action, url));
343
+ }
344
+ catch (err) {
345
+ fail(res, err);
346
+ }
347
+ });
348
+ router.delete('/projects/:pid/preview-sessions/:sid', async (req, res) => {
349
+ const pid = gate(req, res);
350
+ if (!pid)
351
+ return;
352
+ const sid = segment(req.params.sid);
353
+ if (!ID.test(sid)) {
354
+ res.status(400).json({ error: 'Invalid parameter' });
355
+ return;
356
+ }
357
+ try {
358
+ await preview.close(device(req), pid, sid);
359
+ res.json({ ok: true });
360
+ }
361
+ catch (err) {
362
+ fail(res, err);
363
+ }
364
+ });
365
+ return router;
366
+ }
@@ -9,6 +9,7 @@ const mobile_auth_1 = require("./mobile-auth");
9
9
  const mobile_devices_1 = require("./mobile-devices");
10
10
  const mobile_missions_1 = require("./mobile-missions");
11
11
  const mobile_code_1 = require("./mobile-code");
12
+ const mobile_preview_1 = require("./mobile-preview");
12
13
  // The gateway's authenticated REST surface: the `/v1/*` allow-list. Each route
13
14
  // forwards, in-process, via a REAL loopback HTTP request to
14
15
  // http://127.0.0.1:<desktopPort> with the master token injected server-side as
@@ -455,7 +456,9 @@ function createMobileRouter(deps) {
455
456
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
456
457
  return { status: response.status, json: await response.json() };
457
458
  };
458
- v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream }));
459
+ v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream, appPreview: () => deps.preview?.available() ?? false }));
460
+ if (deps.preview)
461
+ v1.use((0, mobile_preview_1.createMobilePreviewRouter)({ db: deps.db, preview: deps.preview }));
459
462
  v1.use((0, mobile_code_1.createMobileCodeRouter)(mobileUpstream));
460
463
  router.use('/v1', v1);
461
464
  // Any unmatched gateway path → 404 JSON (NEVER falls through to a SPA handler;
@@ -472,6 +475,11 @@ exports.MOBILE_ALLOWLIST = [
472
475
  { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/find' },
473
476
  { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/file' },
474
477
  { method: 'GET', path: '/v1/capabilities' },
478
+ { method: 'GET', path: '/v1/projects/:pid/missions/:cid/preview-targets' },
479
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions' },
480
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions/:sid/attach' },
481
+ { method: 'POST', path: '/v1/projects/:pid/preview-sessions/:sid/navigate' },
482
+ { method: 'DELETE', path: '/v1/projects/:pid/preview-sessions/:sid' },
475
483
  { method: 'GET', path: '/v1/mission-models' },
476
484
  { method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
477
485
  { method: 'GET', path: '/v1/projects/:pid/repositories' },
@@ -14,6 +14,9 @@ class MobileWebrtcGateway {
14
14
  // answer to whatever happened to be pending. Per-room keying isolates them.
15
15
  _pending = new Map();
16
16
  _active = new Set();
17
+ /** The live peer of each authenticated device (latest wins), so Desktop can
18
+ * open extra DataChannels (app preview) on the connection it already has. */
19
+ _byDevice = new Map();
17
20
  constructor(_deps) {
18
21
  this._deps = _deps;
19
22
  }
@@ -48,17 +51,23 @@ class MobileWebrtcGateway {
48
51
  // require a valid existing device token instead, and never mint.
49
52
  new mobile_datachannel_1.DataChannelPeer(toChannelLike(dc), {
50
53
  registerDevice: async (input) => {
54
+ let result;
51
55
  if (isReconnect) {
52
- return this._deps.registerDevice({
56
+ result = await this._deps.registerDevice({
53
57
  deviceName: input.deviceName,
54
58
  platform: input.platform,
55
59
  token: input.token,
56
60
  requireExistingToken: true,
57
61
  });
58
62
  }
59
- return input.secret && (0, auth_1.safeEqual)(input.secret, secret)
60
- ? this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
61
- : { ok: false };
63
+ else {
64
+ result = input.secret && (0, auth_1.safeEqual)(input.secret, secret)
65
+ ? await this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
66
+ : { ok: false };
67
+ }
68
+ if (result.ok)
69
+ this._byDevice.set(result.deviceId, pc);
70
+ return result;
62
71
  },
63
72
  rpcDispatch: this._deps.rpcDispatch,
64
73
  bridge: this._deps.bridge,
@@ -67,6 +76,9 @@ class MobileWebrtcGateway {
67
76
  console.log('[webrtc] pc connectionState:', s);
68
77
  if (s === 'failed' || s === 'closed' || s === 'disconnected') {
69
78
  this._active.delete(pc);
79
+ for (const [deviceId, owned] of this._byDevice)
80
+ if (owned === pc)
81
+ this._byDevice.delete(deviceId);
70
82
  // Drop this pc from whichever room slot still holds it.
71
83
  if (this._pending.get(key)?.pc === pc)
72
84
  this._pending.delete(key);
@@ -101,6 +113,24 @@ class MobileWebrtcGateway {
101
113
  return { ok: false };
102
114
  }
103
115
  }
116
+ /** Open an ordered, reliable DataChannel on the device's live peer (no SDP
117
+ * renegotiation: the SCTP association already exists). Null when the
118
+ * device has no connected WebRTC peer. */
119
+ openChannel(deviceId, label) {
120
+ const pc = this._byDevice.get(deviceId);
121
+ if (!pc || pc.connectionState !== 'connected')
122
+ return null;
123
+ try {
124
+ return toPreviewChannel(pc.createDataChannel(label, { ordered: true }));
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /** Forget a device's peer (revoked). Its channels close with the peer. */
131
+ forgetDevice(deviceId) {
132
+ this._byDevice.delete(deviceId);
133
+ }
104
134
  /** Tear down every peer (gateway stopping / disabled). */
105
135
  stop() {
106
136
  for (const key of [...this._pending.keys()])
@@ -108,6 +138,7 @@ class MobileWebrtcGateway {
108
138
  for (const pc of this._active)
109
139
  this._close(pc);
110
140
  this._active.clear();
141
+ this._byDevice.clear();
111
142
  }
112
143
  _close(pc) {
113
144
  try {
@@ -137,6 +168,25 @@ function toChannelLike(dc) {
137
168
  },
138
169
  };
139
170
  }
171
+ function toPreviewChannel(dc) {
172
+ return {
173
+ get label() { return dc.label; },
174
+ get readyState() { return dc.readyState; },
175
+ get bufferedAmount() { return dc.bufferedAmount; },
176
+ send: (data) => dc.send(data),
177
+ close: () => dc.close(),
178
+ onOpen: (cb) => {
179
+ if (dc.readyState === 'open')
180
+ queueMicrotask(cb);
181
+ else
182
+ dc.stateChanged.subscribe((s) => { if (s === 'open')
183
+ cb(); });
184
+ },
185
+ onMessage: (cb) => { dc.onMessage.subscribe((d) => cb(d)); },
186
+ onClose: (cb) => { dc.stateChanged.subscribe((s) => { if (s === 'closed')
187
+ cb(); }); },
188
+ };
189
+ }
140
190
  /** Vanilla ICE: resolve once gathering completes so the offer SDP carries all
141
191
  * candidates (there's no trickle channel — the SDP travels by QR). */
142
192
  function waitIceComplete(pc) {
@@ -33,7 +33,8 @@ function topicFor(type) {
33
33
  case 'agent_pr_decision':
34
34
  case 'background_process.started':
35
35
  case 'background_process.updated':
36
- case 'background_process.exited': return 'missions';
36
+ case 'background_process.exited':
37
+ case 'background_process.preview_targets': return 'missions';
37
38
  case 'queue': return 'queue';
38
39
  case 'phase': return 'phase';
39
40
  case 'ticket_created':
@@ -253,7 +254,8 @@ class MobileWsBridge {
253
254
  continue;
254
255
  const value = msg;
255
256
  const process = value.process;
256
- const cid = typeof value.conversationId === 'string' ? value.conversationId : process?.chatId;
257
+ const cid = typeof value.conversationId === 'string' ? value.conversationId
258
+ : typeof value.chatId === 'string' ? value.chatId : process?.chatId;
257
259
  const projectId = cid ? this._missionProjectFor(cid) : null;
258
260
  if (!projectId || !s.projects.has(projectId))
259
261
  continue;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ // Pure rules for the Companion app preview: which local URLs a background
3
+ // process announces, how loopback origins are normalised, and which
4
+ // navigations a device-owned preview session may perform. No I/O here — the
5
+ // runtime feeds log lines and the announced-origin set in.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.isLoopbackHost = isLoopbackHost;
8
+ exports.previewOrigin = previewOrigin;
9
+ exports.extractPreviewUrls = extractPreviewUrls;
10
+ exports.previewTargetsFromLines = previewTargetsFromLines;
11
+ exports.isPreviewRequestAllowed = isPreviewRequestAllowed;
12
+ exports.isManualPreviewUrlAllowed = isManualPreviewUrlAllowed;
13
+ const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
14
+ // Host alternatives a dev server prints for "this machine". Bracketed IPv6
15
+ // forms are matched literally; the wildcard binds are rewritten to localhost.
16
+ const LOCAL_URL = /\bhttps?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\])(?::\d{1,5})?(?:\/[^\s'"<>)\]`]*)?/gi;
17
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
18
+ /** A loopback host as dev servers print it (wildcard binds included). */
19
+ function isLoopbackHost(hostname) {
20
+ const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
21
+ return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || host === '::' ||
22
+ host === '0.0.0.0' || /^127(?:\.\d{1,3}){3}$/.test(host);
23
+ }
24
+ /**
25
+ * The comparable origin of `raw`, with every loopback spelling collapsed to
26
+ * `localhost` (so `http://127.0.0.1:5173` and `http://[::1]:5173` match an
27
+ * announced `http://localhost:5173`). Null for unparsable or non-http(s) URLs.
28
+ */
29
+ function previewOrigin(raw) {
30
+ let url;
31
+ try {
32
+ url = new URL(raw.trim());
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
38
+ return null;
39
+ const host = isLoopbackHost(url.hostname) ? 'localhost' : url.hostname.toLowerCase();
40
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
41
+ return `${url.protocol}//${host}:${port}`;
42
+ }
43
+ /** Normalise an announced URL: loopback host → `localhost`, explicit path kept. */
44
+ function normalizeAnnouncedUrl(raw) {
45
+ let url;
46
+ try {
47
+ url = new URL(raw.replace(TRAILING_PUNCTUATION, ''));
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (!isLoopbackHost(url.hostname))
53
+ return null;
54
+ const port = url.port ? Number(url.port) : NaN;
55
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
56
+ return null;
57
+ url.hostname = 'localhost';
58
+ url.hash = '';
59
+ return url.toString();
60
+ }
61
+ /**
62
+ * Local URLs a log line announces (`➜ Local: http://localhost:5173/`,
63
+ * `ready on http://0.0.0.0:3000`), normalised and de-duplicated by origin in
64
+ * order of appearance. URLs without an explicit port are ignored: a dev server
65
+ * always prints one, while bare `http://localhost` mentions are usually docs.
66
+ */
67
+ function extractPreviewUrls(line) {
68
+ const text = line.replace(ANSI, '');
69
+ const found = [];
70
+ const origins = new Set();
71
+ for (const match of text.matchAll(LOCAL_URL)) {
72
+ const url = normalizeAnnouncedUrl(match[0]);
73
+ const origin = url && previewOrigin(url);
74
+ if (!url || !origin || origins.has(origin))
75
+ continue;
76
+ origins.add(origin);
77
+ found.push(url);
78
+ }
79
+ return found;
80
+ }
81
+ /**
82
+ * Latest-first, origin-unique targets from a process's log lines (oldest first).
83
+ * A re-announced origin moves to the front with its newest URL.
84
+ */
85
+ function previewTargetsFromLines(lines) {
86
+ const byOrigin = new Map();
87
+ for (const line of lines) {
88
+ for (const url of extractPreviewUrls(line)) {
89
+ const origin = previewOrigin(url);
90
+ byOrigin.delete(origin);
91
+ byOrigin.set(origin, url);
92
+ }
93
+ }
94
+ return [...byOrigin.values()].reverse();
95
+ }
96
+ /**
97
+ * Whether a device-owned preview session may load `url` (a navigation or a
98
+ * subresource request). Announced origins of the project are allowed even
99
+ * though they are loopback; everything else must pass the regular server-side
100
+ * guard (`fallback`, i.e. `isNavigableUrl`), which rejects loopback/private
101
+ * hosts and non-http(s) schemes.
102
+ */
103
+ function isPreviewRequestAllowed(url, announcedOrigins, fallback) {
104
+ const origin = previewOrigin(url);
105
+ if (origin && announcedOrigins.has(origin))
106
+ return true;
107
+ return fallback(url);
108
+ }
109
+ /**
110
+ * Whether a device may *type* `url` (create or manual navigate). Stricter than
111
+ * the request guard: only paths on announced origins, never an arbitrary
112
+ * public URL — Desktop must not become a general browser proxy for the phone.
113
+ */
114
+ function isManualPreviewUrlAllowed(url, announcedOrigins) {
115
+ const origin = previewOrigin(url);
116
+ return origin !== null && announcedOrigins.has(origin);
117
+ }