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
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-CECj3joE.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-IdGpV21X.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-BbYvbtY8.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DDD8FYaU.js">
@@ -236,23 +236,23 @@
236
236
  <link rel="modulepreload" crossorigin href="/assets/narration-DbHnz0qV.js">
237
237
  <link rel="modulepreload" crossorigin href="/assets/nav--u7iPYlS.js">
238
238
  <link rel="modulepreload" crossorigin href="/assets/packet-B0H15elv.js">
239
- <link rel="modulepreload" crossorigin href="/assets/settings-nryN6wuS.js">
239
+ <link rel="modulepreload" crossorigin href="/assets/settings-Pyw8uYtL.js">
240
240
  <link rel="modulepreload" crossorigin href="/assets/setup-CKGUxKr-.js">
241
241
  <link rel="modulepreload" crossorigin href="/assets/specs-DfIdoU5L.js">
242
242
  <link rel="modulepreload" crossorigin href="/assets/subscriptionUsage-CdlE5Mg1.js">
243
243
  <link rel="modulepreload" crossorigin href="/assets/terminal-BAzpHtpY.js">
244
244
  <link rel="modulepreload" crossorigin href="/assets/tickets-D41ZMQpf.js">
245
245
  <link rel="modulepreload" crossorigin href="/assets/createLucideIcon-DtODIU64.js">
246
- <link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-CcsE5986.js">
247
- <link rel="modulepreload" crossorigin href="/assets/Trans-ZiXIfj50.js">
248
- <link rel="modulepreload" crossorigin href="/assets/useDesktop-C0mRIlHT.js">
246
+ <link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-Bj4LMp7b.js">
247
+ <link rel="modulepreload" crossorigin href="/assets/Trans-BbFxRFtR.js">
248
+ <link rel="modulepreload" crossorigin href="/assets/useDesktop-BjLT0_EE.js">
249
249
  <link rel="modulepreload" crossorigin href="/assets/provider-capabilities-lePfeT_Y.js">
250
250
  <link rel="modulepreload" crossorigin href="/assets/save-LmPSfsvZ.js">
251
- <link rel="modulepreload" crossorigin href="/assets/spending-eWiZGhC_.js">
251
+ <link rel="modulepreload" crossorigin href="/assets/spending-CQnrWiR3.js">
252
252
  <link rel="modulepreload" crossorigin href="/assets/chevron-up-6EFqEHBj.js">
253
253
  <link rel="modulepreload" crossorigin href="/assets/sparkles-CCfUdFGY.js">
254
254
  <link rel="modulepreload" crossorigin href="/assets/x-Bu7X_eR2.js">
255
- <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-BHIUPVmC.js">
255
+ <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-DHfigjHS.js">
256
256
  <link rel="modulepreload" crossorigin href="/assets/ban-wqfdzoXt.js">
257
257
  <link rel="modulepreload" crossorigin href="/assets/file-text-C0blZHLh.js">
258
258
  <link rel="modulepreload" crossorigin href="/assets/circle-x-D2ttKotB.js">
@@ -268,21 +268,21 @@
268
268
  <link rel="modulepreload" crossorigin href="/assets/search-BDVp3fkH.js">
269
269
  <link rel="modulepreload" crossorigin href="/assets/trash-D8zYUmTg.js">
270
270
  <link rel="modulepreload" crossorigin href="/assets/triangle-alert-Cxpn8bqr.js">
271
- <link rel="modulepreload" crossorigin href="/assets/jira-api-C_D_RDhl.js">
271
+ <link rel="modulepreload" crossorigin href="/assets/jira-api-Cabj-H0J.js">
272
272
  <link rel="modulepreload" crossorigin href="/assets/zap-bugHyWzQ.js">
273
273
  <link rel="modulepreload" crossorigin href="/assets/clsx-CjueKrWZ.js">
274
274
  <link rel="modulepreload" crossorigin href="/assets/utils-BC4DHlz2.js">
275
275
  <link rel="modulepreload" crossorigin href="/assets/dialog-DtdR05v1.js">
276
276
  <link rel="modulepreload" crossorigin href="/assets/lib-DWJfei6y.js">
