@huakirax/dsh-plugin-pocket 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +237 -2
- package/bin/pocket.js +31 -0
- package/cordis.patch.yml +6 -0
- package/index.js +360 -0
- package/lib/client.js +2453 -0
- package/package.json +91 -4
- package/src/advertise-host.js +69 -0
- package/src/approval-hook.js +50 -0
- package/src/client/ConfirmDialog.tsx +80 -0
- package/src/client/HostUiLauncher.tsx +101 -0
- package/src/client/ManagePanel.tsx +218 -0
- package/src/client/PhoneIcon.tsx +48 -0
- package/src/client/PocketSettings.tsx +13 -0
- package/src/client/api.ts +97 -0
- package/src/client/completion-sync.js +289 -0
- package/src/client/host-ui-styles.ts +62 -0
- package/src/client/index.ts +47 -0
- package/src/client/locales.ts +75 -0
- package/src/client/nav-icon.ts +31 -0
- package/src/client/qr.ts +15 -0
- package/src/client/styles.ts +76 -0
- package/src/completion-route.js +87 -0
- package/src/completion-tracker.js +95 -0
- package/src/devices.js +60 -0
- package/src/extra-ops.js +604 -0
- package/src/host-ui.js +466 -0
- package/src/instance-id.js +33 -0
- package/src/iroh.js +260 -0
- package/src/manage.js +122 -0
- package/src/mdns-advertise.js +75 -0
- package/src/office-preview.js +129 -0
- package/src/pairing-uri.js +42 -0
- package/src/pairing.js +34 -0
- package/src/parity-ops.js +250 -0
- package/src/server.js +761 -0
- package/src/settings-ops.js +387 -0
- package/src/task-events.js +77 -0
- package/src/task-fallback.js +177 -0
- package/src/token.js +18 -0
- package/src/transport-pref.js +47 -0
- package/src/trust.js +25 -0
- package/src/user-question-hook.js +93 -0
- package/src/web-route.js +116 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/client/qr.ts
ADDED
|
@@ -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
|
+
}
|