@elefunc/send 0.1.18 → 0.1.20

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
@@ -35,12 +35,12 @@ In the TUI, the room row includes a `📋` invite link that opens the equivalent
35
35
  `--self` accepts three forms:
36
36
 
37
37
  - `name`
38
- - `name-ID`
39
- - `-ID` using the attached CLI form `--self=-ab12cd34`
38
+ - `name-id`
39
+ - `-id` using the attached CLI form `--self=-ab12cd34`
40
40
 
41
41
  `SEND_SELF` supports the same raw values, including `SEND_SELF=-ab12cd34`.
42
42
 
43
- The ID suffix must be exactly 8 lowercase alphanumeric characters.
43
+ The `id` suffix must be exactly 8 lowercase alphanumeric characters.
44
44
 
45
45
  ## Examples
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/core/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { access, mkdir, open, rm, stat, type FileHandle } from "node:fs/promises"
1
+ import { access, mkdir, open, rename, rm, stat, type FileHandle } from "node:fs/promises"
2
2
  import { basename, extname, join, resolve } from "node:path"
3
3
  import { resolveUserPath } from "./paths"
4
4
 
@@ -101,8 +101,23 @@ export const uniqueOutputPath = async (directory: string, fileName: string, rese
101
101
  }
102
102
  }
103
103
 