277
- <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-62h40I4t.js">
278
- <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-CdN5lTyD.js">
277
+ <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-vJ7qUoqN.js">
278
+ <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-NjPHBLfB.js">
279
279
  <link rel="modulepreload" crossorigin href="/assets/button-4FS1ZDTp.js">
280
- <link rel="modulepreload" crossorigin href="/assets/project-repositories-Be0CWklo.js">
281
- <link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-DeCoUsRN.js">
282
- <link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-DiPngt2g.js">
280
+ <link rel="modulepreload" crossorigin href="/assets/project-repositories-Cnnuj9Ia.js">
281
+ <link rel="modulepreload" crossorigin href="/assets/RepositoryScopeSelector-2eSqR9WK.js">
282
+ <link rel="modulepreload" crossorigin href="/assets/TicketDetailModalContext-Dkwo2cDs.js">
283
283
  <link rel="modulepreload" crossorigin href="/assets/core-B3mKBIh5.js">
284
- <link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-DAIxIYsl.js">
285
- <link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-B1a6GChu.js">
284
+ <link rel="modulepreload" crossorigin href="/assets/RepositoryDeliveries-B4ybHOyB.js">
285
+ <link rel="modulepreload" crossorigin href="/assets/useRuntimeRuns-C97qEqMg.js">
286
286
  <link rel="modulepreload" crossorigin href="/assets/event-Bt7f3wXe.js">
287
287
  <link rel="stylesheet" crossorigin href="/assets/index-BgbCbIua.css">
288
288
  </head>
