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 CHANGED
@@ -5,17 +5,17 @@ build step, no postinstall script.
5
5
 
6
6
  ## 1. Install it
7
7
 
8
- Open DSH on the other desktop, start a session, and send:
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
- > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.0` with plugin_manager, then tell me when it is applied.
11
+ A **Devices** icon appears in the sidebar.
11
12
 
12
- Then restart DSH. A **Devices** icon appears in the sidebar.
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
- `dsh plugin add` does not work for this: it forwards to `pnpm`, which is not on `PATH` outside
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 the two machines
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
- - Device trusts are per install: the published plugin and a locally built copy share the protocol
35
- but keep separate approval stores.
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
- return callRaw(peer, method, pathname, body, secret, self().deviceId)
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) { relay = 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') return { url: raw.url, key: raw.key }
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.0",
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",