@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.
Files changed (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. 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
+ }