@elefunc/send 0.1.3 → 0.1.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.
- package/README.md +3 -3
- package/package.json +2 -6
- package/runtime/install.ts +22 -0
- package/runtime/rezi-files.ts +90 -0
- package/runtime/rezi-input-caret.ts +61 -0
- package/src/core/session.ts +95 -75
- package/src/index.ts +27 -4
- package/src/tui/app.ts +49 -10
- package/patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch +0 -227
- package/patches/werift@0.22.9.patch +0 -31
- package/src/tui/rezi-input-caret.ts +0 -46
- /package/{src/tui → runtime}/rezi-checkbox-click.ts +0 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ send
|
|
|
19
19
|
send peers
|
|
20
20
|
send offer ./file.txt
|
|
21
21
|
send accept
|
|
22
|
-
send tui
|
|
22
|
+
send tui
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
When no subcommand is provided, `send` launches the TUI by default.
|
|
@@ -28,7 +28,7 @@ When no subcommand is provided, `send` launches the TUI by default.
|
|
|
28
28
|
|
|
29
29
|
`--room` is optional on all commands. If you omit it, `send` creates a random room and prints or shows it.
|
|
30
30
|
|
|
31
|
-
In the TUI, the room row includes a `📋` invite link that opens the equivalent web app URL for the current committed room and toggle state.
|
|
31
|
+
In the TUI, the room row includes a `📋` invite link that opens the equivalent web app URL for the current committed room and toggle state.
|
|
32
32
|
|
|
33
33
|
## Self Identity
|
|
34
34
|
|
|
@@ -59,4 +59,4 @@ bun run typecheck
|
|
|
59
59
|
bun test
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
The package is Bun-native and keeps its runtime patches in `
|
|
62
|
+
The package is Bun-native and keeps its runtime patches in `runtime/`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elefunc/send",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
-
"
|
|
16
|
+
"runtime",
|
|
17
17
|
"README.md",
|
|
18
18
|
"LICENSE",
|
|
19
19
|
"package.json"
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
"cac": "^7.0.0",
|
|
42
42
|
"werift": "^0.22.9"
|
|
43
43
|
},
|
|
44
|
-
"patchedDependencies": {
|
|
45
|
-
"@rezi-ui/core@0.1.0-alpha.60": "patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch",
|
|
46
|
-
"werift@0.22.9": "patches/werift@0.22.9.patch"
|
|
47
|
-
},
|
|
48
44
|
"devDependencies": {
|
|
49
45
|
"@types/node": "^24.5.2",
|
|
50
46
|
"typescript": "^5.8.3"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ensureReziFilePatches } from "./rezi-files"
|
|
2
|
+
import { ensureReziInputCaretPatch } from "./rezi-input-caret"
|
|
3
|
+
|
|
4
|
+
let sessionInstallPromise: Promise<void> | null = null
|
|
5
|
+
let tuiInstallPromise: Promise<void> | null = null
|
|
6
|
+
|
|
7
|
+
export const ensureSessionRuntimePatches = () => {
|
|
8
|
+
if (sessionInstallPromise) return sessionInstallPromise
|
|
9
|
+
sessionInstallPromise = Promise.resolve()
|
|
10
|
+
return sessionInstallPromise
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ensureTuiRuntimePatches = () => {
|
|
14
|
+
if (tuiInstallPromise) return tuiInstallPromise
|
|
15
|
+
tuiInstallPromise = (async () => {
|
|
16
|
+
await ensureReziFilePatches()
|
|
17
|
+
await ensureReziInputCaretPatch()
|
|
18
|
+
})()
|
|
19
|
+
return tuiInstallPromise
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ensureRuntimeFilePatches = ensureTuiRuntimePatches
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
type Replacement = readonly [before: string, after: string]
|
|
4
|
+
type FilePatch = { relativeUrl: string; replacements: readonly Replacement[] }
|
|
5
|
+
|
|
6
|
+
const REZI_FILE_PATCHES: readonly FilePatch[] = [
|
|
7
|
+
{
|
|
8
|
+
relativeUrl: "./layout/kinds/box.js",
|
|
9
|
+
replacements: [
|
|
10
|
+
[
|
|
11
|
+
'import { validateBoxProps } from "../validateProps.js";',
|
|
12
|
+
'import { validateBoxProps } from "../validateProps.js";\nconst OVERFLOW_CONTENT_LIMIT = 2147483647;',
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
'const ch = clampNonNegative(outerHLimit - bt - bb - spacing.top - spacing.bottom);\n // Children are laid out as a Column inside the content rect.',
|
|
16
|
+
'const ch = clampNonNegative(outerHLimit - bt - bb - spacing.top - spacing.bottom);\n const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;\n // Children are laid out as a Column inside the content rect.',
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
'const innerRes = measureNode(columnNode, cw, ch, "column");',
|
|
20
|
+
'const innerRes = measureNode(columnNode, cw, flowMeasureH, "column");',
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);\n // The synthetic column wrapper must fill the box content rect so that\n // percentage constraints resolve against the actual available space.\n const innerRes = layoutNode(columnNode, cx, cy, cw, ch, "column", cw, ch);',
|
|
24
|
+
'const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);\n const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;\n const flowMeasureRes = measureNode(columnNode, cw, flowMeasureH, "column");\n if (!flowMeasureRes.ok)\n return flowMeasureRes;\n const flowLayoutH = propsRes.value.overflow === "scroll"\n ? Math.max(ch, flowMeasureRes.value.h)\n : ch;\n // The synthetic column wrapper must fill the box content rect so that\n // percentage constraints resolve against the actual available space.\n const innerRes = layoutNode(columnNode, cx, cy, cw, flowLayoutH, "column", cw, flowLayoutH, flowMeasureRes.value);',
|
|
25
|
+
],
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
relativeUrl: "./app/widgetRenderer.js",
|
|
30
|
+
replacements: [
|
|
31
|
+
[
|
|
32
|
+
" scrollOverrides = new Map();",
|
|
33
|
+
" scrollOverrides = new Map();\n hasPendingScrollOverride = false;",
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
" scrollOverrides: this.scrollOverrides,\n findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),",
|
|
37
|
+
' scrollOverrides: this.scrollOverrides,\n markScrollOverrideDirty: () => {\n this.hasPendingScrollOverride = true;\n },\n findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),',
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
" return Object.freeze([]);\n }\n applyScrollOverridesToVNode(vnode, overrides = this",
|
|
41
|
+
' return Object.freeze([]);\n }\n syncScrollOverridesFromLayoutTree() {\n if (!this.committedRoot || !this.layoutTree) {\n this.scrollOverrides.clear();\n return;\n }\n const nextOverrides = new Map();\n const stack = [\n {\n runtimeNode: this.committedRoot,\n layoutNode: this.layoutTree,\n },\n ];\n while (stack.length > 0) {\n const frame = stack.pop();\n if (!frame)\n continue;\n const runtimeNode = frame.runtimeNode;\n const layoutNode = frame.layoutNode;\n const props = runtimeNode.vnode.props;\n const nodeId = typeof props.id === "string" && props.id.length > 0 ? props.id : null;\n if (nodeId !== null && props.overflow === "scroll" && layoutNode.meta) {\n const meta = layoutNode.meta;\n const hasScrollableAxis = meta.contentWidth > meta.viewportWidth || meta.contentHeight > meta.viewportHeight;\n if (hasScrollableAxis) {\n nextOverrides.set(nodeId, Object.freeze({\n scrollX: meta.scrollX,\n scrollY: meta.scrollY,\n }));\n }\n }\n const childCount = Math.min(runtimeNode.children.length, layoutNode.children.length);\n for (let i = childCount - 1; i >= 0; i--) {\n const runtimeChild = runtimeNode.children[i];\n const layoutChild = layoutNode.children[i];\n if (!runtimeChild || !layoutChild)\n continue;\n stack.push({\n runtimeNode: runtimeChild,\n layoutNode: layoutChild,\n });\n }\n }\n this.scrollOverrides.clear();\n for (const [nodeId, override] of nextOverrides) {\n this.scrollOverrides.set(nodeId, override);\n }\n }\n applyScrollOverridesToVNode(vnode, overrides = this',
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
" if (override) {",
|
|
45
|
+
' if (override && propsForRead.overflow === "scroll") {',
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
" if (this.scrollOverrides.size > 0)",
|
|
49
|
+
" if (this.hasPendingScrollOverride)",
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
" this.scrollOverrides.clear();",
|
|
53
|
+
" this.hasPendingScrollOverride = false;",
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
" this.layoutTree = nextLayoutTree;",
|
|
57
|
+
" this.layoutTree = nextLayoutTree;\n this.syncScrollOverridesFromLayoutTree();",
|
|
58
|
+
],
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
relativeUrl: "./app/widgetRenderer/mouseRouting.js",
|
|
63
|
+
replacements: [[
|
|
64
|
+
" ctx.scrollOverrides.set(nodeId, {\n scrollX: r.nextScrollX ?? meta.scrollX,\n scrollY: r.nextScrollY ?? meta.scrollY,\n });\n return ROUTE_RENDER;",
|
|
65
|
+
' ctx.scrollOverrides.set(nodeId, {\n scrollX: r.nextScrollX ?? meta.scrollX,\n scrollY: r.nextScrollY ?? meta.scrollY,\n });\n ctx.markScrollOverrideDirty?.();\n return ROUTE_RENDER;',
|
|
66
|
+
]],
|
|
67
|
+
},
|
|
68
|
+
] as const
|
|
69
|
+
|
|
70
|
+
let patchedRoots: Set<string> | null = null
|
|
71
|
+
|
|
72
|
+
const applyFilePatch = async (baseUrl: string, patch: FilePatch) => {
|
|
73
|
+
const fileUrl = new URL(patch.relativeUrl, baseUrl)
|
|
74
|
+
const source = await readFile(fileUrl, "utf8")
|
|
75
|
+
let next = source
|
|
76
|
+
for (const [before, after] of patch.replacements) {
|
|
77
|
+
if (next.includes(after)) continue
|
|
78
|
+
if (!next.includes(before)) throw new Error(`Unsupported @rezi-ui/core runtime patch target at ${fileUrl.href}`)
|
|
79
|
+
next = next.replace(before, after)
|
|
80
|
+
}
|
|
81
|
+
if (next !== source) await writeFile(fileUrl, next)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const ensureReziFilePatches = async () => {
|
|
85
|
+
const baseUrl = await import.meta.resolve("@rezi-ui/core")
|
|
86
|
+
patchedRoots ??= new Set<string>()
|
|
87
|
+
if (patchedRoots.has(baseUrl)) return
|
|
88
|
+
for (const patch of REZI_FILE_PATCHES) await applyFilePatch(baseUrl, patch)
|
|
89
|
+
patchedRoots.add(baseUrl)
|
|
90
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
type Replacement = readonly [before: string, after: string]
|
|
4
|
+
type FilePatch = { relativeUrl: string; replacements: readonly Replacement[] }
|
|
5
|
+
|
|
6
|
+
const CARET_SAMPLE = "user"
|
|
7
|
+
const CARET_EXPECTED_WIDTH = CARET_SAMPLE.length + 3
|
|
8
|
+
const CARET_RULE_TEXT = "single-line ui.input width must equal value.length + 3"
|
|
9
|
+
|
|
10
|
+
const REZI_INPUT_CARET_PATCHES: readonly FilePatch[] = [
|
|
11
|
+
{
|
|
12
|
+
relativeUrl: "./layout/engine/intrinsic.js",
|
|
13
|
+
replacements: [[
|
|
14
|
+
"return ok(clampSize({ w: textW + 2, h: 1 }));",
|
|
15
|
+
"return ok(clampSize({ w: textW + 3, h: 1 }));",
|
|
16
|
+
]],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
relativeUrl: "./layout/kinds/leaf.js",
|
|
20
|
+
replacements: [[
|
|
21
|
+
"const w = Math.min(maxW, textW + 2);",
|
|
22
|
+
"const w = Math.min(maxW, textW + 3);",
|
|
23
|
+
]],
|
|
24
|
+
},
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
let verifiedRoots: Set<string> | null = null
|
|
28
|
+
|
|
29
|
+
const applyFilePatch = async (baseUrl: string, patch: FilePatch) => {
|
|
30
|
+
const fileUrl = new URL(patch.relativeUrl, baseUrl)
|
|
31
|
+
const source = await readFile(fileUrl, "utf8")
|
|
32
|
+
let next = source
|
|
33
|
+
for (const [before, after] of patch.replacements) {
|
|
34
|
+
if (next.includes(after)) continue
|
|
35
|
+
if (!next.includes(before)) throw new Error(`Unsupported @rezi-ui/core caret patch target at ${fileUrl.href}`)
|
|
36
|
+
next = next.replace(before, after)
|
|
37
|
+
}
|
|
38
|
+
if (next !== source) await writeFile(fileUrl, next)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const verifyInputCaretWidth = async () => {
|
|
42
|
+
const { createTestRenderer, ui } = await import("@rezi-ui/core")
|
|
43
|
+
const renderer = createTestRenderer({ viewport: { cols: 40, rows: 8 } })
|
|
44
|
+
const view = renderer.render(ui.input({ id: "caret-probe", value: CARET_SAMPLE, onInput: () => {} }))
|
|
45
|
+
const field = view.findById("caret-probe")
|
|
46
|
+
if (!field) throw new Error("Rezi caret probe could not find the rendered input node")
|
|
47
|
+
return field.rect.w
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ensureReziInputCaretPatch = async () => {
|
|
51
|
+
const baseUrl = await import.meta.resolve("@rezi-ui/core")
|
|
52
|
+
verifiedRoots ??= new Set<string>()
|
|
53
|
+
if (verifiedRoots.has(baseUrl)) return
|
|
54
|
+
for (const patch of REZI_INPUT_CARET_PATCHES) await applyFilePatch(baseUrl, patch)
|
|
55
|
+
const width = await verifyInputCaretWidth()
|
|
56
|
+
if (width !== CARET_EXPECTED_WIDTH) {
|
|
57
|
+
const installRoot = new URL("../", baseUrl).href
|
|
58
|
+
throw new Error(`Rezi input caret verification failed for ${installRoot}: ${CARET_RULE_TEXT}; expected ${CARET_EXPECTED_WIDTH} for "${CARET_SAMPLE}", got ${width}. This usually means @rezi-ui/core was imported before runtime patches were applied or the upstream Rezi input layout changed.`)
|
|
59
|
+
}
|
|
60
|
+
verifiedRoots.add(baseUrl)
|
|
61
|
+
}
|
package/src/core/session.ts
CHANGED
|
@@ -157,6 +157,7 @@ export interface SessionConfig {
|
|
|
157
157
|
localId?: string
|
|
158
158
|
name?: string
|
|
159
159
|
saveDir?: string
|
|
160
|
+
peerSelectionMemory?: Map<string, boolean>
|
|
160
161
|
autoAcceptIncoming?: boolean
|
|
161
162
|
autoSaveIncoming?: boolean
|
|
162
163
|
turnUrls?: string[]
|
|
@@ -170,8 +171,6 @@ const STATS_POLL_MS = 1000
|
|
|
170
171
|
const PROFILE_URL = "https://ip.rt.ht/"
|
|
171
172
|
const PULSE_URL = "https://sig.efn.kr/pulse"
|
|
172
173
|
|
|
173
|
-
type StatsEntry = { id?: string; type?: string; [key: string]: unknown }
|
|
174
|
-
|
|
175
174
|
export interface PeerConnectivitySnapshot {
|
|
176
175
|
rttMs: number
|
|
177
176
|
localCandidateType: string
|
|
@@ -191,7 +190,6 @@ export interface PulseSnapshot {
|
|
|
191
190
|
|
|
192
191
|
const progressOf = (transfer: TransferState) => transfer.size ? Math.max(0, Math.min(100, transfer.bytes / transfer.size * 100)) : FINAL_STATUSES.has(transfer.status as never) ? 100 : 0
|
|
193
192
|
const isFinal = (transfer: TransferState) => FINAL_STATUSES.has(transfer.status as never)
|
|
194
|
-
const candidateTypeRank = (type: string) => ({ host: 0, srflx: 1, prflx: 1, relay: 2 }[type] ?? Number.NaN)
|
|
195
193
|
export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
|
|
196
194
|
const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
|
|
197
195
|
const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", at: 0, ms: 0, error: "" })
|
|
@@ -260,76 +258,61 @@ export const turnUsageState = (
|
|
|
260
258
|
return "idle"
|
|
261
259
|
}
|
|
262
260
|
|
|
263
|
-
const timeoutSignal = (ms: number
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (!
|
|
267
|
-
|
|
268
|
-
if (typeof report === "object") {
|
|
269
|
-
const values = report as { values?: () => Iterable<StatsEntry> }
|
|
270
|
-
if (typeof values.values === "function") return [...values.values()]
|
|
271
|
-
const forEachable = report as { forEach?: (fn: (value: StatsEntry) => void) => void }
|
|
272
|
-
if (typeof forEachable.forEach === "function") {
|
|
273
|
-
const entries: StatsEntry[] = []
|
|
274
|
-
forEachable.forEach(value => entries.push(value))
|
|
275
|
-
return entries
|
|
276
|
-
}
|
|
277
|
-
const iterable = report as Iterable<StatsEntry>
|
|
278
|
-
if (typeof (iterable as { [Symbol.iterator]?: () => Iterator<StatsEntry> })[Symbol.iterator] === "function") return [...iterable]
|
|
279
|
-
}
|
|
280
|
-
return []
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const candidatePairEntries = (entries: StatsEntry[], transportId?: string) => entries.filter(entry =>
|
|
284
|
-
entry.type === "candidate-pair"
|
|
285
|
-
&& (!transportId || entry.transportId === transportId))
|
|
286
|
-
|
|
287
|
-
const preferredCandidatePair = (entries: StatsEntry[]) => {
|
|
288
|
-
let selectedFallback: StatsEntry | null = null
|
|
289
|
-
let succeededFallback: StatsEntry | null = null
|
|
290
|
-
for (const entry of entries) {
|
|
291
|
-
const selected = entry.selected === true || entry.nominated === true
|
|
292
|
-
const succeeded = entry.state === "succeeded"
|
|
293
|
-
if (selected && succeeded) return entry
|
|
294
|
-
if (!selectedFallback && selected) selectedFallback = entry
|
|
295
|
-
if (!succeededFallback && succeeded) succeededFallback = entry
|
|
296
|
-
}
|
|
297
|
-
return selectedFallback || succeededFallback
|
|
261
|
+
const timeoutSignal = (ms: number, base?: AbortSignal | null) => {
|
|
262
|
+
const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
|
|
263
|
+
if (!base) return timeout
|
|
264
|
+
if (!timeout) return base
|
|
265
|
+
return typeof AbortSignal.any === "function" ? AbortSignal.any([base, timeout]) : base
|
|
298
266
|
}
|
|
299
267
|
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
268
|
+
const normalizeCandidateType = (value: unknown) => typeof value === "string" ? value.toLowerCase() : ""
|
|
269
|
+
const validCandidateType = (value: string) => ["host", "srflx", "prflx", "relay"].includes(value)
|
|
270
|
+
|
|
271
|
+
export const activeIcePairFromPeerConnection = (pc: { iceTransports?: unknown[] } | null | undefined) => {
|
|
272
|
+
const transports = Array.isArray(pc?.iceTransports) ? pc.iceTransports as Array<Record<string, any>> : []
|
|
273
|
+
let fallback: { transport: Record<string, any>; connection: Record<string, any>; pair: Record<string, any> } | null = null
|
|
274
|
+
for (const transport of transports) {
|
|
275
|
+
const connection = transport?.connection
|
|
276
|
+
const pair = connection?.nominated
|
|
277
|
+
if (!pair) continue
|
|
278
|
+
fallback ||= { transport, connection, pair }
|
|
279
|
+
const state = `${transport?.state ?? ""}`.toLowerCase()
|
|
280
|
+
if (!state || state === "connected" || state === "completed") return { transport, connection, pair }
|
|
281
|
+
}
|
|
282
|
+
return fallback
|
|
308
283
|
}
|
|
309
284
|
|
|
310
|
-
export const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const remoteCandidateType = typeof remoteCandidate?.candidateType === "string" ? remoteCandidate.candidateType : ""
|
|
319
|
-
if (!pair || !Number.isFinite(candidateTypeRank(localCandidateType)) || !Number.isFinite(candidateTypeRank(remoteCandidateType))) {
|
|
320
|
-
return { ...previous, rttMs }
|
|
321
|
-
}
|
|
285
|
+
export const connectivitySnapshotFromPeerConnection = (
|
|
286
|
+
pc: { iceTransports?: unknown[] } | null | undefined,
|
|
287
|
+
previous: PeerConnectivitySnapshot = emptyConnectivitySnapshot(),
|
|
288
|
+
): PeerConnectivitySnapshot => {
|
|
289
|
+
const pair = activeIcePairFromPeerConnection(pc)?.pair
|
|
290
|
+
const localCandidateType = normalizeCandidateType(pair?.localCandidate?.type ?? pair?.localCandidate?.candidateType)
|
|
291
|
+
const remoteCandidateType = normalizeCandidateType(pair?.remoteCandidate?.type ?? pair?.remoteCandidate?.candidateType)
|
|
292
|
+
if (!validCandidateType(localCandidateType) || !validCandidateType(remoteCandidateType)) return previous
|
|
322
293
|
return {
|
|
323
294
|
...previous,
|
|
324
|
-
rttMs,
|
|
325
295
|
localCandidateType,
|
|
326
296
|
remoteCandidateType,
|
|
327
297
|
pathLabel: `${candidateTypeLabel(localCandidateType)} ↔ ${candidateTypeLabel(remoteCandidateType)}`,
|
|
328
298
|
}
|
|
329
299
|
}
|
|
330
300
|
|
|
301
|
+
export const probeIcePairConsentRtt = async (connection: Record<string, any> | null | undefined, pair: Record<string, any> | null | undefined) => {
|
|
302
|
+
if (!connection || !pair?.protocol?.request || typeof connection.buildRequest !== "function" || typeof connection.remotePassword !== "string") return Number.NaN
|
|
303
|
+
const request = connection.buildRequest({
|
|
304
|
+
nominate: false,
|
|
305
|
+
localUsername: connection.localUsername,
|
|
306
|
+
remoteUsername: connection.remoteUsername,
|
|
307
|
+
iceControlling: connection.iceControlling,
|
|
308
|
+
})
|
|
309
|
+
const startedAt = performance.now()
|
|
310
|
+
await pair.protocol.request(request, pair.remoteAddr, Buffer.from(connection.remotePassword, "utf8"), 0)
|
|
311
|
+
return performance.now() - startedAt
|
|
312
|
+
}
|
|
313
|
+
|
|
331
314
|
const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivitySnapshot) =>
|
|
332
|
-
left.rttMs === right.rttMs
|
|
315
|
+
(left.rttMs === right.rttMs || Number.isNaN(left.rttMs) && Number.isNaN(right.rttMs))
|
|
333
316
|
&& left.localCandidateType === right.localCandidateType
|
|
334
317
|
&& left.remoteCandidateType === right.remoteCandidateType
|
|
335
318
|
&& left.pathLabel === right.pathLabel
|
|
@@ -358,6 +341,7 @@ export class SendSession {
|
|
|
358
341
|
private autoSaveIncoming: boolean
|
|
359
342
|
private readonly reconnectSocket: boolean
|
|
360
343
|
private readonly iceServers: RTCIceServer[]
|
|
344
|
+
private readonly peerSelectionMemory: Map<string, boolean>
|
|
361
345
|
private readonly peers = new Map<string, PeerState>()
|
|
362
346
|
private readonly transfers = new Map<string, TransferState>()
|
|
363
347
|
private readonly logs: LogEntry[] = []
|
|
@@ -369,6 +353,7 @@ export class SendSession {
|
|
|
369
353
|
private socketToken = 0
|
|
370
354
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
371
355
|
private peerStatsTimer: ReturnType<typeof setInterval> | null = null
|
|
356
|
+
private lifecycleAbortController: AbortController | null = null
|
|
372
357
|
private stopped = false
|
|
373
358
|
|
|
374
359
|
constructor(config: SessionConfig) {
|
|
@@ -376,6 +361,7 @@ export class SendSession {
|
|
|
376
361
|
this.room = cleanRoom(config.room)
|
|
377
362
|
this.name = cleanName(config.name ?? fallbackName)
|
|
378
363
|
this.saveDir = resolve(config.saveDir ?? resolve(process.cwd(), "downloads"))
|
|
364
|
+
this.peerSelectionMemory = config.peerSelectionMemory ?? new Map()
|
|
379
365
|
this.autoAcceptIncoming = !!config.autoAcceptIncoming
|
|
380
366
|
this.autoSaveIncoming = !!config.autoSaveIncoming
|
|
381
367
|
this.reconnectSocket = config.reconnectSocket ?? true
|
|
@@ -417,6 +403,8 @@ export class SendSession {
|
|
|
417
403
|
|
|
418
404
|
async connect(timeoutMs = 10_000) {
|
|
419
405
|
this.stopped = false
|
|
406
|
+
this.lifecycleAbortController?.abort()
|
|
407
|
+
this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
|
|
420
408
|
this.startPeerStatsPolling()
|
|
421
409
|
void this.loadLocalProfile()
|
|
422
410
|
void this.probePulse()
|
|
@@ -428,6 +416,9 @@ export class SendSession {
|
|
|
428
416
|
this.stopped = true
|
|
429
417
|
this.stopPeerStatsPolling()
|
|
430
418
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
419
|
+
this.reconnectTimer = null
|
|
420
|
+
this.lifecycleAbortController?.abort()
|
|
421
|
+
this.lifecycleAbortController = null
|
|
431
422
|
if (this.socket?.readyState === WebSocket.OPEN) this.sendSignal({ kind: "bye" })
|
|
432
423
|
const socket = this.socket
|
|
433
424
|
this.socket = null
|
|
@@ -451,8 +442,12 @@ export class SendSession {
|
|
|
451
442
|
|
|
452
443
|
setPeerSelected(peerId: string, selected: boolean) {
|
|
453
444
|
const peer = this.peers.get(peerId)
|
|
454
|
-
if (!peer
|
|
455
|
-
|
|
445
|
+
if (!peer) return false
|
|
446
|
+
const next = !!selected
|
|
447
|
+
const rememberedChanged = this.rememberPeerSelected(peerId, next)
|
|
448
|
+
if (peer.presence !== "active") return rememberedChanged
|
|
449
|
+
if (peer.selected === next && !rememberedChanged) return false
|
|
450
|
+
peer.selected = next
|
|
456
451
|
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
457
452
|
this.notify()
|
|
458
453
|
return true
|
|
@@ -710,18 +705,23 @@ export class SendSession {
|
|
|
710
705
|
if (this.stopped) return
|
|
711
706
|
let dirty = false
|
|
712
707
|
for (const peer of this.peers.values()) {
|
|
713
|
-
if (peer.presence !== "active"
|
|
714
|
-
|
|
715
|
-
const next = connectivitySnapshotFromReport(await peer.pc.getStats(), peer.connectivity)
|
|
716
|
-
if (sameConnectivity(peer.connectivity, next)) continue
|
|
717
|
-
peer.connectivity = next
|
|
718
|
-
dirty = true
|
|
719
|
-
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
720
|
-
} catch {}
|
|
708
|
+
if (peer.presence !== "active") continue
|
|
709
|
+
dirty = await this.refreshPeerConnectivity(peer) || dirty
|
|
721
710
|
}
|
|
722
711
|
if (dirty) this.notify()
|
|
723
712
|
}
|
|
724
713
|
|
|
714
|
+
private async refreshPeerConnectivity(peer: PeerState) {
|
|
715
|
+
const activePair = activeIcePairFromPeerConnection(peer.pc as { iceTransports?: unknown[] } | null | undefined)
|
|
716
|
+
const next = connectivitySnapshotFromPeerConnection(peer.pc as { iceTransports?: unknown[] } | null | undefined, peer.connectivity)
|
|
717
|
+
const rttMs = await probeIcePairConsentRtt(activePair?.connection, activePair?.pair).catch(() => Number.NaN)
|
|
718
|
+
if (Number.isFinite(rttMs)) next.rttMs = rttMs
|
|
719
|
+
if (sameConnectivity(peer.connectivity, next)) return false
|
|
720
|
+
peer.connectivity = next
|
|
721
|
+
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
722
|
+
return true
|
|
723
|
+
}
|
|
724
|
+
|
|
725
725
|
private pushLog(kind: string, payload: unknown, level: "info" | "error" = "info") {
|
|
726
726
|
const log = { id: uid(6), at: Date.now(), kind, level, payload }
|
|
727
727
|
this.logs.unshift(log)
|
|
@@ -880,13 +880,17 @@ export class SendSession {
|
|
|
880
880
|
|
|
881
881
|
private async loadLocalProfile() {
|
|
882
882
|
try {
|
|
883
|
-
const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000) })
|
|
883
|
+
const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000, this.lifecycleAbortController?.signal) })
|
|
884
884
|
if (!response.ok) throw new Error(`profile ${response.status}`)
|
|
885
|
-
|
|
885
|
+
const data = await response.json()
|
|
886
|
+
if (this.stopped) return
|
|
887
|
+
this.profile = localProfileFromResponse(data)
|
|
886
888
|
} catch (error) {
|
|
889
|
+
if (this.stopped) return
|
|
887
890
|
this.profile = localProfileFromResponse(null, `${error}`)
|
|
888
891
|
this.pushLog("profile:error", { error: `${error}` }, "error")
|
|
889
892
|
}
|
|
893
|
+
if (this.stopped) return
|
|
890
894
|
this.broadcastProfile()
|
|
891
895
|
this.notify()
|
|
892
896
|
}
|
|
@@ -896,13 +900,16 @@ export class SendSession {
|
|
|
896
900
|
this.pulse = { ...this.pulse, state: "checking", error: "" }
|
|
897
901
|
this.notify()
|
|
898
902
|
try {
|
|
899
|
-
const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500) })
|
|
903
|
+
const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
|
|
900
904
|
if (!response.ok) throw new Error(`pulse ${response.status}`)
|
|
905
|
+
if (this.stopped) return
|
|
901
906
|
this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
|
|
902
907
|
} catch (error) {
|
|
908
|
+
if (this.stopped) return
|
|
903
909
|
this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
|
|
904
910
|
this.pushLog("pulse:error", { error: `${error}` }, "error")
|
|
905
911
|
}
|
|
912
|
+
if (this.stopped) return
|
|
906
913
|
this.notify()
|
|
907
914
|
}
|
|
908
915
|
|
|
@@ -949,12 +956,23 @@ export class SendSession {
|
|
|
949
956
|
return !!peer && !!channel && peer.rtcEpoch === rtcEpoch && peer.dc === channel && channel.readyState === "open"
|
|
950
957
|
}
|
|
951
958
|
|
|
959
|
+
private peerSelected(peerId: string) {
|
|
960
|
+
return this.peerSelectionMemory.get(peerId) ?? true
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private rememberPeerSelected(peerId: string, selected: boolean) {
|
|
964
|
+
const next = !!selected
|
|
965
|
+
const previous = this.peerSelectionMemory.get(peerId)
|
|
966
|
+
this.peerSelectionMemory.set(peerId, next)
|
|
967
|
+
return previous !== next
|
|
968
|
+
}
|
|
969
|
+
|
|
952
970
|
private buildPeer(remoteId: string) {
|
|
953
971
|
const peer: PeerState = {
|
|
954
972
|
id: remoteId,
|
|
955
973
|
name: fallbackName,
|
|
956
974
|
presence: "active",
|
|
957
|
-
selected:
|
|
975
|
+
selected: this.peerSelected(remoteId),
|
|
958
976
|
polite: this.localId > remoteId,
|
|
959
977
|
pc: null,
|
|
960
978
|
dc: null,
|
|
@@ -980,9 +998,11 @@ export class SendSession {
|
|
|
980
998
|
|
|
981
999
|
private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
|
|
982
1000
|
const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
|
|
1001
|
+
const wasTerminal = peer.presence === "terminal"
|
|
983
1002
|
peer.lastSeenAt = Date.now()
|
|
984
1003
|
peer.presence = "active"
|
|
985
1004
|
peer.terminalReason = ""
|
|
1005
|
+
if (wasTerminal) peer.selected = this.peerSelected(remoteId)
|
|
986
1006
|
if (name != null) {
|
|
987
1007
|
peer.name = cleanName(name)
|
|
988
1008
|
for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
|
|
@@ -1319,8 +1339,8 @@ export class SendSession {
|
|
|
1319
1339
|
}
|
|
1320
1340
|
|
|
1321
1341
|
private async handleTransferControl(peer: PeerState, message: DataMessage) {
|
|
1322
|
-
this.pushLog("data:in", message)
|
|
1323
1342
|
if (message.to && message.to !== this.localId && message.to !== "*") return
|
|
1343
|
+
this.pushLog("data:in", message)
|
|
1324
1344
|
switch (message.kind) {
|
|
1325
1345
|
case "file-offer": {
|
|
1326
1346
|
if (!this.transfers.has(message.transferId)) {
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import { cac, type CAC } from "cac"
|
|
4
4
|
import { cleanRoom } from "./core/protocol"
|
|
5
|
-
import { SendSession,
|
|
5
|
+
import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
|
|
6
6
|
import { resolvePeerTargets } from "./core/targeting"
|
|
7
|
-
import {
|
|
7
|
+
import { ensureSessionRuntimePatches, ensureTuiRuntimePatches } from "../runtime/install"
|
|
8
8
|
|
|
9
9
|
export class ExitError extends Error {
|
|
10
10
|
constructor(message: string, readonly code = 1) {
|
|
@@ -122,6 +122,27 @@ const waitForFinalTransfers = async (session: SendSession, transferIds: string[]
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
let sessionRuntimePromise: Promise<typeof import("./core/session")> | null = null
|
|
126
|
+
let tuiRuntimePromise: Promise<typeof import("./tui/app")> | null = null
|
|
127
|
+
|
|
128
|
+
const loadSessionRuntime = () => {
|
|
129
|
+
if (sessionRuntimePromise) return sessionRuntimePromise
|
|
130
|
+
sessionRuntimePromise = (async () => {
|
|
131
|
+
await ensureSessionRuntimePatches()
|
|
132
|
+
return import("./core/session")
|
|
133
|
+
})()
|
|
134
|
+
return sessionRuntimePromise
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const loadTuiRuntime = () => {
|
|
138
|
+
if (tuiRuntimePromise) return tuiRuntimePromise
|
|
139
|
+
tuiRuntimePromise = (async () => {
|
|
140
|
+
await ensureTuiRuntimePatches()
|
|
141
|
+
return import("./tui/app")
|
|
142
|
+
})()
|
|
143
|
+
return tuiRuntimePromise
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
const handleSignals = (session: SendSession) => {
|
|
126
147
|
const onSignal = async () => {
|
|
127
148
|
await session.close()
|
|
@@ -132,6 +153,7 @@ const handleSignals = (session: SendSession) => {
|
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
const peersCommand = async (options: Record<string, unknown>) => {
|
|
156
|
+
const { SendSession } = await loadSessionRuntime()
|
|
135
157
|
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
136
158
|
handleSignals(session)
|
|
137
159
|
printRoomAnnouncement(session.room, !!options.json)
|
|
@@ -158,6 +180,7 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
158
180
|
if (!files.length) throw new ExitError("offer requires at least one file path", 1)
|
|
159
181
|
const selectors = offerSelectors(options.to)
|
|
160
182
|
const timeoutMs = waitPeerTimeout(options.waitPeer)
|
|
183
|
+
const { SendSession } = await loadSessionRuntime()
|
|
161
184
|
const session = new SendSession(sessionConfigFrom(options, {}))
|
|
162
185
|
handleSignals(session)
|
|
163
186
|
printRoomAnnouncement(session.room, !!options.json)
|
|
@@ -173,6 +196,7 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
|
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
const acceptCommand = async (options: Record<string, unknown>) => {
|
|
199
|
+
const { SendSession } = await loadSessionRuntime()
|
|
176
200
|
const session = new SendSession(sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true }))
|
|
177
201
|
handleSignals(session)
|
|
178
202
|
printRoomAnnouncement(session.room, !!options.json)
|
|
@@ -194,8 +218,7 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
194
218
|
|
|
195
219
|
const tuiCommand = async (options: Record<string, unknown>) => {
|
|
196
220
|
const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
|
|
197
|
-
await
|
|
198
|
-
const { startTui } = await import("./tui/app")
|
|
221
|
+
const { startTui } = await loadTuiRuntime()
|
|
199
222
|
await startTui(initialConfig, !!options.events)
|
|
200
223
|
}
|
|
201
224
|
|
package/src/tui/app.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapsho
|
|
|
5
5
|
import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
|
|
6
6
|
import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
|
|
7
7
|
import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
|
|
8
|
-
import { installCheckboxClickPatch } from "
|
|
8
|
+
import { installCheckboxClickPatch } from "../../runtime/rezi-checkbox-click"
|
|
9
9
|
|
|
10
10
|
type Notice = { text: string; variant: "info" | "success" | "warning" | "error" }
|
|
11
11
|
type DraftItem = { id: string; path: string; name: string; size: number; createdAt: number }
|
|
@@ -43,6 +43,7 @@ export type VisiblePane = "peers" | "transfers" | "logs"
|
|
|
43
43
|
export interface TuiState {
|
|
44
44
|
session: SendSession
|
|
45
45
|
sessionSeed: SessionSeed
|
|
46
|
+
peerSelectionByRoom: Map<string, Map<string, boolean>>
|
|
46
47
|
snapshot: SessionSnapshot
|
|
47
48
|
focusedId: string | null
|
|
48
49
|
roomInput: string
|
|
@@ -98,6 +99,10 @@ const DRAFT_INPUT_ID = "draft-input"
|
|
|
98
99
|
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
99
100
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
100
101
|
const DEFAULT_WEB_URL = "https://send.rt.ht/"
|
|
102
|
+
const TRANSFER_DIRECTION_ARROW = {
|
|
103
|
+
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
104
|
+
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
105
|
+
} as const
|
|
101
106
|
|
|
102
107
|
const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
103
108
|
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
@@ -180,6 +185,20 @@ const peerConnectionStatusKind = (status: string) => ({
|
|
|
180
185
|
idle: "unknown",
|
|
181
186
|
new: "unknown",
|
|
182
187
|
}[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
|
|
188
|
+
const transferStatusKind = (status: TransferSnapshot["status"]) => ({
|
|
189
|
+
complete: "online",
|
|
190
|
+
sending: "online",
|
|
191
|
+
receiving: "online",
|
|
192
|
+
"awaiting-done": "online",
|
|
193
|
+
accepted: "busy",
|
|
194
|
+
queued: "busy",
|
|
195
|
+
offered: "busy",
|
|
196
|
+
pending: "busy",
|
|
197
|
+
cancelling: "busy",
|
|
198
|
+
rejected: "offline",
|
|
199
|
+
cancelled: "offline",
|
|
200
|
+
error: "offline",
|
|
201
|
+
}[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
|
|
183
202
|
const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
|
|
184
203
|
const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
|
|
185
204
|
const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
|
|
@@ -298,8 +317,19 @@ const normalizeSessionSeed = (config: SessionConfig): SessionSeed => ({
|
|
|
298
317
|
room: cleanRoom(config.room),
|
|
299
318
|
})
|
|
300
319
|
|
|
301
|
-
const
|
|
320
|
+
const roomPeerSelectionMemory = (peerSelectionByRoom: Map<string, Map<string, boolean>>, room: string) => {
|
|
321
|
+
const roomKey = cleanRoom(room)
|
|
322
|
+
let selectionMemory = peerSelectionByRoom.get(roomKey)
|
|
323
|
+
if (!selectionMemory) {
|
|
324
|
+
selectionMemory = new Map<string, boolean>()
|
|
325
|
+
peerSelectionByRoom.set(roomKey, selectionMemory)
|
|
326
|
+
}
|
|
327
|
+
return selectionMemory
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean, peerSelectionMemory: Map<string, boolean>) => new SendSession({
|
|
302
331
|
...seed,
|
|
332
|
+
peerSelectionMemory,
|
|
303
333
|
autoAcceptIncoming,
|
|
304
334
|
autoSaveIncoming,
|
|
305
335
|
})
|
|
@@ -435,11 +465,13 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
435
465
|
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
436
466
|
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
437
467
|
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
438
|
-
const
|
|
468
|
+
const peerSelectionByRoom = new Map<string, Map<string, boolean>>()
|
|
469
|
+
const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming, roomPeerSelectionMemory(peerSelectionByRoom, sessionSeed.room))
|
|
439
470
|
const focusState = deriveBootFocusState(sessionSeed.name)
|
|
440
471
|
return {
|
|
441
472
|
session,
|
|
442
473
|
sessionSeed,
|
|
474
|
+
peerSelectionByRoom,
|
|
443
475
|
snapshot: session.snapshot(),
|
|
444
476
|
focusedId: null,
|
|
445
477
|
roomInput: sessionSeed.room,
|
|
@@ -755,6 +787,7 @@ const transferPathLabel = (transfer: TransferSnapshot, peersById: Map<string, Pe
|
|
|
755
787
|
|
|
756
788
|
const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
|
|
757
789
|
const hasStarted = !!transfer.startedAt
|
|
790
|
+
const directionArrow = TRANSFER_DIRECTION_ARROW[transfer.direction]
|
|
758
791
|
const facts = [
|
|
759
792
|
renderTransferFact("Size", formatBytes(transfer.size)),
|
|
760
793
|
renderTransferFact("Path", transferPathLabel(transfer, peersById)),
|
|
@@ -767,13 +800,18 @@ const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, Pe
|
|
|
767
800
|
|
|
768
801
|
return denseSection({
|
|
769
802
|
key: transfer.id,
|
|
770
|
-
|
|
803
|
+
titleNode: ui.row({ id: `transfer-title-row-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
|
|
804
|
+
ui.row({ id: `transfer-title-main-${transfer.id}`, gap: 0, items: "center", wrap: true }, [
|
|
805
|
+
ui.text(directionArrow.glyph, { style: directionArrow.style }),
|
|
806
|
+
ui.text(` ${transfer.name}`, { variant: "heading" }),
|
|
807
|
+
]),
|
|
808
|
+
ui.row({ id: `transfer-badges-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
|
|
809
|
+
ui.status(transferStatusKind(transfer.status), { label: transfer.status, showLabel: true }),
|
|
810
|
+
transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
|
|
811
|
+
]),
|
|
812
|
+
]),
|
|
771
813
|
actions: transferActionButtons(transfer, actions),
|
|
772
814
|
}, [
|
|
773
|
-
ui.row({ gap: 1, wrap: true }, [
|
|
774
|
-
tightTag(transfer.status, { variant: statusVariant(transfer.status), bare: true }),
|
|
775
|
-
transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
|
|
776
|
-
]),
|
|
777
815
|
ui.row({ gap: 0, wrap: true }, facts),
|
|
778
816
|
ui.progress(transferProgress(transfer), { showPercent: true, label: `${percentFormat.format(transfer.progress)}%` }),
|
|
779
817
|
ui.row({ gap: 0, wrap: true }, [
|
|
@@ -1188,12 +1226,13 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1188
1226
|
|
|
1189
1227
|
const replaceSession = (nextSeed: SessionSeed, text: string, options: { reseedBootFocus?: boolean } = {}) => {
|
|
1190
1228
|
const previousSession = state.session
|
|
1191
|
-
const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming)
|
|
1229
|
+
const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming, roomPeerSelectionMemory(state.peerSelectionByRoom, nextSeed.room))
|
|
1192
1230
|
stopPreviewSession()
|
|
1193
1231
|
commit(current => withNotice({
|
|
1194
1232
|
...current,
|
|
1195
1233
|
session: nextSession,
|
|
1196
1234
|
sessionSeed: nextSeed,
|
|
1235
|
+
peerSelectionByRoom: current.peerSelectionByRoom,
|
|
1197
1236
|
snapshot: nextSession.snapshot(),
|
|
1198
1237
|
roomInput: nextSeed.room,
|
|
1199
1238
|
nameInput: visibleNameInput(nextSeed.name),
|
|
@@ -1270,7 +1309,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1270
1309
|
setNameInput: value => commit(current => ({ ...current, nameInput: value })),
|
|
1271
1310
|
toggleSelectReadyPeers: () => {
|
|
1272
1311
|
let changed = 0
|
|
1273
|
-
for (const peer of state.snapshot.peers) if (peer.presence === "active" &&
|
|
1312
|
+
for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
|
|
1274
1313
|
commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
|
|
1275
1314
|
maybeOfferDrafts()
|
|
1276
1315
|
},
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
diff --git a/dist/layout/engine/intrinsic.js b/dist/layout/engine/intrinsic.js
|
|
2
|
-
index 8df3d41..d29496d 100644
|
|
3
|
-
--- a/dist/layout/engine/intrinsic.js
|
|
4
|
-
+++ b/dist/layout/engine/intrinsic.js
|
|
5
|
-
@@ -168,7 +168,7 @@ function measureLeafMaxContent(vnode, axis, measureNode) {
|
|
6
|
-
const placeholder = typeof placeholderRaw === "string" ? placeholderRaw : "";
|
|
7
|
-
const content = propsRes.value.value.length > 0 ? propsRes.value.value : placeholder;
|
|
8
|
-
const textW = measureTextCells(content);
|
|
9
|
-
- return ok(clampSize({ w: textW + 2, h: 1 }));
|
|
10
|
-
+ return ok(clampSize({ w: textW + 3, h: 1 }));
|
|
11
|
-
}
|
|
12
|
-
case "progress": {
|
|
13
|
-
const props = vnode.props;
|
|
14
|
-
@@ -388,4 +388,4 @@ export function measureMaxContent(vnode, axis, measureNode) {
|
|
15
|
-
return measureLeafMaxContent(vnode, axis, measureNode);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
-//# sourceMappingURL=intrinsic.js.map
|
|
19
|
-
|
|
20
|
-
+//# sourceMappingURL=intrinsic.js.map
|
|
21
|
-
diff --git a/dist/layout/kinds/leaf.js b/dist/layout/kinds/leaf.js
|
|
22
|
-
index 255750a..4daf5da 100644
|
|
23
|
-
--- a/dist/layout/kinds/leaf.js
|
|
24
|
-
+++ b/dist/layout/kinds/leaf.js
|
|
25
|
-
@@ -71,7 +71,7 @@ export function measureLeaf(vnode, maxW, maxH, axis) {
|
|
26
|
-
const placeholder = typeof placeholderRaw === "string" ? placeholderRaw : "";
|
|
27
|
-
const content = propsRes.value.value.length > 0 ? propsRes.value.value : placeholder;
|
|
28
|
-
const textW = measureTextCells(content);
|
|
29
|
-
- const w = Math.min(maxW, textW + 2);
|
|
30
|
-
+ const w = Math.min(maxW, textW + 3);
|
|
31
|
-
const h = Math.min(maxH, 1);
|
|
32
|
-
return ok({ w, h });
|
|
33
|
-
}
|
|
34
|
-
@@ -533,4 +533,4 @@ export function layoutLeafKind(vnode, x, y, rectW, rectH) {
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
-//# sourceMappingURL=leaf.js.map
|
|
39
|
-
|
|
40
|
-
+//# sourceMappingURL=leaf.js.map
|
|
41
|
-
diff --git a/dist/layout/kinds/box.js b/dist/layout/kinds/box.js
|
|
42
|
-
index dca103c..14f2973 100644
|
|
43
|
-
--- a/dist/layout/kinds/box.js
|
|
44
|
-
+++ b/dist/layout/kinds/box.js
|
|
45
|
-
@@ -4,6 +4,7 @@ import { childHasAbsolutePosition } from "../engine/guards.js";
|
|
46
|
-
import { ok } from "../engine/result.js";
|
|
47
|
-
import { resolveMargin as resolveMarginProps, resolveSpacing as resolveSpacingProps, } from "../spacing.js";
|
|
48
|
-
import { validateBoxProps } from "../validateProps.js";
|
|
49
|
-
+const OVERFLOW_CONTENT_LIMIT = 2147483647;
|
|
50
|
-
const syntheticColumnCache = new WeakMap();
|
|
51
|
-
function computeFlowSignature(children) {
|
|
52
|
-
let signature = `${children.length}:`;
|
|
53
|
-
@@ -88,12 +89,13 @@ export function measureBoxKinds(vnode, maxW, maxH, axis, measureNode) {
|
|
54
|
-
const outerHLimit = forcedH ?? maxHCap;
|
|
55
|
-
const cw = clampNonNegative(outerWLimit - bl - br - spacing.left - spacing.right);
|
|
56
|
-
const ch = clampNonNegative(outerHLimit - bt - bb - spacing.top - spacing.bottom);
|
|
57
|
-
+ const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;
|
|
58
|
-
// Children are laid out as a Column inside the content rect.
|
|
59
|
-
let contentUsedW = 0;
|
|
60
|
-
let contentUsedH = 0;
|
|
61
|
-
if (vnode.children.length > 0) {
|
|
62
|
-
const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);
|
|
63
|
-
- const innerRes = measureNode(columnNode, cw, ch, "column");
|
|
64
|
-
+ const innerRes = measureNode(columnNode, cw, flowMeasureH, "column");
|
|
65
|
-
if (!innerRes.ok)
|
|
66
|
-
return innerRes;
|
|
67
|
-
contentUsedW = innerRes.value.w;
|
|
68
|
-
@@ -154,9 +156,16 @@ export function layoutBoxKinds(vnode, x, y, rectW, rectH, axis, measureNode, lay
|
|
69
|
-
const children = [];
|
|
70
|
-
if (vnode.children.length > 0) {
|
|
71
|
-
const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);
|
|
72
|
-
+ const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;
|
|
73
|
-
+ const flowMeasureRes = measureNode(columnNode, cw, flowMeasureH, "column");
|
|
74
|
-
+ if (!flowMeasureRes.ok)
|
|
75
|
-
+ return flowMeasureRes;
|
|
76
|
-
+ const flowLayoutH = propsRes.value.overflow === "scroll"
|
|
77
|
-
+ ? Math.max(ch, flowMeasureRes.value.h)
|
|
78
|
-
+ : ch;
|
|
79
|
-
// The synthetic column wrapper must fill the box content rect so that
|
|
80
|
-
// percentage constraints resolve against the actual available space.
|
|
81
|
-
- const innerRes = layoutNode(columnNode, cx, cy, cw, ch, "column", cw, ch);
|
|
82
|
-
+ const innerRes = layoutNode(columnNode, cx, cy, cw, flowLayoutH, "column", cw, flowLayoutH, flowMeasureRes.value);
|
|
83
|
-
if (!innerRes.ok)
|
|
84
|
-
return innerRes;
|
|
85
|
-
// Attach the box's children (not the synthetic column wrapper).
|
|
86
|
-
@@ -234,4 +243,4 @@ export function layoutBoxKinds(vnode, x, y, rectW, rectH, axis, measureNode, lay
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
-//# sourceMappingURL=box.js.map
|
|
91
|
-
|
|
92
|
-
+//# sourceMappingURL=box.js.map
|
|
93
|
-
diff --git a/dist/app/widgetRenderer.js b/dist/app/widgetRenderer.js
|
|
94
|
-
index 38d4a5f..69ee099 100644
|
|
95
|
-
--- a/dist/app/widgetRenderer.js
|
|
96
|
-
+++ b/dist/app/widgetRenderer.js
|
|
97
|
-
@@ -614,6 +614,7 @@ export class WidgetRenderer {
|
|
98
|
-
tableStore = createTableStateStore();
|
|
99
|
-
treeStore = createTreeStateStore();
|
|
100
|
-
scrollOverrides = new Map();
|
|
101
|
-
+ hasPendingScrollOverride = false;
|
|
102
|
-
/* --- Tree Lazy-Loading Cache (per tree id, per node key) --- */
|
|
103
|
-
loadedTreeChildrenByTreeId = new Map();
|
|
104
|
-
treeLoadTokenByTreeAndKey = new Map();
|
|
105
|
-
@@ -1472,6 +1473,9 @@ export class WidgetRenderer {
|
|
106
|
-
diffViewerById: this.diffViewerById,
|
|
107
|
-
rectById: this.rectById,
|
|
108
|
-
scrollOverrides: this.scrollOverrides,
|
|
109
|
-
+ markScrollOverrideDirty: () => {
|
|
110
|
-
+ this.hasPendingScrollOverride = true;
|
|
111
|
-
+ },
|
|
112
|
-
findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),
|
|
113
|
-
});
|
|
114
|
-
if (wheelRoute)
|
|
115
|
-
@@ -1750,6 +1754,53 @@ export class WidgetRenderer {
|
|
116
|
-
}
|
|
117
|
-
return Object.freeze([]);
|
|
118
|
-
}
|
|
119
|
-
+ syncScrollOverridesFromLayoutTree() {
|
|
120
|
-
+ if (!this.committedRoot || !this.layoutTree) {
|
|
121
|
-
+ this.scrollOverrides.clear();
|
|
122
|
-
+ return;
|
|
123
|
-
+ }
|
|
124
|
-
+ const nextOverrides = new Map();
|
|
125
|
-
+ const stack = [
|
|
126
|
-
+ {
|
|
127
|
-
+ runtimeNode: this.committedRoot,
|
|
128
|
-
+ layoutNode: this.layoutTree,
|
|
129
|
-
+ },
|
|
130
|
-
+ ];
|
|
131
|
-
+ while (stack.length > 0) {
|
|
132
|
-
+ const frame = stack.pop();
|
|
133
|
-
+ if (!frame)
|
|
134
|
-
+ continue;
|
|
135
|
-
+ const runtimeNode = frame.runtimeNode;
|
|
136
|
-
+ const layoutNode = frame.layoutNode;
|
|
137
|
-
+ const props = runtimeNode.vnode.props;
|
|
138
|
-
+ const nodeId = typeof props.id === "string" && props.id.length > 0 ? props.id : null;
|
|
139
|
-
+ if (nodeId !== null && props.overflow === "scroll" && layoutNode.meta) {
|
|
140
|
-
+ const meta = layoutNode.meta;
|
|
141
|
-
+ const hasScrollableAxis = meta.contentWidth > meta.viewportWidth || meta.contentHeight > meta.viewportHeight;
|
|
142
|
-
+ if (hasScrollableAxis) {
|
|
143
|
-
+ nextOverrides.set(nodeId, Object.freeze({
|
|
144
|
-
+ scrollX: meta.scrollX,
|
|
145
|
-
+ scrollY: meta.scrollY,
|
|
146
|
-
+ }));
|
|
147
|
-
+ }
|
|
148
|
-
+ }
|
|
149
|
-
+ const childCount = Math.min(runtimeNode.children.length, layoutNode.children.length);
|
|
150
|
-
+ for (let i = childCount - 1; i >= 0; i--) {
|
|
151
|
-
+ const runtimeChild = runtimeNode.children[i];
|
|
152
|
-
+ const layoutChild = layoutNode.children[i];
|
|
153
|
-
+ if (!runtimeChild || !layoutChild)
|
|
154
|
-
+ continue;
|
|
155
|
-
+ stack.push({
|
|
156
|
-
+ runtimeNode: runtimeChild,
|
|
157
|
-
+ layoutNode: layoutChild,
|
|
158
|
-
+ });
|
|
159
|
-
+ }
|
|
160
|
-
+ }
|
|
161
|
-
+ this.scrollOverrides.clear();
|
|
162
|
-
+ for (const [nodeId, override] of nextOverrides) {
|
|
163
|
-
+ this.scrollOverrides.set(nodeId, override);
|
|
164
|
-
+ }
|
|
165
|
-
+ }
|
|
166
|
-
applyScrollOverridesToVNode(vnode, overrides = this
|
|
167
|
-
.scrollOverrides) {
|
|
168
|
-
const propsRecord = (vnode.props ?? {});
|
|
169
|
-
@@ -1763,7 +1814,7 @@ export class WidgetRenderer {
|
|
170
|
-
nextPropsMutable = { ...propsRecord };
|
|
171
|
-
return nextPropsMutable;
|
|
172
|
-
};
|
|
173
|
-
- if (override) {
|
|
174
|
-
+ if (override && propsForRead.overflow === "scroll") {
|
|
175
|
-
if (propsForRead.scrollX !== override.scrollX || propsForRead.scrollY !== override.scrollY) {
|
|
176
|
-
const mutable = ensureMutableProps();
|
|
177
|
-
mutable.scrollX = override.scrollX;
|
|
178
|
-
@@ -2643,7 +2694,7 @@ export class WidgetRenderer {
|
|
179
|
-
// layout when explicitly requested (resize/layout dirty), bootstrap lacks
|
|
180
|
-
// a tree, or committed layout signatures changed.
|
|
181
|
-
let doLayout = plan.layout || this.layoutTree === null;
|
|
182
|
-
- if (this.scrollOverrides.size > 0)
|
|
183
|
-
+ if (this.hasPendingScrollOverride)
|
|
184
|
-
doLayout = true;
|
|
185
|
-
const frameNowMs = typeof plan.nowMs === "number" && Number.isFinite(plan.nowMs)
|
|
186
|
-
? plan.nowMs
|
|
187
|
-
@@ -2916,7 +2967,7 @@ export class WidgetRenderer {
|
|
188
|
-
const layoutRootVNode = pendingScrollOverrides !== null
|
|
189
|
-
? this.applyScrollOverridesToVNode(constrainedLayoutRootVNode, pendingScrollOverrides)
|
|
190
|
-
: constrainedLayoutRootVNode;
|
|
191
|
-
- this.scrollOverrides.clear();
|
|
192
|
-
+ this.hasPendingScrollOverride = false;
|
|
193
|
-
const initialLayoutRes = this.layoutWithShapeFallback(layoutRootVNode, constrainedLayoutRootVNode, rootPad, rootW, rootH, true);
|
|
194
|
-
if (!initialLayoutRes.ok) {
|
|
195
|
-
perfMarkEnd("layout", layoutToken);
|
|
196
|
-
@@ -3001,6 +3052,7 @@ export class WidgetRenderer {
|
|
197
|
-
}
|
|
198
|
-
perfMarkEnd("layout", layoutToken);
|
|
199
|
-
this.layoutTree = nextLayoutTree;
|
|
200
|
-
+ this.syncScrollOverridesFromLayoutTree();
|
|
201
|
-
if (doCommit) {
|
|
202
|
-
// Seed/refresh per-instance layout stability signatures after a real
|
|
203
|
-
// layout pass so subsequent commits can take the signature fast path.
|
|
204
|
-
@@ -4076,4 +4128,4 @@ export class WidgetRenderer {
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
-//# sourceMappingURL=widgetRenderer.js.map
|
|
209
|
-
|
|
210
|
-
+//# sourceMappingURL=widgetRenderer.js.map
|
|
211
|
-
diff --git a/dist/app/widgetRenderer/mouseRouting.js b/dist/app/widgetRenderer/mouseRouting.js
|
|
212
|
-
index d3b08cf..00b77a3 100644
|
|
213
|
-
--- a/dist/app/widgetRenderer/mouseRouting.js
|
|
214
|
-
+++ b/dist/app/widgetRenderer/mouseRouting.js
|
|
215
|
-
@@ -1205,9 +1205,10 @@ export function routeMouseWheel(event, ctx) {
|
|
216
|
-
scrollX: r.nextScrollX ?? meta.scrollX,
|
|
217
|
-
scrollY: r.nextScrollY ?? meta.scrollY,
|
|
218
|
-
});
|
|
219
|
-
+ ctx.markScrollOverrideDirty?.();
|
|
220
|
-
return ROUTE_RENDER;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
-//# sourceMappingURL=mouseRouting.js.map
|
|
226
|
-
|
|
227
|
-
+//# sourceMappingURL=mouseRouting.js.map
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
diff --git a/lib/webrtc/src/transport/ice.js b/lib/webrtc/src/transport/ice.js
|
|
2
|
-
index 25c8489..cd06dbf 100644
|
|
3
|
-
--- a/lib/webrtc/src/transport/ice.js
|
|
4
|
-
+++ b/lib/webrtc/src/transport/ice.js
|
|
5
|
-
@@ -230,25 +230,25 @@ class RTCIceTransport {
|
|
6
|
-
stats.push(candidateStats);
|
|
7
|
-
}
|
|
8
|
-
// Candidate pairs
|
|
9
|
-
const pairs = this.connection?.candidatePairs
|
|
10
|
-
? [
|
|
11
|
-
...this.connection.candidatePairs.filter((p) => p.nominated),
|
|
12
|
-
...this.connection.candidatePairs.filter((p) => !p.nominated),
|
|
13
|
-
]
|
|
14
|
-
: [];
|
|
15
|
-
for (const pair of pairs) {
|
|
16
|
-
const pairStats = {
|
|
17
|
-
type: "candidate-pair",
|
|
18
|
-
- id: (0, stats_1.generateStatsId)("candidate-pair", pair.foundation),
|
|
19
|
-
+ id: (0, stats_1.generateStatsId)("candidate-pair", pair.localCandidate.foundation, pair.remoteCandidate.foundation),
|
|
20
|
-
timestamp,
|
|
21
|
-
transportId: (0, stats_1.generateStatsId)("transport", this.id),
|
|
22
|
-
localCandidateId: (0, stats_1.generateStatsId)("local-candidate", pair.localCandidate.foundation),
|
|
23
|
-
remoteCandidateId: (0, stats_1.generateStatsId)("remote-candidate", pair.remoteCandidate.foundation),
|
|
24
|
-
state: pair.state,
|
|
25
|
-
nominated: pair.nominated,
|
|
26
|
-
packetsSent: pair.packetsSent,
|
|
27
|
-
packetsReceived: pair.packetsReceived,
|
|
28
|
-
bytesSent: pair.bytesSent,
|
|
29
|
-
bytesReceived: pair.bytesReceived,
|
|
30
|
-
currentRoundTripTime: pair.rtt,
|
|
31
|
-
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
-
import { fileURLToPath } from "node:url"
|
|
3
|
-
|
|
4
|
-
const PATCH_FLAG = Symbol.for("send.rezi.inputCaretPatchInstalled")
|
|
5
|
-
|
|
6
|
-
type PatchedRuntime = {
|
|
7
|
-
[PATCH_FLAG]?: Set<string>
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
type FilePatchSpec = {
|
|
11
|
-
relativeUrl: string
|
|
12
|
-
before: string
|
|
13
|
-
after: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const INPUT_PATCHES: readonly FilePatchSpec[] = [
|
|
17
|
-
{
|
|
18
|
-
relativeUrl: "./layout/engine/intrinsic.js",
|
|
19
|
-
before: "return ok(clampSize({ w: textW + 2, h: 1 }));",
|
|
20
|
-
after: "return ok(clampSize({ w: textW + 3, h: 1 }));",
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
relativeUrl: "./layout/kinds/leaf.js",
|
|
24
|
-
before: "const w = Math.min(maxW, textW + 2);",
|
|
25
|
-
after: "const w = Math.min(maxW, textW + 3);",
|
|
26
|
-
},
|
|
27
|
-
] as const
|
|
28
|
-
|
|
29
|
-
const patchRuntime = globalThis as PatchedRuntime
|
|
30
|
-
|
|
31
|
-
const patchFile = async (spec: FilePatchSpec, coreIndexUrl: string) => {
|
|
32
|
-
const path = fileURLToPath(new URL(spec.relativeUrl, coreIndexUrl))
|
|
33
|
-
const source = await readFile(path, "utf8")
|
|
34
|
-
if (source.includes(spec.after)) return
|
|
35
|
-
if (!source.includes(spec.before)) throw new Error(`Unsupported @rezi-ui/core input layout at ${path}`)
|
|
36
|
-
await writeFile(path, source.replace(spec.before, spec.after))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export const ensureReziInputCaretPatch = async () => {
|
|
40
|
-
const coreIndexUrl = await import.meta.resolve("@rezi-ui/core")
|
|
41
|
-
const patchedRoots = patchRuntime[PATCH_FLAG] ?? new Set<string>()
|
|
42
|
-
if (patchedRoots.has(coreIndexUrl)) return
|
|
43
|
-
for (const spec of INPUT_PATCHES) await patchFile(spec, coreIndexUrl)
|
|
44
|
-
patchedRoots.add(coreIndexUrl)
|
|
45
|
-
patchRuntime[PATCH_FLAG] = patchedRoots
|
|
46
|
-
}
|
|
File without changes
|