@elefunc/send 0.1.5 → 0.1.7

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/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes
File without changes
File without changes
File without changes
package/src/core/files.ts CHANGED
File without changes
package/src/core/paths.ts CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- import type { RTCIceCandidateInit, RTCSessionDescriptionInit } from "werift"
1
+ import type { RTCIceCandidateInit, RTCIceServer, RTCSessionDescriptionInit } from "werift"
2
2
 
3
3
  export const SIGNAL_WS_URL = "wss://sig.efn.kr/ws"
4
4
  export const BASE_ICE_SERVERS = [
@@ -59,6 +59,7 @@ export interface SignalEnvelope {
59
59
  from: string
60
60
  to: string
61
61
  at: string
62
+ instanceId?: string
62
63
  }
63
64
 
64
65
  export interface HelloSignal extends SignalEnvelope {
@@ -68,6 +69,7 @@ export interface HelloSignal extends SignalEnvelope {
68
69
  profile?: PeerProfile
69
70
  rtcEpoch?: number
70
71
  reply?: boolean
72
+ recovery?: boolean
71
73
  }
72
74
 
73
75
  export interface NameSignal extends SignalEnvelope {
@@ -105,6 +107,11 @@ export interface CandidateSignal extends SignalEnvelope {
105
107
  candidate: RTCIceCandidateInit
106
108
  }
107
109
 
110
+ export interface TurnShareSignal extends SignalEnvelope {
111
+ kind: "turn-share"
112
+ iceServers: RTCIceServer[]
113
+ }
114
+
108
115
  export type SignalMessage =
109
116
  | HelloSignal
110
117
  | NameSignal
@@ -112,6 +119,7 @@ export type SignalMessage =
112
119
  | ByeSignal
113
120
  | DescriptionSignal
114
121
  | CandidateSignal
122
+ | TurnShareSignal
115
123
 
116
124
  export interface DataEnvelope {
117
125
  room: string
@@ -200,6 +208,7 @@ export const cleanText = (value: unknown, max = 72) => `${value ?? ""}`.trim().r
200
208
  export const cleanRoom = (value: unknown) => cleanText(value).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || uid(8)
201
209
  export const cleanName = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24) || fallbackName
202
210
  export const cleanLocalId = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24) || uid(8)
211
+ export const cleanInstanceId = (value: unknown) => cleanText(value, 24).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 24)
203
212
  export const signalEpoch = (value: unknown) => Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : 0
204
213
 
205
214
  export const buildCliProfile = (): PeerProfile => ({
@@ -11,6 +11,7 @@ import {
11
11
  SIGNAL_WS_URL,
12
12
  buildCliProfile,
13
13
  cleanText,
14
+ cleanInstanceId,
14
15
  cleanLocalId,
15
16
  cleanName,
16
17
  cleanRoom,
@@ -39,6 +40,7 @@ interface PeerState {
39
40
  name: string
40
41
  presence: Presence
41
42
  selected: boolean
43
+ remoteInstanceId: string
42
44
  polite: boolean
43
45
  pc: RTCPeerConnection | null
44
46
  dc: RTCDataChannel | null
@@ -258,6 +260,16 @@ export const turnUsageState = (
258
260
  return "idle"
259
261
  }
260
262
 
263
+ export class SessionAbortedError extends Error {
264
+ constructor(message = "session aborted") {
265
+ super(message)
266
+ this.name = "SessionAbortedError"
267
+ }
268
+ }
269
+
270
+ export const isSessionAbortedError = (error: unknown): error is SessionAbortedError =>
271
+ error instanceof SessionAbortedError || error instanceof Error && error.name === "SessionAbortedError"
272
+
261
273
  const timeoutSignal = (ms: number, base?: AbortSignal | null) => {
262
274
  const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
263
275
  if (!base) return timeout
@@ -317,7 +329,30 @@ const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivit
317
329
  && left.remoteCandidateType === right.remoteCandidateType
318
330
  && left.pathLabel === right.pathLabel
319
331
 
320
- const turnServers = (urls: string[], username?: string, credential?: string): RTCIceServer[] => [...new Set(urls.filter(Boolean))].map(urls => ({ urls, ...(username ? { username } : {}), ...(credential ? { credential } : {}) }))
332
+ const turnServerUrls = (server?: RTCIceServer | null) => (Array.isArray(server?.urls) ? server.urls : [server?.urls]).map(url => `${url ?? ""}`.trim()).filter(url => /^turns?:/i.test(url))
333
+ const normalizeTurnServer = (server?: RTCIceServer | null): RTCIceServer | null => {
334
+ const urls = [...new Set(turnServerUrls(server))]
335
+ if (!urls.length) return null
336
+ const username = `${server?.username ?? ""}`.trim()
337
+ const credential = `${server?.credential ?? ""}`.trim()
338
+ return {
339
+ urls: urls[0],
340
+ ...(username ? { username } : {}),
341
+ ...(credential ? { credential } : {}),
342
+ }
343
+ }
344
+ const turnServerKey = (server?: RTCIceServer | null) => {
345
+ const normalized = normalizeTurnServer(server)
346
+ return normalized ? JSON.stringify({
347
+ urls: turnServerUrls(normalized).sort(),
348
+ username: `${normalized.username ?? ""}`,
349
+ credential: `${normalized.credential ?? ""}`,
350
+ }) : ""
351
+ }
352
+ const turnServers = (urls: string[], username?: string, credential?: string): RTCIceServer[] =>
353
+ [...new Set(urls.map(url => `${url ?? ""}`.trim()).filter(url => /^turns?:/i.test(url)))]
354
+ .map(url => normalizeTurnServer({ urls: url, ...(username ? { username } : {}), ...(credential ? { credential } : {}) }))
355
+ .filter((server): server is RTCIceServer => !!server)
321
356
 
322
357
  const messageString = async (value: unknown) => {
323
358
  if (typeof value === "string") return value
@@ -328,6 +363,7 @@ const messageString = async (value: unknown) => {
328
363
  }
329
364
 
330
365
  export class SendSession {
366
+ readonly instanceId: string
331
367
  readonly localId: string
332
368
  profile = sanitizeProfile(buildCliProfile())
333
369
  readonly saveDir: string
@@ -340,7 +376,8 @@ export class SendSession {
340
376
  private autoAcceptIncoming: boolean
341
377
  private autoSaveIncoming: boolean
342
378
  private readonly reconnectSocket: boolean
343
- private readonly iceServers: RTCIceServer[]
379
+ private iceServers: RTCIceServer[]
380
+ private extraTurnServers: RTCIceServer[]
344
381
  private readonly peerSelectionMemory: Map<string, boolean>
345
382
  private readonly peers = new Map<string, PeerState>()
346
383
  private readonly transfers = new Map<string, TransferState>()
@@ -353,10 +390,12 @@ export class SendSession {
353
390
  private socketToken = 0
354
391
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
355
392
  private peerStatsTimer: ReturnType<typeof setInterval> | null = null
393
+ private readonly pendingRtcCloses = new Set<Promise<void>>()
356
394
  private lifecycleAbortController: AbortController | null = null
357
395
  private stopped = false
358
396
 
359
397
  constructor(config: SessionConfig) {
398
+ this.instanceId = cleanInstanceId(uid(10)) || uid(10)
360
399
  this.localId = cleanLocalId(config.localId)
361
400
  this.room = cleanRoom(config.room)
362
401
  this.name = cleanName(config.name ?? fallbackName)
@@ -365,9 +404,9 @@ export class SendSession {
365
404
  this.autoAcceptIncoming = !!config.autoAcceptIncoming
366
405
  this.autoSaveIncoming = !!config.autoSaveIncoming
367
406
  this.reconnectSocket = config.reconnectSocket ?? true
368
- const extraTurn = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
369
- this.iceServers = [...BASE_ICE_SERVERS, ...extraTurn]
370
- this.turnAvailable = extraTurn.length > 0
407
+ this.extraTurnServers = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
408
+ this.iceServers = [...BASE_ICE_SERVERS, ...this.extraTurnServers]
409
+ this.turnAvailable = this.extraTurnServers.length > 0
371
410
  }
372
411
 
373
412
  subscribe(listener: () => void) {
@@ -409,7 +448,7 @@ export class SendSession {
409
448
  void this.loadLocalProfile()
410
449
  void this.probePulse()
411
450
  this.connectSocket()
412
- await this.waitFor(() => this.socketState === "open", timeoutMs)
451
+ await this.waitFor(() => this.socketState === "open", timeoutMs, this.lifecycleAbortController?.signal)
413
452
  }
414
453
 
415
454
  async close() {
@@ -426,6 +465,7 @@ export class SendSession {
426
465
  if (socket) try { socket.close(1000, "normal") } catch {}
427
466
  for (const peer of this.peers.values()) this.destroyPeer(peer, "session-close")
428
467
  this.notify()
468
+ if (this.pendingRtcCloses.size) await Promise.allSettled([...this.pendingRtcCloses])
429
469
  }
430
470
 
431
471
  activePeers() {
@@ -440,6 +480,40 @@ export class SendSession {
440
480
  return this.readyPeers().filter(peer => peer.selected)
441
481
  }
442
482
 
483
+ canShareTurn() {
484
+ return this.extraTurnServers.length > 0
485
+ }
486
+
487
+ shareTurnWithPeer(peerId: string) {
488
+ const peer = this.peers.get(peerId)
489
+ if (!peer || peer.presence !== "active" || !this.extraTurnServers.length) return false
490
+ const sent = this.sendSignal({ kind: "turn-share", to: peer.id, iceServers: this.sharedTurnServers() })
491
+ if (sent) this.pushLog("turn:share-sent", { peer: peer.id, scope: "peer" }, "info")
492
+ return sent
493
+ }
494
+
495
+ shareTurnWithPeers(peerIds: string[]) {
496
+ if (!this.extraTurnServers.length) return 0
497
+ const iceServers = this.sharedTurnServers()
498
+ const sentPeers: string[] = []
499
+ for (const peerId of new Set(peerIds.filter(Boolean))) {
500
+ const peer = this.peers.get(peerId)
501
+ if (!peer || peer.presence !== "active") continue
502
+ if (!this.sendSignal({ kind: "turn-share", to: peer.id, iceServers })) continue
503
+ sentPeers.push(peer.id)
504
+ }
505
+ if (sentPeers.length) this.pushLog("turn:share-sent", { peers: sentPeers.length, scope: "filtered", peerIds: sentPeers }, "info")
506
+ return sentPeers.length
507
+ }
508
+
509
+ shareTurnWithAllPeers() {
510
+ const count = this.activePeers().length
511
+ if (!count || !this.extraTurnServers.length) return 0
512
+ const sent = this.sendSignal({ kind: "turn-share", to: "*", iceServers: this.sharedTurnServers() })
513
+ if (sent) this.pushLog("turn:share-sent", { peers: count, scope: "all" }, "info")
514
+ return sent ? count : 0
515
+ }
516
+
443
517
  setPeerSelected(peerId: string, selected: boolean) {
444
518
  const peer = this.peers.get(peerId)
445
519
  if (!peer) return false
@@ -458,6 +532,32 @@ export class SendSession {
458
532
  return peer ? this.setPeerSelected(peerId, !peer.selected) : false
459
533
  }
460
534
 
535
+ private sharedTurnServers() {
536
+ return this.extraTurnServers.map(server => normalizeTurnServer(server)).filter((server): server is RTCIceServer => !!server)
537
+ }
538
+
539
+ private refreshIceServers() {
540
+ this.turnAvailable = this.extraTurnServers.length > 0
541
+ this.iceServers = [...BASE_ICE_SERVERS, ...this.extraTurnServers]
542
+ }
543
+
544
+ private mergeTurnServers(iceServers: RTCIceServer[] = []) {
545
+ const known = new Set(this.extraTurnServers.map(turnServerKey).filter(Boolean))
546
+ const added: RTCIceServer[] = []
547
+ for (const server of iceServers) {
548
+ const normalized = normalizeTurnServer(server)
549
+ if (!normalized) continue
550
+ const key = turnServerKey(normalized)
551
+ if (!key || known.has(key)) continue
552
+ known.add(key)
553
+ added.push(normalized)
554
+ }
555
+ if (!added.length) return 0
556
+ this.extraTurnServers = [...this.extraTurnServers, ...added]
557
+ this.refreshIceServers()
558
+ return added.length
559
+ }
560
+
461
561
  clearLogs() {
462
562
  this.logs.length = 0
463
563
  this.notify()
@@ -653,19 +753,31 @@ export class SendSession {
653
753
  return this.transfers.get(transferId)
654
754
  }
655
755
 
656
- async waitFor(predicate: () => boolean, timeoutMs: number) {
756
+ async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
657
757
  if (predicate()) return
758
+ if (signal?.aborted) throw new SessionAbortedError()
658
759
  await new Promise<void>((resolveWait, rejectWait) => {
659
- const timeout = setTimeout(() => {
760
+ let unsubscribe = () => {}
761
+ const cleanup = () => {
762
+ clearTimeout(timeout)
763
+ signal?.removeEventListener("abort", onAbort)
660
764
  unsubscribe()
765
+ }
766
+ const onAbort = () => {
767
+ cleanup()
768
+ rejectWait(new SessionAbortedError())
769
+ }
770
+ const timeout = setTimeout(() => {
771
+ cleanup()
661
772
  rejectWait(new Error(`timed out after ${timeoutMs}ms`))
662
773
  }, timeoutMs)
663
- const unsubscribe = this.subscribe(() => {
774
+ unsubscribe = this.subscribe(() => {
664
775
  if (!predicate()) return
665
- clearTimeout(timeout)
666
- unsubscribe()
776
+ cleanup()
667
777
  resolveWait()
668
778
  })
779
+ signal?.addEventListener("abort", onAbort, { once: true })
780
+ if (signal?.aborted) onAbort()
669
781
  })
670
782
  }
671
783
 
@@ -933,7 +1045,7 @@ export class SendSession {
933
1045
 
934
1046
  private sendSignal(payload: { kind: string; to?: string; [key: string]: unknown }) {
935
1047
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
936
- const message = { room: this.room, from: this.localId, to: "*", at: stamp(), ...payload }
1048
+ const message = { room: this.room, from: this.localId, to: "*", at: stamp(), instanceId: this.instanceId, ...payload }
937
1049
  this.socket.send(JSON.stringify(message))
938
1050
  this.pushLog("signal:out", message)
939
1051
  return true
@@ -967,12 +1079,13 @@ export class SendSession {
967
1079
  return previous !== next
968
1080
  }
969
1081
 
970
- private buildPeer(remoteId: string) {
1082
+ private buildPeer(remoteId: string, remoteInstanceId = "") {
971
1083
  const peer: PeerState = {
972
1084
  id: remoteId,
973
1085
  name: fallbackName,
974
1086
  presence: "active",
975
1087
  selected: this.peerSelected(remoteId),
1088
+ remoteInstanceId: cleanInstanceId(remoteInstanceId),
976
1089
  polite: this.localId > remoteId,
977
1090
  pc: null,
978
1091
  dc: null,
@@ -996,16 +1109,42 @@ export class SendSession {
996
1109
  return peer
997
1110
  }
998
1111
 
999
- private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
1000
- const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
1112
+ private resetPeerInstance(peer: PeerState, remoteInstanceId: string) {
1113
+ peer.remoteInstanceId = remoteInstanceId
1114
+ peer.remoteEpoch = 0
1115
+ peer.selected = this.peerSelected(peer.id)
1116
+ peer.presence = "active"
1117
+ peer.terminalReason = ""
1118
+ peer.lastError = ""
1119
+ peer.turnAvailable = false
1120
+ peer.connectivity = emptyConnectivitySnapshot()
1121
+ this.failPeerTransfers(peer, "peer-restarted")
1122
+ this.closePeerRTC(peer)
1123
+ this.pushLog("peer:instance-replaced", { peer: peer.id, instanceId: remoteInstanceId })
1124
+ }
1125
+
1126
+ private acceptPeerInstance(peer: PeerState, remoteInstanceId: unknown, kind: string) {
1127
+ const nextInstanceId = cleanInstanceId(remoteInstanceId)
1128
+ if (!nextInstanceId) return true
1129
+ if (!peer.remoteInstanceId) {
1130
+ peer.remoteInstanceId = nextInstanceId
1131
+ return true
1132
+ }
1133
+ if (peer.remoteInstanceId === nextInstanceId) return true
1134
+ if (kind !== "hello") return false
1135
+ this.resetPeerInstance(peer, nextInstanceId)
1136
+ return true
1137
+ }
1138
+
1139
+ private syncPeerPresence(peer: PeerState, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
1001
1140
  const wasTerminal = peer.presence === "terminal"
1002
1141
  peer.lastSeenAt = Date.now()
1003
1142
  peer.presence = "active"
1004
1143
  peer.terminalReason = ""
1005
- if (wasTerminal) peer.selected = this.peerSelected(remoteId)
1144
+ if (wasTerminal) peer.selected = this.peerSelected(peer.id)
1006
1145
  if (name != null) {
1007
1146
  peer.name = cleanName(name)
1008
- for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
1147
+ for (const transfer of this.transfers.values()) if (transfer.peerId === peer.id) transfer.peerName = peer.name
1009
1148
  }
1010
1149
  if (typeof turnAvailable === "boolean") peer.turnAvailable = turnAvailable
1011
1150
  if (profile) peer.profile = sanitizeProfile(profile)
@@ -1013,14 +1152,21 @@ export class SendSession {
1013
1152
  return peer
1014
1153
  }
1015
1154
 
1016
- private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0) {
1155
+ private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0, recovery = false) {
1017
1156
  const epoch = signalEpoch(remoteEpoch)
1018
1157
  if (epoch && epoch < peer.remoteEpoch) return null
1019
1158
  if (epoch) peer.remoteEpoch = epoch
1020
- if (kind !== "bye" && (!peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
1159
+ if (kind !== "bye" && (recovery || !peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
1021
1160
  return peer
1022
1161
  }
1023
1162
 
1163
+ private restartPeerConnection(peer: PeerState, reason: string, announceRecovery = false) {
1164
+ this.ensurePeerConnection(peer, reason)
1165
+ if (announceRecovery) this.sendPeerHello(peer, { recovery: true })
1166
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1167
+ this.notify()
1168
+ }
1169
+
1024
1170
  private ensurePeerConnection(peer: PeerState, reason: string) {
1025
1171
  const epoch = this.nextRtcEpoch()
1026
1172
  peer.rtcEpoch = epoch
@@ -1110,13 +1256,34 @@ export class SendSession {
1110
1256
  channel.onmessage = ({ data }) => void this.onDataMessage(peer, data)
1111
1257
  }
1112
1258
 
1259
+ private trackRtcClose(closeTask: Promise<void> | null | undefined) {
1260
+ if (!closeTask) return
1261
+ const task = closeTask.catch(() => {}).finally(() => {
1262
+ this.pendingRtcCloses.delete(task)
1263
+ })
1264
+ this.pendingRtcCloses.add(task)
1265
+ }
1266
+
1113
1267
  private closePeerRTC(peer: PeerState) {
1114
1268
  const dc = peer.dc
1115
1269
  const pc = peer.pc
1116
1270
  peer.dc = null
1117
1271
  peer.pc = null
1272
+ if (dc) {
1273
+ ;(dc as any).onopen = null
1274
+ ;(dc as any).onclose = null
1275
+ ;(dc as any).onerror = null
1276
+ ;(dc as any).onmessage = null
1277
+ }
1278
+ if (pc) {
1279
+ ;(pc as any).onicecandidate = null
1280
+ ;(pc as any).ondatachannel = null
1281
+ ;(pc as any).onnegotiationneeded = null
1282
+ ;(pc as any).onconnectionstatechange = null
1283
+ ;(pc as any).oniceconnectionstatechange = null
1284
+ }
1118
1285
  try { dc?.close() } catch {}
1119
- void pc?.close().catch(() => {})
1286
+ this.trackRtcClose(pc ? Promise.resolve().then(() => pc.close()) : null)
1120
1287
  }
1121
1288
 
1122
1289
  private failPeerTransfers(peer: PeerState, reason: string) {
@@ -1136,29 +1303,45 @@ export class SendSession {
1136
1303
  const message = JSON.parse(raw) as SignalMessage
1137
1304
  if (message.room !== this.room || message.from === this.localId || (message.to && message.to !== "*" && message.to !== this.localId)) return
1138
1305
  this.pushLog("signal:in", message)
1306
+ const peer = this.peers.get(message.from) ?? (message.kind === "bye" ? null : this.buildPeer(message.from, message.instanceId))
1307
+ if (peer && !this.acceptPeerInstance(peer, message.instanceId, message.kind)) return
1139
1308
 
1140
1309
  if (message.kind === "hello") {
1141
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch)
1142
- if (peer && !message.reply) this.sendPeerHello(peer, { reply: true })
1310
+ if (!peer) return
1311
+ const synced = this.syncPeerSignal(this.syncPeerPresence(peer, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch, !!message.recovery)
1312
+ if (synced && !message.reply) this.sendPeerHello(synced, { reply: true })
1143
1313
  this.notify()
1144
1314
  return
1145
1315
  }
1146
1316
  if (message.kind === "name") {
1147
- this.syncPeerPresence(message.from, message.name)
1317
+ if (!peer) return
1318
+ this.syncPeerPresence(peer, message.name)
1148
1319
  this.notify()
1149
1320
  return
1150
1321
  }
1151
1322
  if (message.kind === "profile") {
1152
- this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
1323
+ if (!peer) return
1324
+ this.syncPeerSignal(this.syncPeerPresence(peer, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
1153
1325
  this.notify()
1154
1326
  return
1155
1327
  }
1156
1328
  if (message.kind === "bye") {
1157
- const peer = this.peers.get(message.from)
1158
1329
  if (peer) this.destroyPeer(peer, "peer-left")
1159
1330
  this.notify()
1160
1331
  return
1161
1332
  }
1333
+ if (message.kind === "turn-share") {
1334
+ if (!peer) return
1335
+ this.syncPeerPresence(peer)
1336
+ const added = this.mergeTurnServers(message.iceServers)
1337
+ if (added) {
1338
+ this.pushLog("turn:share-applied", { peer: peer.id, added }, "info")
1339
+ this.broadcastProfile()
1340
+ this.restartPeerConnection(peer, "turn-share", true)
1341
+ }
1342
+ this.notify()
1343
+ return
1344
+ }
1162
1345
  if (message.kind === "description") {
1163
1346
  await this.onDescriptionSignal(message)
1164
1347
  return
@@ -1170,7 +1353,9 @@ export class SendSession {
1170
1353
  }
1171
1354
 
1172
1355
  private async onDescriptionSignal(message: DescriptionSignal) {
1173
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
1356
+ const existing = this.peers.get(message.from) ?? this.buildPeer(message.from, message.instanceId)
1357
+ if (!this.acceptPeerInstance(existing, message.instanceId, message.kind)) return
1358
+ const peer = this.syncPeerSignal(this.syncPeerPresence(existing, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
1174
1359
  if (!peer?.pc) return
1175
1360
  const offerCollision = message.description.type === "offer" && !peer.makingOffer && peer.pc.signalingState !== "stable"
1176
1361
  if (!peer.polite && offerCollision) return
@@ -1190,7 +1375,9 @@ export class SendSession {
1190
1375
  }
1191
1376
 
1192
1377
  private async onCandidateSignal(message: CandidateSignal) {
1193
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
1378
+ const existing = this.peers.get(message.from) ?? this.buildPeer(message.from, message.instanceId)
1379
+ if (!this.acceptPeerInstance(existing, message.instanceId, message.kind)) return
1380
+ const peer = this.syncPeerSignal(this.syncPeerPresence(existing, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
1194
1381
  if (!peer?.pc) return
1195
1382
  try {
1196
1383
  await peer.pc.addIceCandidate(message.candidate as RTCIceCandidateInit)
File without changes
package/src/tui/app.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
2
2
  import { createNodeApp } from "@rezi-ui/node"
3
3
  import { inspectLocalFile } from "../core/files"
4
- import { SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
4
+ import { isSessionAbortedError, SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
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"
@@ -45,6 +45,7 @@ export interface TuiState {
45
45
  sessionSeed: SessionSeed
46
46
  peerSelectionByRoom: Map<string, Map<string, boolean>>
47
47
  snapshot: SessionSnapshot
48
+ peerSearch: string
48
49
  focusedId: string | null
49
50
  roomInput: string
50
51
  nameInput: string
@@ -72,10 +73,13 @@ export interface TuiActions {
72
73
  jumpToNewSelf: TuiAction
73
74
  commitName: TuiAction
74
75
  setNameInput: (value: string) => void
76
+ setPeerSearch: (value: string) => void
75
77
  toggleSelectReadyPeers: TuiAction
76
78
  clearPeerSelection: TuiAction
77
79
  toggleHideTerminalPeers: TuiAction
78
80
  togglePeer: (peerId: string) => void
81
+ shareTurnWithPeer: (peerId: string) => void
82
+ shareTurnWithAllPeers: TuiAction
79
83
  toggleAutoOffer: TuiAction
80
84
  toggleAutoAccept: TuiAction
81
85
  toggleAutoSave: TuiAction
@@ -95,9 +99,12 @@ export interface TuiActions {
95
99
 
96
100
  const ROOM_INPUT_ID = "room-input"
97
101
  const NAME_INPUT_ID = "name-input"
102
+ const PEER_SEARCH_INPUT_ID = "peer-search-input"
98
103
  const DRAFT_INPUT_ID = "draft-input"
99
104
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
100
105
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
106
+ const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
107
+ const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
101
108
  const DEFAULT_WEB_URL = "https://send.rt.ht/"
102
109
  const TRANSFER_DIRECTION_ARROW = {
103
110
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
@@ -147,10 +154,13 @@ export const createNoopTuiActions = (): TuiActions => ({
147
154
  jumpToNewSelf: noop,
148
155
  commitName: noop,
149
156
  setNameInput: noop,
157
+ setPeerSearch: noop,
150
158
  toggleSelectReadyPeers: noop,
151
159
  clearPeerSelection: noop,
152
160
  toggleHideTerminalPeers: noop,
153
161
  togglePeer: noop,
162
+ shareTurnWithPeer: noop,
163
+ shareTurnWithAllPeers: noop,
154
164
  toggleAutoOffer: noop,
155
165
  toggleAutoAccept: noop,
156
166
  toggleAutoSave: noop,
@@ -200,6 +210,16 @@ const transferStatusKind = (status: TransferSnapshot["status"]) => ({
200
210
  error: "offline",
201
211
  }[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
202
212
  const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
213
+ const peerSearchNeedle = (value: string) => `${value ?? ""}`.trim().toLowerCase()
214
+ const peerMatchesSearch = (peer: PeerSnapshot, search: string) => !search || peer.displayName.toLowerCase().includes(search)
215
+ export const renderedPeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean, search: string) => {
216
+ const needle = peerSearchNeedle(search)
217
+ return visiblePeers(peers, hideTerminalPeers)
218
+ .filter(peer => peerMatchesSearch(peer, needle))
219
+ .sort((left, right) => left.id.localeCompare(right.id))
220
+ }
221
+ export const renderedReadySelectedPeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean, search: string) =>
222
+ renderedPeers(peers, hideTerminalPeers, search).filter(peer => peer.selected && peer.ready)
203
223
  const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
204
224
  const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
205
225
  const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
@@ -473,6 +493,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
473
493
  sessionSeed,
474
494
  peerSelectionByRoom,
475
495
  snapshot: session.snapshot(),
496
+ peerSearch: "",
476
497
  focusedId: null,
477
498
  roomInput: sessionSeed.room,
478
499
  nameInput: visibleNameInput(sessionSeed.name),
@@ -519,6 +540,32 @@ const ghostButton = (id: string, label: string, onPress?: TuiAction, options: {
519
540
  dsVariant: "ghost",
520
541
  })
521
542
 
543
+ const TEXT_BUTTON_FOCUS_CONFIG = { indicator: "none", showHint: false } as const
544
+
545
+ const textButton = (id: string, label: string, onPress?: TuiAction, options: { focusable?: boolean; accessibleLabel?: string } = {}) => ui.button({
546
+ id,
547
+ label,
548
+ ...(onPress === undefined ? {} : { onPress }),
549
+ ...(options.focusable === undefined ? {} : { focusable: options.focusable }),
550
+ ...(options.accessibleLabel === undefined ? {} : { accessibleLabel: options.accessibleLabel }),
551
+ px: 0,
552
+ dsVariant: "ghost",
553
+ style: PRIMARY_TEXT_STYLE,
554
+ focusConfig: TEXT_BUTTON_FOCUS_CONFIG,
555
+ })
556
+
557
+ const headingTextButton = (id: string, label: string, onPress?: TuiAction, options: { focusable?: boolean; accessibleLabel?: string } = {}) => ui.button({
558
+ id,
559
+ label,
560
+ ...(onPress === undefined ? {} : { onPress }),
561
+ ...(options.focusable === undefined ? {} : { focusable: options.focusable }),
562
+ ...(options.accessibleLabel === undefined ? {} : { accessibleLabel: options.accessibleLabel }),
563
+ px: 0,
564
+ dsVariant: "ghost",
565
+ style: HEADING_TEXT_STYLE,
566
+ focusConfig: TEXT_BUTTON_FOCUS_CONFIG,
567
+ })
568
+
522
569
  const denseSection = (options: DenseSectionOptions, children: readonly DenseSectionChild[]) => ui.box({
523
570
  ...(options.id === undefined ? {} : { id: options.id }),
524
571
  ...(options.key === undefined ? {} : { key: options.key }),
@@ -620,7 +667,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
620
667
  ]),
621
668
  ])
622
669
 
623
- const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection({
670
+ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: TuiActions) => denseSection({
624
671
  id: `peer-row-${peer.id}`,
625
672
  key: peer.id,
626
673
  }, [
@@ -639,9 +686,9 @@ const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection(
639
686
  }),
640
687
  ]),
641
688
  ui.box({ id: `peer-name-slot-${peer.id}`, flex: 1, minWidth: 0, border: "none" }, [
642
- ui.text(peer.displayName, {
643
- id: `peer-name-text-${peer.id}`,
644
- textOverflow: "ellipsis",
689
+ textButton(`peer-share-turn-${peer.id}`, peer.displayName, turnShareEnabled && peer.presence === "active" ? () => actions.shareTurnWithPeer(peer.id) : undefined, {
690
+ focusable: turnShareEnabled && peer.presence === "active",
691
+ accessibleLabel: `share TURN with ${peer.displayName}`,
645
692
  }),
646
693
  ]),
647
694
  ui.row({ id: `peer-status-cluster-${peer.id}`, gap: 1, items: "center" }, [
@@ -668,12 +715,19 @@ const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection(
668
715
  ])
669
716
 
670
717
  const renderPeersCard = (state: TuiState, actions: TuiActions) => {
671
- const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
672
- const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
673
- const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
718
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
719
+ const activeCount = peers.filter(peer => peer.presence === "active").length
720
+ const selectedCount = peers.filter(peer => peer.selectable && peer.selected).length
721
+ const canShareTurn = state.session.canShareTurn()
674
722
  return denseSection({
675
723
  id: "peers-card",
676
- title: `Peers ${selectedCount}/${activeCount}`,
724
+ titleNode: ui.row({ id: "peers-title-row", gap: 1, items: "center" }, [
725
+ headingTextButton("share-turn-all-peers", "Peers", canShareTurn && !!activeCount ? actions.shareTurnWithAllPeers : undefined, {
726
+ focusable: canShareTurn && !!activeCount,
727
+ accessibleLabel: "share TURN with matching active peers",
728
+ }),
729
+ ui.text(`${selectedCount}/${peers.length}`, { id: "peers-count-text", variant: "heading" }),
730
+ ]),
677
731
  flex: 1,
678
732
  actions: [
679
733
  actionButton("select-ready-peers", "All", actions.toggleSelectReadyPeers),
@@ -681,10 +735,16 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
681
735
  toggleButton("toggle-clean-peers", "Clean", state.hideTerminalPeers, actions.toggleHideTerminalPeers),
682
736
  ],
683
737
  }, [
738
+ ui.input({
739
+ id: PEER_SEARCH_INPUT_ID,
740
+ value: state.peerSearch,
741
+ placeholder: "filter",
742
+ onInput: value => actions.setPeerSearch(value),
743
+ }),
684
744
  ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
685
745
  peers.length
686
- ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, actions)))
687
- : ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
746
+ ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, canShareTurn, actions)))
747
+ : ui.empty(state.snapshot.peers.length ? "No peers match current filters." : `Waiting for peers in ${state.snapshot.room}...`),
688
748
  ]),
689
749
  ])
690
750
  }
@@ -729,6 +789,7 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
729
789
  offsetFileSearchMatchIndices(displayPrefix, match.indices),
730
790
  { id: `file-preview-path-${index}`, flex: 1 },
731
791
  ),
792
+ match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
732
793
  match.kind === "directory" ? tightTag("dir", { variant: "info", bare: true }) : null,
733
794
  ])
734
795
 
@@ -1025,7 +1086,11 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1025
1086
  const requestStop = () => {
1026
1087
  if (stopping) return
1027
1088
  stopping = true
1028
- void app.stop()
1089
+ try {
1090
+ process.kill(process.pid, "SIGINT")
1091
+ } catch {
1092
+ void app.stop()
1093
+ }
1029
1094
  }
1030
1095
 
1031
1096
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1190,11 +1255,12 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1190
1255
 
1191
1256
  const maybeOfferDrafts = () => {
1192
1257
  if (!state.autoOfferOutgoing || !state.drafts.length || state.offeringDrafts) return
1193
- if (!state.snapshot.peers.some(peer => peer.presence === "active" && peer.ready && peer.selected)) return
1258
+ const targetPeerIds = renderedReadySelectedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch).map(peer => peer.id)
1259
+ if (!targetPeerIds.length) return
1194
1260
  const session = state.session
1195
1261
  const pendingDrafts = [...state.drafts]
1196
1262
  commit(current => ({ ...current, offeringDrafts: true }))
1197
- void session.offerToSelectedPeers(pendingDrafts.map(draft => draft.path)).then(
1263
+ void session.queueFiles(pendingDrafts.map(draft => draft.path), targetPeerIds).then(
1198
1264
  ids => {
1199
1265
  if (state.session !== session) return
1200
1266
  const offeredIds = new Set(pendingDrafts.map(draft => draft.id))
@@ -1219,7 +1285,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1219
1285
  })
1220
1286
  commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
1221
1287
  void session.connect().catch(error => {
1222
- if (state.session !== session) return
1288
+ if (state.session !== session || stopping || cleanedUp || isSessionAbortedError(error)) return
1223
1289
  commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
1224
1290
  })
1225
1291
  }
@@ -1234,6 +1300,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1234
1300
  sessionSeed: nextSeed,
1235
1301
  peerSelectionByRoom: current.peerSelectionByRoom,
1236
1302
  snapshot: nextSession.snapshot(),
1303
+ peerSearch: "",
1237
1304
  roomInput: nextSeed.room,
1238
1305
  nameInput: visibleNameInput(nextSeed.name),
1239
1306
  draftInput: "",
@@ -1307,22 +1374,51 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1307
1374
  jumpToNewSelf: () => replaceSession({ ...state.sessionSeed, localId: cleanLocalId(uid(8)) }, "Started a fresh self ID.", { reseedBootFocus: true }),
1308
1375
  commitName,
1309
1376
  setNameInput: value => commit(current => ({ ...current, nameInput: value })),
1377
+ setPeerSearch: value => commit(current => ({ ...current, peerSearch: value })),
1310
1378
  toggleSelectReadyPeers: () => {
1379
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1311
1380
  let changed = 0
1312
- for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
1313
- commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
1381
+ for (const peer of peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
1382
+ commit(current => withNotice(current, { text: changed ? "Selected matching ready peers." : "No matching ready peers to select.", variant: changed ? "success" : "info" }))
1314
1383
  maybeOfferDrafts()
1315
1384
  },
1316
1385
  clearPeerSelection: () => {
1386
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1317
1387
  let changed = 0
1318
- for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
1319
- commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "peer selection")}.` : "No peer selections to clear.", variant: changed ? "warning" : "info" }))
1388
+ for (const peer of peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
1389
+ commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "matching peer selection")}.` : "No matching peer selections to clear.", variant: changed ? "warning" : "info" }))
1320
1390
  },
1321
1391
  toggleHideTerminalPeers: () => commit(current => withNotice({ ...current, hideTerminalPeers: !current.hideTerminalPeers }, { text: current.hideTerminalPeers ? "Terminal peers shown." : "Terminal peers hidden.", variant: "info" })),
1322
1392
  togglePeer: peerId => {
1323
1393
  state.session.togglePeerSelection(peerId)
1324
1394
  maybeOfferDrafts()
1325
1395
  },
1396
+ shareTurnWithPeer: peerId => {
1397
+ const peer = state.snapshot.peers.find(item => item.id === peerId)
1398
+ const sent = state.session.shareTurnWithPeer(peerId)
1399
+ commit(current => withNotice(current, {
1400
+ text: !state.session.canShareTurn()
1401
+ ? "TURN is not configured."
1402
+ : sent
1403
+ ? `Shared TURN with ${peer?.displayName ?? peerId}.`
1404
+ : `Unable to share TURN with ${peer?.displayName ?? peerId}.`,
1405
+ variant: !state.session.canShareTurn() ? "info" : sent ? "success" : "warning",
1406
+ }))
1407
+ },
1408
+ shareTurnWithAllPeers: () => {
1409
+ const targetPeerIds = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1410
+ .filter(peer => peer.presence === "active")
1411
+ .map(peer => peer.id)
1412
+ const shared = state.session.shareTurnWithPeers(targetPeerIds)
1413
+ commit(current => withNotice(current, {
1414
+ text: !state.session.canShareTurn()
1415
+ ? "TURN is not configured."
1416
+ : shared
1417
+ ? `Shared TURN with ${plural(shared, "matching peer")}.`
1418
+ : "No matching active peers to share TURN with.",
1419
+ variant: !state.session.canShareTurn() ? "info" : shared ? "success" : "info",
1420
+ }))
1421
+ },
1326
1422
  toggleAutoOffer: () => {
1327
1423
  commit(current => withNotice({ ...current, autoOfferOutgoing: !current.autoOfferOutgoing }, { text: !state.autoOfferOutgoing ? "Auto-offer on." : "Auto-offer off.", variant: !state.autoOfferOutgoing ? "success" : "warning" }))
1328
1424
  maybeOfferDrafts()
@@ -1500,17 +1596,14 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1500
1596
  await state.session.close()
1501
1597
  }
1502
1598
 
1503
- const onSignal = () => requestStop()
1504
- process.once("SIGINT", onSignal)
1505
- process.once("SIGTERM", onSignal)
1506
-
1507
1599
  try {
1508
1600
  bindSession(state.session)
1509
1601
  await app.run()
1510
1602
  } finally {
1511
- process.off("SIGINT", onSignal)
1512
- process.off("SIGTERM", onSignal)
1513
- await stop()
1514
- app.dispose()
1603
+ try {
1604
+ await stop()
1605
+ } finally {
1606
+ app.dispose()
1607
+ }
1515
1608
  }
1516
1609
  }
@@ -3,6 +3,7 @@ export interface FileSearchMatch {
3
3
  absolutePath: string
4
4
  fileName: string
5
5
  kind: "file" | "directory"
6
+ size?: number
6
7
  score: number
7
8
  indices: number[]
8
9
  }
@@ -8,6 +8,7 @@ export interface IndexedEntry {
8
8
  relativePath: string
9
9
  fileName: string
10
10
  kind: "file" | "directory"
11
+ size?: number
11
12
  }
12
13
 
13
14
  export interface FileSearchScope {
@@ -187,6 +188,7 @@ const browseEntries = (entries: readonly IndexedEntry[], resultLimit: number) =>
187
188
  absolutePath: entry.absolutePath,
188
189
  fileName: entry.fileName,
189
190
  kind: entry.kind,
191
+ size: entry.size,
190
192
  score: 0,
191
193
  indices: [],
192
194
  } satisfies FileSearchMatch))
@@ -203,6 +205,7 @@ export const searchEntries = (entries: readonly IndexedEntry[], query: string, r
203
205
  absolutePath: entry.absolutePath,
204
206
  fileName: entry.fileName,
205
207
  kind: entry.kind,
208
+ size: entry.size,
206
209
  score: match.score,
207
210
  indices: match.indices,
208
211
  })
@@ -272,6 +275,7 @@ export const crawlWorkspaceEntries = async (workspaceRoot: string, onEntry: (ent
272
275
  relativePath: normalizeRelativePath(relative(root, absolutePath)),
273
276
  fileName: child.name,
274
277
  kind: "file",
278
+ size: info.size,
275
279
  })
276
280
  }
277
281
  } catch {
File without changes
File without changes
File without changes