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/state.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent identity persistence: `~/.dsh/mobile-link/agent.json`, mode 0600.
|
|
3
|
+
*
|
|
4
|
+
* The file holds the long-lived `agentId` / `agentSecret` for this computer.
|
|
5
|
+
* There are two ways it gets there, and they are deliberately indistinguishable
|
|
6
|
+
* afterwards: the relay operator provisions it by hand (relay/admin.py
|
|
7
|
+
* `agent-register --write-config`), or the person running the computer redeems
|
|
8
|
+
* an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
|
|
9
|
+
* Environment variables and the plugin config block override the file, which is
|
|
10
|
+
* how the integration test injects a throwaway identity.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { isPlaceholderRelayUrl } from './dlp.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Raised when no identity exists yet — a *setup* state, not a failure.
|
|
21
|
+
*
|
|
22
|
+
* The live agent treats it differently from a transport error: retrying a
|
|
23
|
+
* missing credential forever would be noise, and the fix is a person running
|
|
24
|
+
* one command, not a reconnect.
|
|
25
|
+
*/
|
|
26
|
+
export class MissingIdentity extends Error {
|
|
27
|
+
constructor(message, { stateFile } = {}) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = 'MissingIdentity'
|
|
30
|
+
this.stateFile = stateFile
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The environment variable a packaged install can pre-seed with an invite. */
|
|
35
|
+
export const INVITE_ENV = 'DSH_MOBILE_LINK_INVITE'
|
|
36
|
+
|
|
37
|
+
/** What a computer calls itself before its owner renames it. */
|
|
38
|
+
export const DEFAULT_AGENT_NAME = 'My computer'
|
|
39
|
+
|
|
40
|
+
export function dshHome() {
|
|
41
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defaultStatePath() {
|
|
45
|
+
return join(dshHome(), 'mobile-link', 'agent.json')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultEndpointFile() {
|
|
49
|
+
return join(dshHome(), 'desktop-shell', 'endpoint.json')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The desktop shell's log, which is the *second* place the live endpoint can be
|
|
54
|
+
* read from.
|
|
55
|
+
*
|
|
56
|
+
* The shell writes `endpoint.json` as a handoff, but it also removes it again
|
|
57
|
+
* (observed after the shell quit and re-attached to an already running `dsh
|
|
58
|
+
* web`), and a machine whose host was started as a bare `dsh web --port 0` only
|
|
59
|
+
* ever sees the URL printed into this log. Both spellings appear here, so the
|
|
60
|
+
* log is a usable last resort — without it the connector sits at the default
|
|
61
|
+
* port with no launch token and every phone request fails.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultShellLogFile() {
|
|
64
|
+
return join(dshHome(), 'desktop-shell', 'dsh-shell.log')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {Promise<Record<string, unknown> | undefined>} */
|
|
68
|
+
export async function readState(path) {
|
|
69
|
+
try {
|
|
70
|
+
const raw = JSON.parse(await readFile(path, 'utf8'))
|
|
71
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Write the identity atomically and with mode 0600. */
|
|
78
|
+
export async function writeState(path, state) {
|
|
79
|
+
await mkdir(dirname(path), { recursive: true })
|
|
80
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
81
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
|
|
82
|
+
await rename(temporary, path)
|
|
83
|
+
await chmod(path, 0o600).catch(() => {})
|
|
84
|
+
return path
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the effective identity from (in order) explicit config, the
|
|
89
|
+
* environment, then the state file. Secrets are never logged.
|
|
90
|
+
*
|
|
91
|
+
* One exception to that order: the repository's placeholder relay address (see
|
|
92
|
+
* `PLACEHOLDER_RELAY_URL`) counts as *unset*, so an address already enrolled in
|
|
93
|
+
* the state file outranks it. Without this, the placeholder — which ships in the
|
|
94
|
+
* plugin config and is what an unconfigured environment falls back to — would
|
|
95
|
+
* beat the real address of a computer that is already enrolled, and the next
|
|
96
|
+
* restart would both dial the placeholder and overwrite the file with it.
|
|
97
|
+
*
|
|
98
|
+
* When the identity comes from config or the environment (rather than the file)
|
|
99
|
+
* and differs from what is on disk, it is persisted to `stateFile` with mode
|
|
100
|
+
* 0600 — that is how a fresh install turns a pasted config into the durable
|
|
101
|
+
* `~/.dsh/mobile-link/agent.json` the spec calls for. A read-only home
|
|
102
|
+
* directory is not fatal: the in-memory identity is still returned.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUrl } = {}) {
|
|
105
|
+
const path = stateFile || defaultStatePath()
|
|
106
|
+
const file = await readState(path)
|
|
107
|
+
const configured = relayUrl || process.env.DSH_MOBILE_LINK_RELAY || ''
|
|
108
|
+
const resolved = {
|
|
109
|
+
stateFile: path,
|
|
110
|
+
relayUrl: isPlaceholderRelayUrl(configured) ? (file?.relayUrl || configured) : (configured || file?.relayUrl || ''),
|
|
111
|
+
agentId: agentId || process.env.DSH_MOBILE_LINK_AGENT_ID || file?.agentId || '',
|
|
112
|
+
agentSecret: agentSecret || process.env.DSH_MOBILE_LINK_AGENT_SECRET || file?.agentSecret || '',
|
|
113
|
+
agentName: file?.agentName || '',
|
|
114
|
+
exists: Boolean(file),
|
|
115
|
+
}
|
|
116
|
+
if (!resolved.agentId || !resolved.agentSecret) {
|
|
117
|
+
throw new MissingIdentity(
|
|
118
|
+
`mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
|
|
119
|
+
+ ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
|
|
120
|
+
+ ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
|
|
121
|
+
+ ` "python3 relay/admin.py agent-register --account <acc> --name <name>`
|
|
122
|
+
+ ` --write-config ${path}".)`,
|
|
123
|
+
{ stateFile: path },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!resolved.relayUrl) throw new Error('mobile-link: no relay URL configured')
|
|
127
|
+
|
|
128
|
+
const stale = !file
|
|
129
|
+
|| file.agentId !== resolved.agentId
|
|
130
|
+
|| file.agentSecret !== resolved.agentSecret
|
|
131
|
+
|| file.relayUrl !== resolved.relayUrl
|
|
132
|
+
if (stale) {
|
|
133
|
+
try {
|
|
134
|
+
await writeState(path, {
|
|
135
|
+
relayUrl: resolved.relayUrl,
|
|
136
|
+
agentId: resolved.agentId,
|
|
137
|
+
agentSecret: resolved.agentSecret,
|
|
138
|
+
...(resolved.agentName ? { agentName: resolved.agentName } : {}),
|
|
139
|
+
})
|
|
140
|
+
resolved.exists = true
|
|
141
|
+
resolved.persisted = true
|
|
142
|
+
} catch (error) {
|
|
143
|
+
resolved.persisted = false
|
|
144
|
+
resolved.persistError = error instanceof Error ? error.message : String(error)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return resolved
|
|
148
|
+
}
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny WebSocket adapter.
|
|
3
|
+
*
|
|
4
|
+
* DSH runs on modern Node, where `globalThis.WebSocket` exists and accepts a
|
|
5
|
+
* non-standard `{ headers }` option — that is what carries the DLP bearer token
|
|
6
|
+
* without putting it in the URL. When the global is missing we fall back to the
|
|
7
|
+
* `ws` package resolved from the DSH install. Everything above this file only
|
|
8
|
+
* sees `send` / `close` / `onMessage` / `onClose` / `onError`, so a future
|
|
9
|
+
* transport swap stays local.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
export class SocketError extends Error {}
|
|
18
|
+
|
|
19
|
+
let cachedFactory
|
|
20
|
+
|
|
21
|
+
function candidateAnchors() {
|
|
22
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
23
|
+
const anchors = [import.meta.url]
|
|
24
|
+
anchors.push(join(home, 'profiles', 'web', 'package.json'))
|
|
25
|
+
anchors.push(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'))
|
|
26
|
+
if (process.argv[1]) anchors.push(process.argv[1])
|
|
27
|
+
return anchors
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a WebSocket constructor, or throw with an actionable message. */
|
|
31
|
+
export function resolveWebSocket() {
|
|
32
|
+
if (cachedFactory) return cachedFactory
|
|
33
|
+
if (typeof globalThis.WebSocket === 'function') {
|
|
34
|
+
cachedFactory = globalThis.WebSocket
|
|
35
|
+
return cachedFactory
|
|
36
|
+
}
|
|
37
|
+
for (const anchor of candidateAnchors()) {
|
|
38
|
+
try {
|
|
39
|
+
const require = createRequire(anchor)
|
|
40
|
+
const loaded = require('ws')
|
|
41
|
+
const Ctor = loaded?.WebSocket ?? loaded
|
|
42
|
+
if (typeof Ctor === 'function') {
|
|
43
|
+
cachedFactory = Ctor
|
|
44
|
+
return cachedFactory
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
/* try the next anchor */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new SocketError(
|
|
51
|
+
'no WebSocket implementation available: DSH mobile-link needs Node >= 22 (global WebSocket) or the `ws` package',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isEventTargetStyle(socket) {
|
|
56
|
+
return typeof socket.addEventListener === 'function' && typeof socket.removeEventListener === 'function'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class WebSocketHandle {
|
|
60
|
+
constructor(socket) {
|
|
61
|
+
this.socket = socket
|
|
62
|
+
this.closed = false
|
|
63
|
+
this.closeCode = undefined
|
|
64
|
+
this.closeReason = undefined
|
|
65
|
+
this._messageHandlers = []
|
|
66
|
+
this._closeHandlers = []
|
|
67
|
+
this._errorHandlers = []
|
|
68
|
+
this._opened = new Promise((resolve, reject) => {
|
|
69
|
+
this._resolveOpen = resolve
|
|
70
|
+
this._rejectOpen = reject
|
|
71
|
+
})
|
|
72
|
+
this._opened.catch(() => {})
|
|
73
|
+
this._attach()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_attach() {
|
|
77
|
+
const socket = this.socket
|
|
78
|
+
if (isEventTargetStyle(socket)) {
|
|
79
|
+
socket.addEventListener('open', () => this._resolveOpen())
|
|
80
|
+
socket.addEventListener('message', (event) => {
|
|
81
|
+
const data = event?.data
|
|
82
|
+
if (typeof data === 'string') this._emitMessage(data)
|
|
83
|
+
else if (data instanceof ArrayBuffer) this._emitMessage(Buffer.from(data).toString('utf8'))
|
|
84
|
+
else if (ArrayBuffer.isView(data)) this._emitMessage(Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8'))
|
|
85
|
+
else this._emitMessage(undefined, true)
|
|
86
|
+
})
|
|
87
|
+
socket.addEventListener('close', (event) => this._finish(event?.code, event?.reason))
|
|
88
|
+
socket.addEventListener('error', (event) => this._emitError(event?.error ?? new SocketError('websocket error')))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
socket.on('open', () => this._resolveOpen())
|
|
92
|
+
socket.on('message', (data, isBinary) => {
|
|
93
|
+
if (isBinary) this._emitMessage(undefined, true)
|
|
94
|
+
else if (typeof data === 'string') this._emitMessage(data)
|
|
95
|
+
else this._emitMessage(Buffer.from(data).toString('utf8'))
|
|
96
|
+
})
|
|
97
|
+
socket.on('close', (code, reason) => this._finish(code, reason === undefined ? undefined : String(reason)))
|
|
98
|
+
socket.on('error', (error) => this._emitError(error instanceof Error ? error : new SocketError(String(error))))
|
|
99
|
+
socket.on('unexpected-response', (_request, response) => {
|
|
100
|
+
this._emitError(new SocketError(`relay rejected the upgrade with HTTP ${response?.statusCode}`))
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_emitMessage(text, binary = false) {
|
|
105
|
+
for (const handler of [...this._messageHandlers]) {
|
|
106
|
+
try {
|
|
107
|
+
handler(text, binary)
|
|
108
|
+
} catch {
|
|
109
|
+
/* a handler must never break the socket loop */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_emitError(error) {
|
|
115
|
+
for (const handler of [...this._errorHandlers]) handler(error)
|
|
116
|
+
this._rejectOpen(error)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_finish(code, reason) {
|
|
120
|
+
if (this.closed) return
|
|
121
|
+
this.closed = true
|
|
122
|
+
this.closeCode = code
|
|
123
|
+
this.closeReason = reason
|
|
124
|
+
this._rejectOpen(new SocketError(`websocket closed before opening (${code ?? 'unknown'})`))
|
|
125
|
+
for (const handler of [...this._closeHandlers]) handler(code, reason)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @returns {Promise<void>} resolves once the upgrade completed. */
|
|
129
|
+
opened(timeoutMs = 20000) {
|
|
130
|
+
if (timeoutMs <= 0) return this._opened
|
|
131
|
+
return Promise.race([
|
|
132
|
+
this._opened,
|
|
133
|
+
new Promise((_resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(
|
|
135
|
+
() => reject(new SocketError(`websocket did not open within ${timeoutMs}ms`)),
|
|
136
|
+
timeoutMs,
|
|
137
|
+
)
|
|
138
|
+
timer.unref?.()
|
|
139
|
+
this._opened.then(() => clearTimeout(timer), () => clearTimeout(timer))
|
|
140
|
+
}),
|
|
141
|
+
])
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onMessage(handler) {
|
|
145
|
+
this._messageHandlers.push(handler)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onClose(handler) {
|
|
149
|
+
this._closeHandlers.push(handler)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onError(handler) {
|
|
153
|
+
this._errorHandlers.push(handler)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
send(text) {
|
|
157
|
+
if (this.closed) throw new SocketError('websocket is closed')
|
|
158
|
+
this.socket.send(text)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
close(code = 1000, reason = '') {
|
|
162
|
+
this.closed = true
|
|
163
|
+
try {
|
|
164
|
+
this.socket.close(code, reason)
|
|
165
|
+
} catch {
|
|
166
|
+
/* already gone */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Open a WebSocket and resolve once it is usable. */
|
|
172
|
+
export async function connect(url, { headers = {}, timeoutMs = 20000, impl } = {}) {
|
|
173
|
+
const factory = impl ?? resolveWebSocket()
|
|
174
|
+
let socket
|
|
175
|
+
try {
|
|
176
|
+
socket = new factory(url, { headers })
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new SocketError(`cannot open ${url}: ${error instanceof Error ? error.message : error}`)
|
|
179
|
+
}
|
|
180
|
+
const handle = new WebSocketHandle(socket)
|
|
181
|
+
await handle.opened(timeoutMs)
|
|
182
|
+
return handle
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-mobile-link",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"remote",
|
|
10
|
+
"relay",
|
|
11
|
+
"ios",
|
|
12
|
+
"mobile",
|
|
13
|
+
"websocket"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "jayantTang",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/jayantTang/DSH_Mobile.git",
|
|
20
|
+
"directory": "plugins/mobile-link"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/jayantTang/DSH_Mobile#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/jayantTang/DSH_Mobile/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "lib/index.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"dsh-mobile-link": "lib/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./lib/index.js",
|
|
33
|
+
"./dlp": "./lib/dlp.js",
|
|
34
|
+
"./link": "./lib/link.js",
|
|
35
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --test test/*.test.js"
|
|
48
|
+
},
|
|
49
|
+
"dsh": {
|
|
50
|
+
"bundle": {
|
|
51
|
+
"patch": "./cordis.patch.yml"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|