dsh-remote-control-plugin 0.3.6 → 0.4.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/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +170 -0
- package/index.js +205 -28
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -9,6 +9,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { createFabric } from './host/fabric.js'
|
|
10
10
|
import { createRelayLink } from './host/relay-link.js'
|
|
11
11
|
import { createRelay } from './host/relay-core.js'
|
|
12
|
+
import { checkRelayUrl, ensureReachable } from './host/reachability.js'
|
|
13
|
+
import { qrSvg } from './host/qr.js'
|
|
14
|
+
import { createUploadManager } from './host/uploads.js'
|
|
15
|
+
import { loadOrCreateIdentity } from './host/identity.js'
|
|
12
16
|
|
|
13
17
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
14
18
|
const WEB_DIR = path.join(HERE, 'web')
|
|
@@ -38,6 +42,7 @@ export function apply(ctx, rawConfig) {
|
|
|
38
42
|
const config = normalizeConfig(rawConfig)
|
|
39
43
|
const stateFile = path.join(dshHome(), 'remote-control.json')
|
|
40
44
|
const peersFile = path.join(dshHome(), 'remote-control-peers.json')
|
|
45
|
+
const identityFile = path.join(dshHome(), 'remote-control-identity.json')
|
|
41
46
|
const streams = new Set()
|
|
42
47
|
const published = { urls: [], code: '' }
|
|
43
48
|
const info = { port: config.port, proto: config.tls ? 'https' : 'http' }
|
|
@@ -48,7 +53,10 @@ export function apply(ctx, rawConfig) {
|
|
|
48
53
|
let fabric
|
|
49
54
|
let relayLink
|
|
50
55
|
let relayCore
|
|
56
|
+
let deviceIdentity
|
|
51
57
|
let hostedKey = ''
|
|
58
|
+
let hostedPairCode = ''
|
|
59
|
+
let joinPairCode = ''
|
|
52
60
|
|
|
53
61
|
const sessions = {
|
|
54
62
|
async list() {
|
|
@@ -75,12 +83,29 @@ export function apply(ctx, rawConfig) {
|
|
|
75
83
|
signal,
|
|
76
84
|
)
|
|
77
85
|
},
|
|
78
|
-
prompt(sessionId,
|
|
86
|
+
async prompt(sessionId, input) {
|
|
87
|
+
const text = typeof input === 'string' ? input : String(input?.text ?? '')
|
|
88
|
+
const requested = typeof input === 'object' && Array.isArray(input?.attachments) ? input.attachments : []
|
|
89
|
+
const parts = []
|
|
90
|
+
if (text.trim()) parts.push({ type: 'text', text })
|
|
91
|
+
for (const item of requested) {
|
|
92
|
+
if (!item || typeof item !== 'object') continue
|
|
93
|
+
if (item.type === 'image' || item.type === 'file') {
|
|
94
|
+
if (item.attachment && typeof item.attachment === 'object') parts.push({ type: item.type, attachment: item.attachment })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (parts.length === 0) throw new Error('empty prompt')
|
|
98
|
+
// Admit through the attachment service so encoded images are normalized
|
|
99
|
+
// and durable references pass unchanged.
|
|
100
|
+
const attachments = typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined
|
|
101
|
+
const content = attachments && typeof attachments.admitPromptContent === 'function'
|
|
102
|
+
? await attachments.admitPromptContent(parts)
|
|
103
|
+
: parts
|
|
79
104
|
return ctx.sessionController.prompt({
|
|
80
105
|
requestId: randomUUID(),
|
|
81
106
|
sessionId,
|
|
82
107
|
mode: 'queue',
|
|
83
|
-
content
|
|
108
|
+
content,
|
|
84
109
|
clientTimeZone: timeZone(),
|
|
85
110
|
}, AbortSignal.timeout(30000))
|
|
86
111
|
},
|
|
@@ -194,16 +219,30 @@ export function apply(ctx, rawConfig) {
|
|
|
194
219
|
},
|
|
195
220
|
}
|
|
196
221
|
|
|
222
|
+
const uploads = createUploadManager({
|
|
223
|
+
log,
|
|
224
|
+
getAttachments: () => (typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined),
|
|
225
|
+
})
|
|
226
|
+
|
|
197
227
|
fabric = createFabric({
|
|
198
228
|
log,
|
|
199
229
|
config,
|
|
200
230
|
stateFile: peersFile,
|
|
231
|
+
store: createCredentialStore(ctx, peersFile, log),
|
|
232
|
+
uploads,
|
|
233
|
+
identity: () => deviceIdentity,
|
|
234
|
+
limits: { rateMax: config.rateMax, rateWindowMs: config.rateWindowMs },
|
|
201
235
|
self: () => ({
|
|
202
236
|
deviceId: state?.deviceId ?? '',
|
|
203
237
|
deviceName: config.deviceName,
|
|
204
238
|
userId: identity.userId,
|
|
205
239
|
port: info.port,
|
|
206
240
|
proto: info.proto,
|
|
241
|
+
relayUrl: relayLink?.url() ?? '',
|
|
242
|
+
pairCode: hostedPairCode || joinPairCode || config.pairCode || '',
|
|
243
|
+
publicKey: deviceIdentity?.ed25519?.publicKeyPem ?? '',
|
|
244
|
+
fingerprint: deviceIdentity?.fingerprint ?? '',
|
|
245
|
+
agreementKey: deviceIdentity?.x25519?.publicKeyPem ?? '',
|
|
207
246
|
}),
|
|
208
247
|
sessions,
|
|
209
248
|
})
|
|
@@ -211,6 +250,11 @@ export function apply(ctx, rawConfig) {
|
|
|
211
250
|
const ready = loadState(stateFile, config, log)
|
|
212
251
|
.then(async (loaded) => {
|
|
213
252
|
state = loaded
|
|
253
|
+
deviceIdentity = await loadOrCreateIdentity(
|
|
254
|
+
identityFile,
|
|
255
|
+
log,
|
|
256
|
+
createCredentialStore(ctx, identityFile, log, 'remote-control/identity'),
|
|
257
|
+
)
|
|
214
258
|
await refreshIdentity()
|
|
215
259
|
await start()
|
|
216
260
|
try {
|
|
@@ -228,11 +272,12 @@ export function apply(ctx, rawConfig) {
|
|
|
228
272
|
const persisted = await loadRelayConfig()
|
|
229
273
|
if (persisted?.hosted && persisted.key) {
|
|
230
274
|
try {
|
|
231
|
-
await startHostedRelay(persisted.key)
|
|
275
|
+
await startHostedRelay(persisted.key, persisted.pairCode)
|
|
232
276
|
} catch (error) {
|
|
233
277
|
log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
|
|
234
278
|
}
|
|
235
279
|
} else if (persisted) {
|
|
280
|
+
joinPairCode = typeof persisted.pairCode === 'string' ? persisted.pairCode : ''
|
|
236
281
|
try {
|
|
237
282
|
await startRelay(persisted.url, persisted.key)
|
|
238
283
|
} catch (error) {
|
|
@@ -312,10 +357,11 @@ export function apply(ctx, rawConfig) {
|
|
|
312
357
|
}
|
|
313
358
|
|
|
314
359
|
/** Serve the relay from this Host and point our own link at it. */
|
|
315
|
-
async function startHostedRelay(key) {
|
|
360
|
+
async function startHostedRelay(key, code) {
|
|
316
361
|
stopHostedRelay()
|
|
317
362
|
relayCore = createRelay({ log })
|
|
318
363
|
hostedKey = key
|
|
364
|
+
hostedPairCode = code || generatePairCode()
|
|
319
365
|
try {
|
|
320
366
|
await startRelay(`http://127.0.0.1:${info.port}`, key)
|
|
321
367
|
} catch (error) {
|
|
@@ -330,6 +376,7 @@ export function apply(ctx, rawConfig) {
|
|
|
330
376
|
relayCore = undefined
|
|
331
377
|
}
|
|
332
378
|
hostedKey = ''
|
|
379
|
+
hostedPairCode = ''
|
|
333
380
|
}
|
|
334
381
|
|
|
335
382
|
function shareUrls() {
|
|
@@ -342,6 +389,7 @@ export function apply(ctx, rawConfig) {
|
|
|
342
389
|
try { controller.abort() } catch { /* ignore */ }
|
|
343
390
|
}
|
|
344
391
|
streams.clear()
|
|
392
|
+
try { uploads?.close() } catch { /* ignore */ }
|
|
345
393
|
try { relayLink?.stop() } catch { /* ignore */ }
|
|
346
394
|
try { relayCore?.close() } catch { /* ignore */ }
|
|
347
395
|
try { fabric?.stop() } catch { /* ignore */ }
|
|
@@ -387,11 +435,13 @@ export function apply(ctx, rawConfig) {
|
|
|
387
435
|
peers: relayLink?.peers().length ?? 0,
|
|
388
436
|
hosted: Boolean(relayCore),
|
|
389
437
|
urls,
|
|
390
|
-
invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
|
|
438
|
+
invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}#${hostedPairCode}` : '',
|
|
439
|
+
pairCode: hostedPairCode || joinPairCode || '',
|
|
391
440
|
}
|
|
392
441
|
return json(res, 200, snapshot)
|
|
393
442
|
}
|
|
394
443
|
if (action === '/relay/host' && req.method === 'POST') {
|
|
444
|
+
await ready
|
|
395
445
|
const body = await readJson(req)
|
|
396
446
|
if (body?.enabled === false) {
|
|
397
447
|
stopRelay()
|
|
@@ -400,40 +450,66 @@ export function apply(ctx, rawConfig) {
|
|
|
400
450
|
return json(res, 200, { ok: true })
|
|
401
451
|
}
|
|
402
452
|
let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
|
|
453
|
+
const persisted = await loadRelayConfig()
|
|
403
454
|
if (!key) {
|
|
404
|
-
const persisted = await loadRelayConfig()
|
|
405
455
|
key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
|
|
406
456
|
}
|
|
457
|
+
const code = persisted?.pairCode && /^\d{4,8}$/.test(persisted.pairCode) ? persisted.pairCode : ''
|
|
407
458
|
try {
|
|
408
|
-
await startHostedRelay(key)
|
|
459
|
+
await startHostedRelay(key, code)
|
|
409
460
|
} catch (error) {
|
|
410
461
|
return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
|
|
411
462
|
}
|
|
412
463
|
const urls = shareUrls()
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
464
|
+
let reachability = null
|
|
465
|
+
try {
|
|
466
|
+
reachability = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
|
|
467
|
+
} catch { /* diagnostics only */ }
|
|
468
|
+
const inviteBase = reachability?.ok && reachability.url ? reachability.url : (urls[0] ?? '')
|
|
469
|
+
const allUrls = reachability?.ok && reachability.url ? [reachability.url, ...urls] : urls
|
|
470
|
+
await saveRelayConfig({ hosted: true, url: inviteBase, key, pairCode: hostedPairCode })
|
|
471
|
+
log.info(`[remote-control] hosting a relay at ${inviteBase || '(no address)'}`)
|
|
472
|
+
return json(res, 200, {
|
|
473
|
+
ok: true,
|
|
474
|
+
urls: allUrls,
|
|
475
|
+
pairCode: hostedPairCode,
|
|
476
|
+
reachability,
|
|
477
|
+
invite: `${inviteBase} ${key}#${hostedPairCode}`,
|
|
478
|
+
})
|
|
416
479
|
}
|
|
417
480
|
if (action === '/relay' && req.method === 'POST') {
|
|
481
|
+
await ready
|
|
418
482
|
const body = await readJson(req)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
let
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
483
|
+
let url = String(body?.url || '').trim()
|
|
484
|
+
let key = String(body?.key || '')
|
|
485
|
+
let code = String(body?.pairCode || '')
|
|
486
|
+
// Invite form "<url> <key>#<code>": accept it whole or already split.
|
|
487
|
+
const urlHash = url.indexOf('#')
|
|
488
|
+
if (urlHash >= 0) { code = code || url.slice(urlHash + 1); url = url.slice(0, urlHash) }
|
|
489
|
+
const keyHash = key.indexOf('#')
|
|
490
|
+
if (keyHash >= 0) { code = code || key.slice(keyHash + 1); key = key.slice(0, keyHash) }
|
|
491
|
+
const verdict = checkRelayUrl(url, { allowInsecure: config.allowInsecureRelay })
|
|
492
|
+
if (!verdict.ok) return json(res, 400, { error: verdict.error, message: verdict.message })
|
|
426
493
|
if (key.length < 16) {
|
|
427
494
|
return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
|
|
428
495
|
}
|
|
496
|
+
joinPairCode = /^\d{4,8}$/.test(code) ? code : ''
|
|
429
497
|
try {
|
|
430
498
|
await startRelay(url, key)
|
|
431
499
|
} catch (error) {
|
|
432
500
|
return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
|
|
433
501
|
}
|
|
434
502
|
stopHostedRelay()
|
|
435
|
-
await saveRelayConfig({ hosted: false, url, key })
|
|
436
|
-
return json(res, 200, { ok: true })
|
|
503
|
+
await saveRelayConfig({ hosted: false, url, key, pairCode: joinPairCode })
|
|
504
|
+
return json(res, 200, { ok: true, pairCode: joinPairCode })
|
|
505
|
+
}
|
|
506
|
+
if (action === '/relay/reachability' && req.method === 'GET') {
|
|
507
|
+
await ready
|
|
508
|
+
if (!relayCore) {
|
|
509
|
+
return json(res, 409, { error: 'not-hosting', message: 'Create a relay on this device first.' })
|
|
510
|
+
}
|
|
511
|
+
const result = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
|
|
512
|
+
return json(res, 200, result)
|
|
437
513
|
}
|
|
438
514
|
if (action === '/relay/clear' && req.method === 'POST') {
|
|
439
515
|
stopRelay()
|
|
@@ -441,6 +517,33 @@ export function apply(ctx, rawConfig) {
|
|
|
441
517
|
await clearRelayConfig()
|
|
442
518
|
return json(res, 200, { ok: true })
|
|
443
519
|
}
|
|
520
|
+
if (action === '/qr' && req.method === 'GET') {
|
|
521
|
+
const text = String(url.searchParams.get('text') || '')
|
|
522
|
+
if (!text || text.length > 106) return json(res, 400, { error: 'bad-text' })
|
|
523
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-store' })
|
|
524
|
+
return res.end(qrSvg(text))
|
|
525
|
+
}
|
|
526
|
+
if (action.startsWith('/upload/') && req.method === 'POST') {
|
|
527
|
+
const name = action.slice('/upload/'.length)
|
|
528
|
+
const handler = uploads?.[name]
|
|
529
|
+
if (typeof handler !== 'function') return json(res, 404, { error: 'not-found' })
|
|
530
|
+
let body
|
|
531
|
+
try {
|
|
532
|
+
body = await readJson(req, 8 * 1024 * 1024)
|
|
533
|
+
} catch {
|
|
534
|
+
return json(res, 400, { error: 'bad-body' })
|
|
535
|
+
}
|
|
536
|
+
const result = await handler(body)
|
|
537
|
+
return json(res, result.status ?? 200, result)
|
|
538
|
+
}
|
|
539
|
+
if (action === '/revoke' && req.method === 'POST') {
|
|
540
|
+
const body = await readJson(req)
|
|
541
|
+
return json(res, 200, fabric.revoke(String(body?.deviceId || '')))
|
|
542
|
+
}
|
|
543
|
+
if (action === '/scopes' && req.method === 'POST') {
|
|
544
|
+
const body = await readJson(req)
|
|
545
|
+
return json(res, 200, fabric.setScopes(String(body?.deviceId || ''), body?.scopes))
|
|
546
|
+
}
|
|
444
547
|
if (action === '/approve' && req.method === 'POST') {
|
|
445
548
|
const body = await readJson(req)
|
|
446
549
|
return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
|
|
@@ -822,6 +925,75 @@ function escapeHtml(value) {
|
|
|
822
925
|
}[character]))
|
|
823
926
|
}
|
|
824
927
|
|
|
928
|
+
/* ------------------------------------------------------------------ */
|
|
929
|
+
/* approval secret store */
|
|
930
|
+
/* ------------------------------------------------------------------ */
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Peer grants live in the Host credential store as a `grant` record, the
|
|
934
|
+
* sanctioned secret store, with the legacy 0600 JSON file as a fallback: a
|
|
935
|
+
* credential failure may never stop the plugin from starting, and a first run
|
|
936
|
+
* migrates the file content into the record.
|
|
937
|
+
*/
|
|
938
|
+
function createCredentialStore(ctx, legacyFile, log, key = 'remote-control/peers') {
|
|
939
|
+
const file = {
|
|
940
|
+
async load() {
|
|
941
|
+
try {
|
|
942
|
+
return JSON.parse(await readFile(legacyFile, 'utf8'))
|
|
943
|
+
} catch (error) {
|
|
944
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${legacyFile}: ${error?.message || error}`)
|
|
945
|
+
return null
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
async save(value) {
|
|
949
|
+
try {
|
|
950
|
+
await mkdir(path.dirname(legacyFile), { recursive: true })
|
|
951
|
+
await writeFile(legacyFile, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
952
|
+
} catch (error) {
|
|
953
|
+
log.warn(`[remote-control] could not persist ${legacyFile}: ${error?.message || error}`)
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let credentials
|
|
959
|
+
try {
|
|
960
|
+
credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
961
|
+
} catch {
|
|
962
|
+
credentials = undefined
|
|
963
|
+
}
|
|
964
|
+
if (!credentials || typeof credentials.readRecord !== 'function' || typeof credentials.modifyRecord !== 'function') {
|
|
965
|
+
return file
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
async load() {
|
|
970
|
+
try {
|
|
971
|
+
const record = await credentials.readRecord(key)
|
|
972
|
+
if (record && record.kind === 'grant' && record.payload) return record.payload
|
|
973
|
+
} catch (error) {
|
|
974
|
+
log.warn(`[remote-control] credential record read failed: ${error?.message || error}`)
|
|
975
|
+
}
|
|
976
|
+
const legacy = await file.load()
|
|
977
|
+
if (legacy) {
|
|
978
|
+
try {
|
|
979
|
+
await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: legacy }))
|
|
980
|
+
log.info('[remote-control] migrated peer grants into the credential store')
|
|
981
|
+
} catch { /* the file copy remains authoritative */ }
|
|
982
|
+
}
|
|
983
|
+
return legacy
|
|
984
|
+
},
|
|
985
|
+
async save(value) {
|
|
986
|
+
try {
|
|
987
|
+
await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: value }))
|
|
988
|
+
return
|
|
989
|
+
} catch (error) {
|
|
990
|
+
log.warn(`[remote-control] credential record write failed, using the file fallback: ${error?.message || error}`)
|
|
991
|
+
}
|
|
992
|
+
await file.save(value)
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
825
997
|
/* ------------------------------------------------------------------ */
|
|
826
998
|
/* configuration */
|
|
827
999
|
/* ------------------------------------------------------------------ */
|
|
@@ -839,6 +1011,14 @@ function normalizeConfig(raw = {}) {
|
|
|
839
1011
|
discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
|
|
840
1012
|
relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
|
|
841
1013
|
relayKey: typeof raw?.relayKey === 'string' ? raw.relayKey : '',
|
|
1014
|
+
allowInsecureRelay: raw?.allowInsecureRelay === true,
|
|
1015
|
+
pairCode: typeof raw?.pairCode === 'string' ? raw.pairCode.trim() : '',
|
|
1016
|
+
rateMax: Number.isInteger(raw?.rateMax) ? raw.rateMax : 120,
|
|
1017
|
+
rateWindowMs: Number.isInteger(raw?.rateWindowMs) ? raw.rateWindowMs : 10_000,
|
|
1018
|
+
punchPort: Number.isInteger(raw?.punchPort) ? raw.punchPort : 0,
|
|
1019
|
+
stunHost: raw?.stunHost === false ? false : (typeof raw?.stunHost === 'string' ? raw.stunHost : 'stun.l.google.com'),
|
|
1020
|
+
stunPort: Number.isInteger(raw?.stunPort) ? raw.stunPort : 19302,
|
|
1021
|
+
stunTimeoutMs: Number.isInteger(raw?.stunTimeoutMs) ? raw.stunTimeoutMs : 1500,
|
|
842
1022
|
deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
|
|
843
1023
|
tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
|
|
844
1024
|
}
|
|
@@ -855,15 +1035,8 @@ function normalizeConfig(raw = {}) {
|
|
|
855
1035
|
}
|
|
856
1036
|
}
|
|
857
1037
|
if (config.relayUrl) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
parsed = new URL(config.relayUrl)
|
|
861
|
-
} catch {
|
|
862
|
-
throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
|
|
863
|
-
}
|
|
864
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
865
|
-
throw new Error('remote-control: `relayUrl` must use http or https')
|
|
866
|
-
}
|
|
1038
|
+
const verdict = checkRelayUrl(config.relayUrl, { allowInsecure: config.allowInsecureRelay })
|
|
1039
|
+
if (!verdict.ok) throw new Error(`remote-control: ${verdict.message}`)
|
|
867
1040
|
if (!config.relayKey || config.relayKey.length < 16) {
|
|
868
1041
|
throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
|
|
869
1042
|
}
|
|
@@ -919,6 +1092,10 @@ function generateCode() {
|
|
|
919
1092
|
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
|
920
1093
|
}
|
|
921
1094
|
|
|
1095
|
+
function generatePairCode() {
|
|
1096
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
|
1097
|
+
}
|
|
1098
|
+
|
|
922
1099
|
function timeZone() {
|
|
923
1100
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
|
|
924
1101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-control-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remote control for DSH: a Devices panel that discovers other DSH desktops signed into the same account, pairs with them once, and drives their sessions.",
|
|
6
6
|
"exports": {
|