@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Remote desktop channel constants — SDK-independent so tests and the
3
3
  * client half can pin them without importing the host SDK graph.
4
+ *
5
+ * Access model on the 0.1.2-alpha.2 line: the host /api surface has no
6
+ * per-method privilege pin — the "configuration plane is local" behavior
7
+ * lives in the browser (client plugins branch on connection.isLoopback), and
8
+ * the paired remote desktop flips into host mode via the transport hook
9
+ * (ownsHost) while every call rides this gated channel as a loopback-shaped
10
+ * request. A paired device is therefore a full-control credential by
11
+ * design; the only paths that stay physically local are the control planes
12
+ * below (pairing control, self-update, plugin install/remove, host power).
4
13
  */
5
14
 
6
15
  /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
@@ -9,10 +18,17 @@ export const REMOTE_PREFIX = '/remote'
9
18
  /** Connection-plugin method prefix under the gated channel. */
10
19
  export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
11
20
 
12
- /** WebSocket event-stream paths served by the channel (client rewrites to these). */
21
+ /**
22
+ * The gated mirrors of the official client WebSocket paths. On the pinned
23
+ * 0.1.2-alpha.2 line the client opens ONE persistent stream socket — the
24
+ * Typert gateway mux at `/api/remote.mux` — and every Remote stream
25
+ * (workspace follow, session feed, ...) rides it. If that socket is not
26
+ * rewritten onto the gated channel the phone's streams die at the connection
27
+ * fence and the UI shows an empty workspace/session mirror, so the mux is
28
+ * the one path that must never be missing here.
29
+ */
13
30
  export const REMOTE_API_PATHS = {
14
- mux: `${REMOTE_API_PREFIX}/events.mux`,
15
- host: `${REMOTE_API_PREFIX}/events.host`,
31
+ mux: `${REMOTE_API_PREFIX}/remote.mux`,
16
32
  } as const
17
33
 
18
34
  /**
@@ -21,7 +37,6 @@ export const REMOTE_API_PATHS = {
21
37
  */
