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/lib/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-mobile-link — host half.
|
|
3
|
+
*
|
|
4
|
+
* Scope (deliberately tiny so a DSH upgrade can never make it fatal):
|
|
5
|
+
* 1. Run the DLP agent: dial the relay over WSS, serve the iOS client's
|
|
6
|
+
* session/event traffic from the local DSH instance. On a computer that has
|
|
7
|
+
* never been registered, redeem the invite code from the environment (or
|
|
8
|
+
* the config) first, so a fresh install becomes usable without the relay
|
|
9
|
+
* operator provisioning anything by hand.
|
|
10
|
+
* 2. Three routes under `/mobile-link` behind DSH's own connection fence:
|
|
11
|
+
* GET /status — is the link up, which devices are attached, and is
|
|
12
|
+
* this computer registered yet
|
|
13
|
+
* POST /pair-code — mint a pairing code + QR payload through the relay
|
|
14
|
+
* GET /qr — the same code rendered as a scannable QR SVG
|
|
15
|
+
*
|
|
16
|
+
* Deliberate design choices:
|
|
17
|
+
* * The plugin declares **no required services** (`inject` is empty). The DLP
|
|
18
|
+
* agent must run even if a future DSH renames or drops `webServer` /
|
|
19
|
+
* `connection`, so the routes are attached through `ctx.inject([...])`
|
|
20
|
+
* instead of a top-level requirement. Only `ctx.logger` / `ctx.effect` /
|
|
21
|
+
* `ctx.get` (all optional-chained) are used.
|
|
22
|
+
* * Nothing outside `lib/` is imported, and no DSH business API is touched:
|
|
23
|
+
* the tunnel is protocol-transparent by construction (spec §1).
|
|
24
|
+
*
|
|
25
|
+
* Disable by removing the row from the profile's cordis.patch.yml or by setting
|
|
26
|
+
* `enabled: false` in the config block.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { MobileLinkAgent } from './link.js'
|
|
30
|
+
import { registerMobileLinkRoutes } from './routes.js'
|
|
31
|
+
|
|
32
|
+
/** Cordis function-plugin name (the loader row id is separate). */
|
|
33
|
+
export const name = 'mobile-link'
|
|
34
|
+
|
|
35
|
+
const DEFAULTS = {
|
|
36
|
+
enabled: true,
|
|
37
|
+
// 仓库里只留占位符:真值走 config.relayUrl,或 DSH_RELAY_URL / DSH_MOBILE_LINK_RELAY
|
|
38
|
+
// 环境变量(本机的 .env.local 里写着,见 README「配置」一节)。
|
|
39
|
+
relayUrl: process.env.DSH_RELAY_URL
|
|
40
|
+
|| process.env.DSH_MOBILE_LINK_RELAY
|
|
41
|
+
|| 'wss://relay.example.com/dsh-link',
|
|
42
|
+
agentId: '',
|
|
43
|
+
agentSecret: '',
|
|
44
|
+
stateFile: '',
|
|
45
|
+
dshUrl: '',
|
|
46
|
+
endpointFile: '',
|
|
47
|
+
inviteCode: '',
|
|
48
|
+
heartbeatMs: 20000,
|
|
49
|
+
pongTimeoutMs: 60000,
|
|
50
|
+
maxBackoffMs: 30000,
|
|
51
|
+
pairTtlMs: 10 * 60 * 1000,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {unknown} value */
|
|
55
|
+
function asRecord(value) {
|
|
56
|
+
return value !== null && typeof value === 'object' ? value : {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
61
|
+
* @param {Record<string, unknown>} rawConfig
|
|
62
|
+
*/
|
|
63
|
+
export function apply(ctx, rawConfig) {
|
|
64
|
+
const config = { ...DEFAULTS, ...asRecord(rawConfig) }
|
|
65
|
+
const logger = ctx.logger ?? console
|
|
66
|
+
|
|
67
|
+
const agent = new MobileLinkAgent({
|
|
68
|
+
enabled: config.enabled !== false,
|
|
69
|
+
relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
|
|
70
|
+
agentId: typeof config.agentId === 'string' ? config.agentId : '',
|
|
71
|
+
agentSecret: typeof config.agentSecret === 'string' ? config.agentSecret : '',
|
|
72
|
+
stateFile: typeof config.stateFile === 'string' && config.stateFile ? config.stateFile : undefined,
|
|
73
|
+
// An invite code may be pinned in the plugin config for a packaged install;
|
|
74
|
+
// otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
|
|
75
|
+
inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
|
|
76
|
+
dshUrl: typeof config.dshUrl === 'string' && config.dshUrl ? config.dshUrl : undefined,
|
|
77
|
+
endpointFile: typeof config.endpointFile === 'string' && config.endpointFile ? config.endpointFile : undefined,
|
|
78
|
+
heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
|
|
79
|
+
pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
|
|
80
|
+
maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
|
|
81
|
+
logger,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
ctx.effect(() => {
|
|
85
|
+
let disposed = false
|
|
86
|
+
agent.emit('status')
|
|
87
|
+
agent.start().catch((error) => {
|
|
88
|
+
if (!disposed) logger.warn?.(`mobile-link: start failed: ${error}`)
|
|
89
|
+
})
|
|
90
|
+
return () => {
|
|
91
|
+
disposed = true
|
|
92
|
+
void agent.stop().catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
}, 'mobile-link: DLP agent')
|
|
95
|
+
|
|
96
|
+
// Routes ride on DSH's web server when that service exists. `ctx.inject`
|
|
97
|
+
// scopes them without making the service a hard requirement for the agent.
|
|
98
|
+
try {
|
|
99
|
+
ctx.inject(['webServer'], (scope) => {
|
|
100
|
+
const disposers = registerMobileLinkRoutes(scope, { agent, logger, config })
|
|
101
|
+
scope.effect?.(() => () => {
|
|
102
|
+
for (const dispose of disposers) {
|
|
103
|
+
try {
|
|
104
|
+
dispose()
|
|
105
|
+
} catch {
|
|
106
|
+
/* route table already torn down */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, 'mobile-link: routes')
|
|
110
|
+
logger.info?.('mobile-link: routes ready under /mobile-link')
|
|
111
|
+
})
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.warn?.(`mobile-link: routes unavailable (the link itself keeps running): ${error}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { MobileLinkAgent } from './link.js'
|
|
120
|
+
export { DshClient, discoverEndpoint, DshUnavailable } from './dsh-client.js'
|
|
121
|
+
export {
|
|
122
|
+
MAX_FRAME_BYTES, StreamTable, WaterfallDedupe, agentEndpoint, decodeFrame, encodeFrame,
|
|
123
|
+
joinRelayPath, nextBackoff, normalizeRelayUrl, pairCodeEndpoint, qrPayload,
|
|
124
|
+
} from './dlp.js'
|
|
125
|
+
export { resolveIdentity, defaultStatePath, defaultEndpointFile, MissingIdentity } from './state.js'
|
|
126
|
+
export { enrollAgent, enrollPlan, enrollCommand, describeEnrollFailure } from './enroll.js'
|
|
127
|
+
export { INVITE_ENV, DEFAULT_AGENT_NAME } from './state.js'
|
|
128
|
+
export { qrMatrix, qrSvg, versionFor } from './qr.js'
|
package/lib/link.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DLP agent transport: identity, the relay socket, heartbeats, reconnect.
|
|
3
|
+
* Device-level routing lives in `router.js`; this file owns every socket and
|
|
4
|
+
* timer. Spec: docs/RELAY-PROTOCOL.md §4.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
|
|
9
|
+
import { DshClient } from './dsh-client.js'
|
|
10
|
+
import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
|
|
11
|
+
import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
|
|
12
|
+
import { mintPairCode } from './pairing.js'
|
|
13
|
+
import { EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf } from './router.js'
|
|
14
|
+
import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
|
|
15
|
+
import { MissingIdentity, resolveIdentity } from './state.js'
|
|
16
|
+
import { connect as wsConnect } from './ws.js'
|
|
17
|
+
|
|
18
|
+
const PROTOCOL_VERSION = 1
|
|
19
|
+
|
|
20
|
+
function sleep(ms, signal) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const timer = setTimeout(resolve, ms)
|
|
23
|
+
timer.unref?.()
|
|
24
|
+
signal?.addEventListener?.('abort', () => {
|
|
25
|
+
clearTimeout(timer)
|
|
26
|
+
resolve()
|
|
27
|
+
}, { once: true })
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class MobileLinkAgent extends EventEmitter {
|
|
32
|
+
constructor({
|
|
33
|
+
relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
|
|
34
|
+
logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
|
|
35
|
+
random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
|
|
36
|
+
inviteCode, fetchImpl = fetch, env = process.env,
|
|
37
|
+
} = {}) {
|
|
38
|
+
super()
|
|
39
|
+
this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
|
|
40
|
+
this.logger = logger
|
|
41
|
+
this.heartbeatMs = heartbeatMs
|
|
42
|
+
this.pongTimeoutMs = pongTimeoutMs
|
|
43
|
+
this.maxBackoffMs = maxBackoffMs
|
|
44
|
+
this.random = random
|
|
45
|
+
this.now = now
|
|
46
|
+
this.connectImpl = connectImpl
|
|
47
|
+
this.fetchImpl = fetchImpl
|
|
48
|
+
this.env = env
|
|
49
|
+
this.enabled = enabled
|
|
50
|
+
//: Set when this computer has no identity yet: a setup problem for a person
|
|
51
|
+
//: to fix, not a transport problem to retry.
|
|
52
|
+
this.needsEnroll = false
|
|
53
|
+
this.dsh = dshClient ?? new DshClient({ endpointFile, explicitUrl: dshUrl, logger })
|
|
54
|
+
this.router = new DeviceRouter({
|
|
55
|
+
dsh: this.dsh,
|
|
56
|
+
logger,
|
|
57
|
+
now,
|
|
58
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
59
|
+
send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
|
|
60
|
+
onChange: () => this.emit('status'),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
this.identity = undefined
|
|
64
|
+
this.relay = undefined
|
|
65
|
+
this.socket = undefined
|
|
66
|
+
this.state = 'idle'
|
|
67
|
+
this.lastError = undefined
|
|
68
|
+
this.dshError = undefined
|
|
69
|
+
this.startedAt = undefined
|
|
70
|
+
this.connectedAt = undefined
|
|
71
|
+
this.reconnectAttempts = 0
|
|
72
|
+
|
|
73
|
+
this._stopping = false
|
|
74
|
+
this._sessionClosed = undefined
|
|
75
|
+
this._heartbeat = undefined
|
|
76
|
+
this._inbox = Promise.resolve()
|
|
77
|
+
this._lastPong = 0
|
|
78
|
+
this._loop = undefined
|
|
79
|
+
this._abort = new AbortController()
|
|
80
|
+
|
|
81
|
+
// The mux is shared by every device, so its events are wired once here.
|
|
82
|
+
this.dsh.on?.('stream-item', (muxId, value) => this.onStreamItem(muxId, value))
|
|
83
|
+
this.dsh.on?.('stream-error', (muxId, error) => this.onStreamError(muxId, error))
|
|
84
|
+
this.dsh.on?.('stream-end', (muxId) => this.onStreamEnd(muxId))
|
|
85
|
+
this.dsh.on?.('mux-down', (reason) => this.onMuxDown(reason))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Routing state, exposed for status and for tests.
|
|
89
|
+
get devices() {
|
|
90
|
+
return this.router.devices
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get streams() {
|
|
94
|
+
return this.router.streams
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get dedupe() {
|
|
98
|
+
return this.router.dedupe
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The most recent failure from either half of the link. */
|
|
102
|
+
get failure() {
|
|
103
|
+
return this.lastError ?? this.router.lastError
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── lifecycle ───────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async start() {
|
|
109
|
+
if (this.enabled === false) {
|
|
110
|
+
this.state = 'disabled'
|
|
111
|
+
this.emit('status')
|
|
112
|
+
return this
|
|
113
|
+
}
|
|
114
|
+
if (this._loop) return this
|
|
115
|
+
this._stopping = false
|
|
116
|
+
this._abort = new AbortController()
|
|
117
|
+
this.startedAt = this.now()
|
|
118
|
+
this.state = 'connecting'
|
|
119
|
+
this._loop = this.#run().catch((error) => {
|
|
120
|
+
this.lastError = messageOf(error)
|
|
121
|
+
this.state = 'failed'
|
|
122
|
+
this.emit('status')
|
|
123
|
+
})
|
|
124
|
+
return this
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async stop() {
|
|
128
|
+
this._stopping = true
|
|
129
|
+
this._abort.abort()
|
|
130
|
+
this.#teardownSocket('agent stopped')
|
|
131
|
+
try {
|
|
132
|
+
await this._loop
|
|
133
|
+
} catch {
|
|
134
|
+
/* already reported */
|
|
135
|
+
}
|
|
136
|
+
this._loop = undefined
|
|
137
|
+
this.dsh.close()
|
|
138
|
+
this.state = 'stopped'
|
|
139
|
+
this.emit('status')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #run() {
|
|
143
|
+
while (!this._stopping) {
|
|
144
|
+
// First run on a computer that has never been registered: redeem the
|
|
145
|
+
// invite code once, then carry on into the normal connect below. A
|
|
146
|
+
// failure here is a setup error, so it is reported as such and not
|
|
147
|
+
// retried in a loop.
|
|
148
|
+
if (this.needsEnroll || this.#enrollPending()) {
|
|
149
|
+
try {
|
|
150
|
+
await this.enrollNow()
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.needsEnroll = true
|
|
153
|
+
this.state = 'unregistered'
|
|
154
|
+
this.lastError = messageOf(error)
|
|
155
|
+
this.logger.error?.(this.lastError)
|
|
156
|
+
this.emit('status')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.state = this.reconnectAttempts === 0 ? 'connecting' : 'reconnecting'
|
|
161
|
+
this.emit('status')
|
|
162
|
+
try {
|
|
163
|
+
await this.#session()
|
|
164
|
+
this.reconnectAttempts = 0
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// A missing identity is a setup state, not a transport failure:
|
|
167
|
+
// retrying it would only produce the same error every few seconds.
|
|
168
|
+
if (error instanceof MissingIdentity) {
|
|
169
|
+
this.needsEnroll = true
|
|
170
|
+
this.state = 'unregistered'
|
|
171
|
+
this.lastError = messageOf(error)
|
|
172
|
+
this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
|
|
173
|
+
this.emit('status')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
this.lastError = messageOf(error)
|
|
177
|
+
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
178
|
+
}
|
|
179
|
+
if (this._stopping) break
|
|
180
|
+
this.state = 'backoff'
|
|
181
|
+
this.emit('status')
|
|
182
|
+
const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
|
|
183
|
+
this.reconnectAttempts += 1
|
|
184
|
+
await sleep(delay, this._abort.signal)
|
|
185
|
+
}
|
|
186
|
+
this.state = 'stopped'
|
|
187
|
+
this.emit('status')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** An invite code was supplied and there is no identity file yet. */
|
|
191
|
+
#enrollPending() {
|
|
192
|
+
if (!inviteFrom({ inviteCode: this.config.inviteCode, env: this.env })) return false
|
|
193
|
+
return !this.identity
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Redeem this computer's invite code.
|
|
198
|
+
*
|
|
199
|
+
* Exposed because the install path calls it directly (before the link ever
|
|
200
|
+
* starts) and because `status()` reports whether it is still outstanding.
|
|
201
|
+
*/
|
|
202
|
+
async enrollNow() {
|
|
203
|
+
const relayUrl = this.config.relayUrl
|
|
204
|
+
const settled = await enrollAgent({
|
|
205
|
+
relayUrl,
|
|
206
|
+
inviteCode: inviteFrom({ inviteCode: this.config.inviteCode, env: this.env }),
|
|
207
|
+
name: this.config.agentName,
|
|
208
|
+
stateFile: this.config.stateFile,
|
|
209
|
+
fetchImpl: this.fetchImpl,
|
|
210
|
+
logger: this.logger,
|
|
211
|
+
})
|
|
212
|
+
this.needsEnroll = false
|
|
213
|
+
this.lastError = undefined
|
|
214
|
+
this.emit('status')
|
|
215
|
+
return settled
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** What a person has to run to register this computer. */
|
|
219
|
+
enrollHint() {
|
|
220
|
+
return enrollCommand(this.config.relayUrl, this.config.agentName)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One relay connection: resolves when the socket closes. */
|
|
224
|
+
async #session() {
|
|
225
|
+
const identity = await resolveIdentity(this.config)
|
|
226
|
+
this.identity = identity
|
|
227
|
+
this.needsEnroll = false
|
|
228
|
+
this.relay = normalizeRelayUrl(identity.relayUrl)
|
|
229
|
+
const url = `${agentEndpoint(identity.relayUrl)}?agentId=${encodeURIComponent(identity.agentId)}`
|
|
230
|
+
const socket = await this.connectImpl(url, {
|
|
231
|
+
headers: { authorization: `Bearer ${identity.agentSecret}` },
|
|
232
|
+
timeoutMs: 20000,
|
|
233
|
+
})
|
|
234
|
+
this.socket = socket
|
|
235
|
+
this.state = 'connected'
|
|
236
|
+
this.connectedAt = this.now()
|
|
237
|
+
this.lastError = undefined
|
|
238
|
+
this.router.lastError = undefined
|
|
239
|
+
this._lastPong = this.now()
|
|
240
|
+
this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
|
|
241
|
+
this.emit('status')
|
|
242
|
+
|
|
243
|
+
socket.onMessage((text) => {
|
|
244
|
+
this._inbox = this._inbox
|
|
245
|
+
.then(() => this.handleRelayFrame(decodeFrame(text)))
|
|
246
|
+
.catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
|
|
247
|
+
return this._inbox
|
|
248
|
+
})
|
|
249
|
+
socket.onError((error) => this.logger.debug?.(`mobile-link: relay socket error: ${messageOf(error)}`))
|
|
250
|
+
socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
|
|
251
|
+
|
|
252
|
+
this.#startHeartbeat()
|
|
253
|
+
// Verify discovery + auth as soon as the relay link is up, so /mobile-link/
|
|
254
|
+
// status can report the real DSH port (and the failure, if any) before the
|
|
255
|
+
// first device ever asks for anything.
|
|
256
|
+
try {
|
|
257
|
+
await this.dsh.refresh()
|
|
258
|
+
this.dshError = undefined
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.dshError = messageOf(error)
|
|
261
|
+
this.lastError = this.dshError
|
|
262
|
+
this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
|
|
263
|
+
}
|
|
264
|
+
this.emit('status')
|
|
265
|
+
this.sendHostStatus()
|
|
266
|
+
|
|
267
|
+
await new Promise((resolve) => {
|
|
268
|
+
this._sessionClosed = resolve
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#startHeartbeat() {
|
|
273
|
+
this.#stopHeartbeat()
|
|
274
|
+
this._heartbeat = setInterval(() => {
|
|
275
|
+
if (this.now() - this._lastPong > this.pongTimeoutMs) {
|
|
276
|
+
this.lastError = `relay stopped answering pings (>${this.pongTimeoutMs}ms)`
|
|
277
|
+
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
278
|
+
this.#teardownSocket(this.lastError)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
this.#rawSend({ t: 'ping', ts: this.now() })
|
|
282
|
+
}, this.heartbeatMs)
|
|
283
|
+
this._heartbeat.unref?.()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#stopHeartbeat() {
|
|
287
|
+
if (this._heartbeat) clearInterval(this._heartbeat)
|
|
288
|
+
this._heartbeat = undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#teardownSocket(reason) {
|
|
292
|
+
this.#stopHeartbeat()
|
|
293
|
+
const socket = this.socket
|
|
294
|
+
this.socket = undefined
|
|
295
|
+
if (socket) {
|
|
296
|
+
try {
|
|
297
|
+
socket.close(1000, 'reconnecting')
|
|
298
|
+
} catch {
|
|
299
|
+
/* already gone */
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// The relay re-attaches devices on the next connection; drop their streams
|
|
303
|
+
// now so nothing leaks while we are away.
|
|
304
|
+
for (const muxId of this.router.teardownAll()) this.dsh.cancelStream(muxId)
|
|
305
|
+
this.dsh.close()
|
|
306
|
+
if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
|
|
307
|
+
this.lastError = this.lastError ?? reason
|
|
308
|
+
const resolve = this._sessionClosed
|
|
309
|
+
this._sessionClosed = undefined
|
|
310
|
+
this.emit('status')
|
|
311
|
+
resolve?.()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── relay I/O ───────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
#rawSend(frame) {
|
|
317
|
+
const socket = this.socket
|
|
318
|
+
if (!socket || socket.closed) return false
|
|
319
|
+
try {
|
|
320
|
+
socket.send(encodeFrame(frame))
|
|
321
|
+
return true
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
sendToDevice(deviceId, frame) {
|
|
329
|
+
return this.#rawSend({ ...frame, deviceId })
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
sendHostStatus() {
|
|
333
|
+
return this.#rawSend({
|
|
334
|
+
t: 'hostStatus',
|
|
335
|
+
info: {
|
|
336
|
+
online: true,
|
|
337
|
+
agentId: this.identity?.agentId,
|
|
338
|
+
name: this.identity?.agentName || undefined,
|
|
339
|
+
dshPort: this.dsh?.endpoint?.port,
|
|
340
|
+
protocol: PROTOCOL_VERSION,
|
|
341
|
+
},
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Route one frame from the relay. */
|
|
346
|
+
async handleRelayFrame(frame) {
|
|
347
|
+
if (!frame || typeof frame !== 'object') return
|
|
348
|
+
if (frame.t === 'pong') {
|
|
349
|
+
this._lastPong = this.now()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
if (frame.t === 'ping') {
|
|
353
|
+
this.#rawSend(pongFor(frame))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (frame.t === 'error') {
|
|
357
|
+
this.lastError = `${frame.code}: ${frame.message}`
|
|
358
|
+
this.emit('status')
|
|
359
|
+
if (frame.fatal) this.socket?.close(4002, 'relay protocol error')
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
await this.router.handleRelayFrame(frame)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── mux-event delegates ─────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
onStreamItem(muxId, value) {
|
|
368
|
+
this.router.onStreamItem(muxId, value)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
onStreamError(muxId, error) {
|
|
372
|
+
this.router.onStreamError(muxId, error)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
onStreamEnd(muxId) {
|
|
376
|
+
this.router.onStreamEnd(muxId)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
onMuxDown(reason) {
|
|
380
|
+
this.router.onMuxDown(reason)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── relay-side helpers ──────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/** Ask the relay to mint a pairing code for this agent (relay NOTES.md §2). */
|
|
386
|
+
async mintPairCode({ ttlMs = 10 * 60 * 1000, fetchImpl = fetch } = {}) {
|
|
387
|
+
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
388
|
+
return mintPairCode({ identity, ttlMs, fetchImpl })
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Snapshot for `GET /mobile-link/status`. */
|
|
392
|
+
status() {
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
enabled: this.enabled !== false,
|
|
396
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
397
|
+
// Same facts as `_link/hello`, which is the channel the app actually
|
|
398
|
+
// uses: this route only exists on the direct path.
|
|
399
|
+
serverVersion: SERVER_VERSION,
|
|
400
|
+
capabilities: SERVER_CAPABILITIES,
|
|
401
|
+
enroll: {
|
|
402
|
+
// A phone (or the user's browser) can read this to find out that the
|
|
403
|
+
// computer half is installed but not yet registered, and what to run.
|
|
404
|
+
registered: !this.needsEnroll,
|
|
405
|
+
needsEnroll: this.needsEnroll,
|
|
406
|
+
stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
|
|
407
|
+
relayUrl: this.config.relayUrl ?? null,
|
|
408
|
+
command: this.needsEnroll ? this.enrollHint() : null,
|
|
409
|
+
hint: this.needsEnroll
|
|
410
|
+
? '这台电脑还没有登记到中转。请带上邀请码运行一次登记命令,然后重启 DSH。'
|
|
411
|
+
: null,
|
|
412
|
+
},
|
|
413
|
+
state: this.state,
|
|
414
|
+
connected: this.state === 'connected',
|
|
415
|
+
relayUrl: this.identity?.relayUrl ?? this.config.relayUrl ?? null,
|
|
416
|
+
agentId: this.identity?.agentId ?? this.config.agentId ?? null,
|
|
417
|
+
agentName: this.identity?.agentName ?? null,
|
|
418
|
+
stateFile: this.identity?.stateFile ?? this.config.stateFile ?? null,
|
|
419
|
+
dsh: {
|
|
420
|
+
endpoint: this.dsh?.endpoint?.base ?? null,
|
|
421
|
+
source: this.dsh?.endpoint?.source ?? null,
|
|
422
|
+
port: this.dsh?.endpoint?.port ?? null,
|
|
423
|
+
authenticated: Boolean(this.dsh?.cookie),
|
|
424
|
+
muxUp: Boolean(this.dsh?.muxReady),
|
|
425
|
+
error: this.dshError ?? null,
|
|
426
|
+
},
|
|
427
|
+
devices: this.router.snapshot(),
|
|
428
|
+
deviceCount: this.devices.size,
|
|
429
|
+
openStreams: this.streams.size,
|
|
430
|
+
pendingWaterfalls: this.dedupe.size,
|
|
431
|
+
lastError: this.failure ?? null,
|
|
432
|
+
startedAt: this.startedAt ?? null,
|
|
433
|
+
connectedAt: this.connectedAt ?? null,
|
|
434
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export { EVENTS_ENDPOINT, EVENTS_RESULT }
|
package/lib/pairing.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing helper: ask the relay to mint a one-time code and build the QR
|
|
3
|
+
* payload the desktop shows. Spec §2.1 plus relay/NOTES.md §2 (the endpoint the
|
|
4
|
+
* spec's table is missing).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { pairCodeEndpoint, qrPayload } from './dlp.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {{agentId: string, agentSecret: string, relayUrl: string}} options.identity
|
|
12
|
+
* @param {number} [options.ttlMs] requested lifetime (the relay caps it at 1 hour)
|
|
13
|
+
* @param {typeof fetch} [options.fetchImpl] injectable for tests
|
|
14
|
+
*/
|
|
15
|
+
export async function mintPairCode({ identity, ttlMs = 10 * 60 * 1000, fetchImpl = fetch }) {
|
|
16
|
+
const response = await fetchImpl(pairCodeEndpoint(identity.relayUrl), {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {
|
|
19
|
+
'content-type': 'application/json',
|
|
20
|
+
authorization: `Bearer ${identity.agentSecret}`,
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ agentId: identity.agentId, ttlMs }),
|
|
23
|
+
})
|
|
24
|
+
let body
|
|
25
|
+
try {
|
|
26
|
+
body = await response.json()
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`relay returned HTTP ${response.status} for /pair/code`)
|
|
29
|
+
}
|
|
30
|
+
if (!response.ok || body?.ok !== true) {
|
|
31
|
+
throw new Error(body?.error?.message ?? `relay rejected the pairing request (HTTP ${response.status})`)
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
code: body.code,
|
|
35
|
+
expiresAt: body.expiresAt,
|
|
36
|
+
ttlMs: body.ttlMs ?? ttlMs,
|
|
37
|
+
relayUrl: identity.relayUrl,
|
|
38
|
+
agentId: identity.agentId,
|
|
39
|
+
qrPayload: qrPayload({ relay: identity.relayUrl, code: body.code }),
|
|
40
|
+
}
|
|
41
|
+
}
|