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.
Files changed (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. 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
+ }