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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the *local* DSH instance.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities (spec §4.1):
|
|
5
|
+
* 1. Discover the endpoint: `$DSH_HOME/desktop-shell/endpoint.json`, then
|
|
6
|
+
* `$DSH_WEB_URL`, then `http://127.0.0.1:54499`. The file changes on every
|
|
7
|
+
* DSH restart, so it is re-read on every (re)connect.
|
|
8
|
+
* 2. Exchange `?token=` for a `dsh-auth-*` cookie without following the 303.
|
|
9
|
+
* The cookie is bound to the authority, so the client always dials
|
|
10
|
+
* `127.0.0.1:<port>` and never rewrites `Host` (see NOTES.md on spec §7).
|
|
11
|
+
* 3. Unary RPC via `POST /api/<method>` and one shared multiplexing
|
|
12
|
+
* WebSocket at `/api/remote.mux` for every logical stream.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { EventEmitter } from 'node:events'
|
|
16
|
+
import { readFile } from 'node:fs/promises'
|
|
17
|
+
|
|
18
|
+
import { defaultEndpointFile, defaultShellLogFile } from './state.js'
|
|
19
|
+
import { connect as wsConnect } from './ws.js'
|
|
20
|
+
|
|
21
|
+
export class DshUnavailable extends Error {}
|
|
22
|
+
|
|
23
|
+
const FALLBACK_PORT = 54499
|
|
24
|
+
const MUX_PATH = '/api/remote.mux'
|
|
25
|
+
|
|
26
|
+
function portOf(url) {
|
|
27
|
+
if (url.port) return Number(url.port)
|
|
28
|
+
return url.protocol === 'https:' ? 443 : 80
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseBase(raw, { forceLoopback = true } = {}) {
|
|
32
|
+
const url = new URL(raw)
|
|
33
|
+
const port = portOf(url)
|
|
34
|
+
const host = forceLoopback ? '127.0.0.1' : url.hostname
|
|
35
|
+
const scheme = url.protocol === 'https:' ? 'https' : 'http'
|
|
36
|
+
return {
|
|
37
|
+
base: `${scheme}://${host}:${port}`,
|
|
38
|
+
wsBase: `${scheme === 'https' ? 'wss' : 'ws'}://${host}:${port}`,
|
|
39
|
+
port,
|
|
40
|
+
token: url.searchParams.get('token') || '',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve where the local DSH web server lives.
|
|
46
|
+
* @returns {Promise<{base: string, wsBase: string, port: number, token: string, source: string}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function discoverEndpoint({
|
|
49
|
+
endpointFile = defaultEndpointFile(),
|
|
50
|
+
shellLogFile = defaultShellLogFile(),
|
|
51
|
+
explicitUrl,
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (explicitUrl) {
|
|
54
|
+
const parsed = parseBase(explicitUrl, { forceLoopback: false })
|
|
55
|
+
parsed.source = 'config'
|
|
56
|
+
return parsed
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(await readFile(endpointFile, 'utf8'))
|
|
60
|
+
const url = typeof raw?.url === 'string' && raw.url ? raw.url : `http://127.0.0.1:${raw?.port ?? FALLBACK_PORT}`
|
|
61
|
+
const parsed = parseBase(url)
|
|
62
|
+
if (!parsed.port) parsed.port = Number(raw?.port) || FALLBACK_PORT
|
|
63
|
+
parsed.base = `http://127.0.0.1:${parsed.port}`
|
|
64
|
+
parsed.wsBase = `ws://127.0.0.1:${parsed.port}`
|
|
65
|
+
parsed.token = parsed.token || url.searchParams?.get?.('token') || ''
|
|
66
|
+
parsed.source = `endpoint.json:${endpointFile}`
|
|
67
|
+
return parsed
|
|
68
|
+
} catch {
|
|
69
|
+
/* fall through to the log, the environment and the built-in default */
|
|
70
|
+
}
|
|
71
|
+
// The handoff file can be missing while the host is very much alive: the
|
|
72
|
+
// desktop shell deletes it when it quits, and a bare `dsh web --port 0` never
|
|
73
|
+
// writes one at all. The shell's own log keeps every launch line, so the last
|
|
74
|
+
// one that still answers is the live endpoint. This is the same second source
|
|
75
|
+
// `test/tools/host.mjs` reads for the same reason.
|
|
76
|
+
try {
|
|
77
|
+
const log = await readFile(shellLogFile, 'utf8')
|
|
78
|
+
const matches = log.match(/http:\/\/127\.0\.0\.1:(\d+)\/\?token=([A-Za-z0-9_-]+)/g)
|
|
79
|
+
if (matches && matches.length) {
|
|
80
|
+
const parsed = parseBase(matches[matches.length - 1])
|
|
81
|
+
parsed.source = `dsh-shell.log:${shellLogFile}`
|
|
82
|
+
return parsed
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* fall through to the environment and the built-in default */
|
|
86
|
+
}
|
|
87
|
+
if (process.env.DSH_WEB_URL) {
|
|
88
|
+
const parsed = parseBase(process.env.DSH_WEB_URL)
|
|
89
|
+
parsed.source = 'DSH_WEB_URL'
|
|
90
|
+
return parsed
|
|
91
|
+
}
|
|
92
|
+
const parsed = parseBase(`http://127.0.0.1:${FALLBACK_PORT}`)
|
|
93
|
+
parsed.source = 'default'
|
|
94
|
+
return parsed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cookieFrom(response) {
|
|
98
|
+
const list = typeof response.headers.getSetCookie === 'function'
|
|
99
|
+
? response.headers.getSetCookie()
|
|
100
|
+
: [response.headers.get('set-cookie')].filter(Boolean)
|
|
101
|
+
const cookies = list
|
|
102
|
+
.map((value) => String(value).split(';')[0].trim())
|
|
103
|
+
.filter((value) => value.startsWith('dsh-auth-'))
|
|
104
|
+
return cookies.join('; ')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class DshClient extends EventEmitter {
|
|
108
|
+
constructor({ endpointFile, explicitUrl, logger = console, fetchImpl = fetch, connectImpl = wsConnect } = {}) {
|
|
109
|
+
super()
|
|
110
|
+
this.endpointFile = endpointFile || defaultEndpointFile()
|
|
111
|
+
this.explicitUrl = explicitUrl
|
|
112
|
+
this.logger = logger
|
|
113
|
+
this.fetchImpl = fetchImpl
|
|
114
|
+
this.connectImpl = connectImpl
|
|
115
|
+
this.endpoint = undefined
|
|
116
|
+
this.cookie = ''
|
|
117
|
+
this.mux = undefined
|
|
118
|
+
this.muxOpening = undefined
|
|
119
|
+
this.muxReady = false
|
|
120
|
+
this.pendingOpens = []
|
|
121
|
+
this.lastError = undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Re-read endpoint discovery and re-do the token -> cookie exchange. */
|
|
125
|
+
async refresh({ force = false } = {}) {
|
|
126
|
+
const endpoint = await discoverEndpoint({ endpointFile: this.endpointFile, explicitUrl: this.explicitUrl })
|
|
127
|
+
if (force || !this.endpoint || this.endpoint.base !== endpoint.base) this.cookie = ''
|
|
128
|
+
this.endpoint = endpoint
|
|
129
|
+
if (!this.cookie) await this.authenticate()
|
|
130
|
+
return this.endpoint
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async authenticate() {
|
|
134
|
+
const endpoint = this.endpoint ?? await discoverEndpoint({
|
|
135
|
+
endpointFile: this.endpointFile, explicitUrl: this.explicitUrl,
|
|
136
|
+
})
|
|
137
|
+
this.endpoint = endpoint
|
|
138
|
+
if (!endpoint.token) {
|
|
139
|
+
throw new DshUnavailable(
|
|
140
|
+
`no DSH launch token found (looked at ${this.endpoint.source}); restart DSH or set dshUrl in the plugin config`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
const url = `${endpoint.base}/?token=${encodeURIComponent(endpoint.token)}`
|
|
144
|
+
let response
|
|
145
|
+
try {
|
|
146
|
+
response = await this.fetchImpl(url, { redirect: 'manual' })
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new DshUnavailable(`cannot reach DSH at ${endpoint.base}: ${error instanceof Error ? error.message : error}`)
|
|
149
|
+
}
|
|
150
|
+
const cookie = cookieFrom(response)
|
|
151
|
+
if (!cookie) {
|
|
152
|
+
throw new DshUnavailable(`DSH auth handshake returned HTTP ${response.status} without a dsh-auth cookie`)
|
|
153
|
+
}
|
|
154
|
+
this.cookie = cookie
|
|
155
|
+
this.logger.debug?.(`mobile-link: authenticated with DSH at ${endpoint.base}`)
|
|
156
|
+
return cookie
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async ensure() {
|
|
160
|
+
if (!this.endpoint || !this.cookie) await this.refresh()
|
|
161
|
+
return this.endpoint
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One unary RPC. Returns the DSH `RemoteResult` object verbatim. */
|
|
165
|
+
async rpc(method, args = {}, { retry = true } = {}) {
|
|
166
|
+
await this.ensure()
|
|
167
|
+
const rpcId = `ml-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
168
|
+
const body = JSON.stringify({
|
|
169
|
+
type: 'client-request',
|
|
170
|
+
rpcId,
|
|
171
|
+
method,
|
|
172
|
+
payload: { args: args ?? {} },
|
|
173
|
+
})
|
|
174
|
+
let response
|
|
175
|
+
try {
|
|
176
|
+
response = await this.fetchImpl(`${this.endpoint.base}/api/${method}`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'content-type': 'application/json', cookie: this.cookie },
|
|
179
|
+
body,
|
|
180
|
+
})
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new DshUnavailable(`DSH request ${method} failed: ${error instanceof Error ? error.message : error}`)
|
|
183
|
+
}
|
|
184
|
+
if (response.status === 401 && retry) {
|
|
185
|
+
this.logger.debug?.(`mobile-link: DSH session expired, re-authenticating (${method})`)
|
|
186
|
+
this.cookie = ''
|
|
187
|
+
await this.refresh({ force: true })
|
|
188
|
+
return this.rpc(method, args, { retry: false })
|
|
189
|
+
}
|
|
190
|
+
let json
|
|
191
|
+
try {
|
|
192
|
+
json = await response.json()
|
|
193
|
+
} catch {
|
|
194
|
+
return { ok: false, error: { code: 'gateway/bad-response', message: `DSH returned HTTP ${response.status}`, details: {} } }
|
|
195
|
+
}
|
|
196
|
+
if (json?.result && typeof json.result === 'object') return json.result
|
|
197
|
+
return { ok: false, error: { code: 'gateway/bad-response', message: 'DSH returned no result', details: {} } }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The single multiplexing socket; opened lazily and shared by every stream. */
|
|
201
|
+
async ensureMux() {
|
|
202
|
+
if (this.muxReady && this.mux && !this.mux.closed) return this.mux
|
|
203
|
+
if (this.muxOpening) return this.muxOpening
|
|
204
|
+
this.muxOpening = (async () => {
|
|
205
|
+
await this.ensure()
|
|
206
|
+
const handle = await this.connectImpl(`${this.endpoint.wsBase}${MUX_PATH}`, {
|
|
207
|
+
headers: { cookie: this.cookie },
|
|
208
|
+
timeoutMs: 20000,
|
|
209
|
+
})
|
|
210
|
+
this.mux = handle
|
|
211
|
+
this.muxReady = true
|
|
212
|
+
handle.onMessage((text) => this.#onMuxMessage(text))
|
|
213
|
+
handle.onClose((code, reason) => this.#onMuxDown(`closed (${code ?? '?'} ${reason ?? ''})`))
|
|
214
|
+
handle.onError((error) => this.logger.debug?.(`mobile-link: mux socket error: ${error?.message ?? error}`))
|
|
215
|
+
for (const frame of this.pendingOpens.splice(0)) handle.send(frame)
|
|
216
|
+
this.logger.debug?.('mobile-link: DSH mux socket open')
|
|
217
|
+
return handle
|
|
218
|
+
})()
|
|
219
|
+
try {
|
|
220
|
+
return await this.muxOpening
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.muxReady = false
|
|
223
|
+
this.mux = undefined
|
|
224
|
+
throw error
|
|
225
|
+
} finally {
|
|
226
|
+
this.muxOpening = undefined
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#onMuxMessage(text) {
|
|
231
|
+
let frame
|
|
232
|
+
try {
|
|
233
|
+
frame = JSON.parse(text)
|
|
234
|
+
} catch {
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const streamId = frame?.streamId
|
|
238
|
+
if (typeof streamId !== 'string') return
|
|
239
|
+
if (frame.type === 'item') this.emit('stream-item', streamId, frame.value)
|
|
240
|
+
else if (frame.type === 'error') this.emit('stream-error', streamId, frame.error)
|
|
241
|
+
else if (frame.type === 'end') this.emit('stream-end', streamId)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#onMuxDown(reason) {
|
|
245
|
+
if (!this.muxReady && !this.mux) return
|
|
246
|
+
this.muxReady = false
|
|
247
|
+
this.mux = undefined
|
|
248
|
+
this.logger.debug?.(`mobile-link: DSH mux socket down: ${reason}`)
|
|
249
|
+
this.emit('mux-down', reason)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
openStream(muxId, endpoint, args) {
|
|
253
|
+
const text = JSON.stringify({ type: 'open', streamId: muxId, endpoint, payload: { args: args ?? {} } })
|
|
254
|
+
if (this.muxReady && this.mux && !this.mux.closed) this.mux.send(text)
|
|
255
|
+
else this.pendingOpens.push(text)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
cancelStream(muxId) {
|
|
259
|
+
const needle = `"streamId":${JSON.stringify(muxId)}`
|
|
260
|
+
this.pendingOpens = this.pendingOpens.filter((text) => !text.includes(needle))
|
|
261
|
+
if (this.muxReady && this.mux && !this.mux.closed) {
|
|
262
|
+
try {
|
|
263
|
+
this.mux.send(JSON.stringify({ type: 'cancel', streamId: muxId }))
|
|
264
|
+
} catch {
|
|
265
|
+
/* socket died between checks */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
close() {
|
|
271
|
+
this.muxReady = false
|
|
272
|
+
this.pendingOpens = []
|
|
273
|
+
try {
|
|
274
|
+
this.mux?.close(1000, 'agent shutdown')
|
|
275
|
+
} catch {
|
|
276
|
+
/* already closed */
|
|
277
|
+
}
|
|
278
|
+
this.mux = undefined
|
|
279
|
+
}
|
|
280
|
+
}
|
package/lib/enroll.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run enrollment: turn an invite code into a durable agent identity.
|
|
3
|
+
*
|
|
4
|
+
* Until now an identity could only be provisioned by whoever owns the relay
|
|
5
|
+
* (`relay/admin.py agent-register --write-config`). That does not distribute:
|
|
6
|
+
* somebody else installs DSH plus this connector on their own computer and has
|
|
7
|
+
* to be handed a credential by the operator, out of band.
|
|
8
|
+
*
|
|
9
|
+
* So the relay grew `POST /agents/enroll` (relay/api.py), which redeems a
|
|
10
|
+
* one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
|
|
11
|
+
* connector half: one call, then the identity is written to the same
|
|
12
|
+
* `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so
|
|
13
|
+
* nothing downstream can tell the two apart.
|
|
14
|
+
*
|
|
15
|
+
* Enrollment is deliberately *not* automatic against an open endpoint: an
|
|
16
|
+
* invite code must be present, because the relay is on the public internet.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { enrollEndpoint } from './dlp.js'
|
|
20
|
+
import { DEFAULT_AGENT_NAME, INVITE_ENV, defaultStatePath, readState, writeState } from './state.js'
|
|
21
|
+
|
|
22
|
+
/** Where the invite code may come from: explicit argument, then environment. */
|
|
23
|
+
export function inviteFrom({ inviteCode, env = process.env } = {}) {
|
|
24
|
+
const value = typeof inviteCode === 'string' && inviteCode.trim() ? inviteCode : env?.[INVITE_ENV]
|
|
25
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether an enrollment should be attempted, and with what code.
|
|
30
|
+
*
|
|
31
|
+
* Enrollment happens only when there is no identity yet; an existing identity is
|
|
32
|
+
* never replaced behind the user's back (that would silently unpair every phone
|
|
33
|
+
* they had already paired).
|
|
34
|
+
*/
|
|
35
|
+
export async function enrollPlan({ stateFile, inviteCode, relayUrl, env = process.env } = {}) {
|
|
36
|
+
const path = stateFile || defaultStatePath()
|
|
37
|
+
const existing = await readState(path)
|
|
38
|
+
if (existing?.agentId && existing?.agentSecret) {
|
|
39
|
+
return { needed: false, stateFile: path, agentId: existing.agentId }
|
|
40
|
+
}
|
|
41
|
+
const invite = inviteFrom({ inviteCode, env })
|
|
42
|
+
const relay = relayUrl || env?.DSH_MOBILE_LINK_RELAY || ''
|
|
43
|
+
return { needed: true, stateFile: path, invite, relayUrl: relay, canEnroll: Boolean(invite && relay) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Redeem one invite code and persist the resulting identity.
|
|
48
|
+
*
|
|
49
|
+
* The state file is written **after** the relay has answered, and atomically, so
|
|
50
|
+
* a failure anywhere leaves whatever was there before untouched.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @param {string} options.relayUrl relay origin, e.g. `wss://host/dsh-link`
|
|
54
|
+
* @param {string} options.inviteCode the one-time code
|
|
55
|
+
* @param {string} [options.name] what this computer is called on the phone
|
|
56
|
+
* @param {string} [options.stateFile] override, for tests and `$DSH_HOME`
|
|
57
|
+
* @param {typeof fetch} [options.fetchImpl] injectable for tests
|
|
58
|
+
* @returns {Promise<{agentId: string, agentSecret: string, agentName: string, relayUrl: string, stateFile: string}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function enrollAgent({
|
|
61
|
+
relayUrl, inviteCode, name, stateFile, deviceName,
|
|
62
|
+
fetchImpl = fetch, logger = undefined,
|
|
63
|
+
} = {}) {
|
|
64
|
+
if (!inviteCode || !String(inviteCode).trim()) {
|
|
65
|
+
throw new Error('mobile-link: an invite code is required to enroll (--invite <code>)')
|
|
66
|
+
}
|
|
67
|
+
if (!relayUrl || !String(relayUrl).trim()) {
|
|
68
|
+
throw new Error('mobile-link: a relay URL is required to enroll (--relay <url>)')
|
|
69
|
+
}
|
|
70
|
+
const path = stateFile || defaultStatePath()
|
|
71
|
+
const agentName = (name || deviceName || '').trim() || DEFAULT_AGENT_NAME
|
|
72
|
+
|
|
73
|
+
let response
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(enrollEndpoint(relayUrl), {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ inviteCode: String(inviteCode).trim(), name: agentName }),
|
|
79
|
+
})
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`mobile-link: cannot reach the relay at ${relayUrl}: ${messageOf(error)}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let body
|
|
85
|
+
try {
|
|
86
|
+
body = await response.json()
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error(`mobile-link: the relay answered HTTP ${response.status} to /agents/enroll`)
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok || body?.ok !== true || !body.agentId || !body.agentSecret) {
|
|
91
|
+
throw new Error(describeEnrollFailure(body, response.status))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await writeState(path, {
|
|
95
|
+
relayUrl,
|
|
96
|
+
agentId: body.agentId,
|
|
97
|
+
agentSecret: body.agentSecret,
|
|
98
|
+
agentName: body.agentName || agentName,
|
|
99
|
+
})
|
|
100
|
+
logger?.info?.(`mobile-link: enrolled as ${body.agentId} (${body.agentName || agentName})`)
|
|
101
|
+
return {
|
|
102
|
+
agentId: body.agentId,
|
|
103
|
+
agentSecret: body.agentSecret,
|
|
104
|
+
agentName: body.agentName || agentName,
|
|
105
|
+
relayUrl,
|
|
106
|
+
stateFile: path,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Turn the relay's failure into something a person can act on.
|
|
112
|
+
*
|
|
113
|
+
* The three invite failures need different advice, so the relay's error code is
|
|
114
|
+
* mapped rather than replaced by a generic message.
|
|
115
|
+
*/
|
|
116
|
+
export function describeEnrollFailure(body, status) {
|
|
117
|
+
const code = body?.error?.code ?? ''
|
|
118
|
+
const message = body?.error?.message
|
|
119
|
+
switch (code) {
|
|
120
|
+
case 'enroll/unknown':
|
|
121
|
+
return 'mobile-link: that invite code is not valid — check it for a typo, and that it was issued by this relay'
|
|
122
|
+
case 'enroll/used':
|
|
123
|
+
return 'mobile-link: that invite code has already been used — each code enrolls exactly one computer'
|
|
124
|
+
case 'enroll/expired':
|
|
125
|
+
return 'mobile-link: that invite code has expired — ask for a new one'
|
|
126
|
+
case 'enroll/rate-limited':
|
|
127
|
+
return 'mobile-link: too many failed attempts with that code — wait a few minutes and try again'
|
|
128
|
+
default:
|
|
129
|
+
return `mobile-link: enrollment failed (HTTP ${status})${message ? `: ${message}` : ''}`
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The one-liner a fresh install runs, quoted for copy/paste. */
|
|
134
|
+
export function enrollCommand(relayUrl, name = undefined) {
|
|
135
|
+
const parts = ['dsh-mobile-link enroll', `--relay ${relayUrl || '<relay-url>'}`, '--invite <code>']
|
|
136
|
+
if (name) parts.push(`--name ${JSON.stringify(name)}`)
|
|
137
|
+
return parts.join(' ')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function messageOf(error) {
|
|
141
|
+
return error instanceof Error ? error.message : String(error)
|
|
142
|
+
}
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the phone sends, staged on this computer.
|
|
3
|
+
*
|
|
4
|
+
* The Host has no upload endpoint — `workspaceFiles/*` is read-only and the
|
|
5
|
+
* protocol's `{type:'file', receiptId}` part needs a receipt from an
|
|
6
|
+
* `uploadFile` call that does not exist on this build. So the link carries the
|
|
7
|
+
* bytes itself and writes them where the agent can read them with its ordinary
|
|
8
|
+
* tools.
|
|
9
|
+
*
|
|
10
|
+
* The transfer rides the protocol's existing unary `req` frame, addressed to a
|
|
11
|
+
* reserved method name the connector answers locally instead of forwarding, so
|
|
12
|
+
* neither the relay nor the protocol needs to change.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { basename, join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** Reserved method names this module answers; never forwarded to the Host. */
|
|
20
|
+
export const FILE_BEGIN = '_link/fileBegin'
|
|
21
|
+
export const FILE_CHUNK = '_link/fileChunk'
|
|
22
|
+
export const FILE_END = '_link/fileEnd'
|
|
23
|
+
|
|
24
|
+
export function isFileMethod(method) {
|
|
25
|
+
return method === FILE_BEGIN || method === FILE_CHUNK || method === FILE_END
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One in-flight transfer. Chunks are appended in order and the file only
|
|
30
|
+
* appears at its final path once the end frame arrives, so a half-sent file is
|
|
31
|
+
* never mistaken for a complete one.
|
|
32
|
+
*/
|
|
33
|
+
class Transfer {
|
|
34
|
+
constructor({ sessionId, name, bytes }) {
|
|
35
|
+
this.sessionId = sessionId
|
|
36
|
+
this.name = basename(name || 'file')
|
|
37
|
+
this.bytes = Number.isFinite(bytes) ? bytes : undefined
|
|
38
|
+
this.parts = []
|
|
39
|
+
this.received = 0
|
|
40
|
+
this.expectedSeq = 0
|
|
41
|
+
this.directory = join(homedir(), '.dsh', 'inbox', sessionId)
|
|
42
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
|
43
|
+
this.staging = join(this.directory, `.${this.name}.part`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
append(seq, base64) {
|
|
47
|
+
if (seq !== this.expectedSeq) {
|
|
48
|
+
throw new Error(`chunk ${seq} arrived out of order (expected ${this.expectedSeq})`)
|
|
49
|
+
}
|
|
50
|
+
const buffer = Buffer.from(base64, 'base64')
|
|
51
|
+
this.parts.push(buffer)
|
|
52
|
+
this.received += buffer.length
|
|
53
|
+
this.expectedSeq += 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
finish() {
|
|
57
|
+
const body = Buffer.concat(this.parts)
|
|
58
|
+
if (this.bytes !== undefined && body.length !== this.bytes) {
|
|
59
|
+
throw new Error(`expected ${this.bytes} bytes but received ${body.length}`)
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(this.staging, body, { mode: 0o600 })
|
|
62
|
+
// Renamed only now: the final name means "complete".
|
|
63
|
+
const final = join(this.directory, this.name)
|
|
64
|
+
rmSync(final, { force: true })
|
|
65
|
+
writeFileSync(final, body, { mode: 0o600 })
|
|
66
|
+
rmSync(this.staging, { force: true })
|
|
67
|
+
return { path: final, bytes: statSync(final).size }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dispose() {
|
|
71
|
+
rmSync(this.staging, { force: true })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Owns the transfers for one connector. */
|
|
76
|
+
export class FileInbox {
|
|
77
|
+
#transfers = new Map()
|
|
78
|
+
#logger
|
|
79
|
+
|
|
80
|
+
constructor(logger) {
|
|
81
|
+
this.#logger = logger
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Answers one reserved call. Throws `Error` for the router to report. */
|
|
85
|
+
handle(method, args = {}) {
|
|
86
|
+
const transferId = String(args.transferId ?? '')
|
|
87
|
+
if (!transferId) throw new Error('transferId is required')
|
|
88
|
+
|
|
89
|
+
if (method === FILE_BEGIN) {
|
|
90
|
+
const sessionId = String(args.sessionId ?? '')
|
|
91
|
+
if (!sessionId) throw new Error('sessionId is required')
|
|
92
|
+
this.#transfers.get(transferId)?.dispose()
|
|
93
|
+
this.#transfers.set(
|
|
94
|
+
transferId,
|
|
95
|
+
new Transfer({ sessionId, name: args.name, bytes: Number(args.bytes) })
|
|
96
|
+
)
|
|
97
|
+
this.#logger.debug?.(`mobile-link: file transfer ${transferId} started for ${sessionId}`)
|
|
98
|
+
return { accepted: true }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const transfer = this.#transfers.get(transferId)
|
|
102
|
+
if (!transfer) throw new Error(`unknown transfer ${transferId}`)
|
|
103
|
+
|
|
104
|
+
if (method === FILE_CHUNK) {
|
|
105
|
+
transfer.append(Number(args.seq ?? 0), String(args.data ?? ''))
|
|
106
|
+
return { received: transfer.received }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// FILE_END
|
|
110
|
+
try {
|
|
111
|
+
const done = transfer.finish()
|
|
112
|
+
this.#logger.info?.(
|
|
113
|
+
`mobile-link: file ${transfer.name} (${done.bytes} bytes) staged at ${done.path}`
|
|
114
|
+
)
|
|
115
|
+
return done
|
|
116
|
+
} finally {
|
|
117
|
+
transfer.dispose()
|
|
118
|
+
this.#transfers.delete(transferId)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drops every in-flight transfer, e.g. when the link goes down. */
|
|
123
|
+
clear() {
|
|
124
|
+
for (const transfer of this.#transfers.values()) transfer.dispose()
|
|
125
|
+
this.#transfers.clear()
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/hello.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this connector is and what it can do.
|
|
3
|
+
*
|
|
4
|
+
* The connector and the app are deployed separately, so the app has to ask
|
|
5
|
+
* rather than assume. It asks over the link — a reserved method the connector
|
|
6
|
+
* answers locally — because the HTTP status route only exists on the direct
|
|
7
|
+
* path, and the direct path is not what users run.
|
|
8
|
+
*
|
|
9
|
+
* Feature detection, not version comparison: a connector that lacks a
|
|
10
|
+
* capability simply does not list it, and the app hides that entry instead of
|
|
11
|
+
* calling something that would come back as a 404 from the Host.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
|
|
16
|
+
/** Reserved method the app calls once per connection. */
|
|
17
|
+
export const HELLO_METHOD = '_link/hello'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The connector's own version.
|
|
21
|
+
*
|
|
22
|
+
* Read from package.json rather than written out twice: the two drifting apart
|
|
23
|
+
* is exactly the kind of thing nobody notices until a support question depends
|
|
24
|
+
* on it (they had drifted: the manifest said 0.1.0 and this said 0.2.0).
|
|
25
|
+
*/
|
|
26
|
+
export const SERVER_VERSION = readManifestVersion()
|
|
27
|
+
|
|
28
|
+
function readManifestVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const require = createRequire(import.meta.url)
|
|
31
|
+
return require('../package.json').version ?? '0.0.0'
|
|
32
|
+
} catch {
|
|
33
|
+
return '0.0.0'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capabilities the app may rely on.
|
|
39
|
+
*
|
|
40
|
+
* - `file-transfer` files can be staged on the computer (see files.js)
|
|
41
|
+
* - `events` the per-device `$events` stream is served
|
|
42
|
+
* - `session-streams` logical streams (`session/follow`, …) are served
|
|
43
|
+
* - `pair-code` this connector can mint a pairing code on request
|
|
44
|
+
* - `qr-pairing` this connector can render the pairing QR itself
|
|
45
|
+
* (`GET /mobile-link/qr`), so the desktop needs no client
|
|
46
|
+
* - `self-enroll` this connector can register itself with an invite code
|
|
47
|
+
*/
|
|
48
|
+
export const SERVER_CAPABILITIES = [
|
|
49
|
+
'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export function isHelloMethod(method) {
|
|
53
|
+
return method === HELLO_METHOD
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The answer to {@link HELLO_METHOD}. */
|
|
57
|
+
export function helloPayload({ protocolVersion, agentId, name } = {}) {
|
|
58
|
+
return {
|
|
59
|
+
serverVersion: SERVER_VERSION,
|
|
60
|
+
capabilities: SERVER_CAPABILITIES,
|
|
61
|
+
protocolVersion,
|
|
62
|
+
agentId: agentId ?? null,
|
|
63
|
+
name: name ?? null,
|
|
64
|
+
}
|
|
65
|
+
}
|