@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.
- package/README.i18n.yaml +2 -2
- package/README.md +22 -7
- package/README.zh.md +21 -6
- package/lib/client.js +75 -138
- package/lib/client.js.map +1 -1
- package/lib/index.js +162 -22
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +2 -1
- package/lib/types/client/index.js +3 -3
- package/lib/types/client/locales.d.ts +0 -4
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +5 -9
- package/lib/types/client/mobile-adapt.d.ts +2 -2
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +51 -131
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +2 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +27 -1
- package/lib/types/detached-work.d.ts +45 -0
- package/lib/types/detached-work.d.ts.map +1 -0
- package/lib/types/detached-work.js +55 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +31 -10
- package/lib/types/lan-bind.d.ts +21 -4
- package/lib/types/lan-bind.d.ts.map +1 -1
- package/lib/types/lan-bind.js +103 -17
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +4 -1
- package/package.json +19 -19
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/RemoteSettingsCard.tsx +2 -0
- package/src/client/index.ts +3 -3
- package/src/client/locales.ts +5 -9
- package/src/client/mobile-adapt.ts +53 -136
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/detached-work.ts +58 -0
- package/src/index.ts +31 -10
- package/src/lan-bind.ts +111 -17
- 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:
|
|
40
|
-
padding:
|
|
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:
|
|
55
|
-
padding:
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
747
|
-
// runs inside the Host's
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
188
|
-
*
|
|
189
|
-
* never stack a second webserver row onto the
|
|
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
|
|
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
|
-
/**
|
|
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;' +
|