@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
package/package.json CHANGED
@@ -1,6 +1,93 @@
1
1
  {
2
2
  "name": "@huakirax/dsh-plugin-pocket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Bridge plugin for the dsh-android native app: a small authenticated JSON/WebSocket protocol over DeepSeek Harness sessions",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "android",
10
+ "remote"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Huakira/dsh-plugin-pocket.git"
16
+ },
17
+ "type": "module",
18
+ "main": "index.js",
19
+ "exports": {
20
+ ".": "./index.js",
21
+ "./client": "./lib/client.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "bin": {
25
+ "dsh-pocket": "bin/pocket.js"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "src",
30
+ "lib",
31
+ "bin",
32
+ "cordis.patch.yml",
33
+ "README.md",
34
+ "iroh"
35
+ ],
36
+ "engines": {
37
+ "node": "^22.19.0 || >=24.0.0",
38
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
39
+ },
40
+ "scripts": {
41
+ "build:client": "tsdown",
42
+ "build:iroh": "cargo build --release -p pocket-host --manifest-path iroh/Cargo.toml",
43
+ "pretest": "npm run build:client",
44
+ "lint": "eslint .",
45
+ "test": "node --test --test-timeout=60000 test/*.test.js",
46
+ "test:integration": "npm run build:client && node --test test/integration/*.test.js"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-locale",
56
+ "@deepseek-ai/dsh-client-ui-settings"
57
+ ]
58
+ }
59
+ },
60
+ "dependencies": {
61
+ "bonjour-service": "1.4.4",
62
+ "fflate": "^0.8.3",
63
+ "ws": "^8.18.0"
64
+ },
65
+ "peerDependencies": {
66
+ "@deepseek-ai/dsh-api-session-controller": ">=0.2.1-alpha.2 <0.2.3-0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@deepseek-ai/dsh-api-session-controller": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@deepseek-ai/cordis": "^4.0.2",
75
+ "@eslint/js": "^9.14.0",
76
+ "@types/react": "~18.3.1",
77
+ "@types/react-dom": "~18.3.0",
78
+ "eslint": "^9.14.0",
79
+ "globals": "^15.12.0",
80
+ "react": "^18.2.0",
81
+ "react-dom": "^18.2.0",
82
+ "tsdown": "^0.15.0",
83
+ "typescript": "^5.6.0",
84
+ "uqr": "^0.1.3"
85
+ },
86
+ "author": {
87
+ "name": "Huakira",
88
+ "email": "huakexie@proton.me"
89
+ },
90
+ "publishConfig": {
91
+ "access": "public"
92
+ }
93
+ }
@@ -0,0 +1,69 @@
1
+ import { networkInterfaces } from 'node:os'
2
+
3
+ /**
4
+ * Tailscale CGNAT (100.64.0.0/10) or MagicDNS.
5
+ * @param {string} host
6
+ */
7
+ export function isTailscaleHost(host) {
8
+ if (!host || typeof host !== 'string') return false
9
+ if (host.endsWith('.ts.net')) return true
10
+ const p = host.split('.').map((x) => Number(x))
11
+ return p.length === 4 && p.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)
12
+ && p[0] === 100 && p[1] >= 64 && p[1] <= 127
13
+ }
14
+
15
+ /**
16
+ * Discover phone-reachable IPv4 endpoints on the interfaces actually bound by WS.
17
+ * A concrete bind (including a resolved config hostname) must not advertise other NICs.
18
+ *
19
+ * @param {string | undefined | null} configured
20
+ * @param {string} boundHost Actual server.address().address, or the configured bind.
21
+ * @param {ReturnType<typeof networkInterfaces>} [interfaces]
22
+ * @returns {Array<{type: 'lan'|'tailscale', host: string}>}
23
+ */
24
+ export function listAdvertiseNetworks(configured, boundHost = pickBindHost(configured), interfaces = networkInterfaces()) {
25
+ const pinned = typeof configured === 'string' ? configured.trim() : ''
26
+ const wildcard = boundHost === '0.0.0.0' || boundHost === '::'
27
+ const explicit = pinned && pinned !== '0.0.0.0' && pinned !== '::'
28
+ const networks = []
29
+ for (const list of Object.values(interfaces)) {
30
+ for (const n of list ?? []) {
31
+ if (n.family !== 'IPv4' && n.family !== 4) continue
32
+ if (n.internal || !n.address || n.address.startsWith('127.') || n.address === '0.0.0.0') continue
33
+ if (!wildcard && n.address !== boundHost) continue
34
+ if (explicit && pinned !== n.address && boundHost === pinned) continue
35
+ const host = explicit ? pinned : n.address
36
+ const type = isTailscaleHost(host) || isTailscaleHost(n.address) ? 'tailscale' : 'lan'
37
+ if (!networks.some((network) => network.type === type)) networks.push({ type, host })
38
+ }
39
+ }
40
+ return networks.sort((a, b) => Number(a.type === 'tailscale') - Number(b.type === 'tailscale'))
41
+ }
42
+
43
+ /**
44
+ * Omitted selection prefers LAN; explicit unavailable selections never fall back.
45
+ * @param {Array<{type: 'lan'|'tailscale', host: string}>} networks
46
+ * @param {string} [requested]
47
+ */
48
+ export function selectNetwork(networks, requested) {
49
+ if (requested !== undefined && requested !== 'lan' && requested !== 'tailscale') {
50
+ throw Object.assign(new Error('network must be lan or tailscale'), { statusCode: 400 })
51
+ }
52
+ const selected = requested === undefined
53
+ ? networks.find((network) => network.type === 'lan') ?? networks[0]
54
+ : networks.find((network) => network.type === requested)
55
+ if (requested !== undefined && !selected) {
56
+ throw Object.assign(new Error(`${requested} network is unavailable on the bound interfaces`), { statusCode: 503 })
57
+ }
58
+ return selected
59
+ }
60
+
61
+ /**
62
+ * WS bind host: explicit config wins; otherwise all interfaces so Tailscale + LAN work.
63
+ * @param {string | undefined | null} configured
64
+ */
65
+ export function pickBindHost(configured) {
66
+ const pinned = typeof configured === 'string' ? configured.trim() : ''
67
+ if (pinned) return pinned
68
+ return '0.0.0.0'
69
+ }
@@ -0,0 +1,50 @@
1
+ const never = new Promise(() => {})
2
+
3
+ /**
4
+ * Build the `approval/request` waterfall listener.
5
+ *
6
+ * Races the phone answer against the rest of the chain (`next()`: the web UI's
7
+ * forwarded Remote event and any other answerer). First answer wins.
8
+ *
9
+ * When a phone wins we must also clear the web UI's pending card. The Gateway
10
+ * withdraws a forwarded waterfall (sends `{type:'cancel'}` to browsers) only
11
+ * when the request's own `signal` aborts, so before calling `next()` we replace
12
+ * `req.signal` on the shared request object with `AbortSignal.any([orig, ours])`
13
+ * and abort `ours` when the phone wins. ApprovalService keeps its own reference
14
+ * to the original signal, so its cancellation semantics are unchanged.
15
+ * `'unavailable'` from the chain means "no answerer there" and does not win.
16
+ */
17
+ export function createApprovalHook(server, { log = () => {} } = {}) {
18
+ return async function onApprovalRequest(req, next) {
19
+ if (!server.hasDevices()) return next()
20
+ const orig = req.signal
21
+ if (orig?.aborted) return next()
22
+ const ours = new AbortController()
23
+ try {
24
+ req.signal = orig ? AbortSignal.any([orig, ours.signal]) : ours.signal
25
+ } catch (error) {
26
+ log('cannot attach withdraw signal; web card may linger:', error)
27
+ }
28
+ const offer = server.offerApproval({
29
+ sessionId: req.agent?.session?.id ?? req.agent?.id,
30
+ toolName: req.toolName ?? '',
31
+ ...(req.callId ? { callId: req.callId } : {}),
32
+ ...(req.reason ? { reason: req.reason } : {}),
33
+ ...(req.displayReason ? { displayReason: req.displayReason } : {}),
34
+ })
35
+ const chain = Promise.resolve().then(next)
36
+ const web = chain.then(
37
+ (outcome) => (outcome === 'unavailable' ? never : { outcome, by: 'chain' }),
38
+ () => never,
39
+ )
40
+ const aborted = orig ? new Promise((r) => {
41
+ if (orig.aborted) r({ outcome: 'cancelled', by: 'signal' })
42
+ orig.addEventListener('abort', () => r({ outcome: 'cancelled', by: 'signal' }), { once: true })
43
+ }) : never
44
+ const winner = await Promise.race([offer.answer, web, aborted])
45
+ if (winner.by.startsWith('device:')) ours.abort(new Error('answered on a paired device'))
46
+ chain.catch(() => {})
47
+ offer.close(winner.outcome, winner.by)
48
+ return winner.outcome
49
+ }
50
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect, type JSX, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { styles } from './styles.ts'
4
+
5
+ /**
6
+ * Lightweight confirm dialog matching dsh Modal / SignOutDialog chrome
7
+ * (mask + card + title + body + action row), built with theme tokens only.
8
+ * Does not `require` `@deepseek-ai/dsh-client-ui-primitives` (plugin hard rule).
9
+ */
10
+ export function ConfirmDialog({
11
+ open,
12
+ title,
13
+ body,
14
+ cancelLabel,
15
+ confirmLabel,
16
+ busy = false,
17
+ onCancel,
18
+ onConfirm,
19
+ }: {
20
+ open: boolean
21
+ title: string
22
+ body: ReactNode
23
+ cancelLabel: string
24
+ confirmLabel: string
25
+ busy?: boolean
26
+ onCancel: () => void
27
+ onConfirm: () => void
28
+ }): JSX.Element | null {
29
+ useEffect(() => {
30
+ if (!open) return
31
+ const onKey = (e: KeyboardEvent) => {
32
+ if (e.key === 'Escape' && !busy) onCancel()
33
+ }
34
+ document.addEventListener('keydown', onKey)
35
+ return () => document.removeEventListener('keydown', onKey)
36
+ }, [open, busy, onCancel])
37
+
38
+ if (!open || typeof document === 'undefined') return null
39
+
40
+ return createPortal(
41
+ <div
42
+ style={styles.dialogMask}
43
+ role="presentation"
44
+ onClick={(e) => {
45
+ if (e.target === e.currentTarget && !busy) onCancel()
46
+ }}
47
+ >
48
+ <div
49
+ role="dialog"
50
+ aria-modal="true"
51
+ aria-label={title}
52
+ style={styles.dialogCard}
53
+ onClick={(e) => e.stopPropagation()}
54
+ >
55
+ <h2 style={styles.dialogTitle}>{title}</h2>
56
+ <div style={styles.dialogBody}>{body}</div>
57
+ <div style={styles.dialogActions}>
58
+ <button
59
+ type="button"
60
+ style={styles.ghostBtn}
61
+ disabled={busy}
62
+ data-modal-autofocus
63
+ onClick={onCancel}
64
+ >
65
+ {cancelLabel}
66
+ </button>
67
+ <button
68
+ type="button"
69
+ style={styles.destructiveBtn}
70
+ disabled={busy}
71
+ onClick={onConfirm}
72
+ >
73
+ {confirmLabel}
74
+ </button>
75
+ </div>
76
+ </div>
77
+ </div>,
78
+ document.body,
79
+ )
80
+ }
@@ -0,0 +1,101 @@
1
+ import { useEffect } from 'react'
2
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
3
+ import { installHostUiStyles } from './host-ui-styles.ts'
4
+
5
+ /** Public launcher owner props in the shipped Host. openSection is NOT a launcher prop. */
6
+ type LauncherOwnerProps = {
7
+ wide: boolean
8
+ settingsOpen: boolean
9
+ settingsShortcut?: { readonly keys: readonly string[]; readonly aria?: string | undefined }
10
+ openSettings: () => void
11
+ openOnboarding: (id: string) => void
12
+ }
13
+
14
+ type LauncherProps = LauncherOwnerProps & {
15
+ section: string
16
+ slots: ClientContext['slots']
17
+ finish: () => void
18
+ useSessionReadiness: <T>(select: (state: { phase: string }) => T) => T
19
+ }
20
+
21
+ type SettingsStore = { create: () => { actions: { openSection: (id: string) => void } } }
22
+
23
+ /**
24
+ * The shell publishes its store on the settings.launcher-owning sidebar entry.
25
+ * Its create() returns the SAME root-scoped instance used by the renderer and
26
+ * keyboard command (ui-settings-general), not a second private React tree.
27
+ */
28
+ function sectionNavigator(slots: ClientContext['slots']): ((id: string) => void) | undefined {
29
+ const owner = slots.entries('sidebar.settings').find(entry => entry.children?.['settings.launcher'])
30
+ const store: unknown = owner?.store
31
+ if (typeof store !== 'object' || store === null || !('create' in store) || typeof store.create !== 'function') return undefined
32
+ const instance = (store as SettingsStore).create()
33
+ if (typeof instance?.actions?.openSection !== 'function') return undefined
34
+ return id => { instance.actions.openSection(id) }
35
+ }
36
+
37
+ /** A temporary launcher occupant; the official launcher resumes after navigation. */
38
+ export function HostUiLauncher({ section, slots, finish, openSettings, useSessionReadiness }: LauncherProps) {
39
+ const ready = useSessionReadiness(state => state.phase === 'ready')
40
+ useEffect(() => {
41
+ let finished = false
42
+ let shown = false
43
+ const navigate = () => {
44
+ if (!ready || finished) return
45
+ if (!shown) {
46
+ shown = true
47
+ queueMicrotask(() => { if (shown) openSettings() })
48
+ }
49
+ if (!slots.entries('settings.section').some(entry => entry.options.id === section)) return
50
+ const openSection = sectionNavigator(slots)
51
+ if (!openSection) return
52
+ finished = true
53
+ // The official shell closes settings when its initial session snapshot
54
+ // introduces onboarding. Navigate after that render's effects, not before it.
55
+ queueMicrotask(() => {
56
+ if (!finished) return
57
+ openSection(section)
58
+ finish()
59
+ })
60
+ }
61
+ // Subscribe before inspecting: sections may arrive after the shell mounts.
62
+ const offSections = slots.subscribe('settings.section', navigate)
63
+ const offShell = slots.subscribe('sidebar.settings', navigate)
64
+ navigate()
65
+ return () => {
66
+ finished = false
67
+ shown = false
68
+ offSections()
69
+ offShell()
70
+ }
71
+ }, [section, slots, finish, openSettings, ready])
72
+
73
+ return null
74
+ }
75
+
76
+ /** Returns false on ordinary desktop windows, leaving all existing registrations intact. */
77
+ export function installHostUi(ctx: ClientContext): boolean {
78
+ const query = new URLSearchParams(window.location.search)
79
+ if (query.get('pocket-ui') !== '1') return false
80
+ ctx.effect(() => installHostUiStyles(), 'pocket:host-ui-styles')
81
+ const section = query.get('section')
82
+ // Without a deep link keep the entire official client, including its account launcher.
83
+ if (!section) return true
84
+ let completed = false
85
+ ctx.inject(['sessions'], (scope) => {
86
+ scope.slots.inject('settings.launcher', () => {
87
+ if (completed) return
88
+ const finish = () => {
89
+ completed = true
90
+ unregister()
91
+ }
92
+ const unregister = scope.slots.register({
93
+ name: 'settings.launcher',
94
+ priority: -100,
95
+ inject: () => ({ section, slots: scope.slots, finish, hooks: { sessionReadiness: scope.sessions.list } }),
96
+ }, HostUiLauncher)
97
+ return unregister
98
+ })
99
+ })
100
+ return true
101
+ }
@@ -0,0 +1,218 @@
1
+ import { useCallback, useEffect, useRef, useState, type JSX } from 'react'
2
+ import type { DeviceRow, NetworkType, PocketApi, PocketStatus } from './api.ts'
3
+ import { ConfirmDialog } from './ConfirmDialog.tsx'
4
+ import { qrSvg } from './qr.ts'
5
+ import { panelCss, styles } from './styles.ts'
6
+
7
+ type TFn = (key: string, vars?: Record<string, string | number>) => string
8
+
9
+ function Icon({ name, size = 18 }: { name: 'phone' | 'copy' | 'refresh' | 'plus' | 'check', size?: number }): JSX.Element {
10
+ const paths = {
11
+ phone: <><rect x="6" y="2" width="12" height="20" rx="3" /><path d="M11 18h2" /></>,
12
+ copy: <><rect x="8" y="8" width="12" height="14" rx="2" /><path d="M15 4V2H4v14h1" /></>,
13
+ refresh: <><path d="M20 7a9 9 0 1 0 0 10M20 2v5h-5" /></>,
14
+ plus: <path d="M12 4v16M4 12h16" />,
15
+ check: <path d="m5 12 4 4L19 6" />,
16
+ }
17
+ return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
18
+ }
19
+
20
+ function StatusDot({ state }: { state: 'done' | 'error' | 'idle' }): JSX.Element {
21
+ return <span style={styles.stateDotSlot(state)} aria-hidden="true"><span style={styles.stateDotCore} /></span>
22
+ }
23
+
24
+ function DeviceItem({ device, t, api, onChanged, onError }: {
25
+ device: DeviceRow
26
+ t: TFn
27
+ api: PocketApi
28
+ onChanged: (devices: DeviceRow[]) => void
29
+ onError: (message: string) => void
30
+ }): JSX.Element {
31
+ const [editing, setEditing] = useState(false)
32
+ const [name, setName] = useState(device.name)
33
+ const [confirmOpen, setConfirmOpen] = useState(false)
34
+ const [busy, setBusy] = useState(false)
35
+ useEffect(() => { setName(device.name) }, [device.name])
36
+
37
+ const save = async () => {
38
+ const trimmed = name.trim()
39
+ if (!trimmed || trimmed === device.name) { setEditing(false); return }
40
+ setBusy(true)
41
+ try {
42
+ const r = await api.rename(device.id, trimmed)
43
+ onChanged(r.devices)
44
+ setEditing(false)
45
+ } catch (e) { onError(String((e as Error)?.message ?? e)) }
46
+ finally { setBusy(false) }
47
+ }
48
+ const revoke = async () => {
49
+ setBusy(true)
50
+ try {
51
+ const r = await api.revoke(device.id)
52
+ onChanged(r.devices)
53
+ setConfirmOpen(false)
54
+ } catch (e) { onError(String((e as Error)?.message ?? e)) }
55
+ finally { setBusy(false) }
56
+ }
57
+ const lastSeen = device.lastSeenAt == null ? t('neverSeen') : new Date(device.lastSeenAt).toLocaleString(undefined, {
58
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
59
+ })
60
+
61
+ return <div style={styles.deviceRow}>
62
+ <div style={styles.deviceIcon}><Icon name="phone" size={28} /></div>
63
+ <div style={styles.deviceInfo}>
64
+ {editing ? <input style={styles.input} aria-label={t('rename')} value={name} disabled={busy}
65
+ onChange={(e) => setName(e.target.value)} autoFocus
66
+ onKeyDown={(e) => {
67
+ if (e.key === 'Enter') void save()
68
+ if (e.key === 'Escape') { setEditing(false); setName(device.name) }
69
+ }} /> : <div style={styles.deviceHeading}>
70
+ <span style={styles.deviceName} title={device.name}>{device.name}</span>
71
+ <span style={styles.service}><StatusDot state={device.online ? 'done' : 'idle'} />{device.online ? t('online') : t('offline')}</span>
72
+ </div>}
73
+ <div style={styles.deviceMeta}>{t('lastSeen')} {lastSeen}</div>
74
+ </div>
75
+ <div className="pocket-device-actions" style={styles.deviceActions}>
76
+ {editing ? <>
77
+ <button type="button" style={styles.outlineBtnSm} disabled={busy} onClick={() => void save()}>{t('renameSave')}</button>
78
+ <button type="button" style={styles.textBtn} disabled={busy} onClick={() => { setEditing(false); setName(device.name) }}>{t('renameCancel')}</button>
79
+ </> : <>
80
+ <button type="button" style={styles.textBtn} disabled={busy} onClick={() => setEditing(true)}>{t('rename')}</button>
81
+ <span style={styles.divider} />
82
+ <button type="button" style={styles.textBtn} disabled={busy} onClick={() => setConfirmOpen(true)}>{t('revoke')}</button>
83
+ </>}
84
+ </div>
85
+ <ConfirmDialog open={confirmOpen} title={t('revokeTitle')}
86
+ body={t('revokeBody', { name: device.name }).replaceAll('{name}', device.name)} cancelLabel={t('cancel')}
87
+ confirmLabel={t('revoke')} busy={busy} onCancel={() => { if (!busy) setConfirmOpen(false) }} onConfirm={() => void revoke()} />
88
+ </div>
89
+ }
90
+
91
+ export function ManagePanel({ t, api }: { t: TFn, api: PocketApi }): JSX.Element {
92
+ const [status, setStatus] = useState<PocketStatus | null>(null)
93
+ const [error, setError] = useState<string | null>(null)
94
+ const [loading, setLoading] = useState(true)
95
+ const [busy, setBusy] = useState(false)
96
+ const [copied, setCopied] = useState<string | null>(null)
97
+ const network = useRef<NetworkType | undefined>(undefined)
98
+ const request = useRef(0)
99
+ const copyTimer = useRef<number | undefined>(undefined)
100
+
101
+ const refresh = useCallback(async (background = false) => {
102
+ const id = ++request.current
103
+ if (!background) setLoading(true)
104
+ try {
105
+ const s = await api.status(network.current)
106
+ if (id !== request.current) return
107
+ setStatus(s)
108
+ setError(null)
109
+ } catch (e) {
110
+ if (id !== request.current) return
111
+ setStatus(null)
112
+ setError(String((e as Error)?.message ?? e))
113
+ } finally { if (id === request.current) setLoading(false) }
114
+ }, [api])
115
+
116
+ useEffect(() => {
117
+ void refresh()
118
+ return () => { request.current++; clearTimeout(copyTimer.current) }
119
+ }, [refresh])
120
+ useEffect(() => {
121
+ if (!status?.pairing.open) return
122
+ const timer = setInterval(() => { void refresh(true) }, 1000)
123
+ return () => clearInterval(timer)
124
+ }, [status?.pairing.open, refresh])
125
+
126
+ const changeNetwork = (next: NetworkType) => {
127
+ network.current = next
128
+ setCopied(null)
129
+ // Hide the old QR immediately; it must never appear under a new selection.
130
+ setStatus((s) => s ? { ...s, pairing: { open: false } } : s)
131
+ void refresh()
132
+ }
133
+ const updatePairing = async (open: boolean) => {
134
+ setBusy(true)
135
+ setError(null)
136
+ try {
137
+ if (open) await api.openPair(network.current)
138
+ else await api.closePair()
139
+ await refresh()
140
+ } catch (e) { setError(String((e as Error)?.message ?? e)) }
141
+ finally { setBusy(false) }
142
+ }
143
+ const copy = async (text: string, key: string) => {
144
+ try {
145
+ await navigator.clipboard.writeText(text)
146
+ clearTimeout(copyTimer.current)
147
+ setCopied(key)
148
+ copyTimer.current = window.setTimeout(() => setCopied(null), 1500)
149
+ } catch (e) { setError(String((e as Error)?.message ?? e)) }
150
+ }
151
+
152
+ const pairing = status?.pairing
153
+ const selected = status?.connectionType
154
+ const pairReady = Boolean(status?.running && status.networks.length && status.host)
155
+ const pairingOpen = Boolean(pairReady && pairing?.open && pairing.code && pairing.uri)
156
+ const address = status?.host ? `${status.host}:${status.port}` : ''
157
+ const disabled = loading || busy
158
+
159
+ return <div data-pocket-panel style={styles.settingsRoot}>
160
+ <style>{panelCss}</style>
161
+ <header style={styles.header}>
162
+ <h2 style={styles.title}>{t('title')}</h2>
163
+ <span style={styles.service}><StatusDot state={pairReady ? 'done' : 'idle'} />{loading && !status ? t('loading') : t(pairReady ? 'statusRunning' : 'statusStopped')}</span>
164
+ </header>
165
+ {error ? <div role="alert" style={styles.errorText}>{error}</div> : null}
166
+ <section style={styles.pairingCard} aria-label={t('newDevice')}>
167
+ <div style={{ ...styles.pairingTop, ...(pairingOpen ? { minHeight: 0 } : {}) }}>
168
+ <div style={styles.pairControls}>
169
+ <h3 style={styles.rowTitle}>{t('newDevice')}</h3>
170
+ {status && status.networks.length > 1 ? <div style={styles.segments} role="group" aria-label={t('network')}>
171
+ {status.networks.map((n) => <button type="button" key={n.type} aria-pressed={selected === n.type}
172
+ style={{ ...styles.segment, ...(selected === n.type ? styles.segmentActive : {}) }} disabled={disabled}
173
+ onClick={() => changeNetwork(n.type)}>{t(n.type)}</button>)}
174
+ </div> : null}
175
+ {!pairingOpen ? <button type="button" style={styles.primaryBtn} disabled={disabled || !pairReady} onClick={() => void updatePairing(true)}>
176
+ <Icon name="plus" />{t('pair')}
177
+ </button> : null}
178
+ </div>
179
+ {!pairingOpen ? <svg className="pocket-illustration" style={styles.illustration} viewBox="0 0 180 130" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
180
+ <path d="M20 94V22a8 8 0 0 1 8-8h105a8 8 0 0 1 8 8v11M121 94H7v8h114" />
181
+ <rect x="132" y="43" width="40" height="75" rx="8" /><path d="M148 50h9m-8 59h7" />
182
+ </svg> : null}
183
+ </div>
184
+ {pairingOpen ? <div style={styles.pairingContent}>
185
+ <div style={styles.qrBox} role="img" aria-label={t('code')} dangerouslySetInnerHTML={{ __html: qrSvg(pairing!.uri!, 168) }} />
186
+ <div>
187
+ <div style={styles.code}>{pairing!.code}</div>
188
+ <p style={styles.hint}>{t('expiresIn', { n: Math.max(0, pairing!.expiresInSec ?? 0) })}</p>
189
+ <div style={styles.actionRow}>
190
+ <button type="button" style={styles.outlineBtn} onClick={() => void copy(pairing!.pairingString || pairing!.uri!, 'pair')}>{t(copied === 'pair' ? 'copied' : 'copy')}</button>
191
+ <button type="button" style={styles.textBtn} disabled={disabled} onClick={() => void updatePairing(false)}>{t('pairClose')}</button>
192
+ <button type="button" style={styles.textBtn} disabled={disabled} onClick={() => void updatePairing(true)}>{t('pairAgain')}</button>
193
+ </div>
194
+ </div>
195
+ </div> : null}
196
+ <footer style={styles.footer}>
197
+ <div style={styles.address}>
198
+ {address ? <>
199
+ <span>{t(selected === 'tailscale' ? 'tailscaleAddress' : 'lanAddress')}</span>
200
+ <code style={styles.addressValue}>{address}</code>
201
+ <button type="button" style={styles.iconBtn} title={t(copied === 'address' ? 'copied' : 'copyAddress')} aria-label={t(copied === 'address' ? 'copied' : 'copyAddress')} onClick={() => void copy(address, 'address')}><Icon name={copied === 'address' ? 'check' : 'copy'} size={16} /></button>
202
+ </> : <span>{t(loading ? 'loading' : 'noNetwork')}</span>}
203
+ </div>
204
+ <div style={styles.footerActions}>
205
+ <button type="button" style={styles.iconBtn} disabled={disabled} title={t('refresh')} aria-label={t('refresh')}
206
+ onClick={() => { if (!status) network.current = undefined; void refresh() }}><Icon name="refresh" /></button>
207
+ </div>
208
+ </footer>
209
+ </section>
210
+ <section style={styles.devicesSection} aria-label={t('devices')}>
211
+ <div style={styles.devicesHeader}><h3 style={styles.rowTitle}>{t('devices')}</h3><span style={styles.count}>{status?.devices.length ?? 0}</span></div>
212
+ <div style={styles.deviceList}>
213
+ {status?.devices.length ? status.devices.map((device) => <DeviceItem key={device.id} device={device} t={t} api={api} onError={setError}
214
+ onChanged={(devices) => setStatus((s) => s ? { ...s, devices } : s)} />) : <p style={styles.hint}>{t(loading ? 'loading' : 'empty')}</p>}
215
+ </div>
216
+ </section>
217
+ </div>
218
+ }
@@ -0,0 +1,48 @@
1
+ import type { JSX } from 'react'
2
+
3
+ /** Matches dsh ui-primitives Medium outline icons: 16×16 viewBox, 1.3 stroke. */
4
+ export const ICON_MEDIUM_STROKE = 1.3
5
+
6
+ /**
7
+ * Smartphone outline glyph for the Settings nav row.
8
+ * dsh's icon set has no phone/smartphone export; this follows the same
9
+ * Medium outline conventions (size/stroke/currentColor) used by
10
+ * IconSettingsOutlineMedium and neighbors.
11
+ */
12
+ export function PhoneIcon({
13
+ size = 16,
14
+ className,
15
+ strokeWidth = ICON_MEDIUM_STROKE,
16
+ }: {
17
+ size?: number
18
+ className?: string
19
+ strokeWidth?: number
20
+ }): JSX.Element {
21
+ return (
22
+ <svg
23
+ width={size}
24
+ height={size}
25
+ className={className}
26
+ viewBox="0 0 16 16"
27
+ fill="none"
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ aria-hidden="true"
30
+ strokeWidth={strokeWidth}
31
+ >
32
+ <rect x="4.75" y="1.75" width="6.5" height="12.5" rx="1.25" stroke="currentColor" />
33
+ <path d="M7 12.25h2" stroke="currentColor" strokeLinecap="round" />
34
+ </svg>
35
+ )
36
+ }
37
+
38
+ /** Markup used when swapping the shell's default gear in the nav cell. */
39
+ export function phoneIconSvgHtml(className: string): string {
40
+ return (
41
+ `<svg width="16" height="16" class="${className}" viewBox="0 0 16 16" fill="none" ` +
42
+ `xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke-width="${ICON_MEDIUM_STROKE}" ` +
43
+ `data-pocket-icon="1">` +
44
+ `<rect x="4.75" y="1.75" width="6.5" height="12.5" rx="1.25" stroke="currentColor"></rect>` +
45
+ `<path d="M7 12.25h2" stroke="currentColor" stroke-linecap="round"></path>` +
46
+ `</svg>`
47
+ )
48
+ }