@linxin666/dsh-remote-web-ui 0.4.3 → 0.4.4

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.
@@ -54,6 +54,17 @@ const ADAPT_CSS_ID = 'dsh-remote-web-ui/mobile-adapt.css'
54
54
  const ACTIVE_CLASS = 'dsh-remote-portrait'
55
55
  /** Body class while the collapsed rail is hidden behind the whale. */
56
56
  const RAIL_HIDDEN_CLASS = 'dsh-remote-rail-hidden'
57
+ /**
58
+ * The official sidebar column root. Also the scope of the row sweep: every row
59
+ * the drag suppression covers is a descendant of it, so the document-wide scan
60
+ * it replaces only ever returned these.
61
+ */
62
+ const SIDEBAR_SELECTOR = '[class*="_sidebarCol"]'
63
+ /** The official draggable session/project rows (see disableRowDrag). */
64
+ const ROW_SELECTOR = '[class*="_sessionRow"], [class*="_projectRow"]'
65
+ /** The injected stylesheet, addressed through the same cached lookup. */
66
+ const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`
67
+
57
68
  /** Whale button id. */
58
69
  const WHALE_ID = 'dshRemoteWhale'
59
70
  /** Compact picker: synthesized model button id. */
@@ -346,6 +357,8 @@ export function startMobileAdapt(): void {
346
357
  let whaleObserver: MutationObserver | null = null
347
358
  /** Header subtree observer: marks the geometry measurement dirty on re-render. */
348
359
  let headerObserver: MutationObserver | null = null
360
+ /** Document child-list observer: invalidates the cached-absent selector set. */
361
+ let domObserver: MutationObserver | null = null
349
362
  let observedHeader: Element | null = null
350
363
  /** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
351
364
  let headerGeometryDirty = true
@@ -368,17 +381,28 @@ export function startMobileAdapt(): void {
368
381
  * rail compaction) while the body class stays.
369
382
  */
370
383
  function ensureAdaptStyle(): void {
371
- if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return
384
+ if (nodeOf(STYLE_SELECTOR) !== null) return
372
385
  const tag = document.createElement('style')
373
386
  tag.dataset.plugin = 'remote-web-ui'
374
387
  tag.dataset.pluginCss = ADAPT_CSS_ID
375
388
  tag.textContent = ADAPT_CSS.join('')
376
389
  document.head.appendChild(tag)
390
+ // Seed the lookup cache: the tag is created here, outside nodeOf, so the
391
+ // negative cache could otherwise report it missing for one more tick and
392
+ // stack a duplicate stylesheet.
393
+ nodeCache.set(STYLE_SELECTOR, tag)
394
+ nodeMissCache.delete(STYLE_SELECTOR)
377
395
  }
378
396
 
379
397
  function apply(): void {
380
398
  if (active) return
381
399
  active = true
400
+ // First statement of the activation, before any lookup: nothing observed the
401
+ // document while the layer was reverted (the observer is created below), so
402
+ // no "absent" verdict recorded earlier may be served now. Clearing here
403
+ // rather than beside the observer also covers the lookups this path runs
404
+ // before that point.
405
+ forgetAbsentSelectors()
382
406
  document.body.classList.add(ACTIVE_CLASS)
383
407
  // A details panel opened before the viewport rotated into portrait (or
384
408
  // restored across reloads) would sit behind the display:none above;
@@ -397,6 +421,7 @@ export function startMobileAdapt(): void {
397
421
  meta.setAttribute('content', `${savedViewportContent}, viewport-fit=cover`)
398
422
  }
399
423
  ensureWhale()
424
+ ensureDomObserver()
400
425
  syncWhale()
401
426
  // React builds nodes with attributes before inserting them, so
402
427
  // attribute observers miss the initial collapsed state — a light
@@ -429,6 +454,45 @@ export function startMobileAdapt(): void {
429
454
  whaleObserver.disconnect()
430
455
  whaleObserver = null
431
456
  }
457
+ if (domObserver !== null) {
458
+ domObserver.disconnect()
459
+ domObserver = null
460
+ }
461
+ // Nothing observes the document from here on; a verdict recorded now could
462
+ // not be trusted after the next apply().
463
+ forgetAbsentSelectors()
464
+ }
465
+
466
+ /**
467
+ * Observe the document for the two kinds of change that can make a cached-absent
468
+ * target discoverable again (see nodeOf): node insertion/removal, and the
469
+ * class / compat-stamp attributes the cached selectors match on.
470
+ *
471
+ * Body class writes are excluded by target: the layer toggles three body
472
+ * classes every tick, and counting those would invalidate the negative cache
473
+ * on every tick and restore the very scan this cache removes. They cannot
474
+ * create a target either — every cached selector matches an element other
475
+ * than <body>.
476
+ *
477
+ * The record queue is drained on a microtask, so this stays off the layout
478
+ * path; a chat turn's insertions are a handful per second, not per element.
479
+ */
480
+ function ensureDomObserver(): void {
481
+ if (domObserver !== null || typeof MutationObserver === 'undefined' || !document.body) return
482
+ domObserver = new MutationObserver((records) => {
483
+ for (const record of records) {
484
+ if (record.type === 'childList' || record.target !== document.body) {
485
+ noteDomChange()
486
+ return
487
+ }
488
+ }
489
+ })
490
+ domObserver.observe(document.body, {
491
+ childList: true,
492
+ subtree: true,
493
+ attributes: true,
494
+ attributeFilter: ['class', 'data-dsh-frame'],
495
+ })
432
496
  }
433
497
 
434
498
  /** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
@@ -702,7 +766,7 @@ export function startMobileAdapt(): void {
702
766
  const dragOverridden = new Map<Element, string | null>()
703
767
  function disableRowDrag(): void {
704
768
  if (!active) return
705
- const rows = document.querySelectorAll('[class*="_sidebarCol"] [class*="_sessionRow"], [class*="_sidebarCol"] [class*="_projectRow"]')
769
+ const rows = sidebarRows()
706
770
  for (const row of rows) {
707
771
  if (row.getAttribute('draggable') !== 'false') {
708
772
  if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute('draggable'))
@@ -731,15 +795,90 @@ export function startMobileAdapt(): void {
731
795
  * node on a major re-render, which the isConnected guard detects.
732
796
  */
733
797
  const nodeCache = new Map<string, Element>()
798
+ /**
799
+ * Selectors known to be absent as of {@link domGeneration}. A miss is the
800
+ * common case, not the exception: the layer holds no overlay of its own, and
801
+ * several official surfaces it looks for (tabs row, tools row, sidebar rows)
802
+ * legitimately do not exist at once. Re-walking a conversation-sized document
803
+ * for the same absent selector on every 600ms tick was waste that almost
804
+ * always resolved to null: five document-wide lookups per tick on the measured
805
+ * fixture, zero after this cache. That is a lookup count — the wall-clock
806
+ * saving is real while the mounted content is quiet and shrinks to the
807
+ * row-scope saving when a turn streams (see the Agent Note for the harness and
808
+ * the before/after numbers).
809
+ *
810
+ * A miss is trusted only while the observed content has not changed, so a
811
+ * surface that appears is still found on the very next tick — the same
812
+ * discovery latency the unconditional probe had. The cache is never aged by a
813
+ * clock: it is cleared by {@link noteDomChange} while the observer is live and
814
+ * by {@link forgetAbsentSelectors} across the windows where it is not (apply
815
+ * and revert). One blind spot is deliberate and load-bearing: the observer
816
+ * watches <body>, so a head-resident target is not covered by that signal —
817
+ * the one such target is seeded into the cache where it is created (see
818
+ * ensureAdaptStyle).
819
+ */
820
+ const nodeMissCache = new Set<string>()
821
+ /**
822
+ * Bumped whenever a cached target may have appeared or disappeared. A surface
823
+ * qualifies on a class token or on the aggregate compat stamp
824
+ * (`data-dsh-frame`), so both insertions/removals AND those two attributes
825
+ * can change the answer. Nothing the layer writes per tick lands here: its own
826
+ * class writes are all on <body>, and its row/transform/label writes touch
827
+ * other attributes entirely (see ensureDomObserver).
828
+ */
829
+ let domGeneration = 0
830
+ /** The generation the negative cache was recorded against. */
831
+ let missGeneration = -1
832
+
833
+ function noteDomChange(): void {
834
+ domGeneration += 1
835
+ }
836
+
837
+ /**
838
+ * Drop every cached "absent" verdict. Called on apply and revert: while the
839
+ * observer is disconnected the layer is blind to insertions, so a verdict
840
+ * recorded before the gap could otherwise outlive the change that falsified
841
+ * it and be served without a probe. The positive cache needs no equivalent —
842
+ * its `isConnected` guard already re-resolves a replaced node.
843
+ */
844
+ function forgetAbsentSelectors(): void {
845
+ nodeMissCache.clear()
846
+ domGeneration += 1
847
+ }
848
+
734
849
  function nodeOf(selector: string): Element | null {
735
850
  const cached = nodeCache.get(selector)
736
- if (cached !== undefined && cached.isConnected) return cached
851
+ if (cached !== undefined) {
852
+ // A node React replaced is no longer connected; drop it and fall through
853
+ // so the lookup below can find its replacement.
854
+ if (cached.isConnected) return cached
855
+ nodeCache.delete(selector)
856
+ }
857
+ if (missGeneration !== domGeneration) {
858
+ nodeMissCache.clear()
859
+ missGeneration = domGeneration
860
+ }
861
+ if (nodeMissCache.has(selector)) return null
737
862
  const found = document.querySelector(selector)
738
- if (found === null) nodeCache.delete(selector)
739
- else nodeCache.set(selector, found)
863
+ if (found === null) {
864
+ nodeMissCache.add(selector)
865
+ return null
866
+ }
867
+ nodeCache.set(selector, found)
740
868
  return found
741
869
  }
742
870
 
871
+ /**
872
+ * The session/project rows the drag suppression covers, scoped to the cached
873
+ * sidebar root. Each row carries the sidebar column class as an ancestor, so
874
+ * the document-wide scan this replaces only ever returned rows from here.
875
+ */
876
+ function sidebarRows(): Element[] {
877
+ const sidebar = nodeOf(SIDEBAR_SELECTOR)
878
+ if (sidebar === null) return []
879
+ return Array.from(sidebar.querySelectorAll(ROW_SELECTOR))
880
+ }
881
+
743
882
  /** The official application frame, through the same cached lookup. */
744
883
  function frameEl(): HTMLElement | null {
745
884
  const frame = nodeOf(APP_FRAME_SELECTOR)
package/src/index.ts CHANGED
@@ -736,18 +736,22 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
736
736
  console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
737
737
  }
738
738
 
739
- // Apply the effective configuration to the running surfaces once per
740
- // activation: the service tunables, the LAN-bind block/firewall, the tunnel
741
- // plan, the pairing routes, the presence sweep, and the posture probe.
742
- const sync = (): void => {
743
- const value = resolve()
744
- service.config = pairingConfigOf(value)
745
- // LAN bind toggle: only write the managed patch block once the user has
746
- // flipped it (undefined = untouched, never write). Desired host/port come
747
- // from the CLI flags when given (flags win), else from the toggle and
748
- // the currently bound port. The block takes effect on the next start, so
749
- // the re-assert at every boot keeps it in sync with both the toggle and
750
- // the flags.
739
+ // LAN bind toggle: only write the managed patch block once the user has
740
+ // flipped it (undefined = untouched, never write). Desired host/port come
741
+ // from the CLI flags when given (flags win), else from the toggle and
742
+ // the currently bound port. The block takes effect on the next start, so
743
+ // the re-assert at every boot keeps it in sync with both the toggle and
744
+ // the flags.
745
+ //
746
+ // This work must leave the caller's async context first: a settings save
747
+ // runs inside the Host's hmr.runExclusive, and cordis.patch.yml is exactly
748
+ // the file the HMR config watcher refreshes from. A write issued on that
749
+ // same context makes the watcher's refresh re-enter runExclusive, which
750
+ // rejects with "HMR transactions cannot be nested" and fails the user's
751
+ // save (issue #1751). setImmediate starts a fresh AsyncLocalStorage store,
752
+ // so the assertion runs outside the transaction; it is idempotent, so the
753
+ // coalesced follow-up sync() re-reading the same block writes nothing.
754
+ const applyLanBindWork = (value: ResolvedConfig): void => {
751
755
  if (value.lanBind !== undefined) {
752
756
  const startup = ctx.get('webStartup') as StartupFacts | undefined
753
757
  const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
@@ -785,6 +789,36 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
785
789
  }
786
790
  }
787
791
  }
792
+ }
793
+
794
+ // The LAN bind assertion never blocks the caller: one pending work item is
795
+ // enough, and it always re-reads the config through `resolve()` at run time
796
+ // so a toggle flipped twice before the tick lands on the last value.
797
+ let lanBindWorkPending = false
798
+ const scheduleLanBindWork = (): void => {
799
+ if (lanBindWorkPending) return
800
+ lanBindWorkPending = true
801
+ setImmediate(() => {
802
+ lanBindWorkPending = false
803
+ try {
804
+ applyLanBindWork(resolve())
805
+ } catch (error) {
806
+ // A deferred assertion must never surface as an unhandled rejection:
807
+ // the settings save that triggered it already answered, and the card
808
+ // reads the live block state back on its own poll.
809
+ console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`)
810
+ }
811
+ })
812
+ }
813
+ ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
814
+
815
+ // Apply the effective configuration to the running surfaces once per
816
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
817
+ // plan, the pairing routes, the presence sweep, and the posture probe.
818
+ const sync = (): void => {
819
+ const value = resolve()
820
+ service.config = pairingConfigOf(value)
821
+ scheduleLanBindWork()
788
822
  // The plugin-managed tunnels own the public base while one runs: the URL
789
823
  // lands in the service through the tunnel's phase listener (the minted
790
824
  // quick URL, or the named tunnel's fixed public hostname). The manual
package/src/pairing.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  import { randomBytes } from 'node:crypto'
25
25
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
26
26
  import { dirname } from 'node:path'
27
- import type { PostureSnapshot } from './posture.ts'
27
+ import type { PostureHost, PostureSnapshot } from './posture.ts'
28
28
 
29
29
  /** The observable pairing phases the panel renders. */
30
30
  export type PairingPhase =
@@ -663,6 +663,7 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
663
663
  && a.publicUrl === b.publicUrl
664
664
  && tunnelEqual(a.tunnel, b.tunnel)
665
665
  && relayEqual(a.relay, b.relay)
666
+ && postureEqual(a.posture, b.posture)
666
667
  && a.tokenId === b.tokenId
667
668
  && a.tokenExpiresAt === b.tokenExpiresAt
668
669
  && a.deviceCount === b.deviceCount
@@ -695,6 +696,48 @@ function relayEqual(a: RelayStatus | undefined, b: RelayStatus | undefined): boo
695
696
  && a.state === b.state && a.url === b.url && a.error === b.error)
