@elefunc/send 0.1.3 → 0.1.4

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.4",
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
+ }
@@ -170,8 +170,6 @@ const STATS_POLL_MS = 1000
170
170
  const PROFILE_URL = "https://ip.rt.ht/"
171
171
  const PULSE_URL = "https://sig.efn.kr/pulse"
172
172
 
173
- type StatsEntry = { id?: string; type?: string; [key: string]: unknown }
174
-
175
173
  export interface PeerConnectivitySnapshot {
176
174
  rttMs: number
177
175
  localCandidateType: string
@@ -191,7 +189,6 @@ export interface PulseSnapshot {
191
189
 
192
190
  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
191
  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
192
  export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
196
193
  const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
197
194
  const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", at: 0, ms: 0, error: "" })
@@ -262,74 +259,54 @@ export const turnUsageState = (
262
259
 
263
260
  const timeoutSignal = (ms: number) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
264
261
 
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
262
+ const normalizeCandidateType = (value: unknown) => typeof value === "string" ? value.toLowerCase() : ""
263
+ const validCandidateType = (value: string) => ["host", "srflx", "prflx", "relay"].includes(value)
264
+
265
+ export const activeIcePairFromPeerConnection = (pc: { iceTransports?: unknown[] } | null | undefined) => {
266
+ const transports = Array.isArray(pc?.iceTransports) ? pc.iceTransports as Array<Record<string, any>> : []
267
+ let fallback: { transport: Record<string, any>; connection: Record<string, any>; pair: Record<string, any> } | null = null
268
+ for (const transport of transports) {
269
+ const connection = transport?.connection
270
+ const pair = connection?.nominated
271
+ if (!pair) continue
272
+ fallback ||= { transport, connection, pair }
273
+ const state = `${transport?.state ?? ""}`.toLowerCase()
274
+ if (!state || state === "connected" || state === "completed") return { transport, connection, pair }
275
+ }
276
+ return fallback
298
277
  }
299
278
 
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))
308
- }
309
-
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
- }
279
+ export const connectivitySnapshotFromPeerConnection = (
280
+ pc: { iceTransports?: unknown[] } | null | undefined,
281
+ previous: PeerConnectivitySnapshot = emptyConnectivitySnapshot(),
282
+ ): PeerConnectivitySnapshot => {
283
+ const pair = activeIcePairFromPeerConnection(pc)?.pair
284
+ const localCandidateType = normalizeCandidateType(pair?.localCandidate?.type ?? pair?.localCandidate?.candidateType)
285
+ const remoteCandidateType = normalizeCandidateType(pair?.remoteCandidate?.type ?? pair?.remoteCandidate?.candidateType)
286
+ if (!validCandidateType(localCandidateType) || !validCandidateType(remoteCandidateType)) return previous
322
287
  return {
323
288
  ...previous,
324
- rttMs,
325
289
  localCandidateType,
326
290
  remoteCandidateType,
327
291
  pathLabel: `${candidateTypeLabel(localCandidateType)} ↔ ${candidateTypeLabel(remoteCandidateType)}`,
328
292
  }
329
293
  }
330
294
 
295
+ export const probeIcePairConsentRtt = async (connection: Record<string, any> | null | undefined, pair: Record<string, any> | null | undefined) => {
296
+ if (!connection || !pair?.protocol?.request || typeof connection.buildRequest !== "function" || typeof connection.remotePassword !== "string") return Number.NaN
297
+ const request = connection.buildRequest({
298
+ nominate: false,
299
+ localUsername: connection.localUsername,
300
+ remoteUsername: connection.remoteUsername,
301
+ iceControlling: connection.iceControlling,
302
+ })
303
+ const startedAt = performance.now()
304
+ await pair.protocol.request(request, pair.remoteAddr, Buffer.from(connection.remotePassword, "utf8"), 0)
305
+ return performance.now() - startedAt
306
+ }
307
+
331
308
  const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivitySnapshot) =>
332
- left.rttMs === right.rttMs
309
+ (left.rttMs === right.rttMs || Number.isNaN(left.rttMs) && Number.isNaN(right.rttMs))
333
310
  && left.localCandidateType === right.localCandidateType
334
311
  && left.remoteCandidateType === right.remoteCandidateType
335
312
  && left.pathLabel === right.pathLabel
@@ -710,18 +687,23 @@ export class SendSession {
710
687
  if (this.stopped) return
711
688
  let dirty = false
712
689
  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 {}
690
+ if (peer.presence !== "active") continue
691
+ dirty = await this.refreshPeerConnectivity(peer) || dirty
721
692
  }
722
693
  if (dirty) this.notify()
723
694
  }
724
695
 
696
+ private async refreshPeerConnectivity(peer: PeerState) {
697
+ const activePair = activeIcePairFromPeerConnection(peer.pc as { iceTransports?: unknown[] } | null | undefined)
698
+ const next = connectivitySnapshotFromPeerConnection(peer.pc as { iceTransports?: unknown[] } | null | undefined, peer.connectivity)
699
+ const rttMs = await probeIcePairConsentRtt(activePair?.connection, activePair?.pair).catch(() => Number.NaN)
700
+ if (Number.isFinite(rttMs)) next.rttMs = rttMs
701
+ if (sameConnectivity(peer.connectivity, next)) return false
702
+ peer.connectivity = next
703
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
704
+ return true
705
+ }
706
+
725
707
  private pushLog(kind: string, payload: unknown, level: "info" | "error" = "info") {
726
708
  const log = { id: uid(6), at: Date.now(), kind, level, payload }
727
709
  this.logs.unshift(log)
@@ -1319,8 +1301,8 @@ export class SendSession {
1319
1301
  }
1320
1302
 
1321
1303
  private async handleTransferControl(peer: PeerState, message: DataMessage) {
1322
- this.pushLog("data:in", message)
1323
1304
  if (message.to && message.to !== this.localId && message.to !== "*") return
1305
+ this.pushLog("data:in", message)
1324
1306
  switch (message.kind) {
1325
1307
  case "file-offer": {
1326
1308
  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 }
@@ -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