specrails-desktop 2.63.2 → 2.64.1
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/client/dist/assets/{ActivityFeedPage-B1bpTy7O.js → ActivityFeedPage-Dtp7EyHc.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-BussBbN3.js → AgentBrowserCapture-CLqlEFw8.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-BiT1n3BU.js → AgentModeAnalyticsPane-B8_hM3Tw.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-CUjNfeKa.js → AgentModeCodePane-CNF8ZNjE.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-RVEwyS59.js → AgentModeJobsPane-CJpJbO9m.js} +1 -1
- package/client/dist/assets/AgentsPage-BC8XSR79.js +120 -0
- package/client/dist/assets/{AnalyticsPage-Bser2Ly3.js → AnalyticsPage-Cdv4jLtW.js} +1 -1
- package/client/dist/assets/{CodePage-BK0grm6e.js → CodePage-DI1kegAr.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-DEBidZbs.js → DesktopAnalyticsPage-Bdp8E8U6.js} +1 -1
- package/client/dist/assets/{DocsDialog-Dhyo57n9.js → DocsDialog-Bm4RS7FX.js} +1 -1
- package/client/dist/assets/{DocsPage-BcEkPXd6.js → DocsPage-DihYcU8r.js} +1 -1
- package/client/dist/assets/{ExportDropdown-mRmqdkjr.js → ExportDropdown-Bb1lHx3r.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-BlCe3vrU.js → InteractiveJobComposer-BO6ASN-2.js} +1 -1
- package/client/dist/assets/{JobDetailModal-CkAejD1F.js → JobDetailModal-CuPYq7tP.js} +1 -1
- package/client/dist/assets/{JobDetailPage-P-FS_lKz.js → JobDetailPage-BusMjJi9.js} +1 -1
- package/client/dist/assets/{JobsPage-CoUXgYse.js → JobsPage-C0QbsF2d.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-iFylNk68.js → LoopBuilderPage-B7TlU4iS.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-4obS_Ptg.js → LoopPreviewModal-BRWtXOD2.js} +1 -1
- package/client/dist/assets/{LoopWindowSurface-CLr5OPqj.js → LoopWindowSurface-DE0v2K1p.js} +1 -1
- package/client/dist/assets/{LoopsPage-eKpWFtCp.js → LoopsPage-C4q1DmCE.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-BHIUPVmC.js → MinimizedChatsContext-DHfigjHS.js} +1 -1
- package/client/dist/assets/{PluginsPage-BgTC2CQx.js → PluginsPage-Z1JHSMdR.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-B4chTfU8.js → ProjectSettingsDialog-BUNj0tvN.js} +1 -1
- package/client/dist/assets/{RepositoryDeliveries-DAIxIYsl.js → RepositoryDeliveries-B4ybHOyB.js} +1 -1
- package/client/dist/assets/{RepositoryScopeSelector-DeCoUsRN.js → RepositoryScopeSelector-2eSqR9WK.js} +1 -1
- package/client/dist/assets/{ReviewPacketPage-Cktv0-9b.js → ReviewPacketPage-hwfPNjyy.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-BZTV1TM1.js → TemplatePreviewModal-V7FpMWuw.js} +1 -1
- package/client/dist/assets/{TicketDetailModalContext-DiPngt2g.js → TicketDetailModalContext-Dkwo2cDs.js} +1 -1
- package/client/dist/assets/{Trans-ZiXIfj50.js → Trans-BbFxRFtR.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-CdN5lTyD.js → formatDistanceToNow-NjPHBLfB.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-62h40I4t.js → getTimezoneOffsetInMilliseconds-vJ7qUoqN.js} +1 -1
- package/client/dist/assets/{index-CECj3joE.js → index-Ce4yZtKf.js} +36 -36
- package/client/dist/assets/{jira-api-C_D_RDhl.js → jira-api-Cabj-H0J.js} +1 -1
- package/client/dist/assets/{project-repositories-Be0CWklo.js → project-repositories-Cnnuj9Ia.js} +1 -1
- package/client/dist/assets/{settings-Hw7av-B1.js → settings-B1NV8Ow9.js} +1 -1
- package/client/dist/assets/settings-BQ8XFZeE.js +1 -0
- package/client/dist/assets/settings-BRkxfBbO.js +1 -0
- package/client/dist/assets/settings-C_CcU1dP.js +1 -0
- package/client/dist/assets/settings-Cto9n0eL.js +1 -0
- package/client/dist/assets/{settings-nryN6wuS.js → settings-Pyw8uYtL.js} +1 -1
- package/client/dist/assets/settings-b3HVCNNd.js +1 -0
- package/client/dist/assets/settings-hdive4u6.js +1 -0
- package/client/dist/assets/{spending-eWiZGhC_.js → spending-CQnrWiR3.js} +1 -1
- package/client/dist/assets/{useDesktop-C0mRIlHT.js → useDesktop-BjLT0_EE.js} +1 -1
- package/client/dist/assets/{useRuntimeRuns-B1a6GChu.js → useRuntimeRuns-C97qEqMg.js} +1 -1
- package/client/dist/assets/{useSharedWebSocket-CcsE5986.js → useSharedWebSocket-Bj4LMp7b.js} +2 -2
- package/client/dist/index.html +15 -15
- package/docs/internals/api-reference.md +26 -0
- package/docs/internals/core-runtime-updates.md +3 -3
- package/docs/internals/source-map.md +9 -2
- package/local-runner/dist/specrails-local-runner.js +487 -148
- package/mcp-bridge/dist/specrails-mcp.js +330 -42
- package/package.json +6 -3
- package/server/dist/index.js +14 -1
- package/server/dist/mobile/mobile-admin-router.js +16 -0
- package/server/dist/mobile/mobile-gateway.js +18 -0
- package/server/dist/mobile/mobile-missions.js +6 -4
- package/server/dist/mobile/mobile-preview-channel.js +179 -0
- package/server/dist/mobile/mobile-preview.js +366 -0
- package/server/dist/mobile/mobile-router.js +9 -1
- package/server/dist/mobile/mobile-webrtc-peer.js +54 -4
- package/server/dist/mobile/mobile-ws.js +4 -2
- package/server/dist/modules/browser/domain/preview-origins.js +117 -0
- package/server/dist/modules/browser/runtime/browser-capture-manager.js +141 -18
- package/server/dist/modules/browser/runtime/browser-capture-types.js +10 -1
- package/server/dist/modules/browser/runtime/browser-playwright.js +19 -0
- package/server/dist/modules/browser/runtime/preview-targets.js +80 -0
- package/server/dist/modules/terminals/runtime/background-process-service.js +24 -1
- package/server/dist/project-registry.js +10 -0
- package/client/dist/assets/AgentsPage-DKE-eMC5.js +0 -118
- package/client/dist/assets/settings-BO2N-oPf.js +0 -1
- package/client/dist/assets/settings-BQChAs_w.js +0 -1
- package/client/dist/assets/settings-BzmYlcQB.js +0 -1
- package/client/dist/assets/settings-CGjXb6TX.js +0 -1
- package/client/dist/assets/settings-CMxEWY46.js +0 -1
- package/client/dist/assets/settings-C_dRSgI2.js +0 -1
|
@@ -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
|
-
|
|
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;
|
|
@@ -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
|
+
}
|