dsh-plugin-mobile-link 0.2.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/README.md +111 -0
- package/cordis.patch.yml +37 -0
- package/lib/cli.js +191 -0
- package/lib/dlp.js +296 -0
- package/lib/dsh-client.js +280 -0
- package/lib/enroll.js +142 -0
- package/lib/files.js +127 -0
- package/lib/hello.js +65 -0
- package/lib/index.js +128 -0
- package/lib/link.js +439 -0
- package/lib/pairing.js +41 -0
- package/lib/qr.js +576 -0
- package/lib/router.js +379 -0
- package/lib/routes.js +181 -0
- package/lib/state.js +148 -0
- package/lib/ws.js +183 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# dsh-plugin-mobile-link
|
|
2
|
+
|
|
3
|
+
**The computer half of [DSH Mobile](https://github.com/jayantTang/DSH_Mobile): it
|
|
4
|
+
dials a relay over WSS, so the native iOS client reaches this DSH instance even
|
|
5
|
+
though the computer has no public IP.**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
iPhone App ──WSS──▶ Relay(公网,只鉴权与转发)──WSS──▶ this plugin ──HTTP+WS──▶ 127.0.0.1:<port>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The connector always talks to the local DSH as `127.0.0.1:<port>`, so the host's
|
|
12
|
+
authority and its trust fence are unaffected: no `--trusted-host`, no rewritten
|
|
13
|
+
`Host`, no new listening port on this machine.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
plugins/mobile-link/
|
|
17
|
+
├── package.json # type: module, dsh.bundle.patch, bin: dsh-mobile-link
|
|
18
|
+
├── cordis.patch.yml # bundle patch: the loader row + config reference
|
|
19
|
+
├── lib/
|
|
20
|
+
│ ├── index.js # cordis plugin: starts the agent, registers 3 routes
|
|
21
|
+
│ ├── link.js # the DLP agent transport: identity, socket, heartbeat, reconnect
|
|
22
|
+
│ ├── router.js # frames ⇄ local DSH mux streams, per-device `$events`
|
|
23
|
+
│ ├── dlp.js # DLP v1 frame codec (shared with the relay)
|
|
24
|
+
│ ├── files.js # phone → computer file transfers (`_link/file*` methods)
|
|
25
|
+
│ ├── enroll.js # invite-code redemption, writes ~/.dsh/mobile-link/agent.json
|
|
26
|
+
│ ├── routes.js # GET /mobile-link/status, POST /pair-code, GET /qr
|
|
27
|
+
│ ├── qr.js # dependency-free QR SVG
|
|
28
|
+
│ └── cli.js # dsh-mobile-link: enroll / mint-pair-code
|
|
29
|
+
└── test/ # node --test, 100+ tests (no DSH, no network)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile web add dsh-plugin-mobile-link
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then restart DSH (or reload the profile). From a checkout the same command takes
|
|
39
|
+
a path — the leading `./` matters, because DSH anchors a relative spec against
|
|
40
|
+
your current directory instead of resolving it inside the profile:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
dsh plugin --profile web add ./plugins/mobile-link
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
1. Register this computer with a relay (once). Either redeem an invite code:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
dsh-mobile-link enroll --invite <邀请码> --relay wss://<你的站点>/dsh-link
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
…or register it on the relay host with `relay/admin.py agent-register
|
|
55
|
+
--write-config ~/.dsh/mobile-link/agent.json`.
|
|
56
|
+
|
|
57
|
+
2. Mint a pairing code, and show it as a QR the phone camera can read:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
dsh-mobile-link --mint-pair-code # prints JSON: code + relay + agentId
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or open `/mobile-link/qr` on the local DSH (behind DSH's own authentication).
|
|
64
|
+
|
|
65
|
+
3. Scan it in the iOS app. After that the phone reconnects on its own — nothing
|
|
66
|
+
else has to be run by hand.
|
|
67
|
+
|
|
68
|
+
`GET /mobile-link/status` reports whether the link is up, which devices are
|
|
69
|
+
attached, and whether this computer is registered yet.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
All optional; the defaults are what a stock deployment needs. Set them in the
|
|
74
|
+
plugin's config block (`cordis.patch.yml`), not by editing this file.
|
|
75
|
+
|
|
76
|
+
| Key | Default | Meaning |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `enabled` | `true` | `false` stops the outbound link without uninstalling |
|
|
79
|
+
| `relayUrl` | from `agent.json` | Relay base URL; a bare deployment dials `<relay>/link/agent` |
|
|
80
|
+
| `agentId` / `agentSecret` | from `agent.json` | Override the enrolled identity |
|
|
81
|
+
| `stateFile` | `~/.dsh/mobile-link/agent.json` | Where the identity lives |
|
|
82
|
+
| `dshUrl` | discovery | Explicit local DSH base URL |
|
|
83
|
+
| `endpointFile` | `$DSH_HOME/desktop-shell/endpoint.json` | DSH endpoint handoff file |
|
|
84
|
+
| `heartbeatMs` | `20000` | DLP ping interval |
|
|
85
|
+
|
|
86
|
+
Address resolution order: this config block, then `DSH_RELAY_URL` (or
|
|
87
|
+
`DSH_MOBILE_LINK_RELAY`), then the enrolled identity file, then the repository
|
|
88
|
+
placeholder `wss://relay.example.com/dsh-link` — the placeholder being the
|
|
89
|
+
"nothing configured yet" sentinel rather than a real address.
|
|
90
|
+
|
|
91
|
+
## Design notes
|
|
92
|
+
|
|
93
|
+
- **No required services.** `inject` is empty and the routes are attached through
|
|
94
|
+
`ctx.inject([...])`, so a future DSH that renames or drops `webServer` /
|
|
95
|
+
`connection` cannot make the tunnel fatal on boot.
|
|
96
|
+
- **Protocol-transparent.** Nothing outside `lib/` is imported and no DSH
|
|
97
|
+
business API is touched: the connector forwards frames, it does not interpret
|
|
98
|
+
sessions. The relay has the same property.
|
|
99
|
+
- **Disable** by removing the row from the profile's `cordis.patch.yml`, or by
|
|
100
|
+
setting `enabled: false`.
|
|
101
|
+
|
|
102
|
+
## Test
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd plugins/mobile-link && npm test # zero dependencies
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The wire protocol is specified in
|
|
109
|
+
[`docs/RELAY-PROTOCOL.md`](https://github.com/jayantTang/DSH_Mobile/blob/main/docs/RELAY-PROTOCOL.md).
|
|
110
|
+
|
|
111
|
+
MIT.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Bundle patch for dsh-plugin-mobile-link.
|
|
2
|
+
#
|
|
3
|
+
# `dsh plugin --profile web add <this directory>` installs the package into the
|
|
4
|
+
# profile and appends it to dsh.profile.bundles, so the row below joins the tree
|
|
5
|
+
# on every boot with no hand-edited block in the profile's own cordis.patch.yml.
|
|
6
|
+
#
|
|
7
|
+
# `name` is anchored relative to this file (DSH rewrites ./ paths to file URLs),
|
|
8
|
+
# so the plugin keeps resolving wherever the profile lives.
|
|
9
|
+
#
|
|
10
|
+
# Config reference (all optional; the defaults are what a stock deployment
|
|
11
|
+
# needs):
|
|
12
|
+
# enabled false disables the outbound link without uninstalling
|
|
13
|
+
# relayUrl DLP relay base URL. The relay is published under a path prefix
|
|
14
|
+
# on an existing site, so a bare deployment dials
|
|
15
|
+
# <relay>/link/agent. The configured path is always kept, never
|
|
16
|
+
# replaced. ws:// is accepted for local testing (e.g.
|
|
17
|
+
# ws://127.0.0.1:8787). Left unset here on purpose: a value
|
|
18
|
+
# pinned in this file outranks both DSH_RELAY_URL and the
|
|
19
|
+
# address already enrolled in ~/.dsh/mobile-link/agent.json.
|
|
20
|
+
# agentId overrides ~/.dsh/mobile-link/agent.json
|
|
21
|
+
# agentSecret overrides ~/.dsh/mobile-link/agent.json
|
|
22
|
+
# stateFile agent identity file (default ~/.dsh/mobile-link/agent.json)
|
|
23
|
+
# dshUrl explicit local DSH base URL override (default: discovery)
|
|
24
|
+
# endpointFile DSH endpoint handoff file (default $DSH_HOME/desktop-shell/endpoint.json)
|
|
25
|
+
#
|
|
26
|
+
# Address resolution order: this config block, then DSH_RELAY_URL (or
|
|
27
|
+
# DSH_MOBILE_LINK_RELAY), then the enrolled identity file, then the repository
|
|
28
|
+
# placeholder wss://relay.example.com/dsh-link — the placeholder being the
|
|
29
|
+
# "nothing configured yet" sentinel rather than a real address.
|
|
30
|
+
|
|
31
|
+
- insert:
|
|
32
|
+
- id: mobile-link
|
|
33
|
+
name: './lib/index.js'
|
|
34
|
+
config:
|
|
35
|
+
enabled: true
|
|
36
|
+
heartbeatMs: 20000
|
|
37
|
+
reconnect: true
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone runner for the DLP agent, outside DSH.
|
|
4
|
+
*
|
|
5
|
+
* The cordis plugin is the normal entry point; this CLI exists so the agent can
|
|
6
|
+
* be exercised (and the whole middle tier integration-tested) without booting a
|
|
7
|
+
* DSH profile:
|
|
8
|
+
*
|
|
9
|
+
* node lib/cli.js --relay ws://127.0.0.1:8787 \
|
|
10
|
+
* --agent-id agt_x --agent-secret as_y --status-file /tmp/mobile-link.json
|
|
11
|
+
*
|
|
12
|
+
* It prints one machine-readable line per connection state change:
|
|
13
|
+
* MOBILE_LINK_STATE {"state":"connected", ...}
|
|
14
|
+
*
|
|
15
|
+
* `--mint-pair-code` skips the link entirely and just asks the relay for a
|
|
16
|
+
* pairing code (plus its `dsh://pair?...` payload) through the same
|
|
17
|
+
* prefix-preserving URL join the live agent uses.
|
|
18
|
+
*
|
|
19
|
+
* `enroll` is the first-run path for someone else's computer: it redeems an
|
|
20
|
+
* invite code for an identity and writes `~/.dsh/mobile-link/agent.json`, then
|
|
21
|
+
* exits. Everything after that is the ordinary agent.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { rename, writeFile } from 'node:fs/promises'
|
|
25
|
+
|
|
26
|
+
import { enrollAgent, inviteFrom } from './enroll.js'
|
|
27
|
+
import { MobileLinkAgent } from './link.js'
|
|
28
|
+
import { mintPairCode } from './pairing.js'
|
|
29
|
+
import { INVITE_ENV, defaultStatePath, resolveIdentity } from './state.js'
|
|
30
|
+
|
|
31
|
+
/** Atomic status snapshot so readers never observe a half-written file. */
|
|
32
|
+
async function writeJsonAtomic(path, value) {
|
|
33
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
34
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
35
|
+
await rename(temporary, path)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const args = { _: [] }
|
|
40
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
41
|
+
const token = argv[index]
|
|
42
|
+
if (!token.startsWith('--')) {
|
|
43
|
+
args._.push(token)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const eq = token.indexOf('=')
|
|
47
|
+
const key = (eq === -1 ? token.slice(2) : token.slice(2, eq)).replace(/-([a-z])/g, (_m, c) => c.toUpperCase())
|
|
48
|
+
if (eq !== -1) {
|
|
49
|
+
args[key] = token.slice(eq + 1)
|
|
50
|
+
} else if (argv[index + 1] !== undefined && !argv[index + 1].startsWith('--')) {
|
|
51
|
+
args[key] = argv[index + 1]
|
|
52
|
+
index += 1
|
|
53
|
+
} else {
|
|
54
|
+
args[key] = true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return args
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
const args = parseArgs(process.argv.slice(2))
|
|
62
|
+
if (args.help) {
|
|
63
|
+
process.stdout.write(`usage: cli.js enroll --invite <code> [--relay <ws url>] [--name <label>]
|
|
64
|
+
Redeem a one-time invite code for an identity in ${defaultStatePath()}.
|
|
65
|
+
The invite may also arrive as ${INVITE_ENV}. Exits non-zero on failure.
|
|
66
|
+
|
|
67
|
+
cli.js --relay <ws url> --agent-id <id> --agent-secret <secret>
|
|
68
|
+
[--state-file <path>] [--dsh-url <url>] [--endpoint-file <path>]
|
|
69
|
+
[--status-file <path>] [--heartbeat-ms <n>] [--log-level debug|info|warn]
|
|
70
|
+
|
|
71
|
+
cli.js --mint-pair-code --relay <ws url> --agent-id <id> --agent-secret <secret>
|
|
72
|
+
[--ttl-ms <n>] print one pairing code as JSON and exit
|
|
73
|
+
`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (args._[0] === 'enroll' || args.enroll) {
|
|
78
|
+
const explicit = typeof args.enroll === 'string' ? args.enroll : undefined
|
|
79
|
+
const settled = await enrollAgent({
|
|
80
|
+
relayUrl: args.relay,
|
|
81
|
+
inviteCode: inviteFrom({ inviteCode: explicit ?? args.invite }),
|
|
82
|
+
name: args.name,
|
|
83
|
+
stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
|
|
84
|
+
})
|
|
85
|
+
process.stdout.write(`${JSON.stringify({
|
|
86
|
+
ok: true,
|
|
87
|
+
agentId: settled.agentId,
|
|
88
|
+
agentName: settled.agentName,
|
|
89
|
+
relayUrl: settled.relayUrl,
|
|
90
|
+
stateFile: settled.stateFile,
|
|
91
|
+
}, null, 2)}\n`)
|
|
92
|
+
process.stdout.write('这台电脑已登记。重启 DSH 后连接器会使用新的身份。\n')
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (args.mintPairCode) {
|
|
97
|
+
const identity = await resolveIdentity({
|
|
98
|
+
stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
|
|
99
|
+
agentId: args.agentId,
|
|
100
|
+
agentSecret: args.agentSecret,
|
|
101
|
+
relayUrl: args.relay,
|
|
102
|
+
})
|
|
103
|
+
const minted = await mintPairCode({
|
|
104
|
+
identity,
|
|
105
|
+
ttlMs: Number(args.ttlMs) || 10 * 60 * 1000,
|
|
106
|
+
})
|
|
107
|
+
process.stdout.write(`${JSON.stringify(minted, null, 2)}\n`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const level = String(args.logLevel ?? process.env.DSH_MOBILE_LINK_LOG ?? 'info')
|
|
112
|
+
const enabledLevels = level === 'debug' ? ['debug', 'info', 'warn', 'error'] : ['info', 'warn', 'error']
|
|
113
|
+
const write = (stream, prefix) => (...parts) => {
|
|
114
|
+
stream.write(`${prefix} ${parts.map(String).join(' ')}\n`)
|
|
115
|
+
}
|
|
116
|
+
const logger = {
|
|
117
|
+
debug: enabledLevels.includes('debug') ? write(process.stdout, '[debug]') : () => {},
|
|
118
|
+
info: write(process.stdout, '[info]'),
|
|
119
|
+
warn: write(process.stderr, '[warn]'),
|
|
120
|
+
error: write(process.stderr, '[error]'),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const agent = new MobileLinkAgent({
|
|
124
|
+
relayUrl: args.relay,
|
|
125
|
+
agentId: args.agentId,
|
|
126
|
+
agentSecret: args.agentSecret,
|
|
127
|
+
stateFile: typeof args.stateFile === 'string' ? args.stateFile : undefined,
|
|
128
|
+
dshUrl: typeof args.dshUrl === 'string' ? args.dshUrl : undefined,
|
|
129
|
+
endpointFile: typeof args.endpointFile === 'string' ? args.endpointFile : undefined,
|
|
130
|
+
heartbeatMs: Number(args.heartbeatMs) || 20000,
|
|
131
|
+
logger,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
let previous = ''
|
|
135
|
+
agent.on('status', () => {
|
|
136
|
+
const status = agent.status()
|
|
137
|
+
const stamp = JSON.stringify(status)
|
|
138
|
+
if (stamp === previous) return
|
|
139
|
+
previous = stamp
|
|
140
|
+
if (args.statusFile) {
|
|
141
|
+
void writeJsonAtomic(String(args.statusFile), status).catch(() => {})
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write(`MOBILE_LINK_STATE ${JSON.stringify({
|
|
144
|
+
state: status.state,
|
|
145
|
+
connected: status.connected,
|
|
146
|
+
agentId: status.agentId,
|
|
147
|
+
relayUrl: status.relayUrl,
|
|
148
|
+
deviceCount: status.deviceCount,
|
|
149
|
+
lastError: status.lastError,
|
|
150
|
+
})}\n`)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const shutdown = async (signal) => {
|
|
154
|
+
logger.info(`received ${signal}, stopping`)
|
|
155
|
+
await agent.stop()
|
|
156
|
+
process.exit(0)
|
|
157
|
+
}
|
|
158
|
+
process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
159
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
|
160
|
+
|
|
161
|
+
await agent.start()
|
|
162
|
+
if (args.once) {
|
|
163
|
+
if (agent.state !== 'connected') {
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
const timer = setTimeout(resolve, Number(args.onceMs) || 5000)
|
|
166
|
+
agent.on('status', () => {
|
|
167
|
+
if (agent.state === 'connected') {
|
|
168
|
+
clearTimeout(timer)
|
|
169
|
+
resolve()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
const connected = agent.state === 'connected'
|
|
175
|
+
await agent.stop()
|
|
176
|
+
return connected ? 0 : 1
|
|
177
|
+
}
|
|
178
|
+
// Keep the process alive until a signal arrives.
|
|
179
|
+
await new Promise(() => {})
|
|
180
|
+
return 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().then(
|
|
184
|
+
(code) => {
|
|
185
|
+
process.exitCode = code
|
|
186
|
+
},
|
|
187
|
+
(error) => {
|
|
188
|
+
process.stderr.write(`mobile-link: fatal: ${error instanceof Error ? error.stack : error}\n`)
|
|
189
|
+
process.exitCode = 1
|
|
190
|
+
},
|
|
191
|
+
)
|
package/lib/dlp.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure DLP v1 helpers: frame codec, stream-id mapping, waterfall dedupe and
|
|
3
|
+
* reconnect backoff. No sockets, no timers, no filesystem — everything here is
|
|
4
|
+
* driven by `node --test` without a DSH instance.
|
|
5
|
+
*
|
|
6
|
+
* Wire contract: docs/RELAY-PROTOCOL.md §3.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Frame ceiling aligned with DSH's image-attachment limit (spec §5). */
|
|
10
|
+
export const MAX_FRAME_BYTES = 32 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
export const DEVICE_TO_AGENT = new Set(['req', 'open', 'cancel', 'eventResult', 'ping', 'hello'])
|
|
13
|
+
export const AGENT_TO_DEVICE = new Set(['res', 'item', 'end', 'streamError', 'event', 'hostStatus', 'pong', 'error'])
|
|
14
|
+
/** Relay-internal control frames carrying device lifecycle (see relay/NOTES.md). */
|
|
15
|
+
export const RELAY_CONTROL = new Set(['deviceAttach', 'deviceDetach'])
|
|
16
|
+
|
|
17
|
+
const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
|
|
18
|
+
|
|
19
|
+
export function encodeFrame(frame) {
|
|
20
|
+
return JSON.stringify(frame)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decode one wire payload.
|
|
25
|
+
* @returns the frame object, or `undefined` when the payload is unusable
|
|
26
|
+
* (not JSON, not an object, or missing a non-empty string `t`).
|
|
27
|
+
*/
|
|
28
|
+
export function decodeFrame(raw) {
|
|
29
|
+
if (typeof raw !== 'string') return undefined
|
|
30
|
+
if (raw.length > MAX_FRAME_BYTES) return undefined
|
|
31
|
+
let value
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(raw)
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
38
|
+
if (typeof value.t !== 'string' || value.t.length === 0) return undefined
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function frameType(frame) {
|
|
43
|
+
return typeof frame?.t === 'string' ? frame.t : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Structural check for one device -> agent frame; returns an error string or undefined. */
|
|
47
|
+
export function deviceFrameError(frame) {
|
|
48
|
+
const kind = frameType(frame)
|
|
49
|
+
if (!DEVICE_TO_AGENT.has(kind)) return undefined
|
|
50
|
+
if (ID_FRAMES.has(kind) && !nonEmptyString(frame.id)) return `${kind}: missing \`id\``
|
|
51
|
+
if (kind === 'req' && !nonEmptyString(frame.method)) return 'req: missing `method`'
|
|
52
|
+
if (kind === 'open' && !nonEmptyString(frame.endpoint)) return 'open: missing `endpoint`'
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function nonEmptyString(value) {
|
|
57
|
+
return typeof value === 'string' && value.length > 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function deviceIdOf(frame) {
|
|
61
|
+
return nonEmptyString(frame?.deviceId) ? frame.deviceId : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Exponential backoff with ±jitter, capped (spec §4.2: 1s -> 2s -> ... -> 30s ±20%).
|
|
66
|
+
* @param attempt zero-based retry index.
|
|
67
|
+
*/
|
|
68
|
+
export function nextBackoff(attempt, { baseMs = 1000, maxMs = 30000, jitter = 0.2, random = Math.random } = {}) {
|
|
69
|
+
const exponent = Math.max(0, Math.min(30, Number(attempt) || 0))
|
|
70
|
+
const raw = Math.min(maxMs, baseMs * 2 ** exponent)
|
|
71
|
+
const spread = raw * jitter
|
|
72
|
+
const offset = (random() * 2 - 1) * spread
|
|
73
|
+
return Math.max(0, Math.round(raw + offset))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Split a relay URL into its HTTP and WebSocket bases, keeping the path prefix.
|
|
78
|
+
*
|
|
79
|
+
* The relay is published under a path on an existing site, so the configured
|
|
80
|
+
* base carries a prefix (`wss://relay.example.com/dsh-link`) that must be
|
|
81
|
+
* *kept* when an endpoint is appended — never replaced. A trailing slash is
|
|
82
|
+
* tolerated and normalised away. Accepts `wss://host`, `https://host/prefix`,
|
|
83
|
+
* `ws://127.0.0.1:8787` and bare hosts.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeRelayUrl(raw) {
|
|
86
|
+
const text = String(raw ?? '').trim()
|
|
87
|
+
if (!text) throw new Error('relay URL is required')
|
|
88
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `wss://${text}`
|
|
89
|
+
const url = new URL(withScheme)
|
|
90
|
+
const secure = url.protocol === 'wss:' || url.protocol === 'https:'
|
|
91
|
+
if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol)) {
|
|
92
|
+
throw new Error(`unsupported relay scheme ${url.protocol}`)
|
|
93
|
+
}
|
|
94
|
+
const authority = `${url.host}`
|
|
95
|
+
const basePath = url.pathname.replace(/\/+$/, '')
|
|
96
|
+
return {
|
|
97
|
+
httpBase: `${secure ? 'https' : 'http'}://${authority}${basePath}`,
|
|
98
|
+
wsBase: `${secure ? 'wss' : 'ws'}://${authority}${basePath}`,
|
|
99
|
+
secure,
|
|
100
|
+
authority,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The relay address the repository ships as a placeholder.
|
|
106
|
+
*
|
|
107
|
+
* It is a *sentinel*, not a deployment: a value equal to it means "no relay has
|
|
108
|
+
* been configured here", never "dial relay.example.com". The distinction is
|
|
109
|
+
* load-bearing — a configured placeholder used to outrank the identity already
|
|
110
|
+
* enrolled in `agent.json`, so one DSH restart pointed an enrolled computer at
|
|
111
|
+
* the placeholder *and* wrote that back over the real address, which left the
|
|
112
|
+
* phone unable to connect until somebody noticed. Anything that resolves an
|
|
113
|
+
* address has to recognise the sentinel.
|
|
114
|
+
*/
|
|
115
|
+
export const PLACEHOLDER_RELAY_URL = 'wss://relay.example.com/dsh-link'
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* True when `raw` is, or normalises to, the repository placeholder — a trailing
|
|
119
|
+
* slash or an `https://` spelling of the same address still counts.
|
|
120
|
+
*/
|
|
121
|
+
export function isPlaceholderRelayUrl(raw) {
|
|
122
|
+
if (typeof raw !== 'string' || !raw.trim()) return false
|
|
123
|
+
try {
|
|
124
|
+
return normalizeRelayUrl(raw).wsBase === normalizeRelayUrl(PLACEHOLDER_RELAY_URL).wsBase
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Join one endpoint onto a relay base without losing the base's path prefix.
|
|
132
|
+
* `('wss://host/prefix/', '/link/agent')` -> `'wss://host/prefix/link/agent'`.
|
|
133
|
+
*/
|
|
134
|
+
export function joinRelayPath(base, suffix) {
|
|
135
|
+
const head = String(base ?? '').replace(/\/+$/, '')
|
|
136
|
+
const tail = String(suffix ?? '').replace(/^\/+/, '')
|
|
137
|
+
return `${head}/${tail}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The connector's relay endpoint, keeping any configured path prefix. */
|
|
141
|
+
export function agentEndpoint(relayUrl) {
|
|
142
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).wsBase, 'link/agent')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The relay's pairing-code endpoint, keeping any configured path prefix. */
|
|
146
|
+
export function pairCodeEndpoint(relayUrl) {
|
|
147
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'pair/code')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The relay's self-service enrollment endpoint (`POST /agents/enroll`). */
|
|
151
|
+
export function enrollEndpoint(relayUrl) {
|
|
152
|
+
return joinRelayPath(normalizeRelayUrl(relayUrl).httpBase, 'agents/enroll')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `dsh://pair?relay=<relay>&code=<code>` deep link the desktop shows and
|
|
157
|
+
* the iOS app scans. `relay` keeps the full path prefix so the phone can reach
|
|
158
|
+
* `/pair/claim` and `/link/device` under exactly the same base.
|
|
159
|
+
*/
|
|
160
|
+
export function qrPayload({ relay, code }) {
|
|
161
|
+
const params = new URLSearchParams({ relay: String(relay), code: String(code) })
|
|
162
|
+
return `dsh://pair?${params.toString()}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* DLP id <-> mux streamId mapping. One mux stream per (device, DLP stream) so
|
|
167
|
+
* two devices can both call their stream `"2"` without colliding.
|
|
168
|
+
*/
|
|
169
|
+
export class StreamTable {
|
|
170
|
+
constructor() {
|
|
171
|
+
/** @type {Map<string, {deviceId: string, dlpId: string, muxId: string, endpoint: string}>} */
|
|
172
|
+
this.byMux = new Map()
|
|
173
|
+
/** @type {Map<string, string>} `${deviceId}\u0000${dlpId}` -> muxId */
|
|
174
|
+
this.byDlp = new Map()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static key(deviceId, dlpId) {
|
|
178
|
+
return `${deviceId}\u0000${dlpId}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @returns {boolean} false when this device already owns that DLP id. */
|
|
182
|
+
add({ deviceId, dlpId, muxId, endpoint }) {
|
|
183
|
+
if (this.byDlp.has(StreamTable.key(deviceId, dlpId))) return false
|
|
184
|
+
this.byMux.set(muxId, { deviceId, dlpId, muxId, endpoint })
|
|
185
|
+
this.byDlp.set(StreamTable.key(deviceId, dlpId), muxId)
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
muxOf(deviceId, dlpId) {
|
|
190
|
+
return this.byDlp.get(StreamTable.key(deviceId, dlpId))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
entryOfMux(muxId) {
|
|
194
|
+
return this.byMux.get(muxId)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Remove one (device, DLP) pair; returns its mux streamId when it existed. */
|
|
198
|
+
removeByDlp(deviceId, dlpId) {
|
|
199
|
+
const key = StreamTable.key(deviceId, dlpId)
|
|
200
|
+
const muxId = this.byDlp.get(key)
|
|
201
|
+
if (muxId === undefined) return undefined
|
|
202
|
+
this.byDlp.delete(key)
|
|
203
|
+
this.byMux.delete(muxId)
|
|
204
|
+
return muxId
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Remove one mux stream; returns its entry when it existed. */
|
|
208
|
+
removeByMux(muxId) {
|
|
209
|
+
const entry = this.byMux.get(muxId)
|
|
210
|
+
if (entry === undefined) return undefined
|
|
211
|
+
this.byMux.delete(muxId)
|
|
212
|
+
this.byDlp.delete(StreamTable.key(entry.deviceId, entry.dlpId))
|
|
213
|
+
return entry
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Every mux streamId owned by one device (used when it disconnects). */
|
|
217
|
+
removeDevice(deviceId) {
|
|
218
|
+
const removed = []
|
|
219
|
+
for (const entry of [...this.byMux.values()]) {
|
|
220
|
+
if (entry.deviceId === deviceId) {
|
|
221
|
+
removed.push(entry.muxId)
|
|
222
|
+
this.byMux.delete(entry.muxId)
|
|
223
|
+
this.byDlp.delete(StreamTable.key(entry.deviceId, entry.dlpId))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removed
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get size() {
|
|
230
|
+
return this.byMux.size
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* First-answer-wins bookkeeping for `$events` waterfalls (spec §4.1.6).
|
|
236
|
+
*
|
|
237
|
+
* `claim` reserves an event synchronously so two devices answering at the same
|
|
238
|
+
* moment cannot both reach `$events/result`; `release` hands it back when the
|
|
239
|
+
* reservation failed to produce a delivered answer.
|
|
240
|
+
*/
|
|
241
|
+
export class WaterfallDedupe {
|
|
242
|
+
constructor({ ttlMs = 5 * 60 * 1000, now = Date.now } = {}) {
|
|
243
|
+
this.ttlMs = ttlMs
|
|
244
|
+
this.now = now
|
|
245
|
+
/** @type {Map<string, number>} eventId -> claimedAt */
|
|
246
|
+
this.claimed = new Map()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @returns {boolean} true when this caller won the right to answer. */
|
|
250
|
+
claim(eventId) {
|
|
251
|
+
if (!nonEmptyString(eventId)) return false
|
|
252
|
+
this.prune()
|
|
253
|
+
if (this.claimed.has(eventId)) return false
|
|
254
|
+
this.claimed.set(eventId, this.now())
|
|
255
|
+
return true
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
release(eventId) {
|
|
259
|
+
this.claimed.delete(eventId)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
has(eventId) {
|
|
263
|
+
return this.claimed.has(eventId)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
prune(at = this.now()) {
|
|
267
|
+
for (const [eventId, claimedAt] of this.claimed) {
|
|
268
|
+
if (at - claimedAt > this.ttlMs) this.claimed.delete(eventId)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
get size() {
|
|
273
|
+
return this.claimed.size
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `{t:'pong', ts}` for a device/relay heartbeat, preserving `ts` verbatim. */
|
|
278
|
+
export function pongFor(frame) {
|
|
279
|
+
return { t: 'pong', ts: frame?.ts }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `{t:'res', ...}` mapped from a DSH `RemoteResult` (`{ok,value}|{ok:false,error}`). */
|
|
283
|
+
export function resultFrame(dlpId, result, deviceId) {
|
|
284
|
+
const base = { t: 'res', id: dlpId, deviceId }
|
|
285
|
+
if (result && result.ok === true) return { ...base, ok: true, value: result.value }
|
|
286
|
+
const error = result?.error
|
|
287
|
+
return {
|
|
288
|
+
...base,
|
|
289
|
+
ok: false,
|
|
290
|
+
error: {
|
|
291
|
+
code: nonEmptyString(error?.code) ? error.code : 'gateway/failed',
|
|
292
|
+
message: nonEmptyString(error?.message) ? error.message : 'DSH request failed',
|
|
293
|
+
details: error && typeof error.details === 'object' && error.details !== null ? error.details : {},
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|