dsh-remote-control-plugin 0.3.0 → 0.3.2
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/INSTALL.md +13 -9
- package/LICENSE +21 -0
- package/host/fabric.js +41 -8
- package/index.js +9 -3
- package/package.json +2 -1
package/INSTALL.md
CHANGED
|
@@ -5,17 +5,17 @@ build step, no postinstall script.
|
|
|
5
5
|
|
|
6
6
|
## 1. Install it
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
In the Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.2`, keep the
|
|
9
|
+
official npm registry selected, then **Install** and **Enable now**. Restart DSH.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
A **Devices** icon appears in the sidebar.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
From a chat, the same install works where the plugin tool is enabled (Standard or Creator
|
|
14
|
+
preset — PTC disables `tool-plugin-manager`):
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
the app. The agent route uses the bundled pnpm and also registers the bundle in the profile,
|
|
16
|
-
which a bare `pnpm add` would not do.
|
|
16
|
+
> Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.2` with plugin_manager, then tell me when it is applied.
|
|
17
17
|
|
|
18
|
-
## 2. Connect
|
|
18
|
+
## 2. Connect two machines
|
|
19
19
|
|
|
20
20
|
Pick the machine with a reachable address (same LAN, VPN, or a forwarded port):
|
|
21
21
|
|
|
@@ -23,6 +23,8 @@ Pick the machine with a reachable address (same LAN, VPN, or a forwarded port):
|
|
|
23
23
|
2. On the other machine: **Devices → Relay**, paste the invite into the URL field, **Connect**.
|
|
24
24
|
3. Either machine: **Request** next to the peer, then **Allow** on the target.
|
|
25
25
|
|
|
26
|
+
Two machines on the same LAN need no relay at all — LAN discovery lists them directly.
|
|
27
|
+
|
|
26
28
|
No reachable machine? Run a relay anywhere both can reach (`PORT=8787 node relay/server.js`) and
|
|
27
29
|
paste `http://<host>:8787 <workspace key>` into both panels.
|
|
28
30
|
|
|
@@ -31,5 +33,7 @@ paste `http://<host>:8787 <workspace key>` into both panels.
|
|
|
31
33
|
- The account label is cosmetic; trust comes from the one-time **Allow** on the target device.
|
|
32
34
|
- Approvals persist in `$DSH_HOME/remote-control-peers.json`; deleting it revokes every peer.
|
|
33
35
|
- The relay and the control API share one port (default `57330`), so one forwarded port is enough.
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
+
- Peer traffic is signed but not encrypted. Use a private network or TLS in front of the port.
|
|
37
|
+
- 0.3.2 fixes three relay bugs: a hosted relay now survives a restart, peers no
|
|
38
|
+
longer linger as `503 relay-offline` after a relay goes away, and a blocked
|
|
39
|
+
direct LAN route falls back to the outbound relay instead of failing.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hkrds1996
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/host/fabric.js
CHANGED
|
@@ -122,7 +122,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
122
122
|
*/
|
|
123
123
|
function syncRelayPeers() {
|
|
124
124
|
if (!relay) return
|
|
125
|
+
const present = new Set()
|
|
125
126
|
for (const item of relay.peers()) {
|
|
127
|
+
present.add(item.deviceId)
|
|
126
128
|
const existing = peers.get(item.deviceId)
|
|
127
129
|
if (existing && existing.proto !== 'relay') continue
|
|
128
130
|
peers.set(item.deviceId, {
|
|
@@ -137,6 +139,11 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
137
139
|
secret: approvals.get(item.deviceId)?.secret ?? existing?.secret,
|
|
138
140
|
})
|
|
139
141
|
}
|
|
142
|
+
// Drop relay peers the relay no longer lists, so a device that leaves or a
|
|
143
|
+
// relay that goes away cannot leave a peer that always answers 503.
|
|
144
|
+
for (const [deviceId, peer] of peers) {
|
|
145
|
+
if (peer.proto === 'relay' && !present.has(deviceId)) peers.delete(deviceId)
|
|
146
|
+
}
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
async function loadApprovals() {
|
|
@@ -367,8 +374,17 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
367
374
|
*/
|
|
368
375
|
async function peerCall(peer, method, pathname, body, secret) {
|
|
369
376
|
if (peer.proto !== 'relay') {
|
|
370
|
-
|
|
377
|
+
const direct = await callRaw(peer, method, pathname, body, secret, self().deviceId)
|
|
378
|
+
// A direct LAN route can be silently blocked (firewall profile, VPN
|
|
379
|
+
// change). The relay is outbound-only, so fall back instead of failing.
|
|
380
|
+
if (!direct.error || !relay) return direct
|
|
381
|
+
const viaRelay = await relayCall(peer, method, pathname, body, secret)
|
|
382
|
+
return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
|
|
371
383
|
}
|
|
384
|
+
return relayCall(peer, method, pathname, body, secret)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function relayCall(peer, method, pathname, body, secret) {
|
|
372
388
|
if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
|
|
373
389
|
const callerId = self().deviceId
|
|
374
390
|
const timestamp = String(Date.now())
|
|
@@ -432,17 +448,18 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
432
448
|
}, res)
|
|
433
449
|
return
|
|
434
450
|
}
|
|
451
|
+
const streamHeaders = {
|
|
452
|
+
accept: 'text/event-stream',
|
|
453
|
+
'x-dsh-peer-id': callerId,
|
|
454
|
+
'x-dsh-peer-ts': timestamp,
|
|
455
|
+
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
456
|
+
}
|
|
435
457
|
const upstream = http.request({
|
|
436
458
|
host: peer.address,
|
|
437
459
|
port: peer.port,
|
|
438
460
|
path: target,
|
|
439
461
|
method: 'GET',
|
|
440
|
-
headers:
|
|
441
|
-
accept: 'text/event-stream',
|
|
442
|
-
'x-dsh-peer-id': callerId,
|
|
443
|
-
'x-dsh-peer-ts': timestamp,
|
|
444
|
-
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
445
|
-
},
|
|
462
|
+
headers: streamHeaders,
|
|
446
463
|
}, (response) => {
|
|
447
464
|
res.writeHead(response.statusCode ?? 502, {
|
|
448
465
|
'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
|
|
@@ -454,6 +471,12 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
454
471
|
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
455
472
|
})
|
|
456
473
|
upstream.on('error', () => {
|
|
474
|
+
// Same fallback as peerCall: a blocked LAN route may still reach the
|
|
475
|
+
// peer through the outbound relay.
|
|
476
|
+
if (relay && !res.headersSent) {
|
|
477
|
+
relay.stream(peer.deviceId, { path: target, headers: streamHeaders }, res)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
457
480
|
try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
|
|
458
481
|
})
|
|
459
482
|
res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
|
|
@@ -469,7 +492,17 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
469
492
|
callPeer,
|
|
470
493
|
proxyStream,
|
|
471
494
|
handlePeer,
|
|
472
|
-
setRelay(link) {
|
|
495
|
+
setRelay(link) {
|
|
496
|
+
relay = link
|
|
497
|
+
if (!link) {
|
|
498
|
+
// Without a relay every relay-only peer is unreachable; keeping it
|
|
499
|
+
// would make each call fail with a misleading 503.
|
|
500
|
+
for (const [deviceId, peer] of peers) {
|
|
501
|
+
if (peer.proto === 'relay') peers.delete(deviceId)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
|
|
473
506
|
snapshot() {
|
|
474
507
|
syncRelayPeers()
|
|
475
508
|
const who = self()
|
package/index.js
CHANGED
|
@@ -157,7 +157,12 @@ export function apply(ctx, rawConfig) {
|
|
|
157
157
|
async function loadRelayConfig() {
|
|
158
158
|
try {
|
|
159
159
|
const raw = JSON.parse(await readFile(relayFile, 'utf8'))
|
|
160
|
-
if (raw && typeof raw.url === 'string' && typeof raw.key === 'string')
|
|
160
|
+
if (raw && typeof raw.url === 'string' && typeof raw.key === 'string') {
|
|
161
|
+
// `hosted` decides whether boot remounts the relay before connecting.
|
|
162
|
+
// Dropping it made every restart fall into the join branch, which then
|
|
163
|
+
// pointed at an unmounted /v1/* and left the relay dead.
|
|
164
|
+
return { hosted: raw.hosted === true, url: raw.url, key: raw.key }
|
|
165
|
+
}
|
|
161
166
|
} catch (error) {
|
|
162
167
|
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${relayFile}: ${error?.message || error}`)
|
|
163
168
|
}
|
|
@@ -177,7 +182,6 @@ export function apply(ctx, rawConfig) {
|
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
async function startRelay(url, key) {
|
|
180
|
-
stopRelay()
|
|
181
185
|
const link = createRelayLink({
|
|
182
186
|
url,
|
|
183
187
|
key,
|
|
@@ -187,7 +191,9 @@ export function apply(ctx, rawConfig) {
|
|
|
187
191
|
localPort: info.port,
|
|
188
192
|
log,
|
|
189
193
|
})
|
|
194
|
+
// Connect first: a failed join must not tear down a working relay link.
|
|
190
195
|
await link.start()
|
|
196
|
+
stopRelay()
|
|
191
197
|
relayLink = link
|
|
192
198
|
fabric.setRelay(link)
|
|
193
199
|
log.info(`[remote-control] relay connected: ${url}`)
|
|
@@ -316,11 +322,11 @@ export function apply(ctx, rawConfig) {
|
|
|
316
322
|
return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
|
|
317
323
|
}
|
|
318
324
|
try {
|
|
319
|
-
stopHostedRelay()
|
|
320
325
|
await startRelay(url, key)
|
|
321
326
|
} catch (error) {
|
|
322
327
|
return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
|
|
323
328
|
}
|
|
329
|
+
stopHostedRelay()
|
|
324
330
|
await saveRelayConfig({ hosted: false, url, key })
|
|
325
331
|
return json(res, 200, { ok: true })
|
|
326
332
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-control-plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Remote control for DSH: a Devices panel that discovers other DSH desktops, pairs with them once, and drives their sessions across LAN or a relay.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"icon.svg",
|
|
28
28
|
"README.md",
|
|
29
29
|
"INSTALL.md",
|
|
30
|
+
"LICENSE",
|
|
30
31
|
"web/index.html",
|
|
31
32
|
"web/app.js",
|
|
32
33
|
"web/app.css",
|