@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
package/package.json
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huakirax/dsh-plugin-pocket",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|