22
38
  export const REMOTE_UPGRADE_PATHS = [
23
39
  REMOTE_API_PATHS.mux,
24
- REMOTE_API_PATHS.host,
25
40
  `${REMOTE_PREFIX}/sidebar/ws/terminal`,
26
41
  `${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
27
42
  `${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
@@ -33,30 +48,46 @@ export const PLUGIN_MANAGER_PATH = '/api/plugin-manager'
33
48
  /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
34
49
  export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher'
35
50
 
36
- /** Family settings-bridge HTTP prefix: describe/mutate stay physically local. */
51
+ /** Family settings-bridge HTTP prefix — re-exposed to paired devices (plain settings parity). */
37
52
  export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings'
38
53
 
39
54
  /**
40
- * Loopback-only methods of the host API surface, mirrored from
41
- * client-connection's `PRIVILEGED_METHODS` (pinned by
42
- * tests/remote-contract.spec.ts against the installed SDK). They stay
43
- * unreachable from a paired remote desktop, matching the SDK's own stance
44
- * that the configuration plane is loopback-same-origin only.
55
+ * The cookieless device credential: the boot patch reads the device id from
56
+ * the /pair-app URL, keeps it in sessionStorage, and attaches it to every
57
+ * gated HTTP call as this header (and to WebSocket upgrades as the `device`
58
+ * query parameter - WS handshakes cannot carry headers from the Web API).
59
+ * The channel gate accepts it exactly like the device cookie, so the mobile
60
+ * flow works even with browser cookies fully blocked; the cookie remains the
61
+ * primary credential on normal browsers.
62
+ */
63
+ export const REMOTE_DEVICE_HEADER = 'x-dsh-remote-device'
64
+ export const REMOTE_DEVICE_QUERY = 'device'
65
+
66
+ /**
67
+ * Path prefixes that stay physically local even for a paired device. A
68
+ * paired remote desktop may use the full host API (chat, sessions,
69
+ * settings, credentials, presets — it is a full-control credential), but it
70
+ * must not reach the machine-control planes: pairing control itself, the
71
+ * dsh-web self-update installer, plugin install/remove, and desktop
72
+ * launcher actions (host shutdown, shortcuts).
73
+ */
74
+ export const LOCAL_ONLY_PREFIXES: readonly string[] = [
75
+ '/api/pair',
76
+ '/api/update',
77
+ PLUGIN_MANAGER_PATH,
78
+ DESKTOP_LAUNCHER_PATH,
79
+ ] as const
80
+
81
+ /**
82
+ * Whether a paired inner path must stay physically local.
83
+ * @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
84
+ * @returns a denial message, or undefined when the path may be proxied.
45
85
  */
46
- export const LOOPBACK_ONLY_METHODS = new Set([
47
- 'agentPreset.read',
48
- 'agentPreset.copy',
49
- 'agentPreset.openDocument',
50
- 'agentPreset.remove',
51
- 'host.pickDirectory',
52
- 'host.openPath',
53
- 'settings.describe',
54
- 'settings.openDocument',
55
- 'settings.update',
56
- 'settings.replace',
57
- 'settings.mutate',
58
- 'credentials.describe',
59
- 'credentials.set',
60
- 'credentials.unset',
61
- 'llm.discoverModels',
62
- ])
86
+ export function localOnlyDenial(innerPath: string): string | undefined {
87
+ for (const prefix of LOCAL_ONLY_PREFIXES) {
88
+ if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) {
89
+ return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`
90
+ }
91
+ }
92
+ return undefined
93
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Remote-presence to pet-visibility link (user requirement): while at least
3
+ * one paired device is online (an active phone mirror session), the
4
+ * host-global pet is hidden through the pet's OWN hide switch (the
5
+ * PetService.setVisible RPC, the same one the pet's hide button uses);
6
+ * once the last device stays offline past a grace window, the pet is
7
+ * restored. The pet plugin is optional: without it (or on any pet failure)
8
+ * the link is a no-op - the remote-control feature never depends on the
9
+ * pet being installed.
10
+ *
11
+ * The visibility is host-global by design (the pet is host-global, no
12
+ * session dimension): the desktop and the phone share one pet, so the link
13
+ * hides it while the operator is away on a phone mirror and restores it
14
+ * when the mirror ends. A manual user hide is respected: if the pet is
15
+ * already hidden when the first device comes online, nothing is recorded
16
+ * and nothing is restored later.
17
+ */
18
+
19
+ /** The pet service seam the link needs (structural slice, no package edge). */
20
+ export interface PresencePetSeam {
21
+ /** RPC: show or hide the pet. */
22
+ setVisible(visible: boolean): Promise<unknown>
23
+ /** Read the current display state (the visibility the user sees). */
24
+ state(): Promise<{ display?: { visible?: boolean } }>
25
+ }
26
+
27
+ /** Dependencies of the presence link (all injectable for tests). */
28
+ export interface PresencePetDeps {
29
+ /** Subscribe to pairing snapshot changes (fires with every state change). */
30
+ onState(listener: (snapshot: { phase: string; onlineCount: number }) => void): () => void
31
+ /** Resolve the pet service seam (undefined when the pet plugin is absent). */
32
+ pet(): PresencePetSeam | undefined
33
+ /** Grace before restoring the pet after the last device went offline. */
34
+ restoreAfterMs?: number
35
+ timers?: {
36
+ setTimeout(fn: () => void, ms: number): unknown
37
+ clearTimeout(t: unknown): void
38
+ }
39
+ }
40
+
41
+ /** Default restore grace: the presence sweep flips a device offline after
42
+ * ~25 s, and a phone that was briefly backgrounded should not flicker the
43
+ * pet; 2 minutes keeps the restore stable. */
44
+ const DEFAULT_RESTORE_AFTER_MS = 120_000
45
+
46
+ const nodeTimers = { setTimeout, clearTimeout }
47
+
48
+ /**
49
+ * Start the presence link. Returns the disposer (withdraws the state
50
+ * subscription and cancels a pending restore).
51
+ * @param deps - pairing stream + pet seam (+ test seams).
52
+ */
53
+ export function startRemotePresencePet(deps: PresencePetDeps): () => void {
54
+ const restoreAfterMs = deps.restoreAfterMs ?? DEFAULT_RESTORE_AFTER_MS
55
+ const timers = deps.timers ?? nodeTimers
56
+ let previousOnline = false
57
+ let hiddenByRemote = false
58
+ let restoreTimer: unknown | undefined
59
+ let disposed = false
60
+
61
+ const clearRestore = (): void => {
62
+ if (restoreTimer !== undefined) {
63
+ timers.clearTimeout(restoreTimer)
64
+ restoreTimer = undefined
65
+ }
66
+ }
67
+
68
+ const scheduleRestore = (): void => {
69
+ clearRestore()
70
+ restoreTimer = timers.setTimeout(() => {
71
+ restoreTimer = undefined
72
+ restoreAfterTick()
73
+ }, restoreAfterMs)
74
+ }
75
+
76
+ const restoreAfterTick = (): void => {
77
+ if (disposed || !hiddenByRemote) return
78
+ const pet = deps.pet()
79
+ if (pet === undefined) return
80
+ hiddenByRemote = false
81
+ void pet.setVisible(true).catch(() => {
82
+ // The pet bridge failed (e.g. the pet host was mid-reload): the pet
83
+ // stays whatever it is; the next connect/disconnect cycle re-evaluates.
84
+ })
85
+ }
86
+
87
+ const hideForRemote = (): void => {
88
+ if (hiddenByRemote) return
89
+ const pet = deps.pet()
90
+ if (pet === undefined) return
91
+ void pet.state().then(
92
+ (value) => {
93
+ if (disposed || hiddenByRemote) return
94
+ const visible = value.display?.visible
95
+ if (visible !== true) return
96
+ hiddenByRemote = true
97
+ return pet.setVisible(false).catch(() => {
98
+ // Hide failed: leave the flag unset so a later transition retries.
99
+ hiddenByRemote = false
100
+ })
101
+ },
102
+ () => {
103
+ // The state read failed; do not record a hide we never performed.
104
+ },
105
+ )
106
+ }
107
+
108
+ const handle = (snapshot: { phase: string; onlineCount: number }): void => {
109
+ const online = snapshot.phase === 'connected' && snapshot.onlineCount > 0
110
+ if (online && !previousOnline) {
111
+ cancelRestoreAndClear()
112
+ hideForRemote()
113
+ } else if (!online && previousOnline) {
114
+ scheduleRestore()
115
+ }
116
+ previousOnline = online
117
+ }
118
+
119
+ const cancelRestoreAndClear = (): void => {
120
+ clearRestore()
121
+ }
122
+
123
+ const unsubscribe = deps.onState(handle)
124
+ return () => {
125
+ disposed = true
126
+ unsubscribe()
127
+ clearRestore()
128
+ }
129
+ }