696
697
  }
697
698
 
699
+ /**
700
+ * Posture equality. A probe round can change this frame while every other field
701
+ * stays put — the common case, since the probe re-runs on a settings change and
702
+ * on a tunnel reaching running, and a quick-tunnel restart churns the probed
703
+ * host set. Leaving the frame out of the comparison suppressed those emits, so
704
+ * the loopback SSE stream and the desktop panel could sit on a stale "exposed"
705
+ * (or stale "clean") verdict for as long as the phase and roster stayed put.
706
+ */
707
+ function postureEqual(a: PostureSnapshot | undefined, b: PostureSnapshot | undefined): boolean {
708
+ if (a === b) return true
709
+ // A frame that is not an object at all (a hand-built context can pass
710
+ // anything) counts as changed instead of throwing out of the setter.
711
+ if (!isPostureFrame(a) || !isPostureFrame(b)) return false
712
+ return a.checkedAt === b.checkedAt && hostsEqual(a.hosts, b.hosts)
713
+ }
714
+
715
+ /** Whether a value has the shape a posture frame is compared on. */
716
+ function isPostureFrame(value: unknown): value is PostureSnapshot {
717
+ return typeof value === 'object' && value !== null
718
+ }
719
+
720
+ /**
721
+ * Per-host posture equality (a round's verdicts, in probe order). Tolerates a
722
+ * frame that is not the expected shape: this runs inside the emit path, outside
723
+ * the listener error containment, so a malformed snapshot handed in by another
724
+ * host-fiber caller must degrade to "changed" instead of throwing out of the
725
+ * setter that produced it.
726
+ */
727
+ function hostsEqual(a: readonly PostureHost[] | undefined, b: readonly PostureHost[] | undefined): boolean {
728
+ if (a === b) return true
729
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
730
+ return a.length === b.length && a.every((host, index) => {
731
+ const other = b[index]
732
+ return isPostureHost(host) && isPostureHost(other) && host.host === other.host && host.exposed === other.exposed
733
+ })
734
+ }
735
+
736
+ /** Whether a value has the shape one probed host is compared on. */
737
+ function isPostureHost(value: unknown): value is PostureHost {
738
+ return typeof value === 'object' && value !== null
739
+ }
740
+
698
741
  /** Element-wise string list equality (interface order is meaningful). */