104
- export const saveIncomingFile = async (directory: string, fileName: string, data: Buffer) => {
105
- const path = await uniqueOutputPath(directory, fileName)
104
+ export const incomingOutputPath = async (directory: string, fileName: string, overwrite = false, reservedPaths: ReadonlySet<string> = new Set()) => {
105
+ await mkdir(directory, { recursive: true })
106
+ return overwrite ? join(directory, fileName) : uniqueOutputPath(directory, fileName, reservedPaths)
107
+ }
108
+
109
+ export const replaceOutputPath = async (sourcePath: string, destinationPath: string) => {
110
+ try {
111
+ await rename(sourcePath, destinationPath)
112
+ } catch (error) {
113
+ if (!await pathExists(destinationPath)) throw error
114
+ await removePath(destinationPath)
115
+ await rename(sourcePath, destinationPath)
116
+ }
117
+ }
118
+
119
+ export const saveIncomingFile = async (directory: string, fileName: string, data: Buffer, overwrite = false) => {
120
+ const path = await incomingOutputPath(directory, fileName, overwrite)
106
121
  await Bun.write(path, data)
107
122
  return path
108
123
  }
@@ -0,0 +1,148 @@
1
+ import { resolve } from "node:path"
2
+ import { cleanRoom } from "./protocol"
3
+
4
+ export const DEFAULT_WEB_URL = "https://rtme.sh/"
5
+ export const COPY_SERVICE_URL = "https://copy.rt.ht/"
6
+
7
+ export type ShareUrlOptions = {
8
+ room: string
9
+ clean?: boolean
10
+ accept?: boolean
11
+ offer?: boolean
12
+ save?: boolean
13
+ overwrite?: boolean
14
+ }
15
+
16
+ export type ShareCliCommandOptions = ShareUrlOptions & {
17
+ self?: string
18
+ events?: boolean
19
+ saveDir?: string
20
+ defaultSaveDir?: string
21
+ turnUrls?: readonly (string | null | undefined)[]
22
+ turnUsername?: string
23
+ turnCredential?: string
24
+ }
25
+
26
+ export type JoinOutputKind = "offer" | "accept"
27
+
28
+ const hashBool = (value: boolean) => value ? "1" : "0"
29
+ const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
30
+
31
+ const normalizeShareUrlOptions = (options: ShareUrlOptions) => ({
32
+ room: cleanRoom(options.room),
33
+ clean: options.clean ?? true,
34
+ accept: options.accept ?? true,
35
+ offer: options.offer ?? true,
36
+ save: options.save ?? true,
37
+ overwrite: options.overwrite ?? false,
38
+ })
39
+
40
+ const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
41
+
42
+ export const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
43
+ const text = `${value ?? ""}`.trim()
44
+ if (!text) return
45
+ args.push(flag, shellQuote(text))
46
+ }
47
+
48
+ export const appendToggleCliFlags = (args: string[], options: ShareUrlOptions) => {
49
+ const normalized = normalizeShareUrlOptions(options)
50
+ if (!normalized.clean) appendCliFlag(args, "--clean", "0")
51
+ if (!normalized.accept) appendCliFlag(args, "--accept", "0")
52
+ if (!normalized.offer) appendCliFlag(args, "--offer", "0")
53
+ if (!normalized.save) appendCliFlag(args, "--save", "0")
54
+ if (normalized.overwrite) args.push("--overwrite")
55
+ }
56
+
57
+ const buildHashParams = (options: ShareUrlOptions, omitDefaults = false) => {
58
+ const normalized = normalizeShareUrlOptions(options)
59
+ const params = new URLSearchParams({ room: normalized.room })
60
+ if (!omitDefaults || !normalized.clean) params.set("clean", hashBool(normalized.clean))
61
+ if (!omitDefaults || !normalized.accept) params.set("accept", hashBool(normalized.accept))
62
+ if (!omitDefaults || !normalized.offer) params.set("offer", hashBool(normalized.offer))
63
+ if (!omitDefaults || !normalized.save) params.set("save", hashBool(normalized.save))
64
+ if (!omitDefaults || normalized.overwrite) params.set("overwrite", hashBool(normalized.overwrite))
65
+ return params
66
+ }
67
+
68
+ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
69
+ const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
70
+ try {
71
+ return new URL(candidate).toString()
72
+ } catch {
73
+ return DEFAULT_WEB_URL
74
+ }
75
+ }
76
+
77
+ export const renderWebUrl = (options: ShareUrlOptions, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
78
+ const url = new URL(baseUrl)
79
+ url.hash = buildHashParams(options, omitDefaults).toString()
80
+ return url.toString()
81
+ }
82
+
83
+ export const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
84
+
85
+ export const webInviteUrl = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => renderWebUrl(options, baseUrl)
86
+
87
+ export const inviteWebLabel = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(options, baseUrl))
88
+
89
+ export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
90
+
91
+ export const inviteCliCommand = (options: ShareUrlOptions) => {
92
+ const normalized = normalizeShareUrlOptions(options)
93
+ const args: string[] = []
94
+ appendCliFlag(args, "--room", normalized.room)
95
+ appendToggleCliFlags(args, normalized)
96
+ return args.join(" ")
97
+ }
98
+
99
+ export const inviteCliText = (options: ShareUrlOptions, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(options)}`
100
+
101
+ export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
102
+
103
+ export const shareTurnCliArgs = (options: Pick<ShareCliCommandOptions, "turnUrls" | "turnUsername" | "turnCredential">) => {
104
+ const turnUrls = [...new Set((options.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
105
+ if (!turnUrls.length) return []
106
+ const args: string[] = []
107
+ for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
108
+ appendCliFlag(args, "--turn-username", options.turnUsername)
109
+ appendCliFlag(args, "--turn-credential", options.turnCredential)
110
+ return args
111
+ }
112
+
113
+ export const renderCliCommand = (
114
+ options: ShareCliCommandOptions,
115
+ { includeSelf = false, includePrefix = false, packageName }: { includeSelf?: boolean; includePrefix?: boolean; packageName?: string } = {},
116
+ ) => {
117
+ const normalized = normalizeShareUrlOptions(options)
118
+ const args = includePrefix ? ["bunx", packageName || inviteCliPackageName(DEFAULT_WEB_URL)] : []
119
+ appendCliFlag(args, "--room", normalized.room)
120
+ if (includeSelf) appendCliFlag(args, "--self", options.self)
121
+ appendToggleCliFlags(args, normalized)
122
+ if (options.events) args.push("--events")
123
+ if (options.saveDir && (!options.defaultSaveDir || resolve(options.saveDir) !== options.defaultSaveDir)) appendCliFlag(args, "--save-dir", options.saveDir)
124
+ args.push(...shareTurnCliArgs(options))
125
+ return args.join(" ")
126
+ }
127
+
128
+ const joinCliLabel = (kind: JoinOutputKind) => kind === "offer" ? "CLI (receive and save):" : "CLI (append file paths at the end):"
129
+
130
+ const joinCliCommand = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => {
131
+ const prefix = `bunx ${inviteCliPackageName(baseUrl)}`
132
+ const roomArgs = inviteCliCommand({ room })
133
+ return kind === "offer" ? `${prefix} accept ${roomArgs}` : `${prefix} offer ${roomArgs}`
134
+ }
135
+
136
+ export const joinOutputLines = (kind: JoinOutputKind, room: string, baseUrl = resolveWebUrlBase()) => [
137
+ "Join with:",
138
+ "",
139
+ "Web (open in browser):",
140
+ webInviteUrl({ room }, baseUrl),
141
+ "",
142
+ joinCliLabel(kind),
143
+ joinCliCommand(kind, room, baseUrl),
144
+ "",
145
+ "TUI (interactive terminal UI):",
146
+ inviteCliText({ room }, baseUrl),
147
+ "",
148
+ ]
@@ -2,7 +2,7 @@ import { open, rename, type FileHandle } from "node:fs/promises"
2
2
  import { resolve } from "node:path"
3
3
  import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
4
4
  import { RTCPeerConnection } from "werift"
5
- import { closeLocalFile, loadLocalFiles, pathExists, readFileChunk, removePath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
5
+ import { closeLocalFile, incomingOutputPath, loadLocalFiles, pathExists, readFileChunk, removePath, replaceOutputPath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
6
6
  import {
7
7
  BASE_ICE_SERVERS,
8
8
  BUFFER_HIGH,
@@ -176,6 +176,7 @@ export interface SessionConfig {
176
176
  peerSelectionMemory?: Map<string, boolean>
177
177
  autoAcceptIncoming?: boolean
178
178
  autoSaveIncoming?: boolean
179
+ overwriteIncoming?: boolean
179
180
  turnUrls?: string[]
180
181
  turnUsername?: string
181
182
  turnCredential?: string
@@ -184,6 +185,8 @@ export interface SessionConfig {
184
185
 
185
186
  const LOG_LIMIT = 200
186
187
  const STATS_POLL_MS = 1000
188
+ export const PULSE_PROBE_INTERVAL_MS = 15_000
189
+ export const PULSE_STALE_MS = 45_000
187
190
  const PROFILE_URL = "https://ip.rt.ht/"
188
191
  export interface PeerConnectivitySnapshot {
189
192
  rttMs: number
@@ -194,9 +197,12 @@ export interface PeerConnectivitySnapshot {
194
197
 
195
198
  export type TurnState = "none" | "idle" | "used"
196
199
  export type PulseState = "idle" | "checking" | "open" | "error"
200
+ export type SettledPulseState = "idle" | "open" | "error"
201
+ export type SignalMetricState = "idle" | "connecting" | "checking" | "open" | "degraded" | "closed" | "error"
197
202
 
198
203
  export interface PulseSnapshot {
199
204
  state: PulseState
205
+ lastSettledState: SettledPulseState
200
206
  at: number
201
207
  ms: number
202
208
  error: string
@@ -206,7 +212,27 @@ const progressOf = (transfer: TransferState) => transfer.size ? Math.max(0, Math
206
212
  const isFinal = (transfer: TransferState) => FINAL_STATUSES.has(transfer.status as never)
207
213
  export const candidateTypeLabel = (type: string) => ({ host: "Direct", srflx: "NAT", prflx: "NAT", relay: "TURN" }[type] || "—")
208
214
  const emptyConnectivitySnapshot = (): PeerConnectivitySnapshot => ({ rttMs: Number.NaN, localCandidateType: "", remoteCandidateType: "", pathLabel: "—" })
209
- const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", at: 0, ms: 0, error: "" })
215
+ const settledPulseState = (pulse?: Pick<PulseSnapshot, "state" | "lastSettledState"> | null): SettledPulseState =>
216
+ pulse?.lastSettledState === "open" || pulse?.lastSettledState === "error" || pulse?.lastSettledState === "idle"
217
+ ? pulse.lastSettledState
218
+ : pulse?.state === "open"
219
+ ? "open"
220
+ : pulse?.state === "error"
221
+ ? "error"
222
+ : "idle"
223
+ const pulseIsFresh = (pulse: Pick<PulseSnapshot, "at" | "lastSettledState" | "state">, now = Date.now()) =>
224
+ settledPulseState(pulse) === "open" && Number.isFinite(pulse.at) && pulse.at > 0 && now - pulse.at <= PULSE_STALE_MS
225
+ export const signalMetricState = (socketState: SocketState, pulse: PulseSnapshot, now = Date.now()): SignalMetricState => {
226
+ if (socketState === "error") return "error"
227
+ if (socketState === "closed") return "closed"
228
+ if (socketState === "connecting") return "connecting"
229
+ if (socketState === "idle") return "idle"
230
+ const lastSettled = settledPulseState(pulse)
231
+ if (lastSettled === "idle") return "checking"
232
+ if (lastSettled === "error") return "degraded"
233
+ return pulseIsFresh(pulse, now) ? "open" : "degraded"
234
+ }
235
+ const emptyPulseSnapshot = (): PulseSnapshot => ({ state: "idle", lastSettledState: "idle", at: 0, ms: 0, error: "" })
210
236
 
211
237
  export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
212
238
  geo: {
@@ -389,6 +415,7 @@ export class SendSession {
389
415
 
390
416
  private autoAcceptIncoming: boolean
391
417
  private autoSaveIncoming: boolean
418
+ private readonly overwriteIncoming: boolean
392
419
  private readonly reconnectSocket: boolean
393
420
  private iceServers: RTCIceServer[]
394
421
  private extraTurnServers: RTCIceServer[]
@@ -405,6 +432,7 @@ export class SendSession {
405
432
  private socketToken = 0
406
433
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
407
434
  private peerStatsTimer: ReturnType<typeof setInterval> | null = null
435
+ private pulseTimer: ReturnType<typeof setInterval> | null = null
408
436
  private readonly pendingRtcCloses = new Set<Promise<void>>()
409
437
  private lifecycleAbortController: AbortController | null = null
410
438
  private stopped = false
@@ -418,6 +446,7 @@ export class SendSession {
418
446
  this.peerSelectionMemory = config.peerSelectionMemory ?? new Map()
419
447
  this.autoAcceptIncoming = !!config.autoAcceptIncoming
420
448
  this.autoSaveIncoming = !!config.autoSaveIncoming
449
+ this.overwriteIncoming = !!config.overwriteIncoming
421
450
  this.reconnectSocket = config.reconnectSocket ?? true
422
451
  this.extraTurnServers = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
423
452
  this.iceServers = [...BASE_ICE_SERVERS, ...this.extraTurnServers]
@@ -460,6 +489,7 @@ export class SendSession {
460
489
  this.lifecycleAbortController?.abort()
461
490
  this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
462
491
  this.startPeerStatsPolling()
492
+ this.startPulsePolling()
463
493
  void this.loadLocalProfile()
464
494
  void this.probePulse()
465
495
  this.connectSocket()
@@ -469,6 +499,7 @@ export class SendSession {
469
499
  async close() {
470
500
  this.stopped = true
471
501
  this.stopPeerStatsPolling()
502
+ this.stopPulsePolling()
472
503
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
473
504
  this.reconnectTimer = null
474
505
  this.lifecycleAbortController?.abort()
@@ -763,7 +794,7 @@ export class SendSession {
763
794
  if (transfer.savedPath) return transfer.savedPath
764
795
  if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
765
796
  if (!transfer.data) return null
766
- transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
797
+ transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data, this.overwriteIncoming)
767
798
  transfer.savedAt ||= Date.now()
768
799
  this.emitTransferSaved(transfer)
769
800
  this.notify()
@@ -788,8 +819,8 @@ export class SendSession {
788
819
  }
789
820
 
790
821
  private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
791
- const finalPath = await uniqueOutputPath(this.saveDir, fileName || "download", this.reservedSavePaths)
792
- this.reservedSavePaths.add(finalPath)
822
+ const finalPath = await incomingOutputPath(this.saveDir, fileName || "download", this.overwriteIncoming, this.reservedSavePaths)
823
+ if (!this.overwriteIncoming) this.reservedSavePaths.add(finalPath)
793
824
  for (let attempt = 0; ; attempt += 1) {
794
825
  const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
795
826
  try {
@@ -805,7 +836,7 @@ export class SendSession {
805
836
  }
806
837
  } catch (error) {
807
838
  if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
808
- this.reservedSavePaths.delete(finalPath)
839
+ if (!this.overwriteIncoming) this.reservedSavePaths.delete(finalPath)
809
840
  throw error
810
841
  }
811
842
  }
@@ -875,12 +906,13 @@ export class SendSession {
875
906
  disk.closed = true
876
907
  await disk.handle.close()
877
908
  }
878
- if (await pathExists(finalPath)) {
909
+ if (!this.overwriteIncoming && await pathExists(finalPath)) {
879
910
  this.reservedSavePaths.delete(finalPath)
880
911
  finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
881
912
  this.reservedSavePaths.add(finalPath)
882
913
  }
883
- await rename(disk.tempPath, finalPath)
914
+ if (this.overwriteIncoming) await replaceOutputPath(disk.tempPath, finalPath)
915
+ else await rename(disk.tempPath, finalPath)
884
916
  transfer.savedPath = finalPath
885
917
  transfer.savedAt ||= Date.now()
886
918
  return finalPath
@@ -952,6 +984,17 @@ export class SendSession {
952
984
  this.peerStatsTimer = null
953
985
  }
954
986
 
987
+ private startPulsePolling() {
988
+ if (this.pulseTimer) return
989
+ this.pulseTimer = setInterval(() => void this.probePulse(), PULSE_PROBE_INTERVAL_MS)
990
+ }
991
+
992
+ private stopPulsePolling() {
993
+ if (!this.pulseTimer) return
994
+ clearInterval(this.pulseTimer)
995
+ this.pulseTimer = null
996
+ }
997
+
955
998
  private async refreshPeerStats() {
956
999
  if (this.stopped) return
957
1000
  let dirty = false
@@ -1156,10 +1199,10 @@ export class SendSession {
1156
1199
  const response = await fetch(SIGNAL_PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
1157
1200
  if (!response.ok) throw new Error(`pulse ${response.status}`)
1158
1201
  if (this.stopped) return
1159
- this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
1202
+ this.pulse = { state: "open", lastSettledState: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
1160
1203
  } catch (error) {
1161
1204
  if (this.stopped) return
1162
- this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
1205
+ this.pulse = { state: "error", lastSettledState: "error", at: Date.now(), ms: 0, error: `${error}` }
1163
1206
  this.pushLog("pulse:error", { error: `${error}` }, "error")
1164
1207
  }
1165
1208
  if (this.stopped) return
@@ -1,4 +1,4 @@
1
- import { cleanName, displayPeerName } from "./protocol"
1
+ import { cleanText, displayPeerName } from "./protocol"
2
2
 
3
3
  export interface TargetPeer {
4
4
  id: string
@@ -16,23 +16,29 @@ export interface ResolveTargetsResult {
16
16
  const BROADCAST_SELECTOR = "."
17
17
 
18
18
  const uniquePeers = (peers: TargetPeer[]) => [...new Map(peers.map(peer => [peer.id, peer])).values()]
19
-
20
- const matchesSelector = (peer: TargetPeer, selector: string) => selector === peer.id || selector === displayPeerName(peer.name, peer.id) || selector === cleanName(peer.name)
19
+ const normalizeName = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24)
20
+ const parseSelector = (selector: string) => {
21
+ const hyphen = selector.lastIndexOf("-")
22
+ return hyphen < 0
23
+ ? { raw: selector, kind: "name" as const, value: normalizeName(selector) }
24
+ : { raw: selector, kind: "id" as const, value: selector.slice(hyphen + 1) }
25
+ }
26
+ const matchesSelector = (peer: TargetPeer, selector: ReturnType<typeof parseSelector>) =>
27
+ selector.kind === "name" ? normalizeName(peer.name) === selector.value : peer.id === selector.value
21
28
 
22
29
  export const resolvePeerTargets = (peers: TargetPeer[], selectors: string[]): ResolveTargetsResult => {
23
30
  const active = peers.filter(peer => peer.presence === "active")
24
- const ready = active.filter(peer => peer.ready)
25
31
  const requested = [...new Set(selectors.filter(Boolean))]
26
32
  const normalized = requested.length ? requested : [BROADCAST_SELECTOR]
27
33
  if (normalized.includes(BROADCAST_SELECTOR)) {
28
34
  if (normalized.length > 1) return { ok: false, peers: [], error: "broadcast selector `.` cannot be combined with specific peers" }
35
+ const ready = active.filter(peer => peer.ready)
29
36
  return ready.length ? { ok: true, peers: ready } : { ok: false, peers: [], error: "no ready peers" }
30
37
  }
31
- const matches = uniquePeers(active.filter(peer => normalized.some(selector => matchesSelector(peer, selector))))
32
- if (matches.length !== normalized.length) {
33
- const missing = normalized.filter(selector => !matches.some(peer => matchesSelector(peer, selector)))
34
- return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
35
- }
38
+ const parsed = normalized.map(parseSelector)
39
+ const missing = parsed.filter(selector => !active.some(peer => matchesSelector(peer, selector))).map(selector => selector.raw)
40
+ if (missing.length) return { ok: false, peers: [], error: `no matching peer for ${missing.join(", ")}` }
41
+ const matches = uniquePeers(active.flatMap(peer => parsed.some(selector => matchesSelector(peer, selector)) ? [peer] : []))
36
42
  const notReady = matches.filter(peer => !peer.ready)
37
43
  if (notReady.length) return { ok: false, peers: [], error: `peer not ready: ${notReady.map(peer => displayPeerName(peer.name, peer.id)).join(", ")}` }
38
44
  return { ok: true, peers: matches }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { resolve } from "node:path"
3
3
  import { cac, type CAC } from "cac"
4
+ import { joinOutputLines, type JoinOutputKind } from "./core/invite"
4
5
  import { cleanRoom, displayPeerName } from "./core/protocol"
5
6
  import type { SendSession, SessionConfig, SessionEvent } from "./core/session"
6
7
  import { resolvePeerTargets } from "./core/targeting"
@@ -55,8 +56,8 @@ const parseBinaryOptions = <K extends BinaryOptionKey>(options: Record<string, u
55
56
 
56
57
  const SELF_ID_LENGTH = 8
57
58
  const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
58
- const SELF_HELP_TEXT = "self identity: name, name-ID, or -ID (use --self=-ID)"
59
- const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
59
+ const SELF_HELP_TEXT = "self identity: `name`, `name-id`, or `-id`"
60
+ const INVALID_SELF_ID_MESSAGE = `--self id suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
60
61
  type CliCommand = ReturnType<CAC["command"]>
