dsh-zen-remote 2.2.7 → 2.2.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 (34) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/interface.md +6 -4
  4. package/docs/remote-access.md +1 -0
  5. package/lib/client-routes.js +52 -0
  6. package/lib/client-routes.js.map +1 -1
  7. package/lib/client.js +506 -280
  8. package/lib/relay-client.js +71 -0
  9. package/lib/relay-client.js.map +1 -1
  10. package/lib/types/client/MobileAttachButton.d.ts +15 -7
  11. package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
  12. package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
  13. package/lib/types/client/effects/model-sheet-extras.d.ts +29 -25
  14. package/lib/types/client/effects/model-sheet-extras.d.ts.map +1 -1
  15. package/lib/types/client/styles/composer.css.d.ts +1 -1
  16. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  17. package/lib/types/client-data/remote-status.d.ts +39 -2
  18. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  19. package/lib/types/client-routes.d.ts +19 -0
  20. package/lib/types/client-routes.d.ts.map +1 -1
  21. package/lib/types/relay-client.d.ts +10 -0
  22. package/lib/types/relay-client.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/scripts/check-attach-upload.mjs +42 -0
  25. package/src/client/MobileAttachButton.tsx +23 -12
  26. package/src/client/MobileAttachChips.tsx +0 -7
  27. package/src/client/effects/model-sheet-extras.ts +233 -111
  28. package/src/client/styles/composer.css.ts +142 -83
  29. package/src/client-data/remote-status.ts +128 -2
  30. package/src/client-routes.ts +53 -0
  31. package/src/relay-client.ts +76 -0
  32. package/lib/types/client/host-attach.d.ts +0 -38
  33. package/lib/types/client/host-attach.d.ts.map +0 -1
  34. package/src/client/host-attach.ts +0 -54
@@ -57,6 +57,9 @@ const RELAY_PROTOCOL = 1
57
57
  const HANDSHAKE_PATH = '/_dsh/zen-remote/relay/v1/handshake'
58
58
  const INVOKE_PATH = '/_dsh/zen-remote/relay/v1/invoke'
59
59
  const STREAM_PATH = '/_dsh/zen-remote/relay/v1/stream'
60
+ /** the relay's existing authenticated health route
61
+ * (`GET …/relay/ping`, answers `{ok:true}`). Used by probeHealth() only. */
62
+ const PING_PATH = '/_dsh/zen-remote/relay/ping'
60
63
  const UNSHARE_PATH = '/_dsh/zen-remote/relay/v1/unshare'
61
64
  const EVENT_RESULT_PATH = '/_dsh/zen-remote/relay/v1/event-result'
62
65
  const HTTP_PATH = '/_dsh/zen-remote/relay/v1/http'
