@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 CHANGED
@@ -19,7 +19,7 @@ send
19
19
  send peers
20
20
  send offer ./file.txt
21
21
  send accept
22
- send tui --events
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. Set `SEND_WEB_URL` to change its base URL; it defaults to `https://send.rt.ht/`.
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 `patches/`.
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",
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
- "patches",
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
+ }
@@ -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) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
264
-
265
- const statsEntriesFromReport = (report: unknown): StatsEntry[] => {
266
- if (!report) return []
267
- if (Array.isArray(report)) return report as StatsEntry[]
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 selectedCandidatePair = (entries: StatsEntry[], byId: Map<string, StatsEntry>) => {
301
- const transport = entries.find(entry => entry.type === "transport" && typeof entry.selectedCandidatePairId === "string")
302
- const transportId = typeof transport?.id === "string" ? transport.id : undefined
303
- if (transport && typeof transport.selectedCandidatePairId === "string") {
304
- const pair = byId.get(transport.selectedCandidatePairId)
305
- if (pair?.type === "candidate-pair") return pair
306
- }
307
- return preferredCandidatePair(candidatePairEntries(entries, transportId)) || preferredCandidatePair(candidatePairEntries(entries))
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 connectivitySnapshotFromReport = (report: unknown, previous: PeerConnectivitySnapshot = emptyConnectivitySnapshot()): PeerConnectivitySnapshot => {
311
- const entries = statsEntriesFromReport(report)
312
- const byId = new Map(entries.flatMap(entry => typeof entry.id === "string" && entry.id ? [[entry.id, entry] as const] : []))
313
- const pair = selectedCandidatePair(entries, byId)
314
- const rttMs = typeof pair?.currentRoundTripTime === "number" ? pair.currentRoundTripTime * 1000 : previous.rttMs
315
- const localCandidate = typeof pair?.localCandidateId === "string" ? byId.get(pair.localCandidateId) : undefined
316
- const remoteCandidate = typeof pair?.remoteCandidateId === "string" ? byId.get(pair.remoteCandidateId) : undefined
317
- const localCandidateType = typeof localCandidate?.candidateType === "string" ? localCandidate.candidateType : ""
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 || peer.presence !== "active") return false
455
- peer.selected = selected
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" || !peer.pc) continue
714
- try {
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
- this.profile = localProfileFromResponse(await response.json())
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: true,
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, type SessionConfig, type SessionEvent } from "./core/session"
5
+ import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
6
6
  import { resolvePeerTargets } from "./core/targeting"
7
- import { ensureReziInputCaretPatch } from "./tui/rezi-input-caret"
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 ensureReziInputCaretPatch()
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 "./rezi-checkbox-click"
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 makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean) => new SendSession({
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 session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming)
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
- title: `${transfer.direction === "out" ? "" : "←"} ${transfer.name}`,
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" && state.session.setPeerSelected(peer.id, peer.ready)) changed += 1
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