@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.
- 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
|
@@ -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
|
+
}
|
package/src/web-route.js
ADDED
|
@@ -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
|