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

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 (47) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +23 -8
  3. package/README.zh.md +22 -7
  4. package/lib/client.js +194 -140
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +231 -23
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.js +2 -2
  10. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/RemoteSettingsCard.js +2 -1
  12. package/lib/types/client/index.js +3 -3
  13. package/lib/types/client/locales.d.ts +0 -4
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +5 -9
  16. package/lib/types/client/mobile-adapt.d.ts +2 -2
  17. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  18. package/lib/types/client/mobile-adapt.js +163 -136
  19. package/lib/types/client/settings-form.d.ts +16 -0
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +27 -1
  22. package/lib/types/detached-work.d.ts +45 -0
  23. package/lib/types/detached-work.d.ts.map +1 -0
  24. package/lib/types/detached-work.js +54 -0
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +67 -12
  27. package/lib/types/lan-bind.d.ts +21 -4
  28. package/lib/types/lan-bind.d.ts.map +1 -1
  29. package/lib/types/lan-bind.js +103 -17
  30. package/lib/types/pairing.d.ts.map +1 -1
  31. package/lib/types/pairing.js +43 -0
  32. package/lib/types/remote-api.d.ts +13 -1
  33. package/lib/types/remote-api.d.ts.map +1 -1
  34. package/lib/types/remote-api.js +14 -1
  35. package/package.json +19 -19
  36. package/src/client/PluginSettingsCard.tsx +9 -0
  37. package/src/client/RemoteSettingsCard.tsx +2 -0
  38. package/src/client/index.ts +3 -3
  39. package/src/client/locales.ts +5 -9
  40. package/src/client/mobile-adapt.ts +167 -138
  41. package/src/client/settings-card.module.css +31 -5
  42. package/src/client/settings-form.ts +27 -1
  43. package/src/detached-work.ts +57 -0
  44. package/src/index.ts +67 -12
  45. package/src/lan-bind.ts +111 -17
  46. package/src/pairing.ts +44 -1
  47. package/src/remote-api.ts +14 -1
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import { withIdentityEncoding } from './http.ts'
39
39
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
40
40
  import { PublicBaseKeeper } from './public-base.ts'
41
41
  import { mountOnce } from './mount-once.ts'
42
+ import { runDetached } from './detached-work.ts'
42
43
  import { REMOTE_CHANNEL_BOOT_SCRIPT } from './remote-channel-boot.ts'
43
44
  import { UUID_POLYFILL_SCRIPT } from './uuid-polyfill.ts'
44
45
 
@@ -736,18 +737,34 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
736
737
  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
738
  }
738
739
 
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.
740
+ // LAN bind toggle: only write the managed patch block once the user has
741
+ // flipped it (undefined = untouched, never write). Desired host/port come
742
+ // from the CLI flags when given (flags win), else from the toggle and
743
+ // the currently bound port. The block takes effect on the next start, so
744
+ // the re-assert at every boot keeps it in sync with both the toggle and
745
+ // the flags.
746
+ //
747
+ // cordis.patch.yml is exactly the file the Host's HMR config watcher
748
+ // refreshes from, and a settings save runs inside the Host's exclusive HMR
749
+ // transaction. Writing that file while the caller's transaction is open makes
750
+ // the watcher's refresh re-enter it, which the Host refuses with "HMR
751
+ // transactions cannot be nested" and which surfaces on the user's save
752
+ // (#1751, #1754).
753
+ //
754
+ // Deferring alone does NOT escape that context, and the earlier belt rested
755
+ // on a premise that measurement disproves: AsyncLocalStorage is propagated
756
+ // into setImmediate, into node:timers, and into AsyncResource scopes (a
757
+ // plain resource built while the transaction runs inherits it), so
758
+ // "setImmediate starts a fresh store" is false. What does start clean is an
759
+ // AsyncResource created at module scope, before any transaction existed -
760
+ // see detached-work.ts. scheduleLanBindWork runs the scheduling through it,
761
+ // so the write lands outside the caller's transaction.
762
+ //
763
+ // The assertion is also idempotent on its own: the write is guarded by the
764
+ // current-vs-desired comparison below, so once the profile carries the block
765
+ // a coalesced follow-up sync() finds nothing to do. That guard is a second
766
+ // belt, not the fix.
767
+ const applyLanBindWork = (value: ResolvedConfig): void => {
751
768
  if (value.lanBind !== undefined) {
752
769
  const startup = ctx.get('webStartup') as StartupFacts | undefined
753
770
  const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
@@ -785,6 +802,44 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
785
802
  }
786
803
  }
787
804
  }
