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

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 +22 -7
  3. package/README.zh.md +21 -6
  4. package/lib/client.js +75 -138
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +162 -22
  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 +51 -131
  19. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  20. package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
  21. package/lib/types/client/plugin-card-seat.js +2 -0
  22. package/lib/types/client/settings-form.d.ts +16 -0
  23. package/lib/types/client/settings-form.d.ts.map +1 -1
  24. package/lib/types/client/settings-form.js +27 -1
  25. package/lib/types/detached-work.d.ts +45 -0
  26. package/lib/types/detached-work.d.ts.map +1 -0
  27. package/lib/types/detached-work.js +55 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +31 -10
  30. package/lib/types/lan-bind.d.ts +21 -4
  31. package/lib/types/lan-bind.d.ts.map +1 -1
  32. package/lib/types/lan-bind.js +103 -17
  33. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  34. package/lib/types/remote-channel-boot.js +4 -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 +53 -136
  41. package/src/client/plugin-card-seat.ts +7 -0
  42. package/src/client/settings-card.module.css +31 -5
  43. package/src/client/settings-form.ts +27 -1
  44. package/src/detached-work.ts +58 -0
  45. package/src/index.ts +31 -10
  46. package/src/lan-bind.ts +111 -17
  47. package/src/remote-channel-boot.ts +4 -1