61
62
  const ROOM_SELF_OPTIONS = [
62
63
  ["--room <room>", "room id; omit to create a random room"],
@@ -67,6 +68,7 @@ const TURN_OPTIONS = [
67
68
  ["--turn-username <value>", "custom TURN username"],
68
69
  ["--turn-credential <value>", "custom TURN credential"],
69
70
  ] as const
71
+ const OVERWRITE_OPTION = ["--overwrite", "overwrite same-name saved files instead of creating copies"] as const
70
72
  const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
71
73
  const TUI_TOGGLE_OPTIONS = [
72
74
  ["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
@@ -77,6 +79,14 @@ const TUI_TOGGLE_OPTIONS = [
77
79
  export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
78
80
  const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
79
81
  definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
82
+ const withTrailingHelpLine = <T extends { outputHelp: () => void }>(target: T) => {
83
+ const outputHelp = target.outputHelp.bind(target)
84
+ target.outputHelp = () => {
85
+ outputHelp()
86
+ console.info("")
87
+ }
88
+ return target
89
+ }
80
90
 
81
91
  const requireSelfId = (value: string) => {
82
92
  if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
@@ -103,6 +113,7 @@ export const sessionConfigFrom = (options: Record<string, unknown>, defaults: {
103
113
  saveDir: resolve(`${options.saveDir ?? process.env.SEND_SAVE_DIR ?? "."}`),
104
114
  autoAcceptIncoming: accept ?? defaults.autoAcceptIncoming ?? false,
105
115
  autoSaveIncoming: save ?? defaults.autoSaveIncoming ?? false,
116
+ overwriteIncoming: !!options.overwrite,
106
117
  turnUrls: splitList(options.turnUrl ?? process.env.SEND_TURN_URL),
107
118
  turnUsername: `${options.turnUsername ?? process.env.SEND_TURN_USERNAME ?? ""}`.trim() || undefined,
108
119
  turnCredential: `${options.turnCredential ?? process.env.SEND_TURN_CREDENTIAL ?? ""}`.trim() || undefined,
@@ -114,6 +125,16 @@ export const roomAnnouncement = (room: string, self: string, json = false) =>
114
125
 
115
126
  const printRoomAnnouncement = (room: string, self: string, json = false) => console.log(roomAnnouncement(room, self, json))
116
127
 
128
+ export const commandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) =>
129
+ json ? roomAnnouncement(room, self, true) : [roomAnnouncement(room, self), "", ...joinOutputLines(kind, room)].join("\n")
130
+
131
+ const printCommandAnnouncement = (kind: JoinOutputKind, room: string, self: string, json = false) => console.log(commandAnnouncement(kind, room, self, json))
132
+ export const readyStatusLine = (room: string, json = false) => json ? "" : `ready in ${room}`
133
+ const printReadyStatus = (room: string, json = false) => {
134
+ const line = readyStatusLine(room, json)
135
+ if (line) console.log(line)
136
+ }
137
+
117
138
  const printEvent = (event: SessionEvent) => console.log(JSON.stringify(event))
118
139
 
119
140
  const attachReporter = (session: SendSession, json = false) => {
@@ -220,9 +241,10 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
220
241
  const { SendSession } = await loadSessionRuntime()
221
242
  const session = new SendSession(sessionConfigFrom(options, {}))
222
243
  handleSignals(session)
223
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
244
+ printCommandAnnouncement("offer", session.room, displayPeerName(session.name, session.localId), !!options.json)
224
245
  const detachReporter = attachReporter(session, !!options.json)
225
246
  await session.connect()
247
+ printReadyStatus(session.room, !!options.json)
226
248
  const targets = await waitForTargets(session, selectors, timeoutMs)
227
249
  const transferIds = await session.queueFiles(files, targets.map(peer => peer.id))
228
250
  const results = await waitForFinalTransfers(session, transferIds)
@@ -236,10 +258,10 @@ const acceptCommand = async (options: Record<string, unknown>) => {
236
258
  const { SendSession } = await loadSessionRuntime()
237
259
  const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
238
260
  handleSignals(session)
239
- printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
261
+ printCommandAnnouncement("accept", session.room, displayPeerName(session.name, session.localId), !!options.json)
240
262
  const detachReporter = attachReporter(session, !!options.json)
241
263
  await session.connect()
242
- if (!options.json) console.log(`listening in ${session.room}`)
264
+ printReadyStatus(session.room, !!options.json)
243
265
  if (options.once) {
244
266
  for (;;) {
245
267
  const saved = session.snapshot().transfers.find(transfer => transfer.direction === "in" && transfer.savedAt > 0)
@@ -278,71 +300,96 @@ const defaultCliHandlers: CliHandlers = {
278
300
  tui: tuiCommand,
279
301
  }
280
302
 
303
+ const fileNamePart = (value: string) => value.replace(/^.*[\\/]/, "") || value
304
+ const HELP_NAME_COLOR = "\x1b[38;5;214m"
305
+ const HELP_NAME_RESET = "\x1b[0m"
306
+ const colorCliHelpName = (value: string) => `${HELP_NAME_COLOR}${value}${HELP_NAME_RESET}`
307
+ const cliHelpPlainName = () => process.env.SEND_NAME?.trim() || fileNamePart(Bun.main)
308
+ const cliHelpDisplayName = () => {
309
+ const name = cliHelpPlainName()
310
+ if (!process.stdout.isTTY) return name
311
+ return process.env.SEND_NAME_COLORED?.trim() || colorCliHelpName(name)
312
+ }
313
+
281
314
  export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
282
- const cli = cac("send")
315
+ const name = cliHelpDisplayName()
316
+ const cli = cac(name)
283
317
  cli.usage("[command] [options]")
284
318
 
285
- addOptions(cli.command("peers", "list discovered peers"), [
319
+ withTrailingHelpLine(addOptions(cli.command("peers", "list discovered peers"), [
286
320
  ...ROOM_SELF_OPTIONS,
287
321
  ["--wait <ms>", "discovery wait in milliseconds"],
288
322
  ["--json", "print a json snapshot"],
289
323
  SAVE_DIR_OPTION,
290
324
  ...TURN_OPTIONS,
291
- ]).action(handlers.peers)
325
+ ])).action(handlers.peers)
292
326
 
293
- addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
327
+ withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
294
328
  ...ROOM_SELF_OPTIONS,
295
- ["--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`"],
329
+ ["--to <peer>", "target `name`, `name-id`, or `-id`; `.` targets all ready peers by default"],
296
330
  ["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
297
331
  ["--json", "emit ndjson events"],
298
332
  SAVE_DIR_OPTION,
299
333
  ...TURN_OPTIONS,
300
- ]).action(handlers.offer)
334
+ ])).action(handlers.offer)
301
335
 
302
- addOptions(cli.command("accept", "receive and save files"), [
336
+ withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files"), [
303
337
  ...ROOM_SELF_OPTIONS,
304
338
  SAVE_DIR_OPTION,
339
+ OVERWRITE_OPTION,
305
340
  ["--once", "exit after the first saved incoming transfer"],
306
341
  ["--json", "emit ndjson events"],
307
342
  ...TURN_OPTIONS,
308
- ]).action(handlers.accept)
343
+ ])).action(handlers.accept)
309
344
 
310
- addOptions(cli.command("tui", "launch the interactive terminal UI"), [
345
+ withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI"), [
311
346
  ...ROOM_SELF_OPTIONS,
312
347
  ...TUI_TOGGLE_OPTIONS,
313
348
  ["--events", "show the event log pane"],
314
349
  SAVE_DIR_OPTION,
350
+ OVERWRITE_OPTION,
315
351
  ...TURN_OPTIONS,
316
- ]).action(handlers.tui)
352
+ ])).action(handlers.tui)
317
353
 
