@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.1
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/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- package/src/web-route.js +116 -0
package/src/iroh.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { createInterface } from 'node:readline'
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { pipeline } from 'node:stream/promises'
|
|
9
|
+
import { createWriteStream } from 'node:fs'
|
|
10
|
+
import { Readable } from 'node:stream'
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const require = createRequire(import.meta.url)
|
|
14
|
+
const irohRoot = join(here, '..', 'iroh')
|
|
15
|
+
const hostBinName = process.platform === 'win32' ? 'pocket-host.exe' : 'pocket-host'
|
|
16
|
+
|
|
17
|
+
/** Platform → GitHub release asset name (rolling tag dsh-android-latest). */
|
|
18
|
+
export const PREBUILT_ASSET = {
|
|
19
|
+
'darwin-arm64': 'pocket-host-darwin-arm64',
|
|
20
|
+
'darwin-x64': 'pocket-host-darwin-x64',
|
|
21
|
+
'linux-x64': 'pocket-host-linux-x64',
|
|
22
|
+
'linux-arm64': 'pocket-host-linux-arm64',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PREBUILT_BASE =
|
|
26
|
+
process.env.DSH_POCKET_HOST_URL_BASE
|
|
27
|
+
?? 'https://github.com/minglu6/dsh-android/releases/download/dsh-android-latest'
|
|
28
|
+
|
|
29
|
+
function platKey() {
|
|
30
|
+
return `${process.platform}-${process.arch}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheBinPath() {
|
|
34
|
+
const dir = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'pocket', 'bin')
|
|
35
|
+
mkdirSync(dir, { recursive: true })
|
|
36
|
+
return join(dir, hostBinName)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the pocket-host binary for this platform (no download / build).
|
|
41
|
+
* Search order: config → env → cargo target → cache → optional npm package.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveHostBinary(explicit) {
|
|
44
|
+
if (explicit) return explicit
|
|
45
|
+
if (process.env.DSH_POCKET_HOST) return process.env.DSH_POCKET_HOST
|
|
46
|
+
const release = join(irohRoot, 'target', 'release', hostBinName)
|
|
47
|
+
if (existsSync(release)) return release
|
|
48
|
+
const debug = join(irohRoot, 'target', 'debug', hostBinName)
|
|
49
|
+
if (existsSync(debug)) return debug
|
|
50
|
+
const cached = cacheBinPath()
|
|
51
|
+
if (existsSync(cached)) return cached
|
|
52
|
+
const prebuilt = join(irohRoot, 'prebuilt', platKey(), hostBinName)
|
|
53
|
+
if (existsSync(prebuilt)) return prebuilt
|
|
54
|
+
try {
|
|
55
|
+
const pkg = `dsh-pocket-host-${platKey()}`
|
|
56
|
+
const entry = require.resolve(`${pkg}/package.json`)
|
|
57
|
+
const bin = join(dirname(entry), hostBinName)
|
|
58
|
+
if (existsSync(bin)) return bin
|
|
59
|
+
} catch { /* optional dep missing */ }
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cargoAvailable() {
|
|
64
|
+
const r = spawnSync('cargo', ['--version'], { encoding: 'utf8' })
|
|
65
|
+
return r.status === 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build pocket-host via cargo in plugin/iroh (synchronous). */
|
|
69
|
+
export function buildHostWithCargo({ log = console.log } = {}) {
|
|
70
|
+
log('[pocket] building iroh sidecar with cargo (one-time; needs Rust toolchain)…')
|
|
71
|
+
const r = spawnSync(
|
|
72
|
+
'cargo',
|
|
73
|
+
['build', '--release', '-p', 'pocket-host', '--manifest-path', join(irohRoot, 'Cargo.toml')],
|
|
74
|
+
{ stdio: 'inherit', env: process.env },
|
|
75
|
+
)
|
|
76
|
+
if (r.status !== 0) {
|
|
77
|
+
throw new Error(`cargo build pocket-host failed (exit ${r.status})`)
|
|
78
|
+
}
|
|
79
|
+
const bin = join(irohRoot, 'target', 'release', hostBinName)
|
|
80
|
+
if (!existsSync(bin)) throw new Error(`cargo build succeeded but ${bin} missing`)
|
|
81
|
+
return bin
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Download a CI-published pocket-host binary into ~/.dsh/pocket/bin. */
|
|
85
|
+
export async function downloadPrebuiltHost({ log = console.log } = {}) {
|
|
86
|
+
const asset = PREBUILT_ASSET[platKey()]
|
|
87
|
+
if (!asset) {
|
|
88
|
+
throw new Error(`no prebuilt pocket-host for ${platKey()}`)
|
|
89
|
+
}
|
|
90
|
+
const url = `${PREBUILT_BASE.replace(/\/$/, '')}/${asset}`
|
|
91
|
+
log(`[pocket] downloading iroh sidecar from ${url}`)
|
|
92
|
+
const res = await fetch(url, { redirect: 'follow' })
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(`download failed: HTTP ${res.status} for ${url}`)
|
|
95
|
+
}
|
|
96
|
+
const dest = cacheBinPath()
|
|
97
|
+
const tmp = `${dest}.tmp-${process.pid}`
|
|
98
|
+
try {
|
|
99
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp))
|
|
100
|
+
chmodSync(tmp, 0o755)
|
|
101
|
+
renameSync(tmp, dest)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
try { unlinkSync(tmp) } catch { /* ignore */ }
|
|
104
|
+
throw e
|
|
105
|
+
}
|
|
106
|
+
// Write a tiny stamp so we can see which asset we got.
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(`${dest}.url`, `${url}\n`, { mode: 0o644 })
|
|
109
|
+
} catch { /* ignore */ }
|
|
110
|
+
return dest
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ensure a pocket-host binary exists: resolve → download prebuilt → cargo build.
|
|
115
|
+
* Returns the path, or undefined when iroh cannot be enabled (caller keeps WS-only).
|
|
116
|
+
*/
|
|
117
|
+
export async function ensureHostBinary(explicit, { log = (...a) => console.log('[pocket]', ...a), allowDownload = true, allowCargo = true } = {}) {
|
|
118
|
+
// Explicit path: never fall back to download/cargo (tests / config.hostBinary).
|
|
119
|
+
if (explicit) return existsSync(explicit) ? explicit : undefined
|
|
120
|
+
|
|
121
|
+
const existing = resolveHostBinary()
|
|
122
|
+
if (existing && existsSync(existing)) return existing
|
|
123
|
+
|
|
124
|
+
if (allowDownload && PREBUILT_ASSET[platKey()]) {
|
|
125
|
+
try {
|
|
126
|
+
return await downloadPrebuiltHost({ log: (m) => log(m.replace(/^\[pocket\]\s*/, '')) })
|
|
127
|
+
} catch (e) {
|
|
128
|
+
log(`prebuilt sidecar download skipped: ${e.message ?? e}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (allowCargo && cargoAvailable()) {
|
|
133
|
+
try {
|
|
134
|
+
return buildHostWithCargo({ log: (m) => log(m.replace(/^\[pocket\]\s*/, '')) })
|
|
135
|
+
} catch (e) {
|
|
136
|
+
log(`cargo sidecar build failed: ${e.message ?? e}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function missingSidecarHelp() {
|
|
144
|
+
const plat = platKey()
|
|
145
|
+
const asset = PREBUILT_ASSET[plat]
|
|
146
|
+
return [
|
|
147
|
+
`iroh sidecar not found for ${plat} — remote phone pairing is unavailable.`,
|
|
148
|
+
'Phones connect only over iroh (direct hole-punch, else relay); no Tailscale/VPN.',
|
|
149
|
+
'One-line install (from repo): cd plugin && npm run build:iroh',
|
|
150
|
+
asset
|
|
151
|
+
? `Or wait for / download the CI prebuilt: ${PREBUILT_BASE}/${asset}`
|
|
152
|
+
: 'Or install Rust (https://rustup.rs) and re-run; the plugin auto-builds on next start.',
|
|
153
|
+
'Override path with env DSH_POCKET_HOST=/path/to/pocket-host',
|
|
154
|
+
].join('\n[pocket] ')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Spawn pocket-host and bridge peer sessions into the pocket server as pipe clients.
|
|
159
|
+
* @param {{ server: any, dataDir: string, hostBinary?: string, localOnly?: boolean, log?: (...a:any)=>void }} opts
|
|
160
|
+
* @returns {Promise<{ endpoint: Promise<{ id: string, directAddress: string | null }>, close: () => Promise<void> } | null>}
|
|
161
|
+
*/
|
|
162
|
+
export async function startIrohSidecar(opts) {
|
|
163
|
+
const log = opts.log ?? ((...a) => console.log('[pocket]', ...a))
|
|
164
|
+
const binary = await ensureHostBinary(opts.hostBinary, { log })
|
|
165
|
+
if (!binary || !existsSync(binary)) {
|
|
166
|
+
log(missingSidecarHelp())
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
log(`iroh sidecar: ${binary}`)
|
|
170
|
+
const args = ['host', '--data-dir', opts.dataDir]
|
|
171
|
+
if (opts.localOnly) args.push('--local-only')
|
|
172
|
+
const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true })
|
|
173
|
+
let gone
|
|
174
|
+
child.on('exit', (code) => {
|
|
175
|
+
gone = `sidecar exited (code=${code})`
|
|
176
|
+
console.error(`[pocket] iroh ${gone}`)
|
|
177
|
+
rejectReady(new Error(gone))
|
|
178
|
+
})
|
|
179
|
+
child.on('error', (error) => {
|
|
180
|
+
gone = `sidecar failed to start: ${error.message}`
|
|
181
|
+
console.error(`[pocket] iroh ${gone} (${binary})`)
|
|
182
|
+
rejectReady(new Error(gone))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const send = (msg) => {
|
|
186
|
+
if (gone || !child.stdin.writable) return
|
|
187
|
+
child.stdin.write(`${JSON.stringify(msg)}\n`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let resolveReady, rejectReady
|
|
191
|
+
const endpoint = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject })
|
|
192
|
+
/** @type {Map<string, { push(line: string): void, close(): void }>} */
|
|
193
|
+
const sessions = new Map()
|
|
194
|
+
|
|
195
|
+
const rl = createInterface({ input: child.stdout })
|
|
196
|
+
rl.on('line', (line) => {
|
|
197
|
+
let msg
|
|
198
|
+
try { msg = JSON.parse(line) } catch {
|
|
199
|
+
console.error(`[pocket] bad sidecar line: ${line}`)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
switch (msg.type) {
|
|
203
|
+
case 'ready':
|
|
204
|
+
resolveReady({ id: msg.endpoint_id, directAddress: msg.direct_addr ?? null })
|
|
205
|
+
log(`iroh ready, node id: ${msg.endpoint_id}`)
|
|
206
|
+
break
|
|
207
|
+
case 'peer-session': {
|
|
208
|
+
const id = msg.id
|
|
209
|
+
const peer = msg.peer
|
|
210
|
+
const client = opts.server.attachPipe({
|
|
211
|
+
send: (frame) => send({ type: 'session-send', id, line: JSON.stringify(frame) }),
|
|
212
|
+
close: () => send({ type: 'session-close', id }),
|
|
213
|
+
meta: { transport: 'iroh', peer },
|
|
214
|
+
})
|
|
215
|
+
sessions.set(id, client)
|
|
216
|
+
log(`iroh peer session ${id} from ${String(peer).slice(0, 16)}…`)
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
case 'peer-data': {
|
|
220
|
+
const client = sessions.get(msg.id)
|
|
221
|
+
if (!client) return
|
|
222
|
+
try {
|
|
223
|
+
const frame = JSON.parse(msg.line)
|
|
224
|
+
client.onMessage(frame, msg.line.length)
|
|
225
|
+
} catch {
|
|
226
|
+
client.close(1007, 'bad json')
|
|
227
|
+
}
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
case 'peer-path':
|
|
231
|
+
log(`iroh path ${msg.kind} ${msg.remote}`)
|
|
232
|
+
opts.server.noteIrohPath?.(msg.peer, msg.kind)
|
|
233
|
+
break
|
|
234
|
+
case 'peer-session-end': {
|
|
235
|
+
const client = sessions.get(msg.id)
|
|
236
|
+
sessions.delete(msg.id)
|
|
237
|
+
if (msg.peer) opts.server.clearIrohPath?.(msg.peer)
|
|
238
|
+
client?.onRemoteClose()
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
console.error(`[pocket] unknown sidecar message: ${line}`)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
endpoint,
|
|
248
|
+
/** @returns {Promise<void>} */
|
|
249
|
+
close: () => new Promise((resolve) => {
|
|
250
|
+
const done = () => resolve()
|
|
251
|
+
try { rl.close() } catch { /* ignore */ }
|
|
252
|
+
try { child.stdin?.destroy() } catch { /* ignore */ }
|
|
253
|
+
try { child.stdout?.destroy() } catch { /* ignore */ }
|
|
254
|
+
if (child.exitCode !== null || child.signalCode !== null) { done(); return }
|
|
255
|
+
const t = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ }; done() }, 1500)
|
|
256
|
+
child.once('exit', () => { clearTimeout(t); done() })
|
|
257
|
+
try { child.kill('SIGKILL') } catch { clearTimeout(t); done() }
|
|
258
|
+
}),
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/manage.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { selectNetwork } from './advertise-host.js'
|
|
5
|
+
import { normalizeTransport } from './transport-pref.js'
|
|
6
|
+
|
|
7
|
+
const PKG = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Phone QR / deep link — host:port only.
|
|
11
|
+
*
|
|
12
|
+
* `dshpocket://pair?h=<host:port>&c=<code>`
|
|
13
|
+
*
|
|
14
|
+
* @param {{ code?: string, host?: string, port?: number, transport?: string }} opts
|
|
15
|
+
*/
|
|
16
|
+
export function pairingUri({ code, host, port, transport }) {
|
|
17
|
+
if (!code) return undefined
|
|
18
|
+
const mode = normalizeTransport(transport) ?? (host ? 'tailscale' : null)
|
|
19
|
+
if (mode !== 'tailscale') return undefined
|
|
20
|
+
if (!host || !port) return undefined
|
|
21
|
+
return `dshpocket://pair?h=${encodeURIComponent(`${host}:${port}`)}&c=${encodeURIComponent(code)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pasteable one-liner — same URI shape as the QR payload.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ code?: string, host?: string, port?: number, transport?: string }} opts
|
|
28
|
+
*/
|
|
29
|
+
export function pairingString({ code, host, port, transport }) {
|
|
30
|
+
return pairingUri({ code, host, port, transport })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Facade used by management HTTP routes and the client half.
|
|
35
|
+
* @param {{
|
|
36
|
+
* getNetworks: () => Array<{type: 'lan'|'tailscale', host: string}>,
|
|
37
|
+
* port: number,
|
|
38
|
+
* devices: any,
|
|
39
|
+
* pairing: any,
|
|
40
|
+
* server: any,
|
|
41
|
+
* openPairing: Function,
|
|
42
|
+
* getTransport: () => 'tailscale',
|
|
43
|
+
* setTransport?: (mode: string) => 'tailscale',
|
|
44
|
+
* }} opts
|
|
45
|
+
*/
|
|
46
|
+
export function createManageApi({
|
|
47
|
+
getNetworks, port, devices, pairing, server, openPairing, getTransport, setTransport,
|
|
48
|
+
instanceId, getMdns,
|
|
49
|
+
}) {
|
|
50
|
+
const transport = () => getTransport()
|
|
51
|
+
const withOnline = () => {
|
|
52
|
+
const online = server.onlineDeviceIds()
|
|
53
|
+
return devices.list().map((d) => ({ ...d, online: online.has(d.id) }))
|
|
54
|
+
}
|
|
55
|
+
const listenPort = () => {
|
|
56
|
+
const address = server.address()
|
|
57
|
+
return address && typeof address === 'object' ? address.port : port
|
|
58
|
+
}
|
|
59
|
+
const enrichPairing = (status, selected, selectedPort) => {
|
|
60
|
+
if (!selected) return { open: false }
|
|
61
|
+
const { host } = selected
|
|
62
|
+
const code = status?.code
|
|
63
|
+
if (!code) return status?.open ? status : { open: false }
|
|
64
|
+
const mode = transport()
|
|
65
|
+
return {
|
|
66
|
+
open: true,
|
|
67
|
+
...status,
|
|
68
|
+
code,
|
|
69
|
+
transport: mode,
|
|
70
|
+
host,
|
|
71
|
+
port: selectedPort,
|
|
72
|
+
uri: pairingUri({ code, host, port: selectedPort, transport: mode }),
|
|
73
|
+
pairingString: pairingString({ code, host, port: selectedPort, transport: mode }),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
openPairing: (network) => {
|
|
78
|
+
const selected = selectNetwork(getNetworks(), network)
|
|
79
|
+
if (!selected || server.address() === null) {
|
|
80
|
+
throw Object.assign(new Error('No reachable network is available for pairing'), { statusCode: 503 })
|
|
81
|
+
}
|
|
82
|
+
const port = listenPort()
|
|
83
|
+
openPairing({ ...selected, port })
|
|
84
|
+
return enrichPairing(pairing.status(), selected, port)
|
|
85
|
+
},
|
|
86
|
+
closePairing: () => { pairing.close(); return { open: false } },
|
|
87
|
+
pairingStatus: (network) => enrichPairing(pairing.status(), selectNetwork(getNetworks(), network), listenPort()),
|
|
88
|
+
listDevices: withOnline,
|
|
89
|
+
revokeDevice: (id) => server.revokeDevice(id),
|
|
90
|
+
renameDevice: (id, name) => devices.rename(id, name),
|
|
91
|
+
setTransport: (mode) => {
|
|
92
|
+
if (typeof setTransport !== 'function') throw new Error('setTransport unavailable')
|
|
93
|
+
return { transport: setTransport(mode) }
|
|
94
|
+
},
|
|
95
|
+
getStatus: (network) => {
|
|
96
|
+
const networks = getNetworks()
|
|
97
|
+
const selected = selectNetwork(networks, network)
|
|
98
|
+
const addr = server.address()
|
|
99
|
+
const listening = addr !== null
|
|
100
|
+
const listenHost = listening && typeof addr === 'object' ? addr.address : ''
|
|
101
|
+
const port = listenPort()
|
|
102
|
+
const mode = transport()
|
|
103
|
+
return {
|
|
104
|
+
running: listening,
|
|
105
|
+
version: PKG.version,
|
|
106
|
+
plugin: PKG.name,
|
|
107
|
+
transport: mode,
|
|
108
|
+
networks,
|
|
109
|
+
host: selected?.host ?? '',
|
|
110
|
+
port,
|
|
111
|
+
instanceId: typeof instanceId === 'string' ? instanceId : undefined,
|
|
112
|
+
mdns: typeof getMdns === 'function' ? Boolean(getMdns()) : false,
|
|
113
|
+
connectionType: selected?.type ?? null,
|
|
114
|
+
listen: listening
|
|
115
|
+
? [{ host: listenHost, port, address: typeof addr === 'string' ? addr : `${listenHost}:${port}` }]
|
|
116
|
+
: [],
|
|
117
|
+
pairing: enrichPairing(pairing.status(), selected, port),
|
|
118
|
+
devices: withOnline(),
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advertise the pocket WebSocket listener via mDNS/Bonjour (_dshpocket._tcp).
|
|
3
|
+
*
|
|
4
|
+
* Adapted from dsh-mobile gateway mDNS publish
|
|
5
|
+
* (https://github.com/saya-ch/dsh-mobile src/gateway.ts), Apache-2.0.
|
|
6
|
+
*/
|
|
7
|
+
import { hostname } from 'node:os'
|
|
8
|
+
import Bonjour from 'bonjour-service'
|
|
9
|
+
|
|
10
|
+
export const MDNS_SERVICE_TYPE = 'dshpocket'
|
|
11
|
+
export const DISCOVERY_PROTOCOL = 1
|
|
12
|
+
|
|
13
|
+
/** @returns {string} */
|
|
14
|
+
export function discoveryDeviceName() {
|
|
15
|
+
const value = [...hostname().trim()].filter((ch) => {
|
|
16
|
+
const code = ch.codePointAt(0) ?? 0
|
|
17
|
+
return code >= 0x20 && code !== 0x7f
|
|
18
|
+
}).join('')
|
|
19
|
+
return (value === '' ? 'dsh' : value).slice(0, 63)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} instanceId */
|
|
23
|
+
export function discoveryMdnsHost(instanceId) {
|
|
24
|
+
const label = hostname().toLowerCase().replaceAll(/[^a-z0-9-]/gu, '-').replaceAll(/^-+|-+$/gu, '').slice(0, 40)
|
|
25
|
+
return `${label === '' ? 'dsh' : label}-${instanceId.slice(0, 8)}.local`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{
|
|
30
|
+
* host: string,
|
|
31
|
+
* port: number,
|
|
32
|
+
* instanceId: string,
|
|
33
|
+
* deviceName?: string,
|
|
34
|
+
* onError?: (err: Error) => void,
|
|
35
|
+
* log?: (...a: unknown[]) => void,
|
|
36
|
+
* }} opts
|
|
37
|
+
*/
|
|
38
|
+
export function startMdnsAdvertise(opts) {
|
|
39
|
+
const deviceName = opts.deviceName ?? discoveryDeviceName()
|
|
40
|
+
const onError = (error) => {
|
|
41
|
+
try { opts.onError?.(error instanceof Error ? error : new Error(String(error))) } catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
const bonjour = new Bonjour({ disableIPv6: true }, onError)
|
|
44
|
+
// bonjour-service 1.4.4 does not forward multicast-dns EventEmitter errors
|
|
45
|
+
// through errorCallback. Keep a listener on its underlying emitter for its lifetime.
|
|
46
|
+
const mdns = /** @type {{ server: { mdns: { on(event: 'error', listener: (error: Error) => void): void } } }} */ (bonjour).server.mdns
|
|
47
|
+
mdns.on('error', onError)
|
|
48
|
+
const service = bonjour.publish({
|
|
49
|
+
name: `${deviceName} (${opts.instanceId.slice(0, 8)})`,
|
|
50
|
+
type: MDNS_SERVICE_TYPE,
|
|
51
|
+
protocol: 'tcp',
|
|
52
|
+
port: opts.port,
|
|
53
|
+
host: discoveryMdnsHost(opts.instanceId),
|
|
54
|
+
disableIPv6: true,
|
|
55
|
+
txt: {
|
|
56
|
+
deviceName,
|
|
57
|
+
host: opts.host,
|
|
58
|
+
port: String(opts.port),
|
|
59
|
+
instanceId: opts.instanceId,
|
|
60
|
+
protocol: String(DISCOVERY_PROTOCOL),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
opts.log?.(`mdns advertising _${MDNS_SERVICE_TYPE}._tcp (${deviceName}, ${opts.host}:${opts.port})`)
|
|
64
|
+
return {
|
|
65
|
+
deviceName,
|
|
66
|
+
service,
|
|
67
|
+
stop: () => new Promise((resolve) => {
|
|
68
|
+
try {
|
|
69
|
+
bonjour.unpublishAll(() => { bonjour.destroy(() => resolve()) })
|
|
70
|
+
} catch {
|
|
71
|
+
resolve()
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { extname } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const OFFICE_CHUNK_BYTES = 128 * 1024
|
|
4
|
+
const OUTPUT_LIMIT = 100 * 1024 * 1024
|
|
5
|
+
const failure = (reason) => Object.assign(new Error('Office conversion failed.'), {
|
|
6
|
+
code: 'document-render/failed', details: { reason },
|
|
7
|
+
})
|
|
8
|
+
const bad = () => Object.assign(new Error('Invalid Office preview request.'), { code: 'gateway/bad-request' })
|
|
9
|
+
const missing = () => Object.assign(new Error('Office preview handle is unavailable.'), { code: 'office/handle-unavailable' })
|
|
10
|
+
|
|
11
|
+
/** Connection-owned leases over the official converter's caller-owned output; no second conversion cache. */
|
|
12
|
+
export function createOfficePreviewOps(ctx, {
|
|
13
|
+
leaseMs = 60_000, maxHandles = 8, maxRetainedBytes = 200 * 1024 * 1024,
|
|
14
|
+
maxOutputBytes = OUTPUT_LIMIT, renderTimeoutMs = 600_000,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const handles = new Map()
|
|
17
|
+
let retainedBytes = 0
|
|
18
|
+
let disposed = false
|
|
19
|
+
const scopeOf = async (sessionId) => {
|
|
20
|
+
const live = ctx.sessions.get(sessionId)?.header
|
|
21
|
+
const stored = live === undefined ? await ctx.get('sessionPersistence')?.stat(sessionId) : undefined
|
|
22
|
+
const header = live ?? stored?.header
|
|
23
|
+
if (header === undefined) throw Object.assign(new Error('Session not found.'), { code: 'session/not-found' })
|
|
24
|
+
const workspaceRoot = header.cwd ?? ctx.sandboxPolicy.workspaceRoot
|
|
25
|
+
return { sessionId, workspaceRoot }
|
|
26
|
+
}
|
|
27
|
+
const release = (entry) => {
|
|
28
|
+
if (handles.get(entry.id) !== entry) return
|
|
29
|
+
handles.delete(entry.id)
|
|
30
|
+
clearTimeout(entry.timer)
|
|
31
|
+
retainedBytes -= entry.reserved
|
|
32
|
+
entry.data = undefined
|
|
33
|
+
if (entry.client.subs.get(entry.id) === entry.ac) entry.client.subs.delete(entry.id)
|
|
34
|
+
entry.ac.abort()
|
|
35
|
+
}
|
|
36
|
+
const renew = (entry, ms = leaseMs, rendering = false) => {
|
|
37
|
+
clearTimeout(entry.timer)
|
|
38
|
+
entry.timer = setTimeout(() => {
|
|
39
|
+
entry.timedOut = rendering
|
|
40
|
+
release(entry)
|
|
41
|
+
}, ms)
|
|
42
|
+
entry.timer.unref?.()
|
|
43
|
+
}
|
|
44
|
+
const owned = (a, client) => {
|
|
45
|
+
const entry = handles.get(a.handle)
|
|
46
|
+
if (!entry || entry.client !== client || entry.scope?.sessionId !== a.sessionId) throw missing()
|
|
47
|
+
return entry
|
|
48
|
+
}
|
|
49
|
+
const unchanged = async (entry, signal) => {
|
|
50
|
+
const scope = await scopeOf(entry.scope.sessionId)
|
|
51
|
+
if (scope.workspaceRoot !== entry.scope.workspaceRoot) throw failure('source-changed')
|
|
52
|
+
const current = await ctx.workspaceFiles.stat(scope, entry.path, signal)
|
|
53
|
+
if (current.absolutePath !== entry.meta.absolutePath || current.version !== entry.meta.version) throw failure('source-changed')
|
|
54
|
+
signal.throwIfAborted()
|
|
55
|
+
}
|
|
56
|
+
const ops = {
|
|
57
|
+
'office.render': async (a, signal, client) => {
|
|
58
|
+
if (typeof a.sessionId !== 'string' || !a.sessionId || typeof a.path !== 'string' || !a.path ||
|
|
59
|
+
!/^o-[a-zA-Z0-9-]{1,80}$/.test(a.subId ?? '') || !client?.subs || client.subs.has(a.subId) || handles.has(a.subId)) throw bad()
|
|
60
|
+
if (!['.doc', '.docx', '.ppt', '.pptx'].includes(extname(a.path).toLowerCase())) throw failure('unsupported-format')
|
|
61
|
+
if (disposed) throw failure('unavailable')
|
|
62
|
+
if (handles.size >= maxHandles || retainedBytes + maxOutputBytes > maxRetainedBytes) throw failure('busy')
|
|
63
|
+
const ac = new AbortController()
|
|
64
|
+
const entry = { id: a.subId, sessionId: a.sessionId, client, ac, path: a.path, reserved: maxOutputBytes }
|
|
65
|
+
handles.set(entry.id, entry)
|
|
66
|
+
retainedBytes += entry.reserved
|
|
67
|
+
client.subs.set(entry.id, ac)
|
|
68
|
+
ac.signal.addEventListener('abort', () => release(entry), { once: true })
|
|
69
|
+
renew(entry, renderTimeoutMs, true)
|
|
70
|
+
const upstream = AbortSignal.any([ac.signal, signal, AbortSignal.timeout(renderTimeoutMs)])
|
|
71
|
+
try {
|
|
72
|
+
entry.scope = await scopeOf(a.sessionId)
|
|
73
|
+
upstream.throwIfAborted()
|
|
74
|
+
const result = await ctx.officeToPdf.render(entry.scope, a.path, 'foreground', upstream)
|
|
75
|
+
upstream.throwIfAborted()
|
|
76
|
+
if (!(result.data instanceof Uint8Array) || result.eof !== true || result.offset !== 0 ||
|
|
77
|
+
result.bytes !== result.data.byteLength || result.bytes <= 0 ||
|
|
78
|
+
typeof result.version !== 'string' || typeof result.generation !== 'string' ||
|
|
79
|
+
typeof result.absolutePath !== 'string' || !Array.isArray(result.missingFonts) ||
|
|
80
|
+
!result.missingFonts.every(font => typeof font === 'string')) throw failure('invalid-output')
|
|
81
|
+
if (result.bytes > maxOutputBytes) throw failure('output-too-large')
|
|
82
|
+
entry.meta = {
|
|
83
|
+
handle: entry.id, absolutePath: result.absolutePath, version: result.version,
|
|
84
|
+
generation: result.generation, bytes: result.bytes, missingFonts: result.missingFonts,
|
|
85
|
+
chunkBytes: OFFICE_CHUNK_BYTES,
|
|
86
|
+
}
|
|
87
|
+
await unchanged(entry, upstream)
|
|
88
|
+
retainedBytes += result.bytes - entry.reserved
|
|
89
|
+
entry.reserved = result.bytes
|
|
90
|
+
entry.data = result.data
|
|
91
|
+
renew(entry)
|
|
92
|
+
return entry.meta
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const cancelled = upstream.aborted
|
|
95
|
+
const timedOut = entry.timedOut || upstream.reason?.name === 'TimeoutError'
|
|
96
|
+
release(entry)
|
|
97
|
+
if (timedOut) throw failure('timeout')
|
|
98
|
+
if (cancelled) throw Object.assign(new Error('The document preview was cancelled.'), { code: 'gateway/cancelled' })
|
|
99
|
+
throw error
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
'office.read': async (a, signal, client) => {
|
|
103
|
+
const entry = owned(a, client)
|
|
104
|
+
if (!entry.data) throw missing()
|
|
105
|
+
if (!Number.isSafeInteger(a.offset) || a.offset < 0 || a.offset >= entry.data.byteLength ||
|
|
106
|
+
!Number.isSafeInteger(a.length) || a.length < 1 || a.length > OFFICE_CHUNK_BYTES) throw bad()
|
|
107
|
+
try {
|
|
108
|
+
if (a.version !== entry.meta.version || a.generation !== entry.meta.generation) throw failure('source-changed')
|
|
109
|
+
const upstream = AbortSignal.any([signal, entry.ac.signal])
|
|
110
|
+
await unchanged(entry, upstream)
|
|
111
|
+
const end = Math.min(entry.data.byteLength, a.offset + a.length)
|
|
112
|
+
const data = Buffer.from(entry.data.buffer, entry.data.byteOffset + a.offset, end - a.offset).toString('base64')
|
|
113
|
+
renew(entry)
|
|
114
|
+
return { absolutePath: entry.meta.absolutePath, version: entry.meta.version,
|
|
115
|
+
generation: entry.meta.generation, bytes: entry.meta.bytes, offset: a.offset,
|
|
116
|
+
eof: end === entry.data.byteLength, encoding: 'base64', data }
|
|
117
|
+
} catch (error) { release(entry); throw error }
|
|
118
|
+
},
|
|
119
|
+
'office.release': (a, _signal, client) => {
|
|
120
|
+
const entry = handles.get(a.handle)
|
|
121
|
+
if (!entry) return { released: false }
|
|
122
|
+
// Pending renders also have an owner and can be cancelled before scope lookup settles.
|
|
123
|
+
if (entry.client !== client || entry.sessionId !== a.sessionId) throw missing()
|
|
124
|
+
release(entry)
|
|
125
|
+
return { released: true }
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
return { ops, dispose() { disposed = true; for (const entry of handles.values()) release(entry) } }
|
|
129
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure `dshpocket://` pairing URI parser — mirrors Android PairingUriPolicy.
|
|
3
|
+
* Shared vectors: ../../contract/pairing-uri-cases.json
|
|
4
|
+
*
|
|
5
|
+
* Happy path: `dshpocket://pair?h=<host:port>&c=<6-digit>`
|
|
6
|
+
* Legacy iroh forms (`n=` / `nodeId#code`) are rejected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CODE = /^\d{6}$/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} raw
|
|
13
|
+
* @returns {{ host: string, port: number } | null}
|
|
14
|
+
*/
|
|
15
|
+
export function parseHostPort(raw) {
|
|
16
|
+
const t = String(raw || '').trim().replace(/^wss?:\/\//, '')
|
|
17
|
+
const match = /^\[?([^[\]]+?)]?:(\d{1,5})$/.exec(t)
|
|
18
|
+
if (!match) return null
|
|
19
|
+
const port = Number(match[2])
|
|
20
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
21
|
+
const host = match[1]
|
|
22
|
+
if (!host) return null
|
|
23
|
+
return { host, port }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {{ host: string, port: number, code: string } | null}
|
|
29
|
+
*/
|
|
30
|
+
export function parsePairingUri(text) {
|
|
31
|
+
const s = String(text ?? '').trim()
|
|
32
|
+
if (!s.startsWith('dshpocket://pair')) return null
|
|
33
|
+
const q = new URLSearchParams(s.includes('?') ? s.slice(s.indexOf('?') + 1) : '')
|
|
34
|
+
const c = q.get('c')
|
|
35
|
+
if (!c || !CODE.test(c)) return null
|
|
36
|
+
if (q.get('n')) return null
|
|
37
|
+
const h = q.get('h')
|
|
38
|
+
if (!h) return null
|
|
39
|
+
const hp = parseHostPort(h)
|
|
40
|
+
if (!hp) return null
|
|
41
|
+
return { host: hp.host, port: hp.port, code: c }
|
|
42
|
+
}
|
package/src/pairing.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const PAIRING_TTL_MS = 600_000
|
|
4
|
+
export const PAIRING_MAX_ATTEMPTS = 3
|
|
5
|
+
export const PAIR_NO_WINDOW = 'pair/no-window'
|
|
6
|
+
export const PAIR_EXPIRED = 'pair/expired'
|
|
7
|
+
export const PAIR_TOO_MANY = 'pair/too-many-attempts'
|
|
8
|
+
export const PAIR_BAD_CODE = 'pair/bad-code'
|
|
9
|
+
|
|
10
|
+
/** One pairing window at a time: 6-digit code, 10 min TTL, 3 attempts (same rules as dsh-tether). */
|
|
11
|
+
export function createPairing({ now = Date.now, ttlMs = PAIRING_TTL_MS } = {}) {
|
|
12
|
+
let win
|
|
13
|
+
return {
|
|
14
|
+
open() {
|
|
15
|
+
win = { code: String(randomInt(0, 1_000_000)).padStart(6, '0'), deadline: now() + ttlMs, attempts: 0 }
|
|
16
|
+
return { code: win.code, expiresInSec: Math.round(ttlMs / 1000) }
|
|
17
|
+
},
|
|
18
|
+
close() { win = undefined },
|
|
19
|
+
/** Includes `code` while open so the local management UI can re-render after refresh. */
|
|
20
|
+
status: () => (win && now() < win.deadline
|
|
21
|
+
? { open: true, code: win.code, expiresInSec: Math.round((win.deadline - now()) / 1000), attemptsLeft: PAIRING_MAX_ATTEMPTS - win.attempts }
|
|
22
|
+
: { open: false }),
|
|
23
|
+
/** @returns {null | string} null on success (window consumed), else a reason code */
|
|
24
|
+
attempt(code) {
|
|
25
|
+
if (!win) return PAIR_NO_WINDOW
|
|
26
|
+
if (now() >= win.deadline) { win = undefined; return PAIR_EXPIRED }
|
|
27
|
+
if (win.attempts >= PAIRING_MAX_ATTEMPTS) { win = undefined; return PAIR_TOO_MANY }
|
|
28
|
+
win.attempts++
|
|
29
|
+
if (typeof code === 'string' && code === win.code) { win = undefined; return null }
|
|
30
|
+
if (win.attempts >= PAIRING_MAX_ATTEMPTS) { win = undefined; return PAIR_TOO_MANY }
|
|
31
|
+
return PAIR_BAD_CODE
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|