@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.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.
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
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /** Phone path is always host:port WebSocket (Tailscale or LAN). */
5
+ export const TRANSPORTS = Object.freeze(['tailscale'])
6
+
7
+ /**
8
+ * @param {unknown} v
9
+ * @returns {'tailscale'|null}
10
+ */
11
+ export function normalizeTransport(v) {
12
+ if (v === 'tailscale') return v
13
+ return null
14
+ }
15
+
16
+ /**
17
+ * Resolve remote transport: explicit config > persisted last choice > default tailscale.
18
+ * Legacy `iroh` values in the transport file are ignored (phone path no longer supports them).
19
+ * @param {string} dataDir
20
+ * @param {unknown} [configTransport]
21
+ * @returns {'tailscale'}
22
+ */
23
+ export function loadTransport(dataDir, configTransport) {
24
+ const fromConfig = normalizeTransport(configTransport)
25
+ if (fromConfig) return fromConfig
26
+ try {
27
+ const raw = readFileSync(join(dataDir, 'transport'), 'utf8').trim()
28
+ const fromFile = normalizeTransport(raw)
29
+ if (fromFile) return fromFile
30
+ } catch {
31
+ // missing file is fine
32
+ }
33
+ return 'tailscale'
34
+ }
35
+
36
+ /**
37
+ * Persist last choice (ignored when cordis config.transport is set).
38
+ * @param {string} dataDir
39
+ * @param {'tailscale'} mode
40
+ */
41
+ export function saveTransport(dataDir, mode) {
42
+ const m = normalizeTransport(mode)
43
+ if (!m) throw new Error(`transport must be one of: ${TRANSPORTS.join(', ')}`)
44
+ mkdirSync(dataDir, { recursive: true })
45
+ writeFileSync(join(dataDir, 'transport'), `${m}\n`, { mode: 0o600 })
46
+ return m
47
+ }
package/src/trust.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Loopback hostnames: localhost, IPv6 loopback, and the entire 127/8 range. */
2
+ export function isLoopbackHostname(hostname) {
3
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
4
+ const parts = hostname.split('.')
5
+ return parts.length === 4 && parts[0] === '127'
6
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
7
+ }
8
+
9
+ /**
10
+ * Browser-trust fence matching dsh's own `/api` Host/Origin rules (and tether):
11
+ * Host must be a loopback authority; refuse `sec-fetch-site: cross-site`;
12
+ * when Origin is present it must match Host. Does not accept trustedHosts —
13
+ * management routes are local-operator only.
14
+ */
15
+ export function isTrustedRequest(req) {
16
+ const host = req.headers.host
17
+ if (typeof host !== 'string') return false
18
+ let hostUrl
19
+ try { hostUrl = new URL(`http://${host}`) } catch { return false }
20
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
21
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
22
+ const origin = req.headers.origin
23
+ if (typeof origin !== 'string') return true
24
+ try { return new URL(origin).host === hostUrl.host } catch { return false }
25
+ }
@@ -0,0 +1,93 @@
1
+ const never = new Promise(() => {})
2
+ const questionError = (message, code) => Object.assign(new Error(message), { name: 'UserQuestionError', code })
3
+
4
+ /**
5
+ * Keep the mobile offer alongside the desktop waterfall, including reconnects.
6
+ * A timed mobile claim has the Host's remaining duration, never an indefinite hold.
7
+ */
8
+ export function createUserQuestionHook(server, { userQuestions } = {}) {
9
+ return async function onUserQuestionRequest(req, next) {
10
+ const sessionId = req.agent?.session?.id ?? req.agent?.id
11
+ if (!sessionId || req.signal?.aborted) return next()
12
+ const original = req.signal
13
+ const withdraw = new AbortController()
14
+ req.signal = original ? AbortSignal.any([original, withdraw.signal]) : withdraw.signal
15
+ let rejectLifecycle
16
+ const lifecycle = new Promise((_, reject) => { rejectLifecycle = reject })
17
+ let claim
18
+ let timer
19
+ let deadline
20
+ let finished = false
21
+ const offer = server.offerUserQuestion({
22
+ sessionId,
23
+ callId: req.wait?.callId,
24
+ questions: req.questions ?? [],
25
+ wait: req.wait ? { callId: req.wait.callId, timed: Boolean(req.wait.timed) } : undefined,
26
+ }, { isActive: () => !finished && !original?.aborted && (deadline === undefined || Date.now() < deadline) })
27
+ const fail = (error) => {
28
+ if (finished) return
29
+ offer.close('aborted', 'host')
30
+ rejectLifecycle(error)
31
+ }
32
+ const aborted = () => fail(original.reason ?? questionError('user-question aborted', 'ASK_ABORTED'))
33
+ original?.addEventListener('abort', aborted, { once: true })
34
+ const releaseClaim = () => {
35
+ clearTimeout(timer)
36
+ deadline = undefined
37
+ claim?.abort()
38
+ claim = undefined
39
+ }
40
+ const updateClaim = () => {
41
+ if (finished || !req.wait?.timed || !userQuestions?.attachWait) return
42
+ if (!server.hasDevices()) { releaseClaim(); return }
43
+ if (claim) return
44
+ const lifetime = new AbortController()
45
+ claim = lifetime
46
+ const signal = original ? AbortSignal.any([original, lifetime.signal]) : lifetime.signal
47
+ void (async () => {
48
+ try {
49
+ for await (const frame of userQuestions.attachWait(req.agent, req.wait.callId, signal)) {
50
+ if (signal.aborted || finished || claim !== lifetime) return
51
+ deadline = Date.now() + frame.remainingMs
52
+ timer = setTimeout(() => fail(questionError('ask_user_question timed out before the user answered', 'ASK_TIMED_OUT')), frame.remainingMs)
53
+ }
54
+ if (!signal.aborted && !finished && claim === lifetime) {
55
+ fail(questionError('the foreground question wait ended', 'ASK_ABORTED'))
56
+ }
57
+ } catch (error) {
58
+ if (!signal.aborted && !finished && claim === lifetime) fail(error)
59
+ }
60
+ })()
61
+ }
62
+ const unwatch = server.onDevicePresence?.(updateClaim)
63
+ updateClaim()
64
+ const chain = Promise.resolve().then(next).then(
65
+ (answer) => {
66
+ if (answer === undefined || answer === 'unavailable') return never
67
+ offer.close('answered', 'chain')
68
+ return { answer, by: 'chain' }
69
+ },
70
+ (error) => {
71
+ // NO_PROVIDER only means the desktop declined; mobile may still answer.
72
+ if (error?.code === 'NO_PROVIDER' && (req.wait?.timed || server.hasDevices())) return never
73
+ offer.close('aborted', 'chain')
74
+ throw error
75
+ },
76
+ )
77
+ try {
78
+ const winner = await Promise.race([offer.answer, chain, lifecycle])
79
+ offer.close('answered', winner.by)
80
+ return winner.answer
81
+ } finally {
82
+ finished = true
83
+ unwatch?.()
84
+ clearTimeout(timer)
85
+ original?.removeEventListener('abort', aborted)
86
+ withdraw.abort(questionError('question answerer withdrawn', 'ASK_ABORTED'))
87
+ offer.close('aborted', 'host')
88
+ // An accepted answer must reach askTimed before its last claim is released.
89
+ if (claim && original && !original.aborted) original.addEventListener('abort', releaseClaim, { once: true })
90
+ else releaseClaim()
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,116 @@
1
+ import { isTrustedRequest } from './trust.js'
2
+
3
+ const json = (res, status, body) => {
4
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5
+ res.end(JSON.stringify(body))
6
+ }
7
+
8
+ const refuse = (res) => json(res, 403, { error: 'forbidden' })
9
+
10
+ const networkSelection = (req) => new URL(req.url, 'http://localhost').searchParams.get('network') ?? undefined
11
+ const networkResponse = (req, res, action) => {
12
+ try {
13
+ return json(res, 200, action(networkSelection(req)))
14
+ } catch (error) {
15
+ return json(res, error?.statusCode === 400 ? 400 : 503, { error: String(error?.message ?? error) })
16
+ }
17
+ }
18
+
19
+ async function readJson(req, res, limit = 4096) {
20
+ let body = ''
21
+ for await (const c of req) {
22
+ body += c
23
+ if (body.length > limit) {
24
+ json(res, 413, { error: 'too large' })
25
+ return undefined
26
+ }
27
+ }
28
+ if (!body) return {}
29
+ try { return JSON.parse(body) } catch {
30
+ json(res, 400, { error: 'invalid json' })
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Management routes on dsh's webServer (loopback Host/Origin fence).
37
+ * @param {import('@deepseek-ai/cordis').Context} ctx
38
+ * @param {{
39
+ * openPairing: (network?: string) => object,
40
+ * closePairing?: () => object,
41
+ * pairingStatus: (network?: string) => object,
42
+ * getStatus: (network?: string) => object,
43
+ * listDevices: () => object[],
44
+ * revokeDevice: (id: string) => boolean,
45
+ * renameDevice: (id: string, name: string) => boolean,
46
+ * setTransport?: (mode: string) => object,
47
+ * }} api
48
+ */
49
+ export function registerPairRoute(ctx, api) {
50
+ const gate = (req, res) => {
51
+ if (!isTrustedRequest(req)) { refuse(res); return false }
52
+ return true
53
+ }
54
+
55
+ ctx.effect(() => ctx.webServer.register({
56
+ kind: 'exact', path: '/pocket/status',
57
+ handler: async (req, res) => {
58
+ if (!gate(req, res)) return
59
+ if (req.method === 'GET') return networkResponse(req, res, api.getStatus)
60
+ if (req.method === 'PATCH') {
61
+ const body = await readJson(req, res)
62
+ if (body === undefined) return
63
+ if (typeof body.transport !== 'string') return json(res, 400, { error: 'transport required' })
64
+ if (typeof api.setTransport !== 'function') return json(res, 405, { error: 'method not allowed' })
65
+ try {
66
+ api.setTransport(body.transport)
67
+ return networkResponse(req, res, api.getStatus)
68
+ } catch (e) {
69
+ return json(res, 400, { error: String(e?.message ?? e) })
70
+ }
71
+ }
72
+ return json(res, 405, { error: 'method not allowed' })
73
+ },
74
+ }))
75
+
76
+ ctx.effect(() => ctx.webServer.register({
77
+ kind: 'exact', path: '/pocket/pair',
78
+ handler: (req, res) => {
79
+ if (!gate(req, res)) return
80
+ if (req.method === 'POST') return networkResponse(req, res, api.openPairing)
81
+ if (req.method === 'DELETE') return json(res, 200, (api.closePairing ?? (() => ({ open: false })))())
82
+ if (req.method === 'GET') return networkResponse(req, res, api.pairingStatus)
83
+ return json(res, 405, { error: 'method not allowed' })
84
+ },
85
+ }))
86
+
87
+ ctx.effect(() => ctx.webServer.register({
88
+ kind: 'exact', path: '/pocket/devices',
89
+ handler: async (req, res) => {
90
+ if (!gate(req, res)) return
91
+ if (req.method === 'GET') return json(res, 200, { devices: api.listDevices() })
92
+ if (req.method === 'POST') {
93
+ const body = await readJson(req, res)
94
+ if (body === undefined) return
95
+ const id = body.id
96
+ if (typeof id !== 'string' || !id) return json(res, 400, { error: 'id required' })
97
+ return json(res, 200, { removed: api.revokeDevice(id), devices: api.listDevices() })
98
+ }
99
+ if (req.method === 'PATCH') {
100
+ const body = await readJson(req, res)
101
+ if (body === undefined) return
102
+ const id = body.id
103
+ const name = body.name
104
+ if (typeof id !== 'string' || !id) return json(res, 400, { error: 'id required' })
105
+ if (typeof name !== 'string' || !name.trim()) return json(res, 400, { error: 'name required' })
106
+ const renamed = api.renameDevice(id, name.trim())
107
+ if (!renamed) return json(res, 404, { error: 'not found' })
108
+ return json(res, 200, { renamed: true, devices: api.listDevices() })
109
+ }
110
+ return json(res, 405, { error: 'method not allowed' })
111
+ },
112
+ }))
113
+ }
114
+
115
+ /** @deprecated use isTrustedRequest — kept as alias for existing imports/tests */
116
+ export const isLocalRequest = isTrustedRequest