@@ -127,6 +127,32 @@ Loopback-only admin surface for the Mobile Gateway (the gateway itself is a sepa
127
127
  | `GET` | `/devices` | List paired devices |
128
128
  | `DELETE` | `/devices/:id` | Revoke a paired device |
129
129
  | `POST` | `/cert/rotate` | Rotate the gateway TLS identity (unpairs every device) |
130
+ | `GET` | `/previews` | Live Companion app previews (`{ previews: [{ projectId, projectName, sessionId, deviceId, deviceName, url, title, createdAt }] }`) |
131
+ | `DELETE` | `/previews/:projectId/:sessionId` | End a device-owned preview from Desktop (`{ ok }`; Desktop sessions are never affected) |
132
+
133
+ Revoking a device also ends its previews. The app-level WS event `mobile.previews_changed` (no `projectId`) tells Settings ▸ Mobile to refetch the list.
134
+
135
+ #### Companion app preview (gateway `/v1`)
136
+
137
+ A paired Companion views a project's locally served web app through a device-owned server-side browser session. The routes require the mission grant (all-projects device) and the `appPreview` capability (`GET /v1/capabilities` → `features.appPreview`, true when browser capture is enabled).
138
+
139
+ | Method | Path | Notes |
140
+ |--------|------|-------|
141
+ | `GET` | `/v1/projects/:pid/missions/:cid/preview-targets` | `{ targets: [{ processId, urls }] }`: local URLs announced in the logs of the mission's running background processes |
142
+ | `POST` | `/v1/projects/:pid/preview-sessions` | Body `{ missionId, processId, url, viewport? }`; `url` must be announced by that process. `201 { sessionId, channel, url }` |
143
+ | `POST` | `/v1/projects/:pid/preview-sessions/:sid/attach` | Re-attach after a reconnect: `{ sessionId, channel }` (a new DataChannel replaces the old one) |
144
+ | `POST` | `/v1/projects/:pid/preview-sessions/:sid/navigate` | Body `{ action: goto\|back\|forward\|reload, url? }`; `goto` accepts only paths on announced origins |
145
+ | `DELETE` | `/v1/projects/:pid/preview-sessions/:sid` | End the device's own preview |
146
+
147
+ Errors carry `{ error, code }`. Codes: `preview_unavailable` (404), `preview_target_gone` (404), `preview_session_closed` (404), `preview_url_not_announced` (400), `preview_limit` (429), `preview_requires_webrtc` (409), `preview_failed` (502). Sessions owned by Desktop or by another device are reported as `preview_session_closed`.
148
+
149
+ **Channel.** `channel` is the label of an ordered, reliable DataChannel (`preview:<sessionId>`) that Desktop opens on the device's existing WebRTC peer without renegotiation:
150
+
151
+ - **Desktop → Companion, text:** JSON control: `ready` (`{ id, url, title, viewport }`), `nav`, `popup`, `closed` (`{ reason }`) and `nav_rejected`.
152
+ - **Desktop → Companion, binary:** JPEG screencast frames in fragments of at most 16 000 bytes. Each fragment starts with an 8-byte big-endian header `u32 frameSeq | u16 index | u16 count`. Render a frame only when all of its fragments have arrived. A newer `frameSeq` discards an incomplete older frame.
153
+ - **Companion → Desktop:** JSON `{ type: 'input', event }` (mouse / wheel / key / resize, validated) and `{ type: 'navigate', action, url? }`, rate-limited to 6 000 messages per minute.
154
+
155
+ **Origin policy.** A device session may load a loopback origin only while a running background process of the project announces it. Every other request passes the regular server-side SSRF guard. Device sessions have their own budget: one per device (opening a new one replaces it) and two per project. They never count against Desktop's four sessions.
130
156
 
131
157
  ### specrails-tech proxy
132
158
 
@@ -5,7 +5,7 @@ architectural dependency declaration. Search a heading or filename and read only
5
5
  the relevant source and tests. Feature prefixes group the legacy server files;
6
6
  new bounded modules live under `server/modules/`.
7
7
 
8
- Includes 1046 source/build files. Nearby tests are linked where names
8
+ Includes 1050 source/build files. Nearby tests are linked where names
9
9
  match; integration suites may live elsewhere. Use `rg` to find other consumers.
10
10
 
11
11
  ## cli
@@ -1057,6 +1057,8 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1057
1057
  - [mobile-event-bus.ts](../../server/mobile/mobile-event-bus.ts)
1058
1058
  - [mobile-gateway.ts](../../server/mobile/mobile-gateway.ts) · [test](../../server/mobile/mobile-gateway.test.ts)
1059
1059
  - [mobile-missions.ts](../../server/mobile/mobile-missions.ts) · [test](../../server/mobile/mobile-missions.test.ts)
1060
+ - [mobile-preview-channel.ts](../../server/mobile/mobile-preview-channel.ts) · [test](../../server/mobile/mobile-preview-channel.test.ts)
1061
+ - [mobile-preview.ts](../../server/mobile/mobile-preview.ts) · [test](../../server/mobile/mobile-preview.test.ts)
1060
1062
  - [mobile-redact.ts](../../server/mobile/mobile-redact.ts) · [test](../../server/mobile/mobile-redact.test.ts)
1061
1063
  - [mobile-router.ts](../../server/mobile/mobile-router.ts) · [test](../../server/mobile/mobile-router.test.ts)
1062
1064
  - [mobile-signal-reconnect.ts](../../server/mobile/mobile-signal-reconnect.ts) · [test](../../server/mobile/mobile-signal-reconnect.test.ts)
@@ -1148,6 +1150,10 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1148
1150
  - [profile-manager.ts](../../server/modules/agents/runtime/profile-manager.ts) · [test](../../server/modules/agents/runtime/profile-manager.test.ts)
1149
1151
  - [profiles-router.ts](../../server/modules/agents/runtime/profiles-router.ts) · [test](../../server/modules/agents/runtime/profiles-router.test.ts)
1150
1152
 
1153
+ ## server/modules/browser/domain
1154
+
1155
+ - [preview-origins.ts](../../server/modules/browser/domain/preview-origins.ts) · [test](../../server/modules/browser/domain/preview-origins.test.ts)
1156
+
1151
1157
  ## server/modules/browser/runtime
1152
1158
 
1153
1159
  - [browser-capture-manager.ts](../../server/modules/browser/runtime/browser-capture-manager.ts) · [test](../../server/modules/browser/runtime/browser-capture-manager.test.ts)
@@ -1156,6 +1162,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1156
1162
  - [browser-network.ts](../../server/modules/browser/runtime/browser-network.ts) · [test](../../server/modules/browser/runtime/browser-network.test.ts)
1157
1163
  - [browser-playwright.ts](../../server/modules/browser/runtime/browser-playwright.ts) · [test](../../server/modules/browser/runtime/browser-playwright.test.ts)
1158
1164
  - [browser-viewport.ts](../../server/modules/browser/runtime/browser-viewport.ts) · [test](../../server/modules/browser/runtime/browser-viewport.test.ts)
1165
+ - [preview-targets.ts](../../server/modules/browser/runtime/preview-targets.ts) · [test](../../server/modules/browser/runtime/preview-targets.test.ts)
1159
1166
 
1160
1167
  ## server/modules/builder/runtime
1161
1168
 
@@ -1406,7 +1413,7 @@ match; integration suites may live elsewhere. Use `rg` to find other consumers.
1406
1413
  ## server/modules/terminals/runtime
1407
1414
 
1408
1415
  - [background-process-control.ts](../../server/modules/terminals/runtime/background-process-control.ts) · [test](../../server/modules/terminals/runtime/background-process-control.test.ts)
1409
- - [background-process-service.ts](../../server/modules/terminals/runtime/background-process-service.ts)
1416
+ - [background-process-service.ts](../../server/modules/terminals/runtime/background-process-service.ts) · [test](../../server/modules/terminals/runtime/background-process-service.test.ts)
1410
1417
  - [background-process-store.ts](../../server/modules/terminals/runtime/background-process-store.ts) · [test](../../server/modules/terminals/runtime/background-process-store.test.ts)
1411
1418
  - [background-windows-bootstrap.ts](../../server/modules/terminals/runtime/background-windows-bootstrap.ts) · [test](../../server/modules/terminals/runtime/background-windows-bootstrap.test.ts)
1412
1419
  - [terminal-manager.ts](../../server/modules/terminals/runtime/terminal-manager.ts) · [test](../../server/modules/terminals/runtime/terminal-manager.test.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.63.2",
3
+ "version": "2.64.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -623,7 +623,20 @@ function applyPtyWsRateLimiting(ws) {
623
623
  // Second HTTPS+WSS listener in THIS process; the main server stays loopback.
624
624
  // The control plane is desktop-only: loopback + (via /api below) requireAuth.
625
625
  // Mounted before the /api desktop router so its sub-path is unambiguous.
626
- const mobileGateway = new mobile_1.MobileGateway({ desktopDb: registry.desktopDb, desktopPort: port, broadcast });
626
+ const mobileGateway = new mobile_1.MobileGateway({
627
+ desktopDb: registry.desktopDb,
628
+ desktopPort: port,
629
+ broadcast,
630
+ // Companion app preview: each project's browser + the process-log targets.
631
+ preview: {
632
+ enabled: () => BROWSER_CAPTURE_ENABLED,
633
+ browserFor: (projectId) => registry.getContext(projectId)?.browserCaptureManager ?? null,
634
+ allBrowsers: () => registry.listProjects()
635
+ .map((project) => registry.getContext(project.id)?.browserCaptureManager)
636
+ .filter((manager) => manager != null),
637
+ targets: registry.previewTargets,
638
+ },
639
+ });
627
640
  _mobileGateway = mobileGateway;
628
641
  app.use('/api/mobile', auth_1.requireLoopback, (0, mobile_1.createMobileAdminRouter)({
629
642
  gateway: mobileGateway,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createMobileAdminRouter = createMobileAdminRouter;
4
4
  const express_1 = require("express");
5
5
  const mobile_devices_1 = require("./mobile-devices");
6
+ const desktop_db_1 = require("../desktop-db");
6
7
  const DEVICE_ID_RE = /^[A-Za-z0-9-]{1,64}$/;
7
8
  function createMobileAdminRouter(deps) {
8
9
  const router = (0, express_1.Router)();
@@ -37,10 +38,25 @@ function createMobileAdminRouter(deps) {
37
38
  const changed = (0, mobile_devices_1.revokeDevice)(desktopDb, id);
38
39
  if (changed) {
39
40
  gateway.bridge?.closeForDevice(id);
41
+ void gateway.closePreviewsForDevice(id).catch(() => { });
40
42
  deps.broadcast({ type: 'mobile.device_revoked', deviceId: id, timestamp: new Date().toISOString() });
41
43
  }
42
44
  res.json({ ok: changed });
43
45
  });
46
+ // —— Companion app previews (Desktop indicator) ——
47
+ router.get('/previews', (_req, res) => {
48
+ const previews = gateway.preview?.listDeviceSessions() ?? [];
49
+ res.json({ previews: previews.map((p) => ({ ...p, projectName: (0, desktop_db_1.getProject)(desktopDb, p.projectId)?.name ?? p.projectId })) });
50
+ });
51
+ router.delete('/previews/:projectId/:sessionId', async (req, res) => {
52
+ const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
53
+ const sessionId = typeof req.params.sessionId === 'string' ? req.params.sessionId : '';
54
+ if (!/^[A-Za-z0-9_-]{1,160}$/.test(projectId) || !/^[A-Za-z0-9_-]{1,160}$/.test(sessionId)) {
55
+ res.status(400).json({ error: 'Invalid parameter' });
56
+ return;
57
+ }
58
+ res.json({ ok: (await gateway.preview?.endFromDesktop(projectId, sessionId)) ?? false });
59
+ });
44
60
  router.post('/cert/rotate', async (_req, res) => {
45
61
  const status = await gateway.rotateCert();
46
62
  res.json(status);
@@ -17,6 +17,7 @@ const mobile_webrtc_peer_1 = require("./mobile-webrtc-peer");
17
17
  const mobile_signal_reconnect_1 = require("./mobile-signal-reconnect");
18
18
  const mobile_webrtc_1 = require("./mobile-webrtc");
19
19
  const agent_store_1 = require("../modules/agents/runtime/agent-store");
20
+ const mobile_preview_1 = require("./mobile-preview");
20
21
  // Lifecycle owner of the second HTTPS+WSS listener (default :4202), hard-isolated
21
22
  // from the main server. Off by default; started on enable or boot-if-enabled.
22
23
  const DEFAULT_PORT = 4202;
@@ -47,12 +48,28 @@ class MobileGateway {
47
48
  _running = false;
48
49
  _boundPort = DEFAULT_PORT;
49
50
  _portOverride;
51
+ _preview;
50
52
  constructor(deps) {
51
53
  this._db = deps.desktopDb;
52
54
  this._desktopPort = deps.desktopPort;
53
55
  this._broadcast = deps.broadcast;
54
56
  this._bindHost = deps.bindHost ?? '0.0.0.0';
55
57
  this._portOverride = deps.port;
58
+ this._preview = deps.preview
59
+ ? new mobile_preview_1.MobilePreviewService({
60
+ ...deps.preview,
61
+ openChannel: (deviceId, label) => this._webrtc?.openChannel(deviceId, label) ?? null,
62
+ onChange: () => this._broadcast({ type: 'mobile.previews_changed', timestamp: new Date().toISOString() }),
63
+ })
64
+ : null;
65
+ }
66
+ get preview() {
67
+ return this._preview;
68
+ }
69
+ /** A revoked device loses its peer mapping and every preview it owns. */
70
+ async closePreviewsForDevice(deviceId) {
71
+ this._webrtc?.forgetDevice(deviceId);
72
+ await this._preview?.closeForDevice(deviceId);
56
73
  }
57
74
  get bridge() {
58
75
  return this._bridge;
@@ -139,6 +156,7 @@ class MobileGateway {
139
156
  db: this._db,
140
157
  desktopPort: this._desktopPort,
141
158
  currentFingerprint: () => this._cert.fingerprint,
159
+ ...(this._preview ? { preview: this._preview } : {}),
142
160
  }));
143
161
  const server = https_1.default.createServer({ cert: this._cert.certPem, key: this._cert.keyPem }, app);
144
162
  // BUG-MOBILE-02: scope each socket's subscribe to the device's granted
@@ -16,9 +16,11 @@ const record = (value) => value && typeof value === 'object' && !Array.isArray(v
16
16
  const pick = (value, keys) => Object.fromEntries(Object.entries(record(value)).filter(([key]) => keys.includes(key)));
17
17
  exports.MOBILE_PROTOCOL_VERSION = 2;
18
18
  exports.MISSION_ACCESS_REASON = 'Mission agents can access more than their pinned project. Missions require an all-projects device grant; your permitted Specs and Rails remain available.';
19
- function mobileCapabilities(db, deviceId) {
19
+ function mobileCapabilities(db, deviceId, opts = {}) {
20
20
  const missions = process.env.SPECRAILS_AGENT_CHAT !== 'false' && (0, mobile_devices_1.getAllowedProjects)(db, deviceId) === null;
21
- return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true, codeExplorer: (0, feature_flags_1.isCodeExplorerEnabled)(), missionRailCards: missions && (0, feature_flags_1.isMissionRailCardsEnabled)() },
21
+ // App preview targets come from mission processes, so it inherits the
22
+ // mission grant and additionally needs Desktop's browser capture.
23
+ return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true, codeExplorer: (0, feature_flags_1.isCodeExplorerEnabled)(), missionRailCards: missions && (0, feature_flags_1.isMissionRailCardsEnabled)(), appPreview: missions && opts.appPreview === true },
22
24
  ...(!missions ? { missionsUnavailableReason: process.env.SPECRAILS_AGENT_CHAT === 'false' ? 'Missions are disabled on Desktop.' : exports.MISSION_ACCESS_REASON } : {}) };
23
25
  }
24
26
  function conversationView(value) {
@@ -72,7 +74,7 @@ function missionSnapshot(value) {
72
74
  pendingMessages, pendingTruncated: pending.length > 50,
73
75
  live: { ...pick(live, ['isStreaming', 'startedAt']), streamingText, textTruncated: streamingText.length < liveRaw.length } };
74
76
  }
75
- function createMobileMissionsRouter({ db, upstream }) {
77
+ function createMobileMissionsRouter({ db, upstream, appPreview }) {
76
78
  const router = (0, express_1.Router)();
77
79
  const segment = (value) => typeof value === 'string' ? value : '';
78
80
  const send = async (res, method, path, body, shape) => {
@@ -84,7 +86,7 @@ function createMobileMissionsRouter({ db, upstream }) {
84
86
  res.status(502).json({ error: 'Desktop is unreachable. Retry when connected.' });
85
87
  }
86
88
  };
87
- router.get('/capabilities', (req, res) => res.json(mobileCapabilities(db, req.mobileDevice.id)));
89
+ router.get('/capabilities', (req, res) => res.json(mobileCapabilities(db, req.mobileDevice.id, { appPreview: appPreview?.() ?? false })));
88
90
  router.get('/mission-models', (req, res) => {
89
91
  const provider = segment(req.query.provider);
90
92
  if (!/^[a-z0-9_-]{1,40}$/.test(provider)) {
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PreviewInboundLimiter = exports.PreviewChannelClient = exports.PREVIEW_INBOUND_MAX_BYTES = exports.PREVIEW_INBOUND_MAX_PER_MINUTE = exports.PREVIEW_MAX_MESSAGE_BYTES = exports.PREVIEW_CHUNK_HEADER_BYTES = exports.PREVIEW_CHANNEL_PREFIX = void 0;
4
+ exports.encodeFrameChunks = encodeFrameChunks;
5
+ exports.parsePreviewInbound = parsePreviewInbound;
6
+ // Companion app preview: one WebRTC DataChannel per browser session, labelled
7
+ // `preview:<sessionId>`, opened by Desktop on the device's EXISTING peer (a new
8
+ // channel on an established SCTP association needs no SDP renegotiation). It is
9
+ // separate from the `mobile` RPC channel so JPEG frames never queue ahead of RPC.
10
+ //
11
+ // Wire format (Desktop → Companion):
12
+ // - text: JSON control (`ready`, `nav`, `popup`, `closed`) — always small.
13
+ // - binary: screencast frame fragments, each `u32 frameSeq | u16 index |
14
+ // u16 count` (big-endian) + payload. A frame renders only once all
15
+ // of its fragments arrived; a newer `frameSeq` discards an older
16
+ // incomplete one.
17
+ // Wire format (Companion → Desktop): JSON text `{type:'input', event}` and
18
+ // `{type:'navigate', action, url?}`.
19
+ exports.PREVIEW_CHANNEL_PREFIX = 'preview:';
20
+ exports.PREVIEW_CHUNK_HEADER_BYTES = 8;
21
+ /** Same per-message budget the `mobile` channel's text chunking proved safe
22
+ * across werift, libwebrtc (native companion) and browsers (web companion). */
23
+ exports.PREVIEW_MAX_MESSAGE_BYTES = 16000;
24
+ const MAX_CHUNKS = 0xffff;
25
+ const WS_OPEN = 1;
26
+ const WS_CLOSED = 3;
27
+ /** Mirrors the PTY-tuned cap of `/ws/browser/:id` (BUG-MOBILE-07). */
28
+ exports.PREVIEW_INBOUND_MAX_PER_MINUTE = 6000;
29
+ exports.PREVIEW_INBOUND_MAX_BYTES = 64 * 1024;
30
+ /** Split one JPEG frame into header-prefixed binary fragments. */
31
+ function encodeFrameChunks(frameSeq, frame, maxMessageBytes = exports.PREVIEW_MAX_MESSAGE_BYTES) {
32
+ const payload = maxMessageBytes - exports.PREVIEW_CHUNK_HEADER_BYTES;
33
+ if (payload <= 0)
34
+ throw new RangeError('maxMessageBytes too small');
35
+ const count = Math.max(1, Math.ceil(frame.length / payload));
36
+ if (count > MAX_CHUNKS)
37
+ throw new RangeError('frame too large');
38
+ const seq = frameSeq >>> 0;
39
+ const chunks = [];
40
+ for (let index = 0; index < count; index++) {
41
+ const part = frame.subarray(index * payload, (index + 1) * payload);
42
+ const chunk = Buffer.allocUnsafe(exports.PREVIEW_CHUNK_HEADER_BYTES + part.length);
43
+ chunk.writeUInt32BE(seq, 0);
44
+ chunk.writeUInt16BE(index, 4);
45
+ chunk.writeUInt16BE(count, 6);
46
+ part.copy(chunk, exports.PREVIEW_CHUNK_HEADER_BYTES);
47
+ chunks.push(chunk);
48
+ }
49
+ return chunks;
50
+ }
51
+ /**
52
+ * Presents a preview DataChannel to `BrowserCaptureManager` as a
53
+ * `BrowserWsClient`, so attach/detach, popup state and the newest-wins
54
+ * screencast fan-out (which checks `bufferedAmount` BEFORE handing over a
55
+ * frame) are reused unchanged. A frame is always sent whole: conflation runs
56
+ * per frame, never per fragment, so a partial frame never goes out.
57
+ */
58
+ class PreviewChannelClient {
59
+ channel;
60
+ frameSeq = 0;
61
+ constructor(channel) {
62
+ this.channel = channel;
63
+ }
64
+ get readyState() {
65
+ return this.channel.readyState === 'open' ? WS_OPEN : WS_CLOSED;
66
+ }
67
+ get bufferedAmount() {
68
+ return this.channel.bufferedAmount;
69
+ }
70
+ send(data) {
71
+ if (typeof data === 'string') {
72
+ this.channel.send(data);
73
+ return;
74
+ }
75
+ const seq = this.frameSeq;
76
+ this.frameSeq = (this.frameSeq + 1) >>> 0;
77
+ for (const chunk of encodeFrameChunks(seq, data))
78
+ this.channel.send(chunk);
79
+ }
80
+ close(_code, reason) {
81
+ try {
82
+ if (this.channel.readyState === 'open')
83
+ this.channel.send(JSON.stringify({ type: 'closed', reason: reason ?? 'session_closed' }));
84
+ }
85
+ catch { /* the channel is already going away */ }
86
+ try {
87
+ this.channel.close();
88
+ }
89
+ catch { /* ignore */ }
90
+ }
91
+ }
92
+ exports.PreviewChannelClient = PreviewChannelClient;
93
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value);
94
+ const modifiers = (value) => Number.isInteger(value) && value >= 0 && value <= 15 ? value : undefined;
95
+ const BUTTONS = new Set(['left', 'middle', 'right']);
96
+ function parseEvent(raw) {
97
+ if (!raw || typeof raw !== 'object')
98
+ return null;
99
+ const e = raw;
100
+ switch (e.type) {
101
+ case 'mouse': {
102
+ if (!['move', 'down', 'up'].includes(e.action) || !finite(e.x) || !finite(e.y) || e.x < 0 || e.y < 0)
103
+ return null;
104
+ const button = typeof e.button === 'string' && BUTTONS.has(e.button) ? e.button : undefined;
105
+ const clickCount = Number.isInteger(e.clickCount) && e.clickCount >= 1 && e.clickCount <= 3 ? e.clickCount : undefined;
106
+ return { type: 'mouse', action: e.action, x: e.x, y: e.y, ...(button ? { button } : {}), ...(clickCount ? { clickCount } : {}), ...(modifiers(e.modifiers) !== undefined ? { modifiers: modifiers(e.modifiers) } : {}) };
107
+ }
108
+ case 'wheel': {
109
+ if (!finite(e.x) || !finite(e.y) || !finite(e.deltaX) || !finite(e.deltaY) || e.x < 0 || e.y < 0)
110
+ return null;
111
+ const clamp = (v) => Math.max(-10_000, Math.min(10_000, v));
112
+ return { type: 'wheel', x: e.x, y: e.y, deltaX: clamp(e.deltaX), deltaY: clamp(e.deltaY), ...(modifiers(e.modifiers) !== undefined ? { modifiers: modifiers(e.modifiers) } : {}) };
113
+ }
114
+ case 'key': {
115
+ if (!['down', 'up'].includes(e.action) || typeof e.key !== 'string' || e.key.length === 0 || e.key.length > 32)
116
+ return null;
117
+ const code = typeof e.code === 'string' && e.code.length <= 32 ? e.code : undefined;
118
+ const text = typeof e.text === 'string' && e.text.length <= 8 ? e.text : undefined;
119
+ return { type: 'key', action: e.action, key: e.key, ...(code ? { code } : {}), ...(text !== undefined ? { text } : {}), ...(modifiers(e.modifiers) !== undefined ? { modifiers: modifiers(e.modifiers) } : {}) };
120
+ }
121
+ case 'resize': {
122
+ if (!finite(e.width) || !finite(e.height) || e.width <= 0 || e.height <= 0)
123
+ return null;
124
+ return { type: 'resize', width: e.width, height: e.height, ...(finite(e.deviceScaleFactor) ? { deviceScaleFactor: e.deviceScaleFactor } : {}) };
125
+ }
126
+ default:
127
+ return null;
128
+ }
129
+ }
130
+ /** Validate one inbound control message; anything malformed is dropped. */
131
+ function parsePreviewInbound(text) {
132
+ if (text.length > exports.PREVIEW_INBOUND_MAX_BYTES)
133
+ return null;
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(text);
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ if (!msg || typeof msg !== 'object')
142
+ return null;
143
+ if (msg.type === 'input') {
144
+ const event = parseEvent(msg.event);
145
+ return event ? { type: 'input', event } : null;
146
+ }
147
+ if (msg.type === 'navigate') {
148
+ const action = msg.action ?? 'goto';
149
+ if (!['goto', 'back', 'forward', 'reload'].includes(action))
150
+ return null;
151
+ if (action === 'goto' && (typeof msg.url !== 'string' || msg.url.length > 2048))
152
+ return null;
153
+ return { type: 'navigate', action: action, ...(action === 'goto' ? { url: msg.url } : {}) };
154
+ }
155
+ return null;
156
+ }
157
+ /** Sliding one-minute message budget for one channel. */
158
+ class PreviewInboundLimiter {
159
+ now;
160
+ maxPerMinute;
161
+ windowStart = 0;
162
+ count = 0;
163
+ constructor(now = Date.now, maxPerMinute = exports.PREVIEW_INBOUND_MAX_PER_MINUTE) {
164
+ this.now = now;
165
+ this.maxPerMinute = maxPerMinute;
166
+ }
167
+ take() {
168
+ const at = this.now();
169
+ if (at - this.windowStart >= 60_000) {
170
+ this.windowStart = at;
171
+ this.count = 0;
172
+ }
173
+ if (this.count >= this.maxPerMinute)
174
+ return false;
175
+ this.count += 1;
176
+ return true;
177
+ }
178
+ }
179
+ exports.PreviewInboundLimiter = PreviewInboundLimiter;