@@ -318,6 +321,18 @@ export interface RelayClient {
318
321
  * ladder (the settings page's 立即重连). Answers whether an attempt was
319
322
  * started — the reconnect route turns a false into a 409. */
320
323
  reconnect(): boolean
324
+
325
+ /** ONE lightweight authenticated health probe
326
+ * (`GET /ping`). Never changes `state`; de-duplicated while in flight;
327
+ * resolves false on any refusal. NOT a poller — call it on a real signal. */
328
+ probeHealth(): Promise<boolean>
329
+
330
+ /** a CONTROLLED fast-path for a reliable
331
+ * "network is back" signal. Fires only from `offline`, refuses within
332
+ * NETWORK_HINT_MIN_GAP_MS of the previous hint (no storm), and otherwise
333
+ * behaves exactly like reconnect(). connect() de-duplicates concurrent
334
+ * attempts via connectInFlight. Answers whether an attempt started. */
335
+ networkRestored(): boolean
321
336
  /** Notify that the credentials source may have changed (the loader's
322
337
  * volatile-update for the row): a pending reconnect wait is cancelled and,
323
338
  * with credentials present, one immediate attempt runs with the new
@@ -1308,6 +1323,62 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1308
1323
  return true
1309
1324
  }
1310
1325
 
1326
+ /**
1327
+ * one authenticated `GET /ping` — the independent
1328
+ * health confirmation a restored link needs when no business traffic is
1329
+ * flowing. Never throws, never moves `state`, de-duplicated in flight.
1330
+ */
1331
+ let healthProbeInFlight: Promise<boolean> | undefined
1332
+ async function probeHealth(): Promise<boolean> {
1333
+ if (healthProbeInFlight !== undefined) return healthProbeInFlight
1334
+ const attempt = (async (): Promise<boolean> => {
1335
+ let creds: { url: string, token: string }
1336
+ try {
1337
+ creds = requireCredentials()
1338
+ } catch {
1339
+ return false
1340
+ }
1341
+ const ac = new AbortController()
1342
+ const timer = setTimeout(() => ac.abort(), Math.min(requestTimeoutMs, 8_000))
1343
+ if (typeof timer.unref === 'function') timer.unref()
1344
+ try {
1345
+ const response = await fetchImpl(`${creds.url}${PING_PATH}`, {
1346
+ method: 'GET',
1347
+ headers: {
1348
+ authorization: `Bearer ${creds.token}`,
1349
+ accept: 'application/json',
1350
+ },
1351
+ redirect: 'manual',
1352
+ signal: ac.signal,
1353
+ })
1354
+ return response.ok
1355
+ } catch {
1356
+ return false
1357
+ } finally {
1358
+ clearTimeout(timer)
1359
+ }
1360
+ })()
1361
+ healthProbeInFlight = attempt
1362
+ try {
1363
+ return await attempt
1364
+ } finally {
1365
+ if (healthProbeInFlight === attempt) healthProbeInFlight = undefined
1366
+ }
1367
+ }
1368
+
1369
+ /** controlled fast-path; see the interface note. */
1370
+ const NETWORK_HINT_MIN_GAP_MS = 3_000
1371
+ let lastNetworkHintAt = 0
1372
+ function networkRestored(): boolean {
1373
+ if (stopped || state !== 'offline') return false
1374
+ const now = clock.now()
1375
+ if (now - lastNetworkHintAt < NETWORK_HINT_MIN_GAP_MS) return false
1376
+ lastNetworkHintAt = now
1377
+ cancelRetry()
1378
+ void connect().catch(() => {})
1379
+ return true
1380
+ }
1381
+
1311
1382
  /**
1312
1383
  * The credentials source moved (a pairing write, an unpair, a hand edit
1313
1384
  * committed by the loader): drop any pending wait — the ladder belongs to
@@ -1907,6 +1978,11 @@ export function createRelayClient(options: CreateRelayClientOptions): RelayClien
1907
1978
  queueDeviceName,
1908
1979
  connect,
1909
1980
  reconnect,
1981
+ // the two new surfaces. `probeHealth` is the
1982
+ // authenticated GET /ping health check (never moves `state`);
1983
+ // `networkRestored` is the controlled fast-path (offline-only + 3s gap).
1984
+ probeHealth,
1985
+ networkRestored,
1910
1986
  credentialsChanged,
1911
1987
  stop,
1912
1988
  invoke,
@@ -1,38 +0,0 @@
1
- /**
2
- * The host composer's own hidden file picker, when the running DSH ships one.
3
- *
4
- * DSH 0.1.5 grew a native attachment flow (dsh-attachment / dsh-client-file-
5
- * upload): the official tool row carries a second `_add` paperclip whose click
6
- * drives an `<input type="file" hidden>` that is a DIRECT sibling of the
7
- * buttons inside `_tools`. That input is the one structural, locale-free
8
- * proof that the host implements composer attachment upload — the button
9
- * itself can only be told apart from the "+" command menu by its localized
10
- * aria-label, and the capability prop (`addFiles`) lives on the composer
11
- * bar's own inject face, which never reaches `conversation.input.left`
12
- * registrants. Scoped to `_tools` children so a third-party plugin parking
13
- * its own file input somewhere else in the composer cannot be misread as
14
- * the official feature; `:not([data-mobile-nav])` keeps OUR picker
15
- * (`attach-picker`) out of the match.
16
- */
17
- export declare const HOST_ATTACH_INPUT = "[data-slot=\"conversation.composer.bar\"] [class$=\"_tools\"] > input[type=\"file\"]:not([data-mobile-nav])";
18
- /** Synchronous read: does this host's composer ship its own attach picker? */
19
- export declare function hostAttachPresent(): boolean;
20
- /**
21
- * True while OUR attachment UI should mount (the host has none of its own).
22
- *
23
- * The rule this encodes (2026-09-11): where the host implements attachment
24
- * upload, the phone composer shows exactly ONE attach control — the host's —
25
- * and this plugin's S7 button and chips row stand down; on a host without
26
- * one (DSH ≤ 0.1.2), both load as they always have.
27
- *
28
- * `useLayoutEffect`, not `useEffect`: on the composer's very first mount our
29
- * slot child renders in the same React pass as the host's tool row, so the
30
- * state initializer below reads the DOM BEFORE either exists — the layout
31
- * effect re-checks after the commit and before the browser paints, so the
32
- * demotion to hidden (or, on an old host, the decision to stay) never
33
- * reaches the screen as a flash. The MutationObserver covers the lifetime
34
- * after that: the picker's existence only really changes with the page, but
35
- * a dynamic host plugin or a reload must not strand a stale verdict.
36
- */
37
- export declare function useOwnAttachUi(): boolean;
38
- //# sourceMappingURL=host-attach.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"host-attach.d.ts","sourceRoot":"","sources":["../../../src/client/host-attach.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,iBAAiB,gHAC2E,CAAA;AAEzG,8EAA8E;AAC9E,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAUxC"}
@@ -1,54 +0,0 @@
1
- import { useLayoutEffect, useState } from 'react'
2
-
3
- /**
4
- * The host composer's own hidden file picker, when the running DSH ships one.
5
- *
6
- * DSH 0.1.5 grew a native attachment flow (dsh-attachment / dsh-client-file-
7
- * upload): the official tool row carries a second `_add` paperclip whose click
8
- * drives an `<input type="file" hidden>` that is a DIRECT sibling of the
9
- * buttons inside `_tools`. That input is the one structural, locale-free
10
- * proof that the host implements composer attachment upload — the button
11
- * itself can only be told apart from the "+" command menu by its localized
12
- * aria-label, and the capability prop (`addFiles`) lives on the composer
13
- * bar's own inject face, which never reaches `conversation.input.left`
14
- * registrants. Scoped to `_tools` children so a third-party plugin parking
15
- * its own file input somewhere else in the composer cannot be misread as
16
- * the official feature; `:not([data-mobile-nav])` keeps OUR picker
17
- * (`attach-picker`) out of the match.
18
- */
19
- export const HOST_ATTACH_INPUT =
20
- '[data-slot="conversation.composer.bar"] [class$="_tools"] > input[type="file"]:not([data-mobile-nav])'
21
-
22
- /** Synchronous read: does this host's composer ship its own attach picker? */
23
- export function hostAttachPresent(): boolean {
24
- return document.querySelector(HOST_ATTACH_INPUT) !== null
25
- }
26
-
27
- /**
28
- * True while OUR attachment UI should mount (the host has none of its own).
29
- *
30
- * The rule this encodes (2026-09-11): where the host implements attachment
31
- * upload, the phone composer shows exactly ONE attach control — the host's —
32
- * and this plugin's S7 button and chips row stand down; on a host without
33
- * one (DSH ≤ 0.1.2), both load as they always have.
34
- *
35
- * `useLayoutEffect`, not `useEffect`: on the composer's very first mount our
36
- * slot child renders in the same React pass as the host's tool row, so the
37
- * state initializer below reads the DOM BEFORE either exists — the layout
38
- * effect re-checks after the commit and before the browser paints, so the
39
- * demotion to hidden (or, on an old host, the decision to stay) never
40
- * reaches the screen as a flash. The MutationObserver covers the lifetime
41
- * after that: the picker's existence only really changes with the page, but
42
- * a dynamic host plugin or a reload must not strand a stale verdict.
43
- */
44
- export function useOwnAttachUi(): boolean {
45
- const [own, setOwn] = useState(() => !hostAttachPresent())
46
- useLayoutEffect(() => {
47
- const check = (): void => setOwn(!hostAttachPresent())
48
- const observer = new MutationObserver(check)
49
- observer.observe(document.body, { childList: true, subtree: true })
50
- check()
51
- return () => observer.disconnect()
52
- }, [])
53
- return own
54
- }