@@ -86,6 +86,11 @@ export interface PluginCardSeat {
86
86
  label?: () => string
87
87
  /** Locale namespace the card renders with. */
88
88
  locale: string
89
+ /**
90
+ * Child slots this card declares and renders. Forwarded to whichever seat
91
+ * the host renders; absent for a card that declares none.
92
+ */
93
+ children?: Record<string, unknown>
89
94
  /** Business-face factory of the registration. */
90
95
  inject?: () => object
91
96
  /**
@@ -162,12 +167,14 @@ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat):
162
167
  ...(seat.order === undefined ? {} : { order: seat.order }),
163
168
  ...(seat.label === undefined ? {} : { label: seat.label }),
164
169
  locale: seat.locale,
170
+ ...(seat.children === undefined ? {} : { children: seat.children }),
165
171
  ...(seat.inject === undefined ? {} : { inject }),
166
172
  }
167
173
  : {
168
174
  name: OFFICIAL_PLUGIN_CARD_SEAT,
169
175
  key: seat.bundle,
170
176
  locale: seat.locale,
177
+ ...(seat.children === undefined ? {} : { children: seat.children }),
171
178
  ...(seat.inject === undefined ? {} : { inject }),
172
179
  }) as never, component) as () => void
173
180
  current = target
@@ -36,8 +36,8 @@
36
36
  border: 0;
37
37
  border-radius: 12px;
38
38
  align-items: center;
39
- gap: 12px;
40
- padding: 14px 16px;
39
+ gap: 13px;
40
+ padding: 16px;
41
41
  display: flex;
42
42
  }
43
43
 
@@ -51,8 +51,8 @@
51
51
  box-sizing: border-box;
52
52
  border-radius: 12px;
53
53
  align-items: center;
54
- gap: 12px;
55
- padding: 14px 16px;
54
+ gap: 13px;
55
+ padding: 16px;
56
56
  display: flex;
57
57
  }
58
58
 
@@ -64,6 +64,25 @@
64
64
  display: flex;
65
65
  }
66
66
 
67
+ /* Optional leading plugin mark: a quiet tile that takes the ink color on
68
+ hover and while the card is open, so the whole row reads as one control. */
69
+ .mark {
70
+ flex: none;
71
+ display: grid;
72
+ place-items: center;
73
+ width: 34px;
74
+ height: 34px;
75
+ border-radius: 10px;
76
+ background: var(--dsw-alias-bg-module-platform);
77
+ color: var(--dsw-alias-label-secondary);
78
+ transition: color 0.16s;
79
+ }
80
+
81
+ .header:hover .mark,
82
+ .cardOpen .mark {
83
+ color: var(--dsw-alias-label-primary);
84
+ }
85
+
67
86
  .name {
68
87
  color: var(--dsw-alias-label-primary);
69
88
  font-size: 15px;
@@ -95,7 +114,13 @@
95
114
  .chevron {
96
115
  color: var(--dsw-alias-label-tertiary);
97
116
  flex: none;
98
- transition: transform 0.16s;
117
+ width: 16px;
118
+ height: 16px;
119
+ transition: transform 0.16s, color 0.16s;
120
+ }
121
+
122
+ .header:hover .chevron {
123
+ color: var(--dsw-alias-label-secondary);
99
124
  }
100
125
 
101
126
  .chevronOpen {
@@ -406,6 +431,7 @@
406
431
  @media (prefers-reduced-motion: reduce) {
407
432
  .card,
408
433
  .header,
434
+ .mark,
409
435
  .chevron,
410
436
  .chevronOpen,
411
437
  .discard,
@@ -215,6 +215,8 @@ export class CardForm<T> {
215
215
  private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
+ /** A save asked for while one was in flight; it runs once the first settles (#1754). */
219
+ private saveQueued = false
218
220
  private failed = false
219
221
  private failedReason: string | undefined
220
222
 
@@ -283,7 +285,7 @@ export class CardForm<T> {
283
285
  resetField: (field) => {
284
286
  this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
285
287
  },
286
- save: () => { void this.save() },
288
+ save: () => { void this.requestSave() },
287
289
  discard: () => {
288
290
  if (this.staged.size === 0 && !this.failed) return
289
291
  this.staged.clear()
@@ -310,6 +312,30 @@ export class CardForm<T> {
310
312
  * the user can correct them instead of retyping.
311
313
  * @returns settlement after the mutation and the read-back.
312
314
  */
315
+ /**
316
+ * Run one save, and re-run it once if another was asked for while this one
317
+ * was still in flight.
318
+ *
319
+ * A save is a Host round trip that also drives the profile reconcile, and
320
+ * the Host runs that write inside one exclusive transaction. Answering a
321
+ * press that arrives mid-flight by returning immediately dropped the edit
322
+ * with no explanation, which is the "the save button stops working after a
323
+ * few rounds" report (#1754). Serializing instead means a save pressed while
324
+ * another is still settling runs against the settled state - which is what
325
+ * the operator meant by pressing it again.
326
+ * @returns settlement after the mutation and the read-back.
327
+ */
328
+ async requestSave(): Promise<void> {
329
+ if (this.saving) {
330
+ this.saveQueued = true
331
+ return
332
+ }
333
+ await this.save()
334
+ if (!this.saveQueued) return
335
+ this.saveQueued = false
336
+ await this.requestSave()
337
+ }
338
+
313
339
  async save(): Promise<void> {
314
340
  const plan = this.plan()
315
341
  const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
@@ -0,0 +1,58 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/detached-work.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Run one piece of work outside the caller's async context.
4
+ *
5
+ * A Host mutation that runs inside `hmr.runExclusive` marks the running async
6
+ * context with an AsyncLocalStorage store. Side effects on that path that write
7
+ * a file the HMR config watcher refreshes from - `cordis.patch.yml` above all -
8
+ * must not run under that mark, or the watcher's refresh re-enters the
9
+ * exclusive transaction and is refused with "HMR transactions cannot be nested"
10
+ * (#1751, #1754, #1816).
11
+ *
12
+ * Deferring is NOT enough, and the earlier belt that this one replaces was
13
+ * built on a disproven premise: Node propagates an AsyncLocalStorage store into
14
+ * `setImmediate`, into `node:timers`, into promise continuations, and into any
15
+ * AsyncResource scoped to the current async id. Measured on Node 24:
16
+ *
17
+ * ```
18
+ * als.run(true, () => setImmediate(() => als.getStore())) // -> true
19
+ * ```
20
+ *
21
+ * What DOES start clean is an AsyncResource whose `triggerAsyncId` is not the
22
+ * transaction. A resource created once at module scope - that is, before any
23
+ * transaction existed - carries no store, and everything it schedules inherits
24
+ * that emptiness rather than the caller's mark:
25
+ *
26
+ * ```
27
+ * const detached = new AsyncResource('...') // module scope
28
+ * als.run(true, () => detached.runInAsyncScope(() => setImmediate(() => als.getStore())))
29
+ * // -> undefined
30
+ * ```
31
+ *
32
+ * @module dsh-web-shared/host/detached-work
33
+ */
34
+
35
+ import { AsyncResource } from 'node:async_hooks'
36
+
37
+ /**
38
+ * The escape hatch, created at module scope on purpose: a resource built while
39
+ * a transaction is running would capture that transaction, so this single
40
+ * instance is the only one the module ever needs. It carries no context of its
41
+ * own, so every job run through it starts from the process's root context.
42
+ */
43
+ const DETACHED = new AsyncResource('dsh-web:detached-work', { triggerAsyncId: 0 })
44
+
45
+ /**
46
+ * Run `work` outside the caller's AsyncLocalStorage context.
47
+ *
48
+ * The call itself is synchronous - this schedules, it does not defer. Work that
49
+ * must not block the caller should schedule its own `setImmediate` inside
50
+ * `work`, which then also starts clean because it inherits this resource's
51
+ * empty context rather than the caller's.
52
+ *
53
+ * @param work - the side effect to detach from the caller's async context.
54
+ * @returns whatever `work` returns.
55
+ */
56
+ export function runDetached<T>(work: () => T): T {
57
+ return DETACHED.runInAsyncScope(work)
58
+ }
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
 
@@ -743,14 +744,26 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
743
744
  // the re-assert at every boot keeps it in sync with both the toggle and
744
745
  // the flags.
745
746
  //
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.
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.
754
767
  const applyLanBindWork = (value: ResolvedConfig): void => {
755
768
  if (value.lanBind !== undefined) {
756
769
  const startup = ctx.get('webStartup') as StartupFacts | undefined
@@ -794,11 +807,19 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
794
807
  // The LAN bind assertion never blocks the caller: one pending work item is
795
808
  // enough, and it always re-reads the config through `resolve()` at run time
796
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.
797
818
  let lanBindWorkPending = false
798
819
  const scheduleLanBindWork = (): void => {
799
820
  if (lanBindWorkPending) return
800
821
  lanBindWorkPending = true
801
- setImmediate(() => {
822
+ runDetached(() => setImmediate(() => {
802
823
  lanBindWorkPending = false
803
824
  try {
804
825
  applyLanBindWork(resolve())
@@ -808,7 +829,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
808
829
  // reads the live block state back on its own poll.
809
830
  console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`)
810
831
  }
811
- })
832
+ }))
812
833
  }
813
834
  ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
814
835
 
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)
@@ -204,7 +204,7 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
204
204
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
205
205
  export const BOOT_WATCHDOG_KEY = 'dsh-remote-boot-reload'
206
206
 
207
- /** Total wait for the app's first conversation surface before the reload. */
207
+ /** Post-load wait for the app's first conversation surface before the reload. */
208
208
  const BOOT_WATCHDOG_WAIT_MS = 15_000
209
209
 
210
210
  /** Watchdog poll cadence. */
@@ -227,6 +227,9 @@ export function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs =
227
227
  ';function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector("[data-conversation-scroll]")||w.document.querySelector("[data-slot=\\"conversation\\"]")))}catch(e){return false}}' +
228
228
  'function wTick(n){try{' +
229
229
  'if(wBoot()){try{w.sessionStorage.removeItem(' + key + ')}catch(e){}return}' +
230
+ // Parse-time installation must not spend the recovery budget downloading
231
+ // the initial scripts/styles over a slow remote link (issue #1845).
232
+ 'if(!w.document||w.document.readyState!=="complete"){w.setTimeout(function(){wTick(0)},' + pollMs + ');return}' +
230
233
  'if(n<' + waitMs + '){w.setTimeout(function(){wTick(n+' + pollMs + ')},' + pollMs + ');return}' +
231
234
  'var done=false;try{done=w.sessionStorage.getItem(' + key + ')==="1"}catch(e){}' +
232
235
  'if(done)return;' +