805
+ }
806
+
807
+ // The LAN bind assertion never blocks the caller: one pending work item is
808
+ // enough, and it always re-reads the config through `resolve()` at run time
809
+ // so a toggle flipped twice before the tick lands on the last value.
810
+ //
811
+ // The scheduling itself runs through runDetached: the settings save that
812
+ // drives sync() holds the Host's exclusive HMR transaction in its async
813
+ // context, and a `setImmediate` scheduled directly from here would inherit
814
+ // that mark (measured on Node 24), so the write would still land inside the
815
+ // transaction the watcher then tries to re-enter. Scheduling from the
816
+ // module-scope resource starts the timer - and therefore the write - with no
817
+ // store at all.
818
+ let lanBindWorkPending = false
819
+ const scheduleLanBindWork = (): void => {
820
+ if (lanBindWorkPending) return
821
+ lanBindWorkPending = true
822
+ runDetached(() => setImmediate(() => {
823
+ lanBindWorkPending = false
824
+ try {
825
+ applyLanBindWork(resolve())
826
+ } catch (error) {
827
+ // A deferred assertion must never surface as an unhandled rejection:
828
+ // the settings save that triggered it already answered, and the card
829
+ // reads the live block state back on its own poll.
830
+ console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`)
831
+ }
832
+ }))
833
+ }
834
+ ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
835
+
836
+ // Apply the effective configuration to the running surfaces once per
837
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
838
+ // plan, the pairing routes, the presence sweep, and the posture probe.
839
+ const sync = (): void => {
840
+ const value = resolve()
841
+ service.config = pairingConfigOf(value)
842
+ scheduleLanBindWork()
788
843
  // The plugin-managed tunnels own the public base while one runs: the URL
789
844
  // lands in the service through the tunnel's phase listener (the minted
790
845
  // quick URL, or the named tunnel's fixed public hostname). The manual
package/src/lan-bind.ts CHANGED
@@ -57,17 +57,105 @@ function readPatchContent(file: string): string {
57
57
  return readFileSync(file, 'utf8')
58
58
  }
59
59
 
60
- /** Escape one literal string for embedding into a RegExp pattern. */
61
- function escapeRegex(text: string): string {
62
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
+ /** One top-level sequence item inside a managed-block region. */
61
+ interface BlockItem {
62
+ /** Offset of the item's first character inside the region. */
63
+ start: number
64
+ /** Offset just past the item's last character inside the region. */
65
+ end: number
66
+ /** The item's source text, line terminator included. */
67
+ text: string
63
68
  }
64
69
 
65
- /** Remove the managed block (and its markers) from patch content. */
70
+ /**
71
+ * Split a block-sequence region into its top-level items. Only a column-0 `-`
72
+ * starts an item: that is the shape every writer of this file produces - the
73
+ * managed block itself and the YAML document API's block-style re-emission.
74
+ * @param region - text between two line boundaries.
75
+ * @returns the items in source order; text outside them is not returned.
76
+ */
77
+ function blockItems(region: string): BlockItem[] {
78
+ const items: BlockItem[] = []
79
+ let start = -1
80
+ let offset = 0
81
+ for (const line of region.split('\n')) {
82
+ if (line.startsWith('- ') || line === '-') {
83
+ if (start !== -1) items.push({ start, end: offset, text: region.slice(start, offset) })
84
+ start = offset
85
+ }
86
+ offset += line.length + 1
87
+ }
88
+ if (start !== -1) items.push({ start, end: region.length, text: region.slice(start) })
89
+ return items
90
+ }
91
+
92
+ /**
93
+ * Whether one top-level item is the webserver row this plugin's block owns.
94
+ * The row id is the whole signal: the markers declare everything inside them
95
+ * managed, our block always writes `- id: webserver`, and a same-id row later
96
+ * in the patch replaces this one wholesale anyway - so an id-webserver row
97
+ * found inside our markers is this plugin's row (a hand-truncated block may
98
+ * have lost its `name` line).
99
+ * @param item - one top-level item's text.
100
+ * @returns true when the item is the managed webserver row.
101
+ */
102
+ function isManagedWebserverItem(item: string): boolean {
103
+ return /^- id:\s*['"]?webserver['"]?[ \t]*(?:\r?\n|$)/.test(item)
104
+ }
105
+
106
+ /**
107
+ * The text between a block's BEGIN and END markers: from the character after
108
+ * the BEGIN line's terminator to the start of the END marker's line (or to
109
+ * the end of the content without an END marker).
110
+ * @param content - the patch file text.
111
+ * @param begin - index of the BEGIN marker.
112
+ * @param end - index of the END marker, or -1 when the block is unterminated.
113
+ * @returns the region's text.
114
+ */
115
+ function managedRegion(content: string, begin: number, end: number): string {
116
+ const newlineAfterBegin = content.indexOf('\n', begin)
117
+ const innerStart = newlineAfterBegin === -1 ? content.length : newlineAfterBegin + 1
118
+ const endLineStart = end === -1 ? content.length : content.lastIndexOf('\n', end - 1) + 1
119
+ return content.slice(innerStart, Math.max(innerStart, endLineStart))
120
+ }
121
+
122
+ /**
123
+ * Remove the managed block (its markers and the webserver row this plugin
124
+ * wrote) from patch content, keeping every other row.
125
+ *
126
+ * The markers travel with the file through writers that append rows through
127
+ * the YAML document API: the appended rows land at the end of the sequence,
128
+ * which is BEFORE a trailing END comment, so the region between the markers
129
+ * can hold rows this plugin never wrote. Deleting the whole region silently
130
+ * removed the user's model, plugin and remote-control rows on the next rewrite
131
+ * - the remote-control row carries the LAN toggle itself, so the feature reset
132
+ * to `undefined` and the phone was locked out again after a restart (DSH
133
+ * Desktop report, 2026-09-30). Only the managed item is removed here; every
134
+ * foreign row is kept in place and the fresh block is appended after them (the
135
+ * same-id row last wins).
136
+ * @param content - the patch file text.
137
+ * @returns the text with the markers and the managed row removed.
138
+ */
66
139
  export function stripManagedBlock(content: string): string {
67
- const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN)
68
- const end = escapeRegex(LAN_BIND_BLOCK_END)
69
- const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, 'g')
70
- return content.replace(pattern, '\n')
140
+ let text = content
141
+ for (;;) {
142
+ const begin = text.indexOf(LAN_BIND_BLOCK_BEGIN)
143
+ if (begin === -1) return text
144
+ const end = text.indexOf(LAN_BIND_BLOCK_END, begin)
145
+ const blockStart = text.lastIndexOf('\n', begin - 1) + 1
146
+ const newlineAfterEnd = end === -1 ? -1 : text.indexOf('\n', end)
147
+ const resumeAt = end === -1 ? text.length : (newlineAfterEnd === -1 ? text.length : newlineAfterEnd + 1)
148
+ const region = managedRegion(text, begin, end)
149
+ let kept = ''
150
+ let cursor = 0
151
+ for (const item of blockItems(region)) {
152
+ if (!isManagedWebserverItem(item.text)) continue
153
+ kept += region.slice(cursor, item.start)
154
+ cursor = item.end
155
+ }
156
+ kept += region.slice(cursor)
157
+ text = text.slice(0, blockStart) + kept + text.slice(resumeAt)
158
+ }
71
159
  }
72
160
 
73
161
  /**
@@ -163,9 +251,16 @@ export function managedBindOf(content: string): ManagedBindState | undefined {
163
251
  const begin = content.indexOf(LAN_BIND_BLOCK_BEGIN)
164
252
  if (begin === -1) return undefined
165
253
  const end = content.indexOf(LAN_BIND_BLOCK_END, begin)
166
- const block = end === -1 ? content.slice(begin) : content.slice(begin, end)
167
- const hostMatch = /host:\s*'([^']+)'/.exec(block)
168
- const portMatch = /port:\s*(\d+)/.exec(block)
254
+ const region = managedRegion(content, begin, end)
255
+ // Read the bind out of the managed row itself. A region a YAML writer
256
+ // displaced holds foreign rows too, and their `host`/`port` keys are not
257
+ // this block's bind - reading them made the boot re-assert
258
+ // believe the block was already applied. A region without a recognizable
259
+ // managed row (hand-edited literals) keeps the historical whole-region read.
260
+ const items = blockItems(region).filter(item => isManagedWebserverItem(item.text))
261
+ const scope = items.length > 0 ? items.map(item => item.text).join('') : region
262
+ const hostMatch = /host:\s*'([^']+)'/.exec(scope)
263
+ const portMatch = /port:\s*(\d+)/.exec(scope)
169
264
  return {
170
265
  host: hostMatch?.[1] ?? '',
171
266
  port: portMatch !== null ? Number(portMatch[1]) : undefined,
@@ -184,9 +279,10 @@ export function lanBindState(profile: string, home: string = dshHome()): { block
184
279
  * patch file is preserved; the block is rewritten atomically (unique temp
185
280
  * file + rename, so concurrent writers can never rename a half-written
186
281
  * file) with the original file's permissions preserved. A hand-truncated
187
- * unterminated block (BEGIN marker without END, which stripManagedBlock
188
- * cannot match) is truncated at its BEGIN marker first so the rewrite can
189
- * never stack a second webserver row onto the orphan.
282
+ * unterminated block (BEGIN marker without END) is consumed by
283
+ * stripManagedBlock as well - markers and managed row removed, foreign rows
284
+ * kept - so the rewrite can never stack a second webserver row onto the
285
+ * orphan.
190
286
  *
191
287
  * The written file is always exactly one valid YAML document: a base the
192
288
  * plugin manager left in flow style is re-emitted as a block sequence before
@@ -194,9 +290,7 @@ export function lanBindState(profile: string, home: string = dshHome()): { block
194
290
  */
195
291
  export function writeLanBind(host: LanBindHost, port: number, profile: string, home: string = dshHome()): void {
196
292
  const file = profilePatchFile(profile, home)
197
- const stripped = stripManagedBlock(readPatchContent(file))
198
- const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN)
199
- const rawBase = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()
293
+ const rawBase = stripManagedBlock(readPatchContent(file)).trimEnd()
200
294
  const base = rawBase.replace(/\[\s*\]\s*$/, '').trimEnd()
201
295
  const block = managedBlock(host, port)
202
296
  const content = mergePatchContent(base, block, file)
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