specrails-desktop 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dist/assets/{ActivityFeedPage-BTYWMRwB.js → ActivityFeedPage-CoWwVcty.js} +1 -1
- package/client/dist/assets/{AgentsPage-BfOCeHHt.js → AgentsPage-CgPvynWc.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-AbVXKh9v.js → AnalyticsPage-ioz3Ub2D.js} +1 -1
- package/client/dist/assets/{BarChart-DlshJN3Z.js → BarChart-BKXQPcoW.js} +1 -1
- package/client/dist/assets/{CodePage-DJCjDG4I.js → CodePage-CYhXRKiI.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-CTqZ9mbB.js → DesktopAnalyticsPage-CBfPCT3q.js} +1 -1
- package/client/dist/assets/{DocsDialog-KiJOSRvX.js → DocsDialog-uRTBV-3T.js} +1 -1
- package/client/dist/assets/{DocsPage-B17CR54A.js → DocsPage-gH0Lc54I.js} +1 -1
- package/client/dist/assets/{ExportDropdown-BAu6z3b6.js → ExportDropdown-DAp7zWib.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-CCG64Q-6.js → IntegrationsPage-D40Si_7s.js} +1 -1
- package/client/dist/assets/{JobDetailPage-BnGJSMiS.js → JobDetailPage-DSxAvB1n.js} +1 -1
- package/client/dist/assets/{JobsPage-B-tn4CIf.js → JobsPage-ZMBc1BHE.js} +1 -1
- package/client/dist/assets/{dist-js-B16c3VyT.js → dist-js-CKqmDyXR.js} +1 -1
- package/client/dist/assets/{dist-js-P2FkJ6fA.js → dist-js-bTZuok_W.js} +1 -1
- package/client/dist/assets/{index-AfVF6BgE.js → index-B9IKK_QQ.js} +45 -45
- package/client/dist/assets/index-BqAXaTbC.css +2 -0
- package/client/dist/assets/{lib-rNNmltMb.js → lib-B5mjOeEi.js} +1 -1
- package/client/dist/assets/{settings-D3LurcR5.js → settings-BI_cVCqN.js} +1 -1
- package/client/dist/assets/{settings-5tzo0Rn3.js → settings-BRaLLSVi.js} +1 -1
- package/client/dist/assets/{settings-BEWv3VEu.js → settings-BcqH0oea.js} +1 -1
- package/client/dist/assets/settings-C0-7Fpxg.js +1 -0
- package/client/dist/assets/{settings-BORg56um.js → settings-D6QMBlGQ.js} +1 -1
- package/client/dist/assets/{settings-DcqWIEM6.js → settings-GOBKOTGl.js} +1 -1
- package/client/dist/assets/{settings-BDAW3trC.js → settings-pT3MzfRu.js} +1 -1
- package/client/dist/assets/{settings-Dfz8QbZS.js → settings-u-16ISHt.js} +1 -1
- package/client/dist/assets/{setup-D3rNZA9A.js → setup-BIIkb-_K.js} +1 -1
- package/client/dist/assets/{setup-C1IA-9YS.js → setup-BeQxu9kD.js} +1 -1
- package/client/dist/assets/{setup-pjgmYHx6.js → setup-CPa6GnlI.js} +1 -1
- package/client/dist/assets/{setup-gzLG8T6F.js → setup-CZl4OEJx.js} +1 -1
- package/client/dist/assets/{setup-C0dzw8j4.js → setup-ChpodNfn.js} +1 -1
- package/client/dist/assets/{setup-WP6WOYQh.js → setup-D_fjJH6u.js} +1 -1
- package/client/dist/assets/{setup-UD2aanGs.js → setup-YzD8DX4O.js} +1 -1
- package/client/dist/assets/{setup-CpfjaNut.js → setup-fRpDozmq.js} +1 -1
- package/client/dist/assets/{useProjectCache-Cid_GxRM.js → useProjectCache-Cf83MBQh.js} +1 -1
- package/client/dist/index.html +4 -4
- package/docs/internals/api-reference.md +4 -7
- package/package.json +2 -2
- package/server/dist/mobile/index.js +5 -5
- package/server/dist/mobile/mobile-admin-router.js +28 -35
- package/server/dist/mobile/mobile-datachannel.js +167 -0
- package/server/dist/mobile/mobile-gateway.js +72 -98
- package/server/dist/mobile/mobile-router.js +4 -35
- package/server/dist/mobile/mobile-signal-reconnect.js +84 -0
- package/server/dist/mobile/mobile-types.js +5 -5
- package/server/dist/mobile/mobile-webrtc-peer.js +129 -0
- package/server/dist/mobile/mobile-webrtc.js +117 -0
- package/client/dist/assets/index-NlH5BbXJ.css +0 -2
- package/client/dist/assets/settings-yMubjqYw.js +0 -1
- package/server/dist/mobile/mobile-mdns.js +0 -81
- package/server/dist/mobile/mobile-pairing.js +0 -179
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DataChannelPeer = exports.DataChannelSocket = void 0;
|
|
4
|
+
// WebRTC DataChannel transport for the web companion (serverless double-QR
|
|
5
|
+
// pairing). Instead of a LAN WSS gateway, the companion connects peer-to-peer
|
|
6
|
+
// over a WebRTC DataChannel; this module bridges that channel onto the EXISTING
|
|
7
|
+
// mobile semantics so nothing downstream changes:
|
|
8
|
+
// - push frames + per-subscription filtering + redaction + log batching →
|
|
9
|
+
// reused verbatim by presenting the channel to MobileWsBridge as a SocketLike.
|
|
10
|
+
// - control frames (subscribe / watch_job / unwatch_job) →
|
|
11
|
+
// delivered straight to that same bridge.
|
|
12
|
+
// - hello → welcome (device registration + token) → new.
|
|
13
|
+
// - rpc → rpc_result (the /v1 allow-list) → new.
|
|
14
|
+
// The two new flows are dependency-injected so the protocol logic is unit-tested
|
|
15
|
+
// without a real WebRTC stack, the database, or the loopback HTTP forward.
|
|
16
|
+
const WS_OPEN = 1;
|
|
17
|
+
const WS_CLOSED = 3;
|
|
18
|
+
/** Presents a DataChannel to MobileWsBridge as if it were a ws.WebSocket, so the
|
|
19
|
+
* existing fan-out (redaction, per-subscription filtering, log batching) is
|
|
20
|
+
* reused unchanged. WebRTC has no ping/pong frames, so ping() reports liveness
|
|
21
|
+
* synchronously — DTLS keepalive + onClose drive real liveness. */
|
|
22
|
+
class DataChannelSocket {
|
|
23
|
+
_ch;
|
|
24
|
+
_handlers = new Map();
|
|
25
|
+
constructor(_ch) {
|
|
26
|
+
this._ch = _ch;
|
|
27
|
+
}
|
|
28
|
+
get readyState() {
|
|
29
|
+
return this._ch.readyState === 'open' ? WS_OPEN : WS_CLOSED;
|
|
30
|
+
}
|
|
31
|
+
send(data) {
|
|
32
|
+
try {
|
|
33
|
+
this._ch.send(data);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* channel went away mid-send — onClose will reap it */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
on(event, cb) {
|
|
40
|
+
const list = this._handlers.get(event) ?? [];
|
|
41
|
+
list.push(cb);
|
|
42
|
+
this._handlers.set(event, list);
|
|
43
|
+
}
|
|
44
|
+
ping() {
|
|
45
|
+
this._emit('pong');
|
|
46
|
+
}
|
|
47
|
+
terminate() {
|
|
48
|
+
this._emit('close');
|
|
49
|
+
}
|
|
50
|
+
close(code) {
|
|
51
|
+
// 4401 = the device was revoked from the desktop — tell the companion so it
|
|
52
|
+
// drops to the pairing screen instead of silently going stale.
|
|
53
|
+
if (code === 4401) {
|
|
54
|
+
try {
|
|
55
|
+
this._ch.send(JSON.stringify({ type: 'revoked' }));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this._emit('close');
|
|
62
|
+
}
|
|
63
|
+
/** Hand an inbound control frame to the bridge (its `message` listener). */
|
|
64
|
+
deliver(text) {
|
|
65
|
+
this._emit('message', text);
|
|
66
|
+
}
|
|
67
|
+
/** Mark the underlying channel closed (drops the socket from the bridge). */
|
|
68
|
+
closed() {
|
|
69
|
+
this._emit('close');
|
|
70
|
+
}
|
|
71
|
+
_emit(event, ...args) {
|
|
72
|
+
for (const cb of this._handlers.get(event) ?? []) {
|
|
73
|
+
try {
|
|
74
|
+
cb(...args);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* a bad listener must not break fan-out */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.DataChannelSocket = DataChannelSocket;
|
|
83
|
+
/** One paired companion over one DataChannel. Demultiplexes hello / rpc / control
|
|
84
|
+
* frames; everything else (push) flows out via the bridge. */
|
|
85
|
+
class DataChannelPeer {
|
|
86
|
+
_ch;
|
|
87
|
+
_deps;
|
|
88
|
+
_sock;
|
|
89
|
+
_authed = false;
|
|
90
|
+
_token = '';
|
|
91
|
+
constructor(_ch, _deps) {
|
|
92
|
+
this._ch = _ch;
|
|
93
|
+
this._deps = _deps;
|
|
94
|
+
this._sock = new DataChannelSocket(_ch);
|
|
95
|
+
_ch.onMessage((data) => {
|
|
96
|
+
void this._onMessage(data);
|
|
97
|
+
});
|
|
98
|
+
_ch.onClose(() => this._sock.closed());
|
|
99
|
+
}
|
|
100
|
+
get authed() {
|
|
101
|
+
return this._authed;
|
|
102
|
+
}
|
|
103
|
+
_send(obj) {
|
|
104
|
+
try {
|
|
105
|
+
this._ch.send(JSON.stringify(obj));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async _onMessage(data) {
|
|
112
|
+
let msg;
|
|
113
|
+
try {
|
|
114
|
+
msg = JSON.parse(data);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
switch (msg.type) {
|
|
120
|
+
case 'hello': {
|
|
121
|
+
if (this._authed)
|
|
122
|
+
return;
|
|
123
|
+
const secret = typeof msg.sec === 'string' ? msg.sec : '';
|
|
124
|
+
const deviceName = typeof msg.deviceName === 'string' ? msg.deviceName : 'Device';
|
|
125
|
+
const platform = typeof msg.platform === 'string' ? msg.platform : 'web';
|
|
126
|
+
const token = typeof msg.token === 'string' ? msg.token : undefined;
|
|
127
|
+
const r = await this._deps.registerDevice({ secret, deviceName, platform, token });
|
|
128
|
+
console.log('[webrtc] hello received; device registered:', r.ok);
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
this._send({ type: 'pair_denied' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this._authed = true;
|
|
134
|
+
this._token = r.token;
|
|
135
|
+
this._deps.bridge.attach(this._sock, r.deviceId);
|
|
136
|
+
this._send({
|
|
137
|
+
type: 'welcome',
|
|
138
|
+
deviceId: r.deviceId,
|
|
139
|
+
token: r.token,
|
|
140
|
+
// FROZEN field names — mobile pairing wire contract.
|
|
141
|
+
hubName: r.hubName,
|
|
142
|
+
hubInstanceId: r.hubInstanceId,
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case 'rpc': {
|
|
147
|
+
const id = typeof msg.id === 'string' ? msg.id : null;
|
|
148
|
+
if (!id)
|
|
149
|
+
return;
|
|
150
|
+
if (!this._authed) {
|
|
151
|
+
this._send({ type: 'rpc_result', id, status: 401, json: { error: 'not paired' } });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const method = typeof msg.method === 'string' ? msg.method : 'GET';
|
|
155
|
+
const path = typeof msg.path === 'string' ? msg.path : '';
|
|
156
|
+
const r = await this._deps.rpcDispatch({ method, path, body: msg.body, token: this._token });
|
|
157
|
+
this._send({ type: 'rpc_result', id, status: r.status, json: r.json });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
// subscribe / watch_job / unwatch_job — reuse the bridge's inbound logic.
|
|
162
|
+
if (this._authed)
|
|
163
|
+
this._sock.deliver(data);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.DataChannelPeer = DataChannelPeer;
|
|
@@ -8,15 +8,14 @@ const os_1 = __importDefault(require("os"));
|
|
|
8
8
|
const https_1 = __importDefault(require("https"));
|
|
9
9
|
const crypto_1 = require("crypto");
|
|
10
10
|
const express_1 = __importDefault(require("express"));
|
|
11
|
-
const ws_1 = require("ws");
|
|
12
11
|
const desktop_db_1 = require("../desktop-db");
|
|
13
12
|
const mobile_tls_1 = require("./mobile-tls");
|
|
14
|
-
const mobile_pairing_1 = require("./mobile-pairing");
|
|
15
13
|
const mobile_router_1 = require("./mobile-router");
|
|
16
14
|
const mobile_ws_1 = require("./mobile-ws");
|
|
17
|
-
const mobile_mdns_1 = require("./mobile-mdns");
|
|
18
15
|
const mobile_devices_1 = require("./mobile-devices");
|
|
19
|
-
const
|
|
16
|
+
const mobile_webrtc_peer_1 = require("./mobile-webrtc-peer");
|
|
17
|
+
const mobile_signal_reconnect_1 = require("./mobile-signal-reconnect");
|
|
18
|
+
const mobile_webrtc_1 = require("./mobile-webrtc");
|
|
20
19
|
// Lifecycle owner of the second HTTPS+WSS listener (default :4202), hard-isolated
|
|
21
20
|
// from the main server. Off by default; started on enable or boot-if-enabled.
|
|
22
21
|
const DEFAULT_PORT = 4202;
|
|
@@ -25,7 +24,6 @@ const SETTING = {
|
|
|
25
24
|
port: 'mobile.port',
|
|
26
25
|
instanceId: 'mobile.desktop_instance_id',
|
|
27
26
|
name: 'mobile.desktop_name',
|
|
28
|
-
mdns: 'mobile.mdns_enabled',
|
|
29
27
|
fingerprint: 'mobile.cert_fingerprint',
|
|
30
28
|
};
|
|
31
29
|
// Legacy fallback — pre-rebrand (Specrails Hub) setting keys. Values are
|
|
@@ -35,17 +33,6 @@ const LEGACY_SETTING = {
|
|
|
35
33
|
instanceId: 'mobile.hub_instance_id',
|
|
36
34
|
name: 'mobile.hub_name',
|
|
37
35
|
};
|
|
38
|
-
function lanAddresses() {
|
|
39
|
-
const out = [];
|
|
40
|
-
const ifaces = os_1.default.networkInterfaces();
|
|
41
|
-
for (const name of Object.keys(ifaces)) {
|
|
42
|
-
for (const ni of ifaces[name] ?? []) {
|
|
43
|
-
if (ni.family === 'IPv4' && !ni.internal)
|
|
44
|
-
out.push(ni.address);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
36
|
class MobileGateway {
|
|
50
37
|
_db;
|
|
51
38
|
_desktopPort;
|
|
@@ -53,9 +40,9 @@ class MobileGateway {
|
|
|
53
40
|
_bindHost;
|
|
54
41
|
_cert = null;
|
|
55
42
|
_server = null;
|
|
56
|
-
_wss = null;
|
|
57
43
|
_bridge = null;
|
|
58
|
-
|
|
44
|
+
_webrtc = null;
|
|
45
|
+
_signal = null;
|
|
59
46
|
_running = false;
|
|
60
47
|
_boundPort = DEFAULT_PORT;
|
|
61
48
|
_portOverride;
|
|
@@ -66,12 +53,12 @@ class MobileGateway {
|
|
|
66
53
|
this._bindHost = deps.bindHost ?? '0.0.0.0';
|
|
67
54
|
this._portOverride = deps.port;
|
|
68
55
|
}
|
|
69
|
-
get pairing() {
|
|
70
|
-
return this._pairing;
|
|
71
|
-
}
|
|
72
56
|
get bridge() {
|
|
73
57
|
return this._bridge;
|
|
74
58
|
}
|
|
59
|
+
get webrtc() {
|
|
60
|
+
return this._webrtc;
|
|
61
|
+
}
|
|
75
62
|
get running() {
|
|
76
63
|
return this._running;
|
|
77
64
|
}
|
|
@@ -112,9 +99,6 @@ class MobileGateway {
|
|
|
112
99
|
}
|
|
113
100
|
return id;
|
|
114
101
|
}
|
|
115
|
-
mdnsEnabled() {
|
|
116
|
-
return (0, desktop_db_1.getDesktopSetting)(this._db, SETTING.mdns) !== 'false';
|
|
117
|
-
}
|
|
118
102
|
isEnabledSetting() {
|
|
119
103
|
return (0, desktop_db_1.getDesktopSetting)(this._db, SETTING.enabled) === 'true';
|
|
120
104
|
}
|
|
@@ -124,8 +108,6 @@ class MobileGateway {
|
|
|
124
108
|
running: this._running,
|
|
125
109
|
port: this._running ? this._boundPort : this.configuredPort(),
|
|
126
110
|
certFingerprint: this._cert?.fingerprint ?? (0, desktop_db_1.getDesktopSetting)(this._db, SETTING.fingerprint) ?? null,
|
|
127
|
-
lanAddresses: lanAddresses(),
|
|
128
|
-
mdnsEnabled: this.mdnsEnabled(),
|
|
129
111
|
desktopName: this.desktopName(),
|
|
130
112
|
};
|
|
131
113
|
}
|
|
@@ -150,50 +132,16 @@ class MobileGateway {
|
|
|
150
132
|
catch { /* non-fatal */ }
|
|
151
133
|
this._cert = await (0, mobile_tls_1.loadOrCreateCert)((0, mobile_tls_1.mobileDir)());
|
|
152
134
|
(0, desktop_db_1.setDesktopSetting)(this._db, SETTING.fingerprint, this._cert.fingerprint);
|
|
153
|
-
this._pairing = new mobile_pairing_1.PairingManager({
|
|
154
|
-
certFingerprint: () => this._cert.fingerprint,
|
|
155
|
-
desktopInstanceId: () => this.instanceId(),
|
|
156
|
-
desktopName: () => this.desktopName(),
|
|
157
|
-
port: () => this._boundPort,
|
|
158
|
-
lanAddresses,
|
|
159
|
-
createDevice: ({ name, platform, token, certFingerprint }) => {
|
|
160
|
-
const row = (0, mobile_devices_1.createDevice)(this._db, { name, platform, tokenHash: (0, mobile_devices_1.hashToken)(token), certFingerprint });
|
|
161
|
-
this._broadcast({ type: 'mobile.device_paired', deviceId: row.id, name: row.name, timestamp: new Date().toISOString() });
|
|
162
|
-
return row.id;
|
|
163
|
-
},
|
|
164
|
-
onClaimed: (device) => {
|
|
165
|
-
this._broadcast({ type: 'mobile.pair_requested', deviceName: device.name, platform: device.platform, timestamp: new Date().toISOString() });
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
135
|
const app = (0, express_1.default)();
|
|
169
136
|
app.use(express_1.default.json({ limit: '256kb' }));
|
|
170
137
|
app.use((0, mobile_router_1.createMobileRouter)({
|
|
171
138
|
db: this._db,
|
|
172
139
|
desktopPort: this._desktopPort,
|
|
173
140
|
currentFingerprint: () => this._cert.fingerprint,
|
|
174
|
-
pairing: this._pairing,
|
|
175
141
|
}));
|
|
176
142
|
const server = https_1.default.createServer({ cert: this._cert.certPem, key: this._cert.keyPem }, app);
|
|
177
|
-
const wss = new ws_1.WebSocketServer({ noServer: true });
|
|
178
143
|
const bridge = new mobile_ws_1.MobileWsBridge();
|
|
179
144
|
bridge.start();
|
|
180
|
-
server.on('upgrade', (request, socket, head) => {
|
|
181
|
-
if ((request.url ?? '').split('?')[0] !== '/mws') {
|
|
182
|
-
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
183
|
-
socket.destroy();
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const token = tokenFromUpgrade(request);
|
|
187
|
-
const device = (0, mobile_auth_1.resolveDevice)(this._db, token, this._cert.fingerprint);
|
|
188
|
-
if (!device) {
|
|
189
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
190
|
-
socket.destroy();
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
194
|
-
bridge.attach(ws, device.id);
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
145
|
const port = this.configuredPort();
|
|
198
146
|
await new Promise((resolve, reject) => {
|
|
199
147
|
const onError = (err) => {
|
|
@@ -212,32 +160,63 @@ class MobileGateway {
|
|
|
212
160
|
server.listen(port, this._bindHost);
|
|
213
161
|
});
|
|
214
162
|
this._server = server;
|
|
215
|
-
this._wss = wss;
|
|
216
163
|
this._bridge = bridge;
|
|
217
164
|
this._running = true;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
165
|
+
// Serverless WebRTC companion peer (offerer). Reuses this bridge for push +
|
|
166
|
+
// control, and tunnels its RPC to the /v1 allow-list above over loopback.
|
|
167
|
+
const createWebDevice = (0, mobile_webrtc_1.buildRegisterDevice)({
|
|
168
|
+
db: this._db,
|
|
169
|
+
currentFingerprint: () => this._cert.fingerprint,
|
|
170
|
+
desktopName: () => this.desktopName(),
|
|
171
|
+
desktopInstanceId: () => this.instanceId(),
|
|
172
|
+
});
|
|
173
|
+
this._webrtc = new mobile_webrtc_peer_1.MobileWebrtcGateway({
|
|
174
|
+
bridge,
|
|
175
|
+
registerDevice: async (input) => {
|
|
176
|
+
const r = await createWebDevice(input);
|
|
177
|
+
if (r.ok) {
|
|
178
|
+
this._broadcast({
|
|
179
|
+
type: 'mobile.device_paired',
|
|
180
|
+
deviceId: r.deviceId,
|
|
181
|
+
name: input.deviceName || 'Web companion',
|
|
182
|
+
timestamp: new Date().toISOString(),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return r;
|
|
186
|
+
},
|
|
187
|
+
rpcDispatch: (0, mobile_webrtc_1.buildRpcDispatch)({
|
|
188
|
+
gatewayBase: `https://127.0.0.1:${this._boundPort}`,
|
|
189
|
+
doFetch: (0, mobile_webrtc_1.createLoopbackFetch)(),
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
// Outbound reconnect poller: lets a refreshed/reopened companion re-establish
|
|
193
|
+
// the WebRTC link via the public signaling mailbox — no QR re-scan. Polls
|
|
194
|
+
// OUTBOUND only (no inbound, no cert wall); the mailbox only sees the ~5s
|
|
195
|
+
// handshake.
|
|
196
|
+
const signalBase = (0, desktop_db_1.getDesktopSetting)(this._db, 'mobile.signal_url') || 'https://specrails.dev/companion-signal.php';
|
|
197
|
+
this._signal = new mobile_signal_reconnect_1.MobileSignalReconnect({
|
|
198
|
+
signalBase,
|
|
199
|
+
doFetch: (url, init) => fetch(url, init),
|
|
200
|
+
rooms: () => (0, mobile_devices_1.listDevices)(this._db).filter((d) => !d.revoked).map((d) => d.id),
|
|
201
|
+
makeOffer: () => this.webrtcOffer(),
|
|
202
|
+
acceptAnswer: (sdp) => this.webrtcAnswer(sdp),
|
|
203
|
+
});
|
|
204
|
+
this._signal.start();
|
|
226
205
|
}
|
|
227
206
|
/** Idempotent teardown. */
|
|
228
207
|
async stop() {
|
|
229
|
-
|
|
208
|
+
if (this._signal) {
|
|
209
|
+
this._signal.stop();
|
|
210
|
+
this._signal = null;
|
|
211
|
+
}
|
|
212
|
+
if (this._webrtc) {
|
|
213
|
+
this._webrtc.stop();
|
|
214
|
+
this._webrtc = null;
|
|
215
|
+
}
|
|
230
216
|
if (this._bridge) {
|
|
231
217
|
this._bridge.stop();
|
|
232
218
|
this._bridge = null;
|
|
233
219
|
}
|
|
234
|
-
if (this._wss) {
|
|
235
|
-
try {
|
|
236
|
-
this._wss.close();
|
|
237
|
-
}
|
|
238
|
-
catch { /* ignore */ }
|
|
239
|
-
this._wss = null;
|
|
240
|
-
}
|
|
241
220
|
if (this._server) {
|
|
242
221
|
await new Promise((resolve) => this._server.close(() => resolve()));
|
|
243
222
|
this._server = null;
|
|
@@ -260,26 +239,21 @@ class MobileGateway {
|
|
|
260
239
|
this._broadcast({ type: 'mobile.gateway_state', running: this._running, port: this._boundPort, timestamp: new Date().toISOString() });
|
|
261
240
|
return this.status();
|
|
262
241
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// mobile-app v1 wire compat — do not rename: the phone app (v1.0.0)
|
|
279
|
-
// carries its device token in a `hub-token.<token>` subprotocol.
|
|
280
|
-
if (p.startsWith('hub-token.'))
|
|
281
|
-
return p.slice('hub-token.'.length).trim();
|
|
282
|
-
}
|
|
242
|
+
/** Open a serverless WebRTC pairing offer (for the first QR). Returns the offer
|
|
243
|
+
* SDP + a single-use secret + the desktop identity; the webview encodes these
|
|
244
|
+
* into the QR the companion scans. */
|
|
245
|
+
async webrtcOffer() {
|
|
246
|
+
if (!this._webrtc)
|
|
247
|
+
return null;
|
|
248
|
+
const secret = (0, crypto_1.randomBytes)(16).toString('base64url');
|
|
249
|
+
const { sdp } = await this._webrtc.createOffer(secret);
|
|
250
|
+
return { sdp, secret, hubName: this.desktopName(), hubInstanceId: this.instanceId() };
|
|
251
|
+
}
|
|
252
|
+
/** Apply the companion's scanned answer SDP to the open offer. */
|
|
253
|
+
async webrtcAnswer(sdp) {
|
|
254
|
+
if (!this._webrtc)
|
|
255
|
+
return false;
|
|
256
|
+
return (await this._webrtc.acceptAnswer(sdp)).ok;
|
|
283
257
|
}
|
|
284
|
-
return null;
|
|
285
258
|
}
|
|
259
|
+
exports.MobileGateway = MobileGateway;
|
|
@@ -6,12 +6,10 @@ const express_1 = require("express");
|
|
|
6
6
|
const auth_1 = require("../auth");
|
|
7
7
|
const mobile_redact_1 = require("./mobile-redact");
|
|
8
8
|
const mobile_auth_1 = require("./mobile-auth");
|
|
9
|
-
// The
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the master token injected server-side as `x-desktop-token` (it
|
|
14
|
-
// never leaves the box).
|
|
9
|
+
// The gateway's authenticated REST surface: the `/v1/*` allow-list. Each route
|
|
10
|
+
// forwards, in-process, via a REAL loopback HTTP request to
|
|
11
|
+
// http://127.0.0.1:<desktopPort> with the master token injected server-side as
|
|
12
|
+
// `x-desktop-token` (it never leaves the box).
|
|
15
13
|
//
|
|
16
14
|
// Forwarding is PARAMETERISED: the internal path is rebuilt from Express route
|
|
17
15
|
// params (each a single URL segment — `..`/`/` can't appear), never from the raw
|
|
@@ -28,35 +26,6 @@ function createMobileRouter(deps) {
|
|
|
28
26
|
// segment (an array — which path-to-regexp never produces here — collapses to
|
|
29
27
|
// '' and fails the validators below).
|
|
30
28
|
const seg = (v) => (typeof v === 'string' ? v : '');
|
|
31
|
-
// ─── Pairing (unauthenticated, rate-limited inside PairingManager) ──────────
|
|
32
|
-
const pairRouter = (0, express_1.Router)();
|
|
33
|
-
pairRouter.post('/claim', (req, res) => {
|
|
34
|
-
const body = (req.body ?? {});
|
|
35
|
-
const secret = typeof body.secret === 'string' ? body.secret : '';
|
|
36
|
-
const deviceName = typeof body.deviceName === 'string' ? body.deviceName : 'Device';
|
|
37
|
-
const platform = body.platform === 'android' ? 'android' : 'ios';
|
|
38
|
-
const ip = req.socket?.remoteAddress ?? 'unknown';
|
|
39
|
-
if (!secret) {
|
|
40
|
-
res.status(400).json({ error: 'secret required' });
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const result = deps.pairing.claim(secret, { name: deviceName, platform }, ip);
|
|
44
|
-
if (result.ok) {
|
|
45
|
-
res.json({ ok: true });
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
const code = result.reason === 'locked' ? 429 : result.reason === 'no-session' || result.reason === 'expired' ? 410 : 403;
|
|
49
|
-
res.status(code).json({ ok: false, reason: result.reason });
|
|
50
|
-
});
|
|
51
|
-
pairRouter.get('/status', (req, res) => {
|
|
52
|
-
const claimId = typeof req.query.claimId === 'string' ? req.query.claimId : '';
|
|
53
|
-
if (!claimId) {
|
|
54
|
-
res.status(400).json({ error: 'claimId required' });
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
res.json(deps.pairing.pollStatus(claimId));
|
|
58
|
-
});
|
|
59
|
-
router.use('/pair', pairRouter);
|
|
60
29
|
// ─── Authenticated allow-list (/v1) ─────────────────────────────────────────
|
|
61
30
|
const v1 = (0, express_1.Router)();
|
|
62
31
|
v1.use((0, mobile_auth_1.createMobileAuthMiddleware)({ db: deps.db, currentFingerprint: deps.currentFingerprint }));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Outbound reconnect poller for the serverless web companion.
|
|
3
|
+
//
|
|
4
|
+
// After a page reload the companion's WebRTC link is gone. To re-establish it
|
|
5
|
+
// without re-scanning a QR, the companion drops a "reconnect request" into a
|
|
6
|
+
// tiny public mailbox (companion-signal.php on specrails.dev); the desktop polls
|
|
7
|
+
// that mailbox (OUTBOUND — no inbound needed, no cert wall), answers with a
|
|
8
|
+
// fresh offer, and reads back the companion's answer. The mailbox only ever
|
|
9
|
+
// carries the ~5s SDP handshake; once connected, all traffic is P2P. The desktop
|
|
10
|
+
// authenticates the reconnecting device by its existing token (see
|
|
11
|
+
// buildRegisterDevice), so no new pairing/device is created.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.MobileSignalReconnect = void 0;
|
|
14
|
+
class MobileSignalReconnect {
|
|
15
|
+
_deps;
|
|
16
|
+
_timer = null;
|
|
17
|
+
_busy = false;
|
|
18
|
+
constructor(_deps) {
|
|
19
|
+
this._deps = _deps;
|
|
20
|
+
}
|
|
21
|
+
start(intervalMs = 3000) {
|
|
22
|
+
if (this._timer)
|
|
23
|
+
return;
|
|
24
|
+
this._timer = setInterval(() => void this.poll(), intervalMs);
|
|
25
|
+
this._timer.unref?.();
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
if (this._timer) {
|
|
29
|
+
clearInterval(this._timer);
|
|
30
|
+
this._timer = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** One poll cycle across all rooms. Exposed for tests. */
|
|
34
|
+
async poll() {
|
|
35
|
+
if (this._busy)
|
|
36
|
+
return; // never overlap cycles (a slow makeOffer must not stack)
|
|
37
|
+
this._busy = true;
|
|
38
|
+
try {
|
|
39
|
+
for (const room of this._deps.rooms()) {
|
|
40
|
+
try {
|
|
41
|
+
// A phone asking to reconnect? → answer with a fresh offer.
|
|
42
|
+
const req = await this._get(room, 'req');
|
|
43
|
+
if (req !== null) {
|
|
44
|
+
const offer = await this._deps.makeOffer();
|
|
45
|
+
if (offer) {
|
|
46
|
+
await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, sec: offer.secret, hub: offer.hubInstanceId, name: offer.hubName }));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// A phone's answer waiting? → complete the connection.
|
|
50
|
+
const ans = await this._get(room, 'answer');
|
|
51
|
+
if (ans) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(ans);
|
|
54
|
+
if (typeof parsed.sdp === 'string')
|
|
55
|
+
await this._deps.acceptAnswer(parsed.sdp);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* malformed answer — ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* transient per-room network error — retry next cycle */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this._busy = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async _get(room, slot) {
|
|
72
|
+
const res = await this._deps.doFetch(`${this._deps.signalBase}?room=${encodeURIComponent(room)}&slot=${slot}`);
|
|
73
|
+
if (res.status !== 200)
|
|
74
|
+
return null;
|
|
75
|
+
return res.text();
|
|
76
|
+
}
|
|
77
|
+
async _post(room, slot, body) {
|
|
78
|
+
await this._deps.doFetch(`${this._deps.signalBase}?room=${encodeURIComponent(room)}&slot=${slot}`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
body,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.MobileSignalReconnect = MobileSignalReconnect;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// Shared types for the Mobile Gateway (server/mobile/*).
|
|
3
3
|
//
|
|
4
|
-
// The gateway is a second HTTPS
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// redacted, over a per-device token. The main server at
|
|
8
|
-
// itself exposed.
|
|
4
|
+
// The gateway is a second HTTPS listener in the SAME Node process as the main
|
|
5
|
+
// server, default port 4202, OFF by default. It pairs the web companion
|
|
6
|
+
// serverlessly over WebRTC (double-QR) and exposes a deny-by-default allow-list
|
|
7
|
+
// of the existing API, redacted, over a per-device token. The main server at
|
|
8
|
+
// 127.0.0.1:4200 is never itself exposed.
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|