@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +237 -2
  3. package/bin/pocket.js +31 -0
  4. package/cordis.patch.yml +6 -0
  5. package/index.js +360 -0
  6. package/lib/client.js +2453 -0
  7. package/package.json +91 -4
  8. package/src/advertise-host.js +69 -0
  9. package/src/approval-hook.js +50 -0
  10. package/src/client/ConfirmDialog.tsx +80 -0
  11. package/src/client/HostUiLauncher.tsx +101 -0
  12. package/src/client/ManagePanel.tsx +218 -0
  13. package/src/client/PhoneIcon.tsx +48 -0
  14. package/src/client/PocketSettings.tsx +13 -0
  15. package/src/client/api.ts +97 -0
  16. package/src/client/completion-sync.js +289 -0
  17. package/src/client/host-ui-styles.ts +62 -0
  18. package/src/client/index.ts +47 -0
  19. package/src/client/locales.ts +75 -0
  20. package/src/client/nav-icon.ts +31 -0
  21. package/src/client/qr.ts +15 -0
  22. package/src/client/styles.ts +76 -0
  23. package/src/completion-route.js +87 -0
  24. package/src/completion-tracker.js +95 -0
  25. package/src/devices.js +60 -0
  26. package/src/extra-ops.js +604 -0
  27. package/src/host-ui.js +466 -0
  28. package/src/instance-id.js +33 -0
  29. package/src/iroh.js +260 -0
  30. package/src/manage.js +122 -0
  31. package/src/mdns-advertise.js +75 -0
  32. package/src/office-preview.js +129 -0
  33. package/src/pairing-uri.js +42 -0
  34. package/src/pairing.js +34 -0
  35. package/src/parity-ops.js +250 -0
  36. package/src/server.js +761 -0
  37. package/src/settings-ops.js +387 -0
  38. package/src/task-events.js +77 -0
  39. package/src/task-fallback.js +177 -0
  40. package/src/token.js +18 -0
  41. package/src/transport-pref.js +47 -0
  42. package/src/trust.js +25 -0
  43. package/src/user-question-hook.js +93 -0
  44. package/src/web-route.js +116 -0