699
742
  function sameStrings(a: readonly string[], b: readonly string[]): boolean {
700
743
  return a.length === b.length && a.every((value, index) => value === b[index])
package/src/remote-api.ts CHANGED
@@ -182,7 +182,19 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
182
182
  }
183
183
 
184
184
  /**
185
- * Map one outer upgrade URL onto the loopback path (query string included).
185
+ * Map one outer upgrade URL onto the loopback path, dropping the cookieless
186
+ * device credential on the way in.
187
+ *
188
+ * The outer handshake carries the device id as `?device=<id>` because WebSocket
189
+ * handshakes cannot carry headers from the Web API. That id is a live session
190
+ * credential — it opens the full host API through this same channel — and the
191
+ * inner leg is a request the rest of the loopback origin sees (on a WebSocket
192
+ * leg, a raw handshake request line written to a loopback socket), so forwarding
193
+ * it handed a working credential to every other observer of the local port for
194
+ * no benefit: the gate above has already authenticated the caller, and no
195
+ * upstream route reads the parameter (the gateway mux matches on path alone).
196
+ * The rest of the query rides through unchanged, so a caller's own parameters
197
+ * still reach the upstream.
186
198
  */
187
199
  export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string {
188
200
  if (reqUrl === undefined || reqUrl === '') return fallbackPath
@@ -194,6 +206,7 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
194
206
  }
195
207
  const inner = innerPathOf(url.pathname)
196
208
  if (inner === undefined) return fallbackPath
209
+ url.searchParams.delete(REMOTE_DEVICE_QUERY)
197
210
  return `${inner}${url.search}`
198
211
  }
199
212