@elefunc/send 0.1.5 → 0.1.6

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.6",
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,26 @@ 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
+ shareTurnWithAllPeers() {
496
+ const count = this.activePeers().length
497
+ if (!count || !this.extraTurnServers.length) return 0
498
+ const sent = this.sendSignal({ kind: "turn-share", to: "*", iceServers: this.sharedTurnServers() })
499
+ if (sent) this.pushLog("turn:share-sent", { peers: count, scope: "all" }, "info")
500
+ return sent ? count : 0
501
+ }
502
+
443
503
  setPeerSelected(peerId: string, selected: boolean) {
444
504
  const peer = this.peers.get(peerId)
445
505
  if (!peer) return false
@@ -458,6 +518,32 @@ export class SendSession {
458
518
  return peer ? this.setPeerSelected(peerId, !peer.selected) : false
459
519
  }
460
520
 
521
+ private sharedTurnServers() {
522
+ return this.extraTurnServers.map(server => normalizeTurnServer(server)).filter((server): server is RTCIceServer => !!server)
523
+ }
524
+
525
+ private refreshIceServers() {
526
+ this.turnAvailable = this.extraTurnServers.length > 0
527
+ this.iceServers = [...BASE_ICE_SERVERS, ...this.extraTurnServers]
528
+ }
529
+
530
+ private mergeTurnServers(iceServers: RTCIceServer[] = []) {
531
+ const known = new Set(this.extraTurnServers.map(turnServerKey).filter(Boolean))
532
+ const added: RTCIceServer[] = []
533
+ for (const server of iceServers) {
534
+ const normalized = normalizeTurnServer(server)
535
+ if (!normalized) continue
536
+ const key = turnServerKey(normalized)
537
+ if (!key || known.has(key)) continue
538
+ known.add(key)
539
+ added.push(normalized)
540
+ }
541
+ if (!added.length) return 0
542
+ this.extraTurnServers = [...this.extraTurnServers, ...added]
543
+ this.refreshIceServers()
544
+ return added.length
545
+ }
546
+
461
547
  clearLogs() {
462
548
  this.logs.length = 0
463
549
  this.notify()
@@ -653,19 +739,31 @@ export class SendSession {
653
739
  return this.transfers.get(transferId)
654
740
  }
655
741
 
656
- async waitFor(predicate: () => boolean, timeoutMs: number) {
742
+ async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
657
743
  if (predicate()) return
744
+ if (signal?.aborted) throw new SessionAbortedError()
658
745
  await new Promise<void>((resolveWait, rejectWait) => {
659
- const timeout = setTimeout(() => {
746
+ let unsubscribe = () => {}
747
+ const cleanup = () => {
748
+ clearTimeout(timeout)
749
+ signal?.removeEventListener("abort", onAbort)
660
750
  unsubscribe()
751
+ }
752
+ const onAbort = () => {
753
+ cleanup()
754
+ rejectWait(new SessionAbortedError())
755
+ }
756
+ const timeout = setTimeout(() => {
757
+ cleanup()
661
758
  rejectWait(new Error(`timed out after ${timeoutMs}ms`))
662
759
  }, timeoutMs)
663
- const unsubscribe = this.subscribe(() => {
760
+ unsubscribe = this.subscribe(() => {
664
761
  if (!predicate()) return
665
- clearTimeout(timeout)
666
- unsubscribe()
762
+ cleanup()
667
763
  resolveWait()
668
764
  })
765
+ signal?.addEventListener("abort", onAbort, { once: true })
766
+ if (signal?.aborted) onAbort()
669
767
  })
670
768
  }
671
769
 
@@ -933,7 +1031,7 @@ export class SendSession {
933
1031
 
934
1032
  private sendSignal(payload: { kind: string; to?: string; [key: string]: unknown }) {
935
1033
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
936
- const message = { room: this.room, from: this.localId, to: "*", at: stamp(), ...payload }
1034
+ const message = { room: this.room, from: this.localId, to: "*", at: stamp(), instanceId: this.instanceId, ...payload }
937
1035
  this.socket.send(JSON.stringify(message))
938
1036
  this.pushLog("signal:out", message)
939
1037
  return true
@@ -967,12 +1065,13 @@ export class SendSession {
967
1065
  return previous !== next
968
1066
  }
969
1067
 
970
- private buildPeer(remoteId: string) {
1068
+ private buildPeer(remoteId: string, remoteInstanceId = "") {
971
1069
  const peer: PeerState = {
972
1070
  id: remoteId,
973
1071
  name: fallbackName,
974
1072
  presence: "active",
975
1073
  selected: this.peerSelected(remoteId),
1074
+ remoteInstanceId: cleanInstanceId(remoteInstanceId),
976
1075
  polite: this.localId > remoteId,
977
1076
  pc: null,
978
1077
  dc: null,
@@ -996,16 +1095,42 @@ export class SendSession {
996
1095
  return peer
997
1096
  }
998
1097
 
999
- private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
1000
- const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
1098
+ private resetPeerInstance(peer: PeerState, remoteInstanceId: string) {
1099
+ peer.remoteInstanceId = remoteInstanceId
1100
+ peer.remoteEpoch = 0
1101
+ peer.selected = this.peerSelected(peer.id)
1102
+ peer.presence = "active"
1103
+ peer.terminalReason = ""
1104
+ peer.lastError = ""
1105
+ peer.turnAvailable = false
1106
+ peer.connectivity = emptyConnectivitySnapshot()
1107
+ this.failPeerTransfers(peer, "peer-restarted")
1108
+ this.closePeerRTC(peer)
1109
+ this.pushLog("peer:instance-replaced", { peer: peer.id, instanceId: remoteInstanceId })
1110
+ }
1111
+
1112
+ private acceptPeerInstance(peer: PeerState, remoteInstanceId: unknown, kind: string) {
1113
+ const nextInstanceId = cleanInstanceId(remoteInstanceId)
1114
+ if (!nextInstanceId) return true
1115
+ if (!peer.remoteInstanceId) {
1116
+ peer.remoteInstanceId = nextInstanceId
1117
+ return true
1118
+ }
1119
+ if (peer.remoteInstanceId === nextInstanceId) return true
1120
+ if (kind !== "hello") return false
1121
+ this.resetPeerInstance(peer, nextInstanceId)
1122
+ return true
1123
+ }
1124
+
1125
+ private syncPeerPresence(peer: PeerState, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
1001
1126
  const wasTerminal = peer.presence === "terminal"
1002
1127
  peer.lastSeenAt = Date.now()
1003
1128
  peer.presence = "active"
1004
1129
  peer.terminalReason = ""
1005
- if (wasTerminal) peer.selected = this.peerSelected(remoteId)
1130
+ if (wasTerminal) peer.selected = this.peerSelected(peer.id)
1006
1131
  if (name != null) {
1007
1132
  peer.name = cleanName(name)
1008
- for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
1133
+ for (const transfer of this.transfers.values()) if (transfer.peerId === peer.id) transfer.peerName = peer.name
1009
1134
  }
1010
1135
  if (typeof turnAvailable === "boolean") peer.turnAvailable = turnAvailable
1011
1136
  if (profile) peer.profile = sanitizeProfile(profile)
@@ -1013,14 +1138,21 @@ export class SendSession {
1013
1138
  return peer
1014
1139
  }
1015
1140
 
1016
- private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0) {
1141
+ private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0, recovery = false) {
1017
1142
  const epoch = signalEpoch(remoteEpoch)
1018
1143
  if (epoch && epoch < peer.remoteEpoch) return null
1019
1144
  if (epoch) peer.remoteEpoch = epoch
1020
- if (kind !== "bye" && (!peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
1145
+ if (kind !== "bye" && (recovery || !peer.pc || peer.pc.connectionState === "closed" || peer.dc?.readyState === "closed")) this.ensurePeerConnection(peer, `signal:${kind}`)
1021
1146
  return peer
1022
1147
  }
1023
1148
 
1149
+ private restartPeerConnection(peer: PeerState, reason: string, announceRecovery = false) {
1150
+ this.ensurePeerConnection(peer, reason)
1151
+ if (announceRecovery) this.sendPeerHello(peer, { recovery: true })
1152
+ this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
1153
+ this.notify()
1154
+ }
1155
+
1024
1156
  private ensurePeerConnection(peer: PeerState, reason: string) {
1025
1157
  const epoch = this.nextRtcEpoch()
1026
1158
  peer.rtcEpoch = epoch
@@ -1110,13 +1242,34 @@ export class SendSession {
1110
1242
  channel.onmessage = ({ data }) => void this.onDataMessage(peer, data)
1111
1243
  }
1112
1244
 
1245
+ private trackRtcClose(closeTask: Promise<void> | null | undefined) {
1246
+ if (!closeTask) return
1247
+ const task = closeTask.catch(() => {}).finally(() => {
1248
+ this.pendingRtcCloses.delete(task)
1249
+ })
1250
+ this.pendingRtcCloses.add(task)
1251
+ }
1252
+
1113
1253
  private closePeerRTC(peer: PeerState) {
1114
1254
  const dc = peer.dc
1115
1255
  const pc = peer.pc
1116
1256
  peer.dc = null
1117
1257
  peer.pc = null
1258
+ if (dc) {
1259
+ ;(dc as any).onopen = null
1260
+ ;(dc as any).onclose = null
1261
+ ;(dc as any).onerror = null
1262
+ ;(dc as any).onmessage = null
1263
+ }
1264
+ if (pc) {
1265
+ ;(pc as any).onicecandidate = null
1266
+ ;(pc as any).ondatachannel = null
1267
+ ;(pc as any).onnegotiationneeded = null
1268
+ ;(pc as any).onconnectionstatechange = null
1269
+ ;(pc as any).oniceconnectionstatechange = null
1270
+ }
1118
1271
  try { dc?.close() } catch {}
1119
- void pc?.close().catch(() => {})
1272
+ this.trackRtcClose(pc ? Promise.resolve().then(() => pc.close()) : null)
1120
1273
  }
1121
1274
 
1122
1275
  private failPeerTransfers(peer: PeerState, reason: string) {
@@ -1136,29 +1289,45 @@ export class SendSession {
1136
1289
  const message = JSON.parse(raw) as SignalMessage
1137
1290
  if (message.room !== this.room || message.from === this.localId || (message.to && message.to !== "*" && message.to !== this.localId)) return
1138
1291
  this.pushLog("signal:in", message)
1292
+ const peer = this.peers.get(message.from) ?? (message.kind === "bye" ? null : this.buildPeer(message.from, message.instanceId))
1293
+ if (peer && !this.acceptPeerInstance(peer, message.instanceId, message.kind)) return
1139
1294
 
1140
1295
  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 })
1296
+ if (!peer) return
1297
+ const synced = this.syncPeerSignal(this.syncPeerPresence(peer, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch, !!message.recovery)
1298
+ if (synced && !message.reply) this.sendPeerHello(synced, { reply: true })
1143
1299
  this.notify()
1144
1300
  return
1145
1301
  }
1146
1302
  if (message.kind === "name") {
1147
- this.syncPeerPresence(message.from, message.name)
1303
+ if (!peer) return
1304
+ this.syncPeerPresence(peer, message.name)
1148
1305
  this.notify()
1149
1306
  return
1150
1307
  }
1151
1308
  if (message.kind === "profile") {
1152
- this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
1309
+ if (!peer) return
1310
+ this.syncPeerSignal(this.syncPeerPresence(peer, message.name, message.profile, message.turnAvailable), "profile", message.rtcEpoch)
1153
1311
  this.notify()
1154
1312
  return
1155
1313
  }
1156
1314
  if (message.kind === "bye") {
1157
- const peer = this.peers.get(message.from)
1158
1315
  if (peer) this.destroyPeer(peer, "peer-left")
1159
1316
  this.notify()
1160
1317
  return
1161
1318
  }
1319
+ if (message.kind === "turn-share") {
1320
+ if (!peer) return
1321
+ this.syncPeerPresence(peer)
1322
+ const added = this.mergeTurnServers(message.iceServers)
1323
+ if (added) {
1324
+ this.pushLog("turn:share-applied", { peer: peer.id, added }, "info")
1325
+ this.broadcastProfile()
1326
+ this.restartPeerConnection(peer, "turn-share", true)
1327
+ }
1328
+ this.notify()
1329
+ return
1330
+ }
1162
1331
  if (message.kind === "description") {
1163
1332
  await this.onDescriptionSignal(message)
1164
1333
  return
@@ -1170,7 +1339,9 @@ export class SendSession {
1170
1339
  }
1171
1340
 
1172
1341
  private async onDescriptionSignal(message: DescriptionSignal) {
1173
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
1342
+ const existing = this.peers.get(message.from) ?? this.buildPeer(message.from, message.instanceId)
1343
+ if (!this.acceptPeerInstance(existing, message.instanceId, message.kind)) return
1344
+ const peer = this.syncPeerSignal(this.syncPeerPresence(existing, message.name, message.profile, message.turnAvailable), "description", message.rtcEpoch)
1174
1345
  if (!peer?.pc) return
1175
1346
  const offerCollision = message.description.type === "offer" && !peer.makingOffer && peer.pc.signalingState !== "stable"
1176
1347
  if (!peer.polite && offerCollision) return
@@ -1190,7 +1361,9 @@ export class SendSession {
1190
1361
  }
1191
1362
 
1192
1363
  private async onCandidateSignal(message: CandidateSignal) {
1193
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
1364
+ const existing = this.peers.get(message.from) ?? this.buildPeer(message.from, message.instanceId)
1365
+ if (!this.acceptPeerInstance(existing, message.instanceId, message.kind)) return
1366
+ const peer = this.syncPeerSignal(this.syncPeerPresence(existing, message.name, message.profile, message.turnAvailable), "candidate", message.rtcEpoch)
1194
1367
  if (!peer?.pc) return
1195
1368
  try {
1196
1369
  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"
@@ -76,6 +76,8 @@ export interface TuiActions {
76
76
  clearPeerSelection: TuiAction
77
77
  toggleHideTerminalPeers: TuiAction
78
78
  togglePeer: (peerId: string) => void
79
+ shareTurnWithPeer: (peerId: string) => void
80
+ shareTurnWithAllPeers: TuiAction
79
81
  toggleAutoOffer: TuiAction
80
82
  toggleAutoAccept: TuiAction
81
83
  toggleAutoSave: TuiAction
@@ -98,6 +100,8 @@ const NAME_INPUT_ID = "name-input"
98
100
  const DRAFT_INPUT_ID = "draft-input"
99
101
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
100
102
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
103
+ const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
104
+ const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
101
105
  const DEFAULT_WEB_URL = "https://send.rt.ht/"
102
106
  const TRANSFER_DIRECTION_ARROW = {
103
107
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
@@ -151,6 +155,8 @@ export const createNoopTuiActions = (): TuiActions => ({
151
155
  clearPeerSelection: noop,
152
156
  toggleHideTerminalPeers: noop,
153
157
  togglePeer: noop,
158
+ shareTurnWithPeer: noop,
159
+ shareTurnWithAllPeers: noop,
154
160
  toggleAutoOffer: noop,
155
161
  toggleAutoAccept: noop,
156
162
  toggleAutoSave: noop,
@@ -519,6 +525,32 @@ const ghostButton = (id: string, label: string, onPress?: TuiAction, options: {
519
525
  dsVariant: "ghost",
520
526
  })
521
527
 
528
+ const TEXT_BUTTON_FOCUS_CONFIG = { indicator: "none", showHint: false } as const
529
+
530
+ const textButton = (id: string, label: string, onPress?: TuiAction, options: { focusable?: boolean; accessibleLabel?: string } = {}) => ui.button({
531
+ id,
532
+ label,
533
+ ...(onPress === undefined ? {} : { onPress }),
534
+ ...(options.focusable === undefined ? {} : { focusable: options.focusable }),
535
+ ...(options.accessibleLabel === undefined ? {} : { accessibleLabel: options.accessibleLabel }),
536
+ px: 0,
537
+ dsVariant: "ghost",
538
+ style: PRIMARY_TEXT_STYLE,
539
+ focusConfig: TEXT_BUTTON_FOCUS_CONFIG,
540
+ })
541
+
542
+ const headingTextButton = (id: string, label: string, onPress?: TuiAction, options: { focusable?: boolean; accessibleLabel?: string } = {}) => ui.button({
543
+ id,
544
+ label,
545
+ ...(onPress === undefined ? {} : { onPress }),
546
+ ...(options.focusable === undefined ? {} : { focusable: options.focusable }),
547
+ ...(options.accessibleLabel === undefined ? {} : { accessibleLabel: options.accessibleLabel }),
548
+ px: 0,
549
+ dsVariant: "ghost",
550
+ style: HEADING_TEXT_STYLE,
551
+ focusConfig: TEXT_BUTTON_FOCUS_CONFIG,
552
+ })
553
+
522
554
  const denseSection = (options: DenseSectionOptions, children: readonly DenseSectionChild[]) => ui.box({
523
555
  ...(options.id === undefined ? {} : { id: options.id }),
524
556
  ...(options.key === undefined ? {} : { key: options.key }),
@@ -620,7 +652,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
620
652
  ]),
621
653
  ])
622
654
 
623
- const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection({
655
+ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: TuiActions) => denseSection({
624
656
  id: `peer-row-${peer.id}`,
625
657
  key: peer.id,
626
658
  }, [
@@ -639,9 +671,9 @@ const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection(
639
671
  }),
640
672
  ]),
641
673
  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",
674
+ textButton(`peer-share-turn-${peer.id}`, peer.displayName, turnShareEnabled && peer.presence === "active" ? () => actions.shareTurnWithPeer(peer.id) : undefined, {
675
+ focusable: turnShareEnabled && peer.presence === "active",
676
+ accessibleLabel: `share TURN with ${peer.displayName}`,
645
677
  }),
646
678
  ]),
647
679
  ui.row({ id: `peer-status-cluster-${peer.id}`, gap: 1, items: "center" }, [
@@ -671,9 +703,16 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
671
703
  const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
672
704
  const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
673
705
  const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
706
+ const canShareTurn = state.session.canShareTurn()
674
707
  return denseSection({
675
708
  id: "peers-card",
676
- title: `Peers ${selectedCount}/${activeCount}`,
709
+ titleNode: ui.row({ id: "peers-title-row", gap: 1, items: "center" }, [
710
+ headingTextButton("share-turn-all-peers", "Peers", canShareTurn && !!activeCount ? actions.shareTurnWithAllPeers : undefined, {
711
+ focusable: canShareTurn && !!activeCount,
712
+ accessibleLabel: "share TURN with all active peers",
713
+ }),
714
+ ui.text(`${selectedCount}/${activeCount}`, { id: "peers-count-text", variant: "heading" }),
715
+ ]),
677
716
  flex: 1,
678
717
  actions: [
679
718
  actionButton("select-ready-peers", "All", actions.toggleSelectReadyPeers),
@@ -683,7 +722,7 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
683
722
  }, [
684
723
  ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
685
724
  peers.length
686
- ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, actions)))
725
+ ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, canShareTurn, actions)))
687
726
  : ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
688
727
  ]),
689
728
  ])
@@ -1025,7 +1064,11 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1025
1064
  const requestStop = () => {
1026
1065
  if (stopping) return
1027
1066
  stopping = true
1028
- void app.stop()
1067
+ try {
1068
+ process.kill(process.pid, "SIGINT")
1069
+ } catch {
1070
+ void app.stop()
1071
+ }
1029
1072
  }
1030
1073
 
1031
1074
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1219,7 +1262,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1219
1262
  })
1220
1263
  commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
1221
1264
  void session.connect().catch(error => {
1222
- if (state.session !== session) return
1265
+ if (state.session !== session || stopping || cleanedUp || isSessionAbortedError(error)) return
1223
1266
  commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
1224
1267
  })
1225
1268
  }
@@ -1323,6 +1366,29 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1323
1366
  state.session.togglePeerSelection(peerId)
1324
1367
  maybeOfferDrafts()
1325
1368
  },
1369
+ shareTurnWithPeer: peerId => {
1370
+ const peer = state.snapshot.peers.find(item => item.id === peerId)
1371
+ const sent = state.session.shareTurnWithPeer(peerId)
1372
+ commit(current => withNotice(current, {
1373
+ text: !state.session.canShareTurn()
1374
+ ? "TURN is not configured."
1375
+ : sent
1376
+ ? `Shared TURN with ${peer?.displayName ?? peerId}.`
1377
+ : `Unable to share TURN with ${peer?.displayName ?? peerId}.`,
1378
+ variant: !state.session.canShareTurn() ? "info" : sent ? "success" : "warning",
1379
+ }))
1380
+ },
1381
+ shareTurnWithAllPeers: () => {
1382
+ const shared = state.session.shareTurnWithAllPeers()
1383
+ commit(current => withNotice(current, {
1384
+ text: !state.session.canShareTurn()
1385
+ ? "TURN is not configured."
1386
+ : shared
1387
+ ? `Shared TURN with ${plural(shared, "active peer")}.`
1388
+ : "No active peers to share TURN with.",
1389
+ variant: !state.session.canShareTurn() ? "info" : shared ? "success" : "info",
1390
+ }))
1391
+ },
1326
1392
  toggleAutoOffer: () => {
1327
1393
  commit(current => withNotice({ ...current, autoOfferOutgoing: !current.autoOfferOutgoing }, { text: !state.autoOfferOutgoing ? "Auto-offer on." : "Auto-offer off.", variant: !state.autoOfferOutgoing ? "success" : "warning" }))
1328
1394
  maybeOfferDrafts()
@@ -1500,17 +1566,14 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1500
1566
  await state.session.close()
1501
1567
  }
1502
1568
 
1503
- const onSignal = () => requestStop()
1504
- process.once("SIGINT", onSignal)
1505
- process.once("SIGTERM", onSignal)
1506
-
1507
1569
  try {
1508
1570
  bindSession(state.session)
1509
1571
  await app.run()
1510
1572
  } finally {
1511
- process.off("SIGINT", onSignal)
1512
- process.off("SIGTERM", onSignal)
1513
- await stop()
1514
- app.dispose()
1573
+ try {
1574
+ await stop()
1575
+ } finally {
1576
+ app.dispose()
1577
+ }
1515
1578
  }
1516
1579
  }
File without changes
File without changes
File without changes
File without changes
File without changes