@@ -0,0 +1,13 @@
1
+ import type { JSX } from 'react'
2
+ import type { PocketApi } from './api.ts'
3
+ import { ManagePanel } from './ManagePanel.tsx'
4
+
5
+ export function PocketSettings({
6
+ t, api,
7
+ }: {
8
+ t: (key: string, vars?: Record<string, string | number>) => string
9
+ api: PocketApi
10
+ close: () => void
11
+ }): JSX.Element {
12
+ return <ManagePanel t={t} api={api} />
13
+ }
@@ -0,0 +1,97 @@
1
+ export type NetworkType = 'lan' | 'tailscale'
2
+
3
+ export type PairingStatus = {
4
+ open: boolean
5
+ code?: string
6
+ expiresInSec?: number
7
+ attemptsLeft?: number
8
+ transport?: 'tailscale'
9
+ host?: string
10
+ port?: number
11
+ uri?: string
12
+ pairingString?: string
13
+ error?: string
14
+ }
15
+
16
+ export type DeviceRow = {
17
+ id: string
18
+ name: string
19
+ createdAt: number
20
+ lastSeenAt: number | null
21
+ online: boolean
22
+ }
23
+
24
+ export type PocketStatus = {
25
+ running: boolean
26
+ version: string
27
+ plugin: string
28
+ /** Phone path is always host:port WebSocket. */
29
+ transport?: 'tailscale'
30
+ host: string
31
+ port: number
32
+ connectionType: NetworkType | null
33
+ networks: Array<{ type: NetworkType, host: string }>
34
+ listen: Array<{ host: string, port: number, address: string }>
35
+ pairing: PairingStatus
36
+ devices: DeviceRow[]
37
+ }
38
+
39
+ export interface PocketApi {
40
+ status(network?: NetworkType): Promise<PocketStatus>
41
+ pairStatus(network?: NetworkType): Promise<PairingStatus>
42
+ openPair(network?: NetworkType): Promise<PairingStatus>
43
+ closePair(): Promise<{ open: false }>
44
+ listDevices(): Promise<DeviceRow[]>
45
+ revoke(id: string): Promise<{ removed: boolean, devices: DeviceRow[] }>
46
+ rename(id: string, name: string): Promise<{ renamed: boolean, devices: DeviceRow[] }>
47
+ }
48
+
49
+ async function readBody(res: Response): Promise<unknown> {
50
+ if (typeof res?.json === 'function') {
51
+ try { return await res.json() } catch { /* fall through */ }
52
+ }
53
+ if (typeof res?.text === 'function') {
54
+ try { return await res.text() } catch { /* fall through */ }
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ async function parse<T>(res: Response): Promise<T> {
60
+ if (!res || res.ok !== true) {
61
+ const body = await readBody(res)
62
+ const detail = body === undefined
63
+ ? `HTTP ${res?.status ?? '?'}`
64
+ : (typeof body === 'string' ? body : JSON.stringify(body))
65
+ throw new Error(detail || `HTTP ${res?.status ?? '?'}`)
66
+ }
67
+ return (await readBody(res)) as T
68
+ }
69
+
70
+ /** Same-origin fetch helpers for the Host management routes. */
71
+ export function createPocketApi(base = ''): PocketApi {
72
+ const get = async <T>(path: string, init?: RequestInit) => parse<T>(await fetch(`${base}${path}`, init))
73
+ const networkPath = (path: string, network?: NetworkType) => network ? `${path}?network=${network}` : path
74
+ return {
75
+ status: (network?: NetworkType) => get<PocketStatus>(networkPath('/pocket/status', network)),
76
+ pairStatus: (network?: NetworkType) => get<PairingStatus>(networkPath('/pocket/pair', network)),
77
+ openPair: (network?: NetworkType) => get<PairingStatus>(
78
+ networkPath('/pocket/pair', network), { method: 'POST' },
79
+ ),
80
+ closePair: () => get<{ open: false }>('/pocket/pair', { method: 'DELETE' }),
81
+ listDevices: () => get<{ devices: DeviceRow[] }>('/pocket/devices').then((r) => r.devices),
82
+ revoke: (id: string) => get<{ removed: boolean, devices: DeviceRow[] }>(
83
+ '/pocket/devices', {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({ id }),
87
+ },
88
+ ),
89
+ rename: (id: string, name: string) => get<{ renamed: boolean, devices: DeviceRow[] }>(
90
+ '/pocket/devices', {
91
+ method: 'PATCH',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify({ id, name }),
94
+ },
95
+ ),
96
+ }
97
+ }
@@ -0,0 +1,289 @@
1
+ /** Overlay Host unread onto official sessionStatus; Host is source of truth for the green-dot bit. */
2
+ export function overlayCompletionUnread(official, unread) {
3
+ const next = new Map()
4
+ const ids = new Set(unread)
5
+ if (official && typeof official.keys === 'function') {
6
+ for (const id of official.keys()) ids.add(id)
7
+ }
8
+ for (const id of ids) {
9
+ const base = official?.get?.(id)
10
+ next.set(id, {
11
+ running: base?.running,
12
+ pendingInteraction: base?.pendingInteraction,
13
+ completionUnread: unread.has(id),
14
+ })
15
+ }
16
+ return next
17
+ }
18
+
19
+ /**
20
+ * Official ui-session@0.2.1-alpha.2 has no setCompletionUnreadSource. Its public
21
+ * sessionStatus HostObservable (and the instance listener Set it closes over)
22
+ * is the mounted extension point: keep running/pending from official, replace
23
+ * completionUnread with the Host snapshot, and notify the same subscribers
24
+ * StateDot already uses. Keep the derived snapshot stable until either input
25
+ * snapshot changes. Missing that surface is a hard error — do not no-op.
26
+ */
27
+ export function overlaySessionStatusCompletionUnread(sessionStatus, source, listenerSet) {
28
+ if (!sessionStatus || typeof sessionStatus.getSnapshot !== 'function' || typeof sessionStatus.subscribe !== 'function') {
29
+ throw new Error('uiSession.sessionStatus is not an overlayable HostObservable')
30
+ }
31
+ if (!(listenerSet instanceof Set)) {
32
+ throw new Error('uiSession status listeners are not reachable; cannot sync completion unread')
33
+ }
34
+ const origGet = sessionStatus.getSnapshot.bind(sessionStatus)
35
+ let official = origGet()
36
+ let unread = source.getSnapshot()
37
+ let snapshot = overlayCompletionUnread(official, unread)
38
+ sessionStatus.getSnapshot = () => {
39
+ const nextOfficial = origGet()
40
+ const nextUnread = source.getSnapshot()
41
+ if (nextOfficial !== official || nextUnread !== unread) {
42
+ official = nextOfficial
43
+ unread = nextUnread
44
+ snapshot = overlayCompletionUnread(official, unread)
45
+ }
46
+ return snapshot
47
+ }
48
+ const notify = () => {
49
+ for (const listener of [...listenerSet]) listener()
50
+ }
51
+ const off = source.subscribe(notify)
52
+ notify()
53
+ return () => {
54
+ off()
55
+ sessionStatus.getSnapshot = origGet
56
+ }
57
+ }
58
+
59
+ /** Prefer an official hook when present; otherwise overlay mounted sessionStatus. */
60
+ export function bindCompletionUnreadSource(uiSession, source) {
61
+ if (typeof uiSession?.setCompletionUnreadSource === 'function') {
62
+ return uiSession.setCompletionUnreadSource(source)
63
+ }
64
+ return overlaySessionStatusCompletionUnread(
65
+ uiSession?.sessionStatus,
66
+ source,
67
+ uiSession?.statusListeners,
68
+ )
69
+ }
70
+
71
+ /** Host completion snapshots and view presence, independent of rendering and transport mocks. */
72
+ export function createCompletionSync({ connect, postView, installSource, visibleSession, reportError = console.error }) {
73
+ let snapshot
74
+ let unread = new Set()
75
+ const listeners = new Set()
76
+ const source = {
77
+ getSnapshot: () => unread,
78
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) },
79
+ }
80
+ let uninstall
81
+ let clientId = null
82
+ let generation = 0
83
+ let disposed = false
84
+ let suspended = false
85
+ let stream
86
+ let carrier = 0
87
+ let desired
88
+ let queue = []
89
+ let pending
90
+ const accept = (value, allowEpoch) => {
91
+ if (!value || typeof value.epoch !== 'string' || !value.epoch
92
+ || !Number.isSafeInteger(value.revision) || value.revision < 0
93
+ || !Array.isArray(value.unreadIds) || value.unreadIds.some(id => typeof id !== 'string' || !id)) {
94
+ throw new Error('Invalid completion snapshot')
95
+ }
96
+ if (snapshot?.epoch !== value.epoch && snapshot && !allowEpoch) return false
97
+ if (snapshot?.epoch === value.epoch && snapshot.revision > value.revision) return false
98
+ if (snapshot?.epoch !== value.epoch || snapshot?.revision !== value.revision) {
99
+ snapshot = value
100
+ unread = new Set(value.unreadIds)
101
+ for (const listener of listeners) listener()
102
+ }
103
+ return true
104
+ }
105
+ const disconnect = () => {
106
+ generation++
107
+ clientId = null
108
+ desired = undefined
109
+ queue = []
110
+ pending?.controller.abort()
111
+ pending = undefined
112
+ }
113
+ const drain = async () => {
114
+ if (disposed || !clientId || pending || !queue.length) return
115
+ const request = { generation, controller: new AbortController() }
116
+ pending = request
117
+ const id = clientId
118
+ const sessionId = queue.shift()
119
+ let received = false
120
+ try {
121
+ const value = await postView(id, sessionId, request.controller.signal)
122
+ received = true
123
+ if (!disposed && generation === request.generation && clientId === id) accept(value, false)
124
+ } catch (error) {
125
+ if (!disposed && generation === request.generation) {
126
+ // An acknowledgement may have committed before its response was lost.
127
+ // Release its presence and obtain a new stream baseline before retrying.
128
+ disconnect()
129
+ carrier++
130
+ stream.close()
131
+ reportError(error)
132
+ if (!received) openStream()
133
+ }
134
+ } finally {
135
+ if (pending === request) {
136
+ pending = undefined
137
+ void drain()
138
+ }
139
+ }
140
+ }
141
+ const updateView = () => {
142
+ if (disposed || !clientId) return
143
+ const next = visibleSession()
144
+ if (next === desired) return
145
+ desired = next
146
+ queue.push(next)
147
+ void drain()
148
+ }
149
+ const openStream = () => {
150
+ const token = ++carrier
151
+ stream = connect({
152
+ message(value) {
153
+ if (disposed || suspended || token !== carrier) return
154
+ try {
155
+ if (typeof value?.clientId !== 'string' || !value.clientId) throw new Error('Invalid completion client identity')
156
+ if (clientId !== null && clientId !== value.clientId) return
157
+ if (!accept(value, clientId === null)) return
158
+ clientId = value.clientId
159
+ if (!uninstall) uninstall = installSource(source)
160
+ updateView()
161
+ } catch (error) {
162
+ disconnect()
163
+ carrier++
164
+ stream.close()
165
+ reportError(error)
166
+ }
167
+ },
168
+ disconnect() {
169
+ if (!disposed && !suspended && token === carrier) disconnect()
170
+ },
171
+ })
172
+ }
173
+ openStream()
174
+ return {
175
+ updateView,
176
+ suspend() {
177
+ if (disposed || suspended) return
178
+ suspended = true
179
+ disconnect()
180
+ carrier++
181
+ stream.close()
182
+ },
183
+ resume() {
184
+ if (disposed || !suspended) return
185
+ suspended = false
186
+ openStream()
187
+ },
188
+ dispose() {
189
+ if (disposed) return
190
+ disposed = true
191
+ disconnect()
192
+ stream.close()
193
+ uninstall?.()
194
+ listeners.clear()
195
+ },
196
+ }
197
+ }
198
+
199
+ /** Observe only already-retained, loaded main-view content; never open or select a Session. */
200
+ export function installCompletionSync({ uiSession, sessions, layout, slots, window: win = window, document: doc = document, fetch: request = fetch, EventSource: Events = EventSource, reportError = console.error }) {
201
+ let binding
202
+ let settings
203
+ let offSession = () => {}
204
+ let offRetain = () => {}
205
+ let offSettings = () => {}
206
+ const visibleSession = () => {
207
+ if (doc.visibilityState !== 'visible' || !doc.hasFocus() || sessions.list.getSnapshot().phase !== 'ready') return null
208
+ if (layout.panelInfo.getSnapshot().activePanelId !== null || settings?.getSnapshot().open) return null
209
+ if (!binding || (sessions.retainInfo(binding.sessionId).getSnapshot().retainedBy.mainView ?? 0) <= 0) return null
210
+ const state = binding.session.getSnapshot()
211
+ return state.openState === 'open' && !state.removed ? binding.sessionId : null
212
+ }
213
+ const sync = createCompletionSync({
214
+ visibleSession,
215
+ reportError,
216
+ installSource: source => bindCompletionUnreadSource(uiSession, source),
217
+ connect({ message, disconnect }) {
218
+ const events = new Events('/pocket/completion/events')
219
+ events.onmessage = event => {
220
+ try { message(JSON.parse(event.data)) } catch (error) {
221
+ disconnect()
222
+ events.close()
223
+ reportError(error)
224
+ }
225
+ }
226
+ events.onerror = disconnect
227
+ return { close: () => events.close() }
228
+ },
229
+ async postView(clientId, sessionId, signal) {
230
+ const response = await request('/pocket/completion/view', {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/json' },
233
+ body: JSON.stringify({ clientId, sessionId }),
234
+ signal,
235
+ })
236
+ if (!response.ok) throw new Error(`Completion view failed: HTTP ${response.status}`)
237
+ return response.json()
238
+ },
239
+ })
240
+ const watchMain = () => {
241
+ const id = uiSession.adapter.current.getSnapshot().key
242
+ const next = id === undefined ? undefined : sessions.binding(id)
243
+ if (next !== binding) {
244
+ offSession()
245
+ offRetain()
246
+ binding = next
247
+ offSession = binding ? binding.session.subscribe(sync.updateView) : () => {}
248
+ offRetain = binding ? sessions.retainInfo(binding.sessionId).subscribe(sync.updateView) : () => {}
249
+ }
250
+ sync.updateView()
251
+ }
252
+ const watchSettings = () => {
253
+ const owner = slots.entries('sidebar.settings').find(entry => entry.children?.['settings.launcher'])
254
+ // SettingsRoot publishes this root-scoped instance for its launcher and shortcuts.
255
+ const next = owner?.store?.create()
256
+ if (next !== settings) {
257
+ offSettings()
258
+ settings = next
259
+ offSettings = settings ? settings.subscribe(sync.updateView) : () => {}
260
+ }
261
+ sync.updateView()
262
+ }
263
+ const offMain = uiSession.adapter.current.subscribe(watchMain)
264
+ const offList = sessions.list.subscribe(watchMain)
265
+ const offSettingsOwner = slots.subscribe('sidebar.settings', watchSettings)
266
+ const offPanel = layout.panelInfo.subscribe(sync.updateView)
267
+ doc.addEventListener('visibilitychange', sync.updateView)
268
+ win.addEventListener('focus', sync.updateView)
269
+ win.addEventListener('blur', sync.updateView)
270
+ win.addEventListener('pagehide', sync.suspend)
271
+ win.addEventListener('pageshow', sync.resume)
272
+ watchSettings()
273
+ watchMain()
274
+ return () => {
275
+ offMain()
276
+ offList()
277
+ offSettingsOwner()
278
+ offSettings()
279
+ offPanel()
280
+ offSession()
281
+ offRetain()
282
+ doc.removeEventListener('visibilitychange', sync.updateView)
283
+ win.removeEventListener('focus', sync.updateView)
284
+ win.removeEventListener('blur', sync.updateView)
285
+ win.removeEventListener('pagehide', sync.suspend)
286
+ win.removeEventListener('pageshow', sync.resume)
287
+ sync.dispose()
288
+ }
289
+ }
@@ -0,0 +1,62 @@
1
+ /** Scoped to this WebView window; no generated classes or plugin-specific selectors. */
2
+ const css = `
3
+ @media (max-width: 700px) {
4
+ html[data-pocket-ui] [data-shortcut-modal="settings"] {
5
+ box-sizing: border-box;
6
+ flex-direction: column;
7
+ width: 100%;
8
+ max-width: 100%;
9
+ height: 100dvh;
10
+ max-height: 100dvh;
11
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
12
+ border-radius: 0;
13
+ }
14
+ html[data-pocket-ui] [data-shortcut-modal="settings"] > nav {
15
+ width: 100%;
16
+ min-width: 0;
17
+ padding: 12px 12px 0;
18
+ gap: 8px;
19
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
20
+ }
21
+ html[data-pocket-ui] [data-shortcut-modal="settings"] > nav > div:last-child {
22
+ flex-direction: row;
23
+ overflow-x: auto;
24
+ overflow-y: hidden;
25
+ padding-bottom: 8px;
26
+ overscroll-behavior-x: contain;
27
+ }
28
+ html[data-pocket-ui] [data-shortcut-modal="settings"] > nav button {
29
+ flex: 0 0 auto;
30
+ min-height: 44px;
31
+ white-space: nowrap;
32
+ }
33
+ html[data-pocket-ui] [data-shortcut-modal="settings"] > nav + div {
34
+ min-width: 0;
35
+ min-height: 0;
36
+ width: 100%;
37
+ }
38
+ html[data-pocket-ui] [data-shortcut-modal="settings"] > nav + div > div:last-child {
39
+ min-width: 0;
40
+ min-height: 0;
41
+ padding-left: 16px;
42
+ padding-right: 16px;
43
+ overflow: auto;
44
+ overscroll-behavior: contain;
45
+ }
46
+ }
47
+ `
48
+
49
+ export function installHostUiStyles(): () => void {
50
+ const root = document.documentElement
51
+ const previous = root.getAttribute('data-pocket-ui')
52
+ const style = document.createElement('style')
53
+ style.dataset.pocketUiStyles = ''
54
+ style.textContent = css
55
+ root.setAttribute('data-pocket-ui', '')
56
+ document.head.append(style)
57
+ return () => {
58
+ style.remove()
59
+ if (previous === null) root.removeAttribute('data-pocket-ui')
60
+ else root.setAttribute('data-pocket-ui', previous)
61
+ }
62
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Official desktop pairing section, or the complete Host client in a phone
3
+ * WebView. pocket-ui is a window-local presentation flag, not authentication.
4
+ * Phone windows never register the loopback-only pairing management page.
5
+ * All other plugin sections and provider extensions stay with their owners.
6
+ */
7
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
8
+ import { createPocketApi } from './api.ts'
9
+ import { zh, en } from './locales.ts'
10
+ import { installPocketNavIcon } from './nav-icon.ts'
11
+ import { PocketSettings } from './PocketSettings.tsx'
12
+ import { installHostUi } from './HostUiLauncher.tsx'
13
+ import { installCompletionSync } from './completion-sync.js'
14
+
15
+ /** Must be a static array — cordis rejects function-form inject. */
16
+ export const inject = ['slots', 'locale'] as const
17
+
18
+ const NS = 'pocket'
19
+
20
+ export function apply(ctx: ClientContext): void {
21
+ // The phone proxy excludes management routes; its native client owns presence.
22
+ if (installHostUi(ctx)) return
23
+ ctx.inject(['uiSession', 'sessions', 'layout'], (scope) => {
24
+ const uiSession = scope.get('uiSession')
25
+ const sessions = scope.get('sessions')
26
+ scope.effect(() => installCompletionSync({
27
+ uiSession, sessions, layout: scope.get('layout'), slots: scope.slots,
28
+ }), 'pocket:completion-sync')
29
+ })
30
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pocket:locale')
31
+ ctx.effect(() => installPocketNavIcon(), 'pocket:nav-icon')
32
+ const t = ctx.locale.bind(NS)
33
+ const api = createPocketApi()
34
+
35
+ ctx.slots.inject('settings.section', () =>
36
+ ctx.slots.register(
37
+ {
38
+ name: 'settings.section',
39
+ id: 'pocket',
40
+ order: 55,
41
+ label: () => t('entry'),
42
+ locale: NS,
43
+ inject: () => ({ api }),
44
+ },
45
+ PocketSettings,
46
+ ))
47
+ }
@@ -0,0 +1,75 @@
1
+ export const zh = {
2
+ entry: '手机连接',
3
+ title: '手机连接',
4
+ statusRunning: '服务就绪',
5
+ statusStopped: '服务未就绪',
6
+ newDevice: '连接新设备',
7
+ network: '连接网络',
8
+ lan: '局域网',
9
+ tailscale: 'Tailscale',
10
+ lanAddress: '局域网地址',
11
+ tailscaleAddress: 'Tailscale 地址',
12
+ noNetwork: '没有可用的网络地址',
13
+ pair: '开始配对',
14
+ pairAgain: '重新配对',
15
+ pairClose: '关闭配对',
16
+ code: '配对码',
17
+ expiresIn: '{n} 秒后过期',
18
+ copy: '复制配对链接',
19
+ copyAddress: '复制地址',
20
+ copied: '已复制',
21
+ devices: '已配对设备',
22
+ empty: '暂无已配对设备',
23
+ online: '在线',
24
+ offline: '离线',
25
+ lastSeen: '上次在线',
26
+ neverSeen: '尚未连接',
27
+ rename: '重命名',
28
+ renameSave: '保存',
29
+ renameCancel: '取消',
30
+ revoke: '解除配对',
31
+ revokeTitle: '解除配对',
32
+ revokeBody: '解除与「{name}」的配对?该手机需重新配对才能连接。',
33
+ cancel: '取消',
34
+ loading: '加载中…',
35
+ refresh: '刷新',
36
+ } as const
37
+
38
+ export const en = {
39
+ entry: 'Phone connection',
40
+ title: 'Phone connection',
41
+ statusRunning: 'Service ready',
42
+ statusStopped: 'Service unavailable',
43
+ newDevice: 'Connect a device',
44
+ network: 'Connection network',
45
+ lan: 'LAN',
46
+ tailscale: 'Tailscale',
47
+ lanAddress: 'LAN address',
48
+ tailscaleAddress: 'Tailscale address',
49
+ noNetwork: 'No network address available',
50
+ pair: 'Start pairing',
51
+ pairAgain: 'Pair again',
52
+ pairClose: 'Close pairing',
53
+ code: 'Pairing code',
54
+ expiresIn: 'Expires in {n}s',
55
+ copy: 'Copy pairing link',
56
+ copyAddress: 'Copy address',
57
+ copied: 'Copied',
58
+ devices: 'Paired devices',
59
+ empty: 'No paired devices',
60
+ online: 'Online',
61
+ offline: 'Offline',
62
+ lastSeen: 'Last online',
63
+ neverSeen: 'Never connected',
64
+ rename: 'Rename',
65
+ renameSave: 'Save',
66
+ renameCancel: 'Cancel',
67
+ revoke: 'Unpair',
68
+ revokeTitle: 'Unpair device',
69
+ revokeBody: 'Unpair “{name}”? The phone will need to pair again to reconnect.',
70
+ cancel: 'Cancel',
71
+ loading: 'Loading…',
72
+ refresh: 'Refresh',
73
+ } as const
74
+
75
+ export type PocketLocaleKey = keyof typeof zh
@@ -0,0 +1,31 @@
1
+ import { phoneIconSvgHtml } from './PhoneIcon.tsx'
2
+
3
+ const LABELS = new Set(['手机连接', 'Phone connection'])
4
+
5
+ /**
6
+ * Settings nav icons are hardcoded by section id in
7
+ * `@deepseek-ai/dsh-client-ui-settings-general` (`navIcon`): unknown ids fall
8
+ * back to the gear. `settings.section` has no `icon` register option, and the
9
+ * primitives icon set has no phone glyph. Swap the gear SVG on our nav cell
10
+ * for a Medium-outline smartphone that matches shipped stroke/size.
11
+ */
12
+ export function installPocketNavIcon(): () => void {
13
+ const sync = () => {
14
+ for (const cell of document.querySelectorAll<HTMLElement>('[class*="navCell"]')) {
15
+ const label = (cell.innerText || '').trim()
16
+ if (!LABELS.has(label)) continue
17
+ const svg = cell.querySelector('svg')
18
+ if (!svg || svg.getAttribute('data-pocket-icon') === '1') continue
19
+ const cls = svg.getAttribute('class') || ''
20
+ const wrap = document.createElement('span')
21
+ wrap.innerHTML = phoneIconSvgHtml(cls)
22
+ const next = wrap.firstElementChild
23
+ if (next) svg.replaceWith(next)
24
+ }
25
+ }
26
+
27
+ const mo = new MutationObserver(() => sync())
28
+ mo.observe(document.documentElement, { childList: true, subtree: true, characterData: true })
29
+ sync()
30
+ return () => mo.disconnect()
31
+ }
@@ -0,0 +1,15 @@
1
+ import { renderSVG } from 'uqr'
2
+
3
+ /** Render a pairing URI as an SVG QR string. */
4
+ export function qrSvg(text: string, size = 180): string {
5
+ const svg = renderSVG(text, {
6
+ ecc: 'M',
7
+ pixelSize: 8,
8
+ border: 2,
9
+ blackColor: '#111',
10
+ whiteColor: '#fff',
11
+ })
12
+ // uqr emits viewBox-only <svg>; pin display size on the root element (do not
13
+ // rewrite the white <rect width/height>, which share the same attribute names).
14
+ return svg.replace('<svg ', `<svg width="${size}" height="${size}" `)
15
+ }