318
354
  cli.help(sections => {
319
355
  const usage = sections.find(section => section.title === "Usage:")
320
- if (usage) usage.body = " $ send [command] [options]"
356
+ if (usage) usage.body = ` $ ${name} [command] [options]`
321
357
  const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
322
358
  const defaultSection = {
323
359
  title: "Default",
324
- body: " send with no command launches the terminal UI (same as `send tui`).",
360
+ body: ` ${name} with no command launches the terminal UI (same as \`${name} tui\`).`,
325
361
  }
326
362
  if (moreInfoIndex < 0) sections.push(defaultSection)
327
363
  else sections.splice(moreInfoIndex, 0, defaultSection)
328
364
  })
365
+ withTrailingHelpLine(cli.globalCommand)
329
366
 
330
367
  return cli
331
368
  }
332
369
 
333
- const explicitCommand = (cli: CAC, argv: string[]) => {
334
- const command = argv[2]
335
- if (!command || command.startsWith("-")) return undefined
336
- if (cli.commands.some(entry => entry.isMatched(command))) return command
337
- throw new ExitError(`Unknown command \`${command}\``, 1)
370
+ const argvPrefix = (argv: string[]) => [argv[0] ?? process.argv[0] ?? "bun", argv[1] ?? process.argv[1] ?? cliHelpPlainName()]
371
+ const printSubcommandHelp = (argv: string[], handlers: CliHandlers, subcommand: string) =>
372
+ void createCli(handlers).parse([...argvPrefix(argv), subcommand, "--help"], { run: false })
373
+
374
+ const explicitCommand = (argv: string[], handlers: CliHandlers) => {
375
+ const cli = createCli(handlers)
376
+ cli.showHelpOnExit = false
377
+ cli.parse(argv, { run: false })
378
+ if (cli.matchedCommandName) return cli.matchedCommandName
379
+ if (cli.args[0]) throw new ExitError(`Unknown command \`${cli.args[0]}\``, 1)
380
+ return undefined
338
381
  }
339
382
 
340
383
  export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
341
384
  const cli = createCli(handlers)
342
- const command = explicitCommand(cli, argv)
385
+ const command = explicitCommand(argv, handlers)
343
386
  const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
344
387
  const helpRequested = !!parsed.options.help || !!parsed.options.h
345
- if (!command && !helpRequested) {
388
+ if (!command) {
389
+ if (helpRequested) {
390
+ printSubcommandHelp(argv, handlers, "tui")
391
+ return
392
+ }
346
393
  await handlers.tui(parsed.options)
347
394
  return
348
395
  }
package/src/tui/app.ts CHANGED
@@ -2,7 +2,20 @@ import { resolve } from "node:path"
2
2
  import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
3
3
  import { createNodeApp } from "@rezi-ui/node"
4
4
  import { inspectLocalFile } from "../core/files"
5
- import { isSessionAbortedError, SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
5
+ import {
6
+ DEFAULT_WEB_URL,
7
+ inviteCliCommand as formatInviteCliCommand,
8
+ inviteCliPackageName as formatInviteCliPackageName,
9
+ inviteCliText as formatInviteCliText,
10
+ inviteCopyUrl as formatInviteCopyUrl,
11
+ inviteWebLabel as formatInviteWebLabel,
12
+ renderCliCommand as renderSharedCliCommand,
13
+ renderWebUrl,
14
+ resolveWebUrlBase as resolveSharedWebUrlBase,
15
+ schemeLessUrlText,
16
+ webInviteUrl as formatWebInviteUrl,
17
+ } from "../core/invite"
18
+ import { isSessionAbortedError, SendSession, signalMetricState, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
6
19
  import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
7
20
  import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
8
21
  import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
@@ -72,6 +85,7 @@ export interface TuiState {
72
85
  autoOfferOutgoing: boolean
73
86
  autoAcceptIncoming: boolean
74
87
  autoSaveIncoming: boolean
88
+ overwriteIncoming: boolean
75
89
  hideTerminalPeers: boolean
76
90
  eventsExpanded: boolean
77
91
  offeringDrafts: boolean
@@ -86,6 +100,7 @@ export interface TuiActions {
86
100
  closeInviteDropdown: TuiAction
87
101
  copyWebInvite: TuiAction
88
102
  copyCliInvite: TuiAction
103
+ copyLogs: TuiAction
89
104
  jumpToRandomRoom: TuiAction
90
105
  commitRoom: TuiAction
91
106
  setRoomInput: (value: string) => void
@@ -128,14 +143,17 @@ const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
128
143
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
129
144
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
130
145
  const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
131
- const DEFAULT_WEB_URL = "https://rtme.sh/"
132
146
  const DEFAULT_SAVE_DIR = resolve(process.cwd())
133
147
  const ABOUT_ELEFUNC_URL = "https://rtme.sh/send"
134
148
  const ABOUT_TITLE = "About Send"
135
149
  const ABOUT_INTRO = "Peer-to-Peer Transfers – Web & CLI"
136
- const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
137
- const ABOUT_RUNTIME = "Send uses lightweight signaling to discover peers and negotiate WebRTC. Files move over WebRTC data channels, using direct paths when possible and TURN relay when needed."
138
- const COPY_SERVICE_URL = "https://copy.rt.ht/"
150
+ const ABOUT_BULLETS = [
151
+ "• Join a room, see who is there, and filter or select exactly which peers to target before offering files.",
152
+ "• File data does not travel through the signaling service; Send uses lightweight signaling to discover peers and negotiate WebRTC, then transfers directly peer-to-peer when possible, with TURN relay when needed.",
153
+ "• Incoming transfers can be auto-accepted and auto-saved, and same-name files can either stay as numbered copies or overwrite the original when that mode is enabled.",
154
+ "• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag.",
155
+ "• Other features include copyable web and CLI invites, rendered-peer filtering and selection, TURN sharing, and live connection insight like signaling state, RTT, data state, and path labels.",
156
+ ] as const
139
157
  const TRANSFER_DIRECTION_ARROW = {
140
158
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
141
159
  in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
@@ -154,8 +172,6 @@ export const isEditableFocusId = (focusedId: string | null) =>
154
172
  focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
155
173
  export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
156
174
 
157
- const hashBool = (value: boolean) => value ? "1" : "0"
158
- const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
159
175
  const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
160
176
  type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
161
177
  type ProcessSignalLike = {
@@ -169,63 +185,32 @@ type BunSpawn = (cmd: string[], options: {
169
185
  stderr?: "pipe" | "inherit" | "ignore"
170
186
  }) => { unref?: () => void }
171
187
  type BunLike = { spawn?: BunSpawn }
172
- type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
188
+ type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming" | "overwriteIncoming">
173
189
  type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
174
- const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
175
- const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
176
- const text = `${value ?? ""}`.trim()
177
- if (!text) return
178
- args.push(flag, shellQuote(text))
179
- }
180
- const SHARE_TOGGLE_FLAGS = [
181
- ["clean", "hideTerminalPeers", "--clean"],
182
- ["accept", "autoAcceptIncoming", "--accept"],
183
- ["offer", "autoOfferOutgoing", "--offer"],
184
- ["save", "autoSaveIncoming", "--save"],
185
- ] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
186
- const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
187
- for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
188
- }
189
- const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
190
- const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
191
- for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
192
- return params
193
- }
194
- const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
195
- const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
196
- if (!turnUrls.length) return []
197
- const args: string[] = []
198
- for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
199
- appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
200
- appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
201
- return args
202
- }
203
-
204
- export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
205
- const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
206
- try {
207
- return new URL(candidate).toString()
208
- } catch {
209
- return DEFAULT_WEB_URL
210
- }
211
- }
212
-
213
- const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
214
- const url = new URL(baseUrl)
215
- url.hash = buildHashParams(state, omitDefaults).toString()
216
- return url.toString()
217
- }
218
- const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
219
- export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
220
- export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
221
- export const inviteCliCommand = (state: ShareUrlState) => {
222
- const args: string[] = []
223
- appendCliFlag(args, "--room", state.snapshot.room)
224
- appendToggleCliFlags(args, state)
225
- return args.join(" ")
226
- }
227
- export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
228
- export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
190
+ const shareUrlOptions = (state: ShareUrlState) => ({
191
+ room: cleanRoom(state.snapshot.room),
192
+ clean: state.hideTerminalPeers,
193
+ accept: state.autoAcceptIncoming,
194
+ offer: state.autoOfferOutgoing,
195
+ save: state.autoSaveIncoming,
196
+ overwrite: state.overwriteIncoming,
197
+ })
198
+ const shareCliOptions = (state: ShareCliState) => ({
199
+ ...shareUrlOptions(state),
200
+ self: displayPeerName(state.snapshot.name, state.snapshot.localId),
201
+ events: state.eventsExpanded,
202
+ saveDir: state.snapshot.saveDir,
203
+ defaultSaveDir: DEFAULT_SAVE_DIR,
204
+ turnUrls: state.sessionSeed.turnUrls,
205
+ turnUsername: state.sessionSeed.turnUsername,
206
+ turnCredential: state.sessionSeed.turnCredential,
207
+ })
208
+ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => resolveSharedWebUrlBase(value)
209
+ export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteWebLabel(shareUrlOptions(state), baseUrl)
210
+ export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => formatInviteCliPackageName(baseUrl)
211
+ export const inviteCliCommand = (state: ShareUrlState) => formatInviteCliCommand(shareUrlOptions(state))
212
+ export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatInviteCliText(shareUrlOptions(state), baseUrl)
213
+ export const inviteCopyUrl = (text: string) => formatInviteCopyUrl(text)
229
214
  export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
230
215
  export const externalOpenCommand = (url: string, platform = process.platform) =>
231
216
  platform === "darwin" ? ["open", url]
@@ -236,30 +221,17 @@ const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
236
221
  return typeof marker === "function" ? marker as BackendRawWrite : null
237
222
  }
238
223
  const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
239
- const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
240
- const args = includePrefix ? ["bunx", "rtme.sh"] : []
241
- appendCliFlag(args, "--room", state.snapshot.room)
242
- if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
243
- appendToggleCliFlags(args, state)
244
- if (state.eventsExpanded) args.push("--events")
245
- if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
246
- args.push(...shareTurnCliArgs(state.sessionSeed))
247
- return args.join(" ")
248
- }
249
-
250
- export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
251
- return renderWebUrl(state, baseUrl)
252
- }
224
+ export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => formatWebInviteUrl(shareUrlOptions(state), baseUrl)
253
225
 
