dsh-zen-remote 1.1.16 → 2.0.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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-session status data for the browser half (T34): the poll store
|
|
3
|
+
* behind the two sub-client parts — the title-row connection icon and the
|
|
4
|
+
* composer's readonly banner. Built as a mirror of shares.ts (T33b):
|
|
5
|
+
* browser-import-free so the desktop-gate test and check scripts drive it
|
|
6
|
+
* with a fake fetch.
|
|
7
|
+
*
|
|
8
|
+
* - `parseRemoteStatusBody(body, now)` maps the
|
|
9
|
+
* `GET /_dsh/zen-remote/client/remote-status` body
|
|
10
|
+
* (`{ state, versionMismatch, serverName, closed }`) into a tolerant view;
|
|
11
|
+
* - `describeRemoteStatus(view)` maps one view to the icon's three states —
|
|
12
|
+
* online (lit) / offline (grey; `revoked` / `unpaired` count as offline —
|
|
13
|
+
* the link is not serving either way) / mismatch (yellow, only ever while
|
|
14
|
+
* online) — plus the hover line;
|
|
15
|
+
* - `bannerText(view, sessionId)` picks the composer banner copy for one
|
|
16
|
+
* session: the closed reason (idle / manual / client) outranks the offline
|
|
17
|
+
* line — a closed session stays closed even while the server is down;
|
|
18
|
+
* - `createRemoteStatusStore(fetchImpl, visibility?, pollMs?)` is the
|
|
19
|
+
* subscription store both parts share. There is NO role gate here (unlike
|
|
20
|
+
* shares.ts): both roles register the parts, and the parts subscribe only
|
|
21
|
+
* while a VIRTUAL-id session is on screen — a host page (local sessions
|
|
22
|
+
* only) never opens a subscription, so nothing polls. A failed GET fails
|
|
23
|
+
* exactly its own round; the poll cadence is visibility-shaped like the
|
|
24
|
+
* shares store's;
|
|
25
|
+
* - `subscribeIfVirtual(store, sessionId, listener)` is the subscribe face
|
|
26
|
+
* the parts hand `useSyncExternalStore`: the virtual-id gate lives HERE,
|
|
27
|
+
* so a local session mounts without ever opening a subscription;
|
|
28
|
+
* - `getRemoteStatusStore()` is the page-wide singleton.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createLatestGate } from './settings-form.ts'
|
|
32
|
+
import { isVirtual } from '../virtual-id.ts'
|
|
33
|
+
import { zh } from '../client/locales.ts'
|
|
34
|
+
|
|
35
|
+
/** Same-origin client route feeding this store (host half: T34,
|
|
36
|
+
* src/client-routes.ts). */
|
|
37
|
+
export const REMOTE_STATUS_ROUTE = '/_dsh/zen-remote/client/remote-status'
|
|
38
|
+
|
|
39
|
+
/** Same-origin client route closing one remote session from this machine. */
|
|
40
|
+
export const CLIENT_UNSHARE_ROUTE = '/_dsh/zen-remote/client/unshare'
|
|
41
|
+
|
|
42
|
+
/** The relay's serving state, as the route words it. */
|
|
43
|
+
export type RemoteStatusState = 'online' | 'offline' | 'revoked' | 'unpaired'
|
|
44
|
+
|
|
45
|
+
/** Why the server closed a session's remote access. */
|
|
46
|
+
export type RemoteClosedReason = 'manual' | 'client' | 'idle'
|
|
47
|
+
|
|
48
|
+
/** One parsed status body, stamped with the parse time for future decay. */
|
|
49
|
+
export interface RemoteStatusView {
|
|
50
|
+
state: RemoteStatusState
|
|
51
|
+
versionMismatch: boolean
|
|
52
|
+
serverName: string
|
|
53
|
+
closed: Readonly<Record<string, RemoteClosedReason>>
|
|
54
|
+
/** When this view was parsed off a GET body. */
|
|
55
|
+
asOf: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Everything the parts read off the store, one frozen object per change. */
|
|
59
|
+
export interface RemoteStatusSnapshot {
|
|
60
|
+
/** One GET has answered ok — parts render nothing before this. */
|
|
61
|
+
ready: boolean
|
|
62
|
+
view: RemoteStatusView | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse one GET body tolerantly: a garbage shape is "not online yet", a
|
|
66
|
+
* garbage field degrades to its default — never a throw into the polling
|
|
67
|
+
* loop. */
|
|
68
|
+
export function parseRemoteStatusBody(body: unknown, now: number): RemoteStatusView {
|
|
69
|
+
const record = body !== null && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
|
70
|
+
const state = record.state === 'online' || record.state === 'revoked' || record.state === 'unpaired' ? record.state : 'offline'
|
|
71
|
+
const closed: Record<string, RemoteClosedReason> = {}
|
|
72
|
+
if (record.closed !== null && typeof record.closed === 'object') {
|
|
73
|
+
for (const [sessionId, reason] of Object.entries(record.closed as Record<string, unknown>)) {
|
|
74
|
+
if (sessionId === '') continue
|
|
75
|
+
closed[sessionId] = reason === 'client' || reason === 'idle' ? reason : 'manual'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
state,
|
|
80
|
+
versionMismatch: record.versionMismatch === true,
|
|
81
|
+
serverName: typeof record.serverName === 'string' ? record.serverName : '',
|
|
82
|
+
closed,
|
|
83
|
+
asOf: now,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- the icon's states -------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** The locale keys `describeRemoteStatus` needs. */
|
|
90
|
+
export type RemoteStatusTextKey =
|
|
91
|
+
| 'remoteStatusOnline'
|
|
92
|
+
| 'remoteStatusOffline'
|
|
93
|
+
| 'remoteStatusMismatch'
|
|
94
|
+
| 'remoteStatusRevoked'
|
|
95
|
+
| 'remoteStatusUnpaired'
|
|
96
|
+
|
|
97
|
+
/** Minimal shape of the framework `t` seat over the plugin's namespace. */
|
|
98
|
+
export type RemoteStatusFormatter = (key: RemoteStatusTextKey) => string
|
|
99
|
+
|
|
100
|
+
/** Default formatter: the plugin's Chinese dictionary. Components pass their
|
|
101
|
+
* real framework `t`; this serves the pure-function callers (tests, check
|
|
102
|
+
* scripts). */
|
|
103
|
+
export function createZhRemoteStatusFormatter(): RemoteStatusFormatter {
|
|
104
|
+
return (key) => zh[key]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The icon's states and its hover line (T34, refined by T34-fix): online
|
|
108
|
+
* (lit), offline (grey, reconnecting), mismatch (yellow, only ever while
|
|
109
|
+
* online — an offline link outranks it), and `revoked` / `unpaired` as
|
|
110
|
+
* states of their OWN (T34-fix) — the hover and the click say the precise
|
|
111
|
+
* word, never "reconnecting", because neither recovers on its own. */
|
|
112
|
+
export interface RemoteStatusDescription {
|
|
113
|
+
state: 'online' | 'offline' | 'mismatch' | 'revoked' | 'unpaired'
|
|
114
|
+
hoverText: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function describeRemoteStatus(
|
|
118
|
+
view: RemoteStatusView | undefined | null,
|
|
119
|
+
t: RemoteStatusFormatter = createZhRemoteStatusFormatter(),
|
|
120
|
+
): RemoteStatusDescription {
|
|
121
|
+
if (view === undefined || view === null) return { state: 'offline', hoverText: t('remoteStatusOffline') }
|
|
122
|
+
if (view.state === 'revoked') return { state: 'revoked', hoverText: t('remoteStatusRevoked') }
|
|
123
|
+
if (view.state === 'unpaired') return { state: 'unpaired', hoverText: t('remoteStatusUnpaired') }
|
|
124
|
+
if (view.state === 'online') {
|
|
125
|
+
return view.versionMismatch
|
|
126
|
+
? { state: 'mismatch', hoverText: t('remoteStatusMismatch') }
|
|
127
|
+
: { state: 'online', hoverText: t('remoteStatusOnline') }
|
|
128
|
+
}
|
|
129
|
+
return { state: 'offline', hoverText: t('remoteStatusOffline') }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- the composer banner copy ------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** The locale keys `bannerText` needs. */
|
|
135
|
+
export type RemoteBannerTextKey =
|
|
136
|
+
| 'remoteBannerOffline'
|
|
137
|
+
| 'remoteBannerRevoked'
|
|
138
|
+
| 'remoteBannerUnpaired'
|
|
139
|
+
| 'remoteBannerClosedIdle'
|
|
140
|
+
| 'remoteBannerClosedManual'
|
|
141
|
+
| 'remoteBannerClosedClient'
|
|
142
|
+
|
|
143
|
+
export type RemoteBannerFormatter = (key: RemoteBannerTextKey) => string
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The composer banner copy for one virtual session (T34): the session's
|
|
147
|
+
* closed reason — if the server closed this session — outranks the
|
|
148
|
+
* link-level lines (offline, T41a-fix2's revoked / unpaired); a merely
|
|
149
|
+
* offline link reads the temporarily-readonly copy, and a revoked token or
|
|
150
|
+
* an unpaired client reads the "not coming back on its own" copy — both
|
|
151
|
+
* stand a banner AND disable the input (the component raises its composer
|
|
152
|
+
* block from whatever this returns), since neither recovers without the
|
|
153
|
+
* settings page. `undefined` when nothing applies (online and not closed):
|
|
154
|
+
* no banner.
|
|
155
|
+
*/
|
|
156
|
+
export function bannerText(
|
|
157
|
+
view: RemoteStatusView | undefined | null,
|
|
158
|
+
sessionId: string,
|
|
159
|
+
t: RemoteBannerFormatter = (key) => zh[key],
|
|
160
|
+
): string | undefined {
|
|
161
|
+
const reason = view?.closed[sessionId]
|
|
162
|
+
if (reason === 'idle') return t('remoteBannerClosedIdle')
|
|
163
|
+
if (reason === 'client') return t('remoteBannerClosedClient')
|
|
164
|
+
// 'manual' — and any word this build does not know (a newer server's
|
|
165
|
+
// vocabulary): a closure with no certain reason reads as the manual close.
|
|
166
|
+
if (reason !== undefined) return t('remoteBannerClosedManual')
|
|
167
|
+
if (view !== undefined && view !== null) {
|
|
168
|
+
if (view.state === 'offline') return t('remoteBannerOffline')
|
|
169
|
+
// T41a-fix2: a revoked token / an unpaired client is not "reconnecting"
|
|
170
|
+
// — the composer must not sit disabled on a promise no reconnect keeps.
|
|
171
|
+
if (view.state === 'revoked') return t('remoteBannerRevoked')
|
|
172
|
+
if (view.state === 'unpaired') return t('remoteBannerUnpaired')
|
|
173
|
+
}
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- the store ---------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Whether the page is currently visible — the same seam shares.ts uses to
|
|
180
|
+
* keep the poll cadence visibility-shaped (testable via injection). */
|
|
181
|
+
export interface VisibilitySource {
|
|
182
|
+
visible(): boolean
|
|
183
|
+
subscribe(listener: () => void): () => void
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The real source: `visibilitychange` off `document` (Node: always visible,
|
|
187
|
+
* never notifying). */
|
|
188
|
+
export const documentVisibility: VisibilitySource = {
|
|
189
|
+
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
|
190
|
+
subscribe: (listener) => {
|
|
191
|
+
if (typeof document === 'undefined') return () => {}
|
|
192
|
+
document.addEventListener('visibilitychange', listener)
|
|
193
|
+
return () => { document.removeEventListener('visibilitychange', listener) }
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** How one `client/unshare` POST ended. A refused action carries the
|
|
198
|
+
* backend's error `code` when it sent one (`not-shared`, `remote-mismatch`,
|
|
199
|
+
* …). */
|
|
200
|
+
export type RemoteUnshareOutcome =
|
|
201
|
+
| { ok: true }
|
|
202
|
+
| { ok: false, code?: string, message?: string }
|
|
203
|
+
|
|
204
|
+
/** The store face the two T34 parts share. */
|
|
205
|
+
export interface RemoteStatusStore {
|
|
206
|
+
/** The current snapshot — a stable frozen reference until the next change. */
|
|
207
|
+
getSnapshot(): RemoteStatusSnapshot
|
|
208
|
+
/** Observe snapshot replacements; polling runs while at least one listener
|
|
209
|
+
* is attached AND the page is visible. */
|
|
210
|
+
subscribe(listener: () => void): () => void
|
|
211
|
+
/** GET now (latest-wins). @returns whether an ok body landed. */
|
|
212
|
+
refresh(): Promise<boolean>
|
|
213
|
+
/** POST client/unshare for one VIRTUAL session id, then refresh.
|
|
214
|
+
* @returns the action's outcome. */
|
|
215
|
+
unshare(sessionId: string): Promise<RemoteUnshareOutcome>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Poll cadence while the page is visible and at least one part is mounted
|
|
219
|
+
* (T34: 15 秒). */
|
|
220
|
+
export const REMOTE_STATUS_POLL_MS = 15_000
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build one store over an injectable fetch and visibility source — the
|
|
224
|
+
* shares-store discipline: latest-wins (an earlier request answering late
|
|
225
|
+
* never overwrites a newer table), every GET failure fails its own round,
|
|
226
|
+
* the timer is real while visible and gone while hidden, and becoming
|
|
227
|
+
* visible pulls once immediately. `pollMs` exists for tests: the shipped
|
|
228
|
+
* cadence is far too slow to observe.
|
|
229
|
+
*/
|
|
230
|
+
export function createRemoteStatusStore(
|
|
231
|
+
fetchImpl: typeof fetch,
|
|
232
|
+
visibility: VisibilitySource = documentVisibility,
|
|
233
|
+
pollMs: number = REMOTE_STATUS_POLL_MS,
|
|
234
|
+
): RemoteStatusStore {
|
|
235
|
+
const gate = createLatestGate()
|
|
236
|
+
const listeners = new Set<() => void>()
|
|
237
|
+
let snapshot: RemoteStatusSnapshot = Object.freeze({ ready: false, view: undefined })
|
|
238
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
239
|
+
let offVisibility: (() => void) | undefined
|
|
240
|
+
|
|
241
|
+
const publish = (): void => {
|
|
242
|
+
for (const listener of [...listeners]) listener()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const applyBody = (body: unknown): void => {
|
|
246
|
+
snapshot = Object.freeze({ ready: true, view: Object.freeze(parseRemoteStatusBody(body, Date.now())) })
|
|
247
|
+
publish()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const refresh = async (): Promise<boolean> => {
|
|
251
|
+
const ticket = gate.next()
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetchImpl(REMOTE_STATUS_ROUTE, { cache: 'no-store' })
|
|
254
|
+
if (!gate.isLatest(ticket)) return false
|
|
255
|
+
// 404 during a plugin reload, a 5xx, a broken body — all fail exactly
|
|
256
|
+
// this round: keep the last view, answer false, retry next tick.
|
|
257
|
+
if (!res.ok) return false
|
|
258
|
+
const body = await res.json().catch(() => undefined) as unknown
|
|
259
|
+
if (body === undefined || body === null || typeof body !== 'object') return false
|
|
260
|
+
if (!gate.isLatest(ticket)) return false
|
|
261
|
+
applyBody(body)
|
|
262
|
+
return true
|
|
263
|
+
} catch {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const unshare = async (sessionId: string): Promise<RemoteUnshareOutcome> => {
|
|
269
|
+
let outcome: RemoteUnshareOutcome = { ok: false }
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetchImpl(CLIENT_UNSHARE_ROUTE, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({ sessionId }),
|
|
275
|
+
})
|
|
276
|
+
const body = await res.json().catch(() => ({})) as { ok?: unknown, error?: { code?: unknown, message?: unknown } }
|
|
277
|
+
if (res.ok && body.ok === true) outcome = { ok: true }
|
|
278
|
+
else {
|
|
279
|
+
outcome = { ok: false }
|
|
280
|
+
const error = body.error !== null && typeof body.error === 'object' ? body.error : {}
|
|
281
|
+
if (typeof error.code === 'string') outcome.code = error.code
|
|
282
|
+
if (typeof error.message === 'string') outcome.message = error.message
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
outcome = { ok: false }
|
|
286
|
+
}
|
|
287
|
+
// The action landed (or demonstrably failed) — refresh so the next
|
|
288
|
+
// render reflects the table the route now serves.
|
|
289
|
+
void refresh()
|
|
290
|
+
return outcome
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const startTimer = (): void => {
|
|
294
|
+
if (timer === undefined) timer = setInterval(() => { void refresh() }, pollMs)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const stopTimer = (): void => {
|
|
298
|
+
if (timer !== undefined) {
|
|
299
|
+
clearInterval(timer)
|
|
300
|
+
timer = undefined
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const onVisibility = (): void => {
|
|
305
|
+
if (listeners.size === 0) return
|
|
306
|
+
if (visibility.visible()) {
|
|
307
|
+
// Becoming visible pulls once immediately, then resumes the cadence.
|
|
308
|
+
void refresh()
|
|
309
|
+
startTimer()
|
|
310
|
+
} else {
|
|
311
|
+
// Hidden: the timer is really gone, not just gated inside the tick.
|
|
312
|
+
stopTimer()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Reconcile the poll machinery with (listeners, visibility). */
|
|
317
|
+
const syncPolling = (): void => {
|
|
318
|
+
if (listeners.size === 0) {
|
|
319
|
+
stopTimer()
|
|
320
|
+
if (offVisibility !== undefined) {
|
|
321
|
+
offVisibility()
|
|
322
|
+
offVisibility = undefined
|
|
323
|
+
}
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (offVisibility === undefined) offVisibility = visibility.subscribe(onVisibility)
|
|
327
|
+
if (!visibility.visible()) {
|
|
328
|
+
stopTimer()
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (timer === undefined) {
|
|
332
|
+
// Fresh run — first subscriber (or a return to visibility): pull once
|
|
333
|
+
// right away instead of leaving the first view one cadence away.
|
|
334
|
+
void refresh()
|
|
335
|
+
startTimer()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
getSnapshot: () => snapshot,
|
|
341
|
+
subscribe(listener: () => void): () => void {
|
|
342
|
+
listeners.add(listener)
|
|
343
|
+
syncPolling()
|
|
344
|
+
return () => {
|
|
345
|
+
listeners.delete(listener)
|
|
346
|
+
syncPolling()
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
refresh,
|
|
350
|
+
unshare,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let singleton: RemoteStatusStore | undefined
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The subscribe face both T34 parts hand `useSyncExternalStore`: a VIRTUAL-id
|
|
358
|
+
* session subscribes to the store — the subscription is what keeps it
|
|
359
|
+
* polling — while a local session gets a no-op, so a page showing only local
|
|
360
|
+
* sessions (a host's own view of itself) never opens a subscription and never
|
|
361
|
+
* sends a remote-status request. Pure over (store, sessionId) so the
|
|
362
|
+
* desktop-gate test drives it with a counting fake fetch.
|
|
363
|
+
*/
|
|
364
|
+
export function subscribeIfVirtual(store: RemoteStatusStore, sessionId: string, listener: () => void): () => void {
|
|
365
|
+
return isVirtual(sessionId) ? store.subscribe(listener) : () => {}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The page-wide store every registered part reads — one poll loop no
|
|
369
|
+
* matter how many of the icon and banner parts are mounted at once. Created
|
|
370
|
+
* lazily over the real fetch, so check scripts never construct it. */
|
|
371
|
+
export function getRemoteStatusStore(): RemoteStatusStore {
|
|
372
|
+
singleton ??= createRemoteStatusStore((input, init) => fetch(input, init))
|
|
373
|
+
return singleton
|
|
374
|
+
}
|