@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
@@ -0,0 +1,76 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ const text = 'var(--dsw-alias-label-primary)'
4
+ const muted = 'var(--dsw-alias-label-secondary)'
5
+ const border = '1px solid var(--dsw-alias-border-l2)'
6
+ const surface = 'var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-base))'
7
+ const button: CSSProperties = {
8
+ appearance: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
9
+ gap: 8, border: 'none', borderRadius: 8, background: 'transparent', color: muted,
10
+ cursor: 'pointer', font: 'inherit', fontSize: 13, lineHeight: '20px', padding: '8px 10px',
11
+ }
12
+
13
+ export const styles = {
14
+ settingsRoot: { width: '100%', padding: '8px 0 24px', color: text, containerType: 'inline-size' },
15
+ header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 24 },
16
+ title: { margin: 0, fontSize: 24, fontWeight: 650, lineHeight: '32px' },
17
+ service: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: muted },
18
+ pairingCard: { border, borderRadius: 16, background: surface, padding: '24px 24px 0' },
19
+ pairingTop: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, minHeight: 168, paddingBottom: 24 },
20
+ pairControls: { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 18, minWidth: 0 },
21
+ rowTitle: { margin: 0, fontSize: 16, lineHeight: '24px', fontWeight: 600, color: text },
22
+ illustration: { width: 180, maxWidth: '30%', flexShrink: 0, color: 'var(--dsw-alias-label-tertiary)', opacity: .55, marginRight: 20 },
23
+ segments: { display: 'inline-flex', padding: 3, borderRadius: 10, gap: 2, background: 'var(--dsw-alias-bg-overlay)' },
24
+ segment: { ...button, minWidth: 88, padding: '6px 16px' },
25
+ segmentActive: { color: text, background: 'var(--dsw-alias-bg-base)', boxShadow: '0 1px 4px rgba(0,0,0,.08)' },
26
+ footer: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '8px 16px', borderTop: border, padding: '14px 0' },
27
+ address: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, minWidth: 0, fontSize: 12, color: muted },
28
+ addressValue: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 12, color: text, overflowWrap: 'anywhere' },
29
+ footerActions: { display: 'flex', alignItems: 'center', gap: 10, marginLeft: 'auto' },
30
+ divider: { width: 1, height: 16, background: 'var(--dsw-alias-border-l2)', flexShrink: 0 },
31
+ iconBtn: { ...button, padding: 8 },
32
+ textBtn: button,
33
+ primaryBtn: { ...button, height: 40, padding: '0 18px', fontSize: 14, fontWeight: 550, borderRadius: 10, background: 'var(--dsw-alias-button-primary-fill)', color: 'var(--dsw-alias-label-primary-foreground, var(--dsw-alias-bg-base))' },
34
+ outlineBtn: { ...button, border, color: text },
35
+ outlineBtnSm: { ...button, padding: '5px 8px', color: text },
36
+ destructiveBtn: { ...button, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', padding: '8px 14px' },
37
+ ghostBtn: { ...button, border, color: text },
38
+ hint: { margin: 0, fontSize: 12, lineHeight: '20px', color: muted },
39
+ errorText: { margin: '0 0 16px', fontSize: 12, lineHeight: '20px', color: 'var(--dsw-alias-state-error-primary)' },
40
+ stateDotSlot: (state: 'done' | 'error' | 'idle'): CSSProperties => ({
41
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 10, height: 10, flexShrink: 0,
42
+ color: state === 'done' ? 'var(--dsw-alias-state-success-primary)' : state === 'error' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-tertiary)',
43
+ }),
44
+ stateDotCore: { width: 6, height: 6, borderRadius: '50%', background: 'currentColor' },
45
+ pairingContent: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 24, padding: '0 0 24px' },
46
+ code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 28, letterSpacing: '.16em', fontWeight: 600, margin: '4px 0 8px' },
47
+ qrBox: { padding: 10, borderRadius: 10, background: '#fff', lineHeight: 0 },
48
+ actionRow: { display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 16 },
49
+ devicesSection: { marginTop: 30 },
50
+ devicesHeader: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 },
51
+ count: { background: surface, color: muted, borderRadius: 8, minWidth: 24, textAlign: 'center', fontSize: 12, padding: '2px 4px' },
52
+ deviceList: { display: 'flex', flexDirection: 'column', gap: 10 },
53
+ deviceRow: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 14, padding: '18px 20px', border, borderRadius: 14 },
54
+ deviceIcon: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 48, borderRadius: 11, background: surface, color: muted },
55
+ deviceInfo: { flex: '1 1 120px', minWidth: 0 },
56
+ deviceHeading: { display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 },
57
+ deviceName: { fontSize: 14, lineHeight: '22px', fontWeight: 550, color: text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
58
+ deviceMeta: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: muted },
59
+ deviceActions: { display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto' },
60
+ input: { boxSizing: 'border-box', width: '100%', minWidth: 0, height: 34, border, borderRadius: 8, padding: '0 10px', background: 'var(--dsw-alias-bg-base)', color: text, font: 'inherit', fontSize: 13 },
61
+ dialogMask: { position: 'fixed', inset: 0, zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'auto', background: 'rgba(0,0,0,.45)', padding: 24 },
62
+ dialogCard: { width: 'min(560px, calc(100vw - 48px))', maxHeight: '80vh', overflow: 'auto', borderRadius: 14, border, background: 'var(--dsw-alias-bg-base)', color: text, boxShadow: '0 12px 40px rgba(0,0,0,.2)', padding: 24, display: 'flex', flexDirection: 'column', gap: 12 },
63
+ dialogTitle: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: '24px' },
64
+ dialogBody: { margin: 0, fontSize: 14, lineHeight: '22px', color: muted },
65
+ dialogActions: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8 },
66
+ } satisfies Record<string, CSSProperties | ((state: 'done' | 'error' | 'idle') => CSSProperties)>
67
+
68
+ export const panelCss = `
69
+ [data-pocket-panel] button:disabled { opacity: .45; cursor: not-allowed !important; }
70
+ [data-pocket-panel] button:not(:disabled):hover { filter: brightness(.94); }
71
+ [data-pocket-panel] button:focus-visible { outline: 2px solid var(--dsw-alias-label-secondary); outline-offset: 3px; }
72
+ @container (max-width: 440px) {
73
+ [data-pocket-panel] .pocket-illustration { display: none; }
74
+ [data-pocket-panel] .pocket-device-actions { width: 100%; justify-content: flex-end; }
75
+ }
76
+ `
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { isTrustedRequest } from './trust.js'
3
+
4
+ const json = (res, status, body) => {
5
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
6
+ res.end(JSON.stringify(body))
7
+ }
8
+
9
+ /** Same local-operator trust fence as the existing Pocket management routes. */
10
+ export function registerCompletionRoutes(ctx, tracker) {
11
+ ctx.effect(() => {
12
+ const clients = new Map()
13
+ const gate = (req, res) => {
14
+ if (isTrustedRequest(req)) return true
15
+ json(res, 403, { error: 'forbidden' })
16
+ return false
17
+ }
18
+ const unregisterEvents = ctx.webServer.register({
19
+ kind: 'exact', path: '/pocket/completion/events',
20
+ handler: (req, res) => {
21
+ if (!gate(req, res)) return
22
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
23
+ const clientId = randomUUID()
24
+ let closed = false
25
+ let unsubscribe = () => {}
26
+ let heartbeat
27
+ const close = () => {
28
+ if (closed) return
29
+ closed = true
30
+ clearInterval(heartbeat)
31
+ unsubscribe()
32
+ clients.delete(clientId)
33
+ tracker.disconnect(clientId)
34
+ res.end()
35
+ }
36
+ const send = (snapshot) => {
37
+ if (closed) return
38
+ // Disconnect slow consumers instead of retaining unbounded snapshots.
39
+ if (!res.write(`data: ${JSON.stringify({ clientId, ...snapshot })}\n\n`)) close()
40
+ }
41
+ clients.set(clientId, close)
42
+ res.on('close', close)
43
+ res.on('error', close)
44
+ res.writeHead(200, {
45
+ 'content-type': 'text/event-stream; charset=utf-8',
46
+ 'cache-control': 'no-store',
47
+ 'x-accel-buffering': 'no',
48
+ })
49
+ unsubscribe = tracker.subscribe(send)
50
+ send(tracker.snapshot())
51
+ if (!closed) {
52
+ heartbeat = setInterval(() => { if (!res.write(': keepalive\n\n')) close() }, 30_000)
53
+ heartbeat.unref()
54
+ }
55
+ },
56
+ })
57
+ const unregisterView = ctx.webServer.register({
58
+ kind: 'exact', path: '/pocket/completion/view',
59
+ handler: async (req, res) => {
60
+ if (!gate(req, res)) return
61
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
62
+ let body = ''
63
+ try {
64
+ for await (const chunk of req) {
65
+ body += chunk
66
+ if (body.length > 4096) return json(res, 413, { error: 'too large' })
67
+ }
68
+ body = JSON.parse(body)
69
+ } catch {
70
+ return json(res, 400, { error: 'invalid json' })
71
+ }
72
+ if (!body || typeof body.clientId !== 'string'
73
+ || !(body.sessionId === null || (typeof body.sessionId === 'string' && body.sessionId.length > 0))) {
74
+ return json(res, 400, { error: 'clientId and sessionId (string or null) required' })
75
+ }
76
+ // Checked after reading the body: the SSE may have disconnected meanwhile.
77
+ if (!clients.has(body.clientId)) return json(res, 409, { error: 'completion/client-expired' })
78
+ return json(res, 200, tracker.view(body.clientId, body.sessionId))
79
+ },
80
+ })
81
+ return () => {
82
+ unregisterEvents()
83
+ unregisterView()
84
+ for (const close of clients.values()) close()
85
+ }
86
+ })
87
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ /** Host-owned unread state. A follow subscription is deliberately not a view. */
4
+ export class CompletionTracker {
5
+ constructor({ initializing = false } = {}) {
6
+ this.epoch = randomUUID()
7
+ this.revision = 0
8
+ this.running = new Set()
9
+ this.unread = new Set()
10
+ this.views = new Map()
11
+ this.listeners = new Set()
12
+ this.pending = initializing ? [] : null
13
+ }
14
+
15
+ snapshot() {
16
+ return { epoch: this.epoch, revision: this.revision, unreadIds: [...this.unread] }
17
+ }
18
+
19
+ subscribe(listener) {
20
+ this.listeners.add(listener)
21
+ return () => this.listeners.delete(listener)
22
+ }
23
+
24
+ publish() {
25
+ this.revision++
26
+ const snapshot = this.snapshot()
27
+ for (const listener of this.listeners) listener(snapshot)
28
+ }
29
+
30
+ /** Seed once, then replay events received while the asynchronous list was read. */
31
+ seed(items) {
32
+ if (this.pending === null) return
33
+ const pending = this.pending
34
+ this.pending = null
35
+ for (const item of items) {
36
+ if (item?.running === true && typeof item.sessionId === 'string') this.running.add(item.sessionId)
37
+ }
38
+ const observed = new Set()
39
+ for (const [method, args] of pending) {
40
+ const sessionId = args[0]
41
+ if (method === 'status' || method === 'added' || method === 'remove') {
42
+ // The list may already reflect this stop. Before the first baseline,
43
+ // a live stop is completion evidence unless an earlier event says idle
44
+ // or removed; historical idle rows alone are never completion evidence.
45
+ if (method === 'status' && args[1] === false && !observed.has(sessionId)) this.running.add(sessionId)
46
+ observed.add(sessionId)
47
+ }
48
+ this[method](...args)
49
+ }
50
+ }
51
+
52
+ status(sessionId, running) {
53
+ if (typeof sessionId !== 'string' || !sessionId) return
54
+ if (this.pending !== null) { this.pending.push(['status', [sessionId, running]]); return }
55
+ const wasRunning = this.running.has(sessionId)
56
+ if (running) {
57
+ this.running.add(sessionId)
58
+ if (this.unread.delete(sessionId)) this.publish()
59
+ } else {
60
+ this.running.delete(sessionId)
61
+ if (wasRunning && ![...this.views.values()].includes(sessionId) && !this.unread.has(sessionId)) {
62
+ this.unread.add(sessionId)
63
+ this.publish()
64
+ }
65
+ }
66
+ }
67
+
68
+ /** An added summary establishes running state; it is not a stop event. */
69
+ added(sessionId, running) {
70
+ if (typeof sessionId !== 'string' || !sessionId) return
71
+ if (this.pending !== null) { this.pending.push(['added', [sessionId, running]]); return }
72
+ if (running) this.status(sessionId, true)
73
+ else this.running.delete(sessionId)
74
+ }
75
+
76
+ remove(sessionId) {
77
+ if (this.pending !== null) { this.pending.push(['remove', [sessionId]]); return }
78
+ this.running.delete(sessionId)
79
+ for (const [client, viewed] of this.views) if (viewed === sessionId) this.views.delete(client)
80
+ if (this.unread.delete(sessionId)) this.publish()
81
+ }
82
+
83
+ view(client, sessionId) {
84
+ if (this.pending !== null) { this.pending.push(['view', [client, sessionId]]); return this.snapshot() }
85
+ if (sessionId === null) this.views.delete(client)
86
+ else this.views.set(client, sessionId)
87
+ if (this.unread.delete(sessionId)) this.publish()
88
+ return this.snapshot()
89
+ }
90
+
91
+ disconnect(client) {
92
+ if (this.pending !== null) { this.pending.push(['disconnect', [client]]); return }
93
+ this.views.delete(client)
94
+ }
95
+ }
package/src/devices.js ADDED
@@ -0,0 +1,60 @@
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const hash = (s) => createHash('sha256').update(String(s)).digest('hex')
6
+ const eq = (a, b) => timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
7
+
8
+ /**
9
+ * Paired-device store. Tokens are stored as sha256 only.
10
+ * The owner token (M0 `token` file / config) authenticates as device "owner"
11
+ * and is the only identity allowed to open pairing windows.
12
+ */
13
+ export function createDeviceStore({ dataDir, ownerToken, persist = true, now = Date.now }) {
14
+ const path = dataDir ? join(dataDir, 'devices.json') : undefined
15
+ let devices = []
16
+ if (persist && path && existsSync(path)) {
17
+ try { devices = JSON.parse(readFileSync(path, 'utf8')).devices ?? [] } catch { devices = [] }
18
+ }
19
+ const save = () => {
20
+ if (!persist || !path) return
21
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 })
22
+ const tmp = `${path}.tmp`
23
+ writeFileSync(tmp, JSON.stringify({ version: 1, devices }, null, 2), { mode: 0o600 })
24
+ chmodSync(tmp, 0o600)
25
+ renameSync(tmp, path)
26
+ }
27
+ const ownerHash = hash(ownerToken)
28
+ return {
29
+ /** @returns {{id: string, name: string, owner: boolean} | undefined} */
30
+ authenticate(token) {
31
+ if (typeof token !== 'string' || token.length === 0) return undefined
32
+ const h = hash(token)
33
+ if (eq(h, ownerHash)) return { id: 'owner', name: 'owner', owner: true }
34
+ const d = devices.find((x) => eq(x.tokenHash, h))
35
+ if (!d) return undefined
36
+ d.lastSeenAt = now(); save()
37
+ return { id: d.id, name: d.name, owner: false }
38
+ },
39
+ add(name) {
40
+ const token = randomBytes(32).toString('base64url')
41
+ const d = { id: randomUUID(), name: String(name || 'device').slice(0, 64), tokenHash: hash(token), createdAt: now(), lastSeenAt: null }
42
+ devices.push(d); save()
43
+ return { deviceId: d.id, token }
44
+ },
45
+ list: () => devices.map(({ id, name, createdAt, lastSeenAt }) => ({ id, name, createdAt, lastSeenAt })),
46
+ rename(id, name) {
47
+ const d = devices.find((x) => x.id === id)
48
+ if (!d) return false
49
+ d.name = String(name || 'device').slice(0, 64)
50
+ save()
51
+ return true
52
+ },
53
+ revoke(id) {
54
+ const before = devices.length
55
+ devices = devices.filter((d) => d.id !== id)
56
+ if (devices.length !== before) save()
57
+ return devices.length !== before
58
+ },
59
+ }
60
+ }