254
- export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
226
+ export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
255
227
 
256
- export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
228
+ export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => schemeLessUrlText(aboutWebUrl(state, baseUrl))
257
229
 
258
- export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
230
+ export const aboutCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state))
259
231
 
260
- export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
232
+ export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(shareUrlOptions(state), baseUrl)
261
233
 
262
- export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
234
+ export const resumeCliCommand = (state: ShareCliState) => renderSharedCliCommand(shareCliOptions(state), { includeSelf: true, includePrefix: true, packageName: "rtme.sh" })
263
235
 
264
236
  export const resumeOutputLines = (state: ShareCliState) => [
265
237
  "Rejoin with:",
@@ -304,6 +276,7 @@ export const createNoopTuiActions = (): TuiActions => ({
304
276
  closeInviteDropdown: noop,
305
277
  copyWebInvite: noop,
306
278
  copyCliInvite: noop,
279
+ copyLogs: noop,
307
280
  jumpToRandomRoom: noop,
308
281
  commitRoom: noop,
309
282
  setRoomInput: noop,
@@ -339,6 +312,15 @@ const shortText = (value: unknown, max = 88) => {
339
312
  const text = typeof value === "string" ? value : JSON.stringify(value)
340
313
  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
341
314
  }
315
+ const formatLogPayloadText = (payload: unknown) => {
316
+ if (typeof payload === "string") return payload
317
+ try {
318
+ return JSON.stringify(payload, null, 2) ?? `${payload}`
319
+ } catch {
320
+ return `${payload}`
321
+ }
322
+ }
323
+ export const formatLogsForCopy = (logs: readonly LogEntry[]) => logs.map(log => `${timeFormat.format(log.at)} ${log.kind}\n${formatLogPayloadText(log.payload)}`).join("\n\n")
342
324
  const toggleIntent = (active: boolean) => active ? "success" : "secondary"
343
325
  const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
344
326
  const peerConnectionStatusKind = (status: string) => ({
@@ -393,7 +375,7 @@ const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
393
375
  cancelling: "warning",
394
376
  "awaiting-done": "info",
395
377
  }[status] || "default") as BadgeVariant
396
- const statusToneVariant = (value: string): BadgeVariant => ({
378
+ export const statusToneVariant = (value: string): BadgeVariant => ({
397
379
  open: "success",
398
380
  connected: "success",
399
381
  complete: "success",
@@ -411,6 +393,7 @@ const statusToneVariant = (value: string): BadgeVariant => ({
411
393
  pending: "warning",
412
394
  cancelling: "warning",
413
395
  checking: "warning",
396
+ degraded: "warning",
414
397
  disconnected: "warning",
415
398
  left: "warning",
416
399
  rejected: "warning",
@@ -730,6 +713,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
730
713
  const sessionSeed = normalizeSessionSeed(initialConfig)
731
714
  const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
732
715
  const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
716
+ const overwriteIncoming = !!initialConfig.overwriteIncoming
733
717
  const peerSelectionByRoom = new Map<string, Map<string, boolean>>()
734
718
  const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming, roomPeerSelectionMemory(peerSelectionByRoom, sessionSeed.room))
735
719
  const focusState = deriveBootFocusState(sessionSeed.name)
@@ -753,6 +737,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
753
737
  autoOfferOutgoing: launchOptions.offer ?? true,
754
738
  autoAcceptIncoming,
755
739
  autoSaveIncoming,
740
+ overwriteIncoming,
756
741
  hideTerminalPeers: launchOptions.clean ?? true,
757
742
  eventsExpanded: showEvents,
758
743
  offeringDrafts: false,
@@ -854,8 +839,7 @@ const renderAboutModal = (_state: TuiState, actions: TuiActions) => {
854
839
  title: ABOUT_TITLE,
855
840
  content: ui.column({ gap: 1 }, [
856
841
  ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
857
- ui.text(ABOUT_SUMMARY, { id: "about-summary", wrap: true }),
858
- ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
842
+ ...ABOUT_BULLETS.map((line, index) => ui.text(line, { id: `about-bullet-${index + 1}`, wrap: true })),
859
843
  ]),
860
844
  actions: [
861
845
  ui.link({
@@ -957,8 +941,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
957
941
  ui.text(`-${state.snapshot.localId}`),
958
942
  ]),
959
943
  ui.row({ gap: 0, wrap: true }, [
960
- renderSelfMetric("Signaling", state.snapshot.socketState),
961
- renderSelfMetric("Pulse", state.snapshot.pulse.state),
944
+ renderSelfMetric("Signaling", signalMetricState(state.snapshot.socketState, state.snapshot.pulse)),
962
945
  renderSelfMetric("TURN", state.snapshot.turnState),
963
946
  ]),
964
947
  ui.column({ gap: 0 }, [
@@ -1000,10 +983,10 @@ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: T
1000
983
  ]),
1001
984
  ui.row({ gap: 0 }, [
1002
985
  renderPeerMetric("RTT", formatPeerRtt(peer.rttMs)),
1003
- renderPeerMetric("Data", peer.dataState, true),
986
+ renderPeerMetric("TURN", peer.turnState, true),
1004
987
  ]),
1005
988
  ui.row({ gap: 0 }, [
1006
- renderPeerMetric("TURN", peer.turnState, true),
989
+ renderPeerMetric("Data", peer.dataState, true),
1007
990
  renderPeerMetric("Path", peer.pathLabel || "—"),
1008
991
  ]),
1009
992
  ui.column({ gap: 0 }, [
@@ -1141,7 +1124,7 @@ const transferActionButtons = (transfer: TransferSnapshot, actions: TuiActions):
1141
1124
  return []
1142
1125
  }
1143
1126
 
1144
- const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12 }, [
1127
+ const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
1145
1128
  ui.column({ gap: 0 }, [
1146
1129
  ui.text(label, { variant: "caption" }),
1147
1130
  ui.text(value),
@@ -1267,8 +1250,8 @@ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection(
1267
1250
  id: "events-card",
1268
1251
  title: "Events",
1269
1252
  actions: [
1253
+ actionButton("copy-events", "Copy", actions.copyLogs, "secondary", !state.snapshot.logs.length),
1270
1254
  actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
1271
- actionButton("hide-events", "Hide", actions.toggleEvents),
1272
1255
  ],
1273
1256
  }, [
1274
1257
  ui.box({ maxHeight: 24, overflow: "scroll" }, [
@@ -1385,23 +1368,30 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1385
1368
  return false
1386
1369
  }
1387
1370
  }
1388
- const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
1389
- const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
1371
+ const copyTextPayload = async (payload: string, notices: { copied: string; opened: string; failed: string }, finish = (notice: Notice) => commit(current => withNotice(current, notice))) => {
1390
1372
  const rawWrite = getBackendRawWriter(app.backend)
1391
1373
  if (rawWrite && await ensureOsc52Support()) {
1392
1374
  const sequence = buildOsc52ClipboardSequence(payload)
1393
1375
  if (sequence) {
1394
1376
  try {
1395
1377
  rawWrite(sequence)
1396
- closeDropdown({ text: `Copied ${label} invite.`, variant: "success" })
1378
+ finish({ text: notices.copied, variant: "success" })
1397
1379
  return
1398
1380
  } catch {}
1399
1381
  }
1400
1382
  }
1401
1383
  const opened = openExternalUrl(inviteCopyUrl(payload))
1402
- closeDropdown(opened
1403
- ? { text: `Opened ${label} copy link.`, variant: "info" }
1404
- : { text: `Unable to copy ${label} invite.`, variant: "error" })
1384
+ finish(opened
1385
+ ? { text: notices.opened, variant: "info" }
1386
+ : { text: notices.failed, variant: "error" })
1387
+ }
1388
+ const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
1389
+ const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
1390
+ await copyTextPayload(payload, {
1391
+ copied: `Copied ${label} invite.`,
1392
+ opened: `Opened ${label} copy link.`,
1393
+ failed: `Unable to copy ${label} invite.`,
1394
+ }, closeDropdown)
1405
1395
  }
1406
1396
 
1407
1397
  const flushUpdate = () => {
@@ -1676,6 +1666,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1676
1666
  filePreview: resetFilePreview(),
1677
1667
  drafts: [],
1678
1668
  offeringDrafts: false,
1669
+ overwriteIncoming: !!nextSeed.overwriteIncoming,
1679
1670
  ...(options.reseedBootFocus
1680
1671
  ? deriveBootFocusState(nextSeed.name, current.focusRequestEpoch + 1)
1681
1672
  : {
@@ -1751,6 +1742,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1751
1742
  closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
1752
1743
  copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
1753
1744
  copyCliInvite: () => { void copyInvitePayload(inviteCliText(state), "CLI") },
1745
+ copyLogs: () => {
1746
+ const payload = formatLogsForCopy(state.snapshot.logs)
1747
+ if (!payload) return
1748
+ void copyTextPayload(payload, {
1749
+ copied: "Copied events.",
1750
+ opened: "Opened event copy link.",
1751
+ failed: "Unable to copy events.",
1752
+ })
1753
+ },
1754
1754
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1755
1755
  commitRoom,
1756
1756
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
@@ -1,5 +1,6 @@
1
1
  declare const Bun: {
2
2
  file(path: string): Blob & { type: string }
3
+ main: string
3
4
  write(path: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise<number>
4
5
  sleep(ms: number): Promise<void>
5
6
  }