@elefunc/send 0.1.4 → 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.4",
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
@@ -157,6 +159,7 @@ export interface SessionConfig {
157
159
  localId?: string
158
160
  name?: string
159
161
  saveDir?: string
162
+ peerSelectionMemory?: Map<string, boolean>
160
163
  autoAcceptIncoming?: boolean
161
164
  autoSaveIncoming?: boolean
162
165
  turnUrls?: string[]
@@ -257,7 +260,22 @@ export const turnUsageState = (
257
260
  return "idle"
258
261
  }
259
262
 
260
- const timeoutSignal = (ms: number) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
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
+
273
+ const timeoutSignal = (ms: number, base?: AbortSignal | null) => {
274
+ const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
275
+ if (!base) return timeout
276
+ if (!timeout) return base
277
+ return typeof AbortSignal.any === "function" ? AbortSignal.any([base, timeout]) : base
278
+ }
261
279
 
262
280
  const normalizeCandidateType = (value: unknown) => typeof value === "string" ? value.toLowerCase() : ""
263
281
  const validCandidateType = (value: string) => ["host", "srflx", "prflx", "relay"].includes(value)
@@ -311,7 +329,30 @@ const sameConnectivity = (left: PeerConnectivitySnapshot, right: PeerConnectivit
311
329
  && left.remoteCandidateType === right.remoteCandidateType
312
330
  && left.pathLabel === right.pathLabel
313
331
 
314
- 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)
315
356
 
316
357
  const messageString = async (value: unknown) => {
317
358
  if (typeof value === "string") return value
@@ -322,6 +363,7 @@ const messageString = async (value: unknown) => {
322
363
  }
323
364
 
324
365
  export class SendSession {
366
+ readonly instanceId: string
325
367
  readonly localId: string
326
368
  profile = sanitizeProfile(buildCliProfile())
327
369
  readonly saveDir: string
@@ -334,7 +376,9 @@ export class SendSession {
334
376
  private autoAcceptIncoming: boolean
335
377
  private autoSaveIncoming: boolean
336
378
  private readonly reconnectSocket: boolean
337
- private readonly iceServers: RTCIceServer[]
379
+ private iceServers: RTCIceServer[]
380
+ private extraTurnServers: RTCIceServer[]
381
+ private readonly peerSelectionMemory: Map<string, boolean>
338
382
  private readonly peers = new Map<string, PeerState>()
339
383
  private readonly transfers = new Map<string, TransferState>()
340
384
  private readonly logs: LogEntry[] = []
@@ -346,19 +390,23 @@ export class SendSession {
346
390
  private socketToken = 0
347
391
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
348
392
  private peerStatsTimer: ReturnType<typeof setInterval> | null = null
393
+ private readonly pendingRtcCloses = new Set<Promise<void>>()
394
+ private lifecycleAbortController: AbortController | null = null
349
395
  private stopped = false
350
396
 
351
397
  constructor(config: SessionConfig) {
398
+ this.instanceId = cleanInstanceId(uid(10)) || uid(10)
352
399
  this.localId = cleanLocalId(config.localId)
353
400
  this.room = cleanRoom(config.room)
354
401
  this.name = cleanName(config.name ?? fallbackName)
355
402
  this.saveDir = resolve(config.saveDir ?? resolve(process.cwd(), "downloads"))
403
+ this.peerSelectionMemory = config.peerSelectionMemory ?? new Map()
356
404
  this.autoAcceptIncoming = !!config.autoAcceptIncoming
357
405
  this.autoSaveIncoming = !!config.autoSaveIncoming
358
406
  this.reconnectSocket = config.reconnectSocket ?? true
359
- const extraTurn = turnServers(config.turnUrls ?? [], config.turnUsername, config.turnCredential)
360
- this.iceServers = [...BASE_ICE_SERVERS, ...extraTurn]
361
- 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
362
410
  }
363
411
 
364
412
  subscribe(listener: () => void) {
@@ -394,17 +442,22 @@ export class SendSession {
394
442
 
395
443
  async connect(timeoutMs = 10_000) {
396
444
  this.stopped = false
445
+ this.lifecycleAbortController?.abort()
446
+ this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
397
447
  this.startPeerStatsPolling()
398
448
  void this.loadLocalProfile()
399
449
  void this.probePulse()
400
450
  this.connectSocket()
401
- await this.waitFor(() => this.socketState === "open", timeoutMs)
451
+ await this.waitFor(() => this.socketState === "open", timeoutMs, this.lifecycleAbortController?.signal)
402
452
  }
403
453
 
404
454
  async close() {
405
455
  this.stopped = true
406
456
  this.stopPeerStatsPolling()
407
457
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
458
+ this.reconnectTimer = null
459
+ this.lifecycleAbortController?.abort()
460
+ this.lifecycleAbortController = null
408
461
  if (this.socket?.readyState === WebSocket.OPEN) this.sendSignal({ kind: "bye" })
409
462
  const socket = this.socket
410
463
  this.socket = null
@@ -412,6 +465,7 @@ export class SendSession {
412
465
  if (socket) try { socket.close(1000, "normal") } catch {}
413
466
  for (const peer of this.peers.values()) this.destroyPeer(peer, "session-close")
414
467
  this.notify()
468
+ if (this.pendingRtcCloses.size) await Promise.allSettled([...this.pendingRtcCloses])
415
469
  }
416
470
 
417
471
  activePeers() {
@@ -426,10 +480,34 @@ export class SendSession {
426
480
  return this.readyPeers().filter(peer => peer.selected)
427
481
  }
428
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
+
429
503
  setPeerSelected(peerId: string, selected: boolean) {
430
504
  const peer = this.peers.get(peerId)
431
- if (!peer || peer.presence !== "active") return false
432
- peer.selected = selected
505
+ if (!peer) return false
506
+ const next = !!selected
507
+ const rememberedChanged = this.rememberPeerSelected(peerId, next)
508
+ if (peer.presence !== "active") return rememberedChanged
509
+ if (peer.selected === next && !rememberedChanged) return false
510
+ peer.selected = next
433
511
  this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
434
512
  this.notify()
435
513
  return true
@@ -440,6 +518,32 @@ export class SendSession {
440
518
  return peer ? this.setPeerSelected(peerId, !peer.selected) : false
441
519
  }
442
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
+
443
547
  clearLogs() {
444
548
  this.logs.length = 0
445
549
  this.notify()
@@ -635,19 +739,31 @@ export class SendSession {
635
739
  return this.transfers.get(transferId)
636
740
  }
637
741
 
638
- async waitFor(predicate: () => boolean, timeoutMs: number) {
742
+ async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
639
743
  if (predicate()) return
744
+ if (signal?.aborted) throw new SessionAbortedError()
640
745
  await new Promise<void>((resolveWait, rejectWait) => {
641
- const timeout = setTimeout(() => {
746
+ let unsubscribe = () => {}
747
+ const cleanup = () => {
748
+ clearTimeout(timeout)
749
+ signal?.removeEventListener("abort", onAbort)
642
750
  unsubscribe()
751
+ }
752
+ const onAbort = () => {
753
+ cleanup()
754
+ rejectWait(new SessionAbortedError())
755
+ }
756
+ const timeout = setTimeout(() => {
757
+ cleanup()
643
758
  rejectWait(new Error(`timed out after ${timeoutMs}ms`))
644
759
  }, timeoutMs)
645
- const unsubscribe = this.subscribe(() => {
760
+ unsubscribe = this.subscribe(() => {
646
761
  if (!predicate()) return
647
- clearTimeout(timeout)
648
- unsubscribe()
762
+ cleanup()
649
763
  resolveWait()
650
764
  })
765
+ signal?.addEventListener("abort", onAbort, { once: true })
766
+ if (signal?.aborted) onAbort()
651
767
  })
652
768
  }
653
769
 
@@ -862,13 +978,17 @@ export class SendSession {
862
978
 
863
979
  private async loadLocalProfile() {
864
980
  try {
865
- const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000) })
981
+ const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000, this.lifecycleAbortController?.signal) })
866
982
  if (!response.ok) throw new Error(`profile ${response.status}`)
867
- this.profile = localProfileFromResponse(await response.json())
983
+ const data = await response.json()
984
+ if (this.stopped) return
985
+ this.profile = localProfileFromResponse(data)
868
986
  } catch (error) {
987
+ if (this.stopped) return
869
988
  this.profile = localProfileFromResponse(null, `${error}`)
870
989
  this.pushLog("profile:error", { error: `${error}` }, "error")
871
990
  }
991
+ if (this.stopped) return
872
992
  this.broadcastProfile()
873
993
  this.notify()
874
994
  }
@@ -878,13 +998,16 @@ export class SendSession {
878
998
  this.pulse = { ...this.pulse, state: "checking", error: "" }
879
999
  this.notify()
880
1000
  try {
881
- const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500) })
1001
+ const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
882
1002
  if (!response.ok) throw new Error(`pulse ${response.status}`)
1003
+ if (this.stopped) return
883
1004
  this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
884
1005
  } catch (error) {
1006
+ if (this.stopped) return
885
1007
  this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
886
1008
  this.pushLog("pulse:error", { error: `${error}` }, "error")
887
1009
  }
1010
+ if (this.stopped) return
888
1011
  this.notify()
889
1012
  }
890
1013
 
@@ -908,7 +1031,7 @@ export class SendSession {
908
1031
 
909
1032
  private sendSignal(payload: { kind: string; to?: string; [key: string]: unknown }) {
910
1033
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false
911
- 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 }
912
1035
  this.socket.send(JSON.stringify(message))
913
1036
  this.pushLog("signal:out", message)
914
1037
  return true
@@ -931,12 +1054,24 @@ export class SendSession {
931
1054
  return !!peer && !!channel && peer.rtcEpoch === rtcEpoch && peer.dc === channel && channel.readyState === "open"
932
1055
  }
933
1056
 
934
- private buildPeer(remoteId: string) {
1057
+ private peerSelected(peerId: string) {
1058
+ return this.peerSelectionMemory.get(peerId) ?? true
1059
+ }
1060
+
1061
+ private rememberPeerSelected(peerId: string, selected: boolean) {
1062
+ const next = !!selected
1063
+ const previous = this.peerSelectionMemory.get(peerId)
1064
+ this.peerSelectionMemory.set(peerId, next)
1065
+ return previous !== next
1066
+ }
1067
+
1068
+ private buildPeer(remoteId: string, remoteInstanceId = "") {
935
1069
  const peer: PeerState = {
936
1070
  id: remoteId,
937
1071
  name: fallbackName,
938
1072
  presence: "active",
939
- selected: true,
1073
+ selected: this.peerSelected(remoteId),
1074
+ remoteInstanceId: cleanInstanceId(remoteInstanceId),
940
1075
  polite: this.localId > remoteId,
941
1076
  pc: null,
942
1077
  dc: null,
@@ -960,14 +1095,42 @@ export class SendSession {
960
1095
  return peer
961
1096
  }
962
1097
 
963
- private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
964
- 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) {
1126
+ const wasTerminal = peer.presence === "terminal"
965
1127
  peer.lastSeenAt = Date.now()
966
1128
  peer.presence = "active"
967
1129
  peer.terminalReason = ""
1130
+ if (wasTerminal) peer.selected = this.peerSelected(peer.id)
968
1131
  if (name != null) {
969
1132
  peer.name = cleanName(name)
970
- 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
971
1134
  }
972
1135
  if (typeof turnAvailable === "boolean") peer.turnAvailable = turnAvailable
973
1136
  if (profile) peer.profile = sanitizeProfile(profile)
@@ -975,14 +1138,21 @@ export class SendSession {
975
1138
  return peer
976
1139
  }
977
1140
 
978
- private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0) {
1141
+ private syncPeerSignal(peer: PeerState, kind: string, remoteEpoch = 0, recovery = false) {
979
1142
  const epoch = signalEpoch(remoteEpoch)
980
1143
  if (epoch && epoch < peer.remoteEpoch) return null
981
1144
  if (epoch) peer.remoteEpoch = epoch
982
- 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}`)
983
1146
  return peer
984
1147
  }
985
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
+
986
1156
  private ensurePeerConnection(peer: PeerState, reason: string) {
987
1157
  const epoch = this.nextRtcEpoch()
988
1158
  peer.rtcEpoch = epoch
@@ -1072,13 +1242,34 @@ export class SendSession {
1072
1242
  channel.onmessage = ({ data }) => void this.onDataMessage(peer, data)
1073
1243
  }
1074
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
+
1075
1253
  private closePeerRTC(peer: PeerState) {
1076
1254
  const dc = peer.dc
1077
1255
  const pc = peer.pc
1078
1256
  peer.dc = null
1079
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
+ }
1080
1271
  try { dc?.close() } catch {}
1081
- void pc?.close().catch(() => {})
1272
+ this.trackRtcClose(pc ? Promise.resolve().then(() => pc.close()) : null)
1082
1273
  }
1083
1274
 
1084
1275
  private failPeerTransfers(peer: PeerState, reason: string) {
@@ -1098,29 +1289,45 @@ export class SendSession {
1098
1289
  const message = JSON.parse(raw) as SignalMessage
1099
1290
  if (message.room !== this.room || message.from === this.localId || (message.to && message.to !== "*" && message.to !== this.localId)) return
1100
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
1101
1294
 
1102
1295
  if (message.kind === "hello") {
1103
- const peer = this.syncPeerSignal(this.syncPeerPresence(message.from, message.name, message.profile, message.turnAvailable), "hello", message.rtcEpoch)
1104
- 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 })
1105
1299
  this.notify()
1106
1300
  return
1107
1301
  }
1108
1302
  if (message.kind === "name") {
1109
- this.syncPeerPresence(message.from, message.name)
1303
+ if (!peer) return
1304
+ this.syncPeerPresence(peer, message.name)
1110
1305
  this.notify()
1111
1306
  return
1112
1307
  }
1113
1308
  if (message.kind === "profile") {
1114
- 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)
1115
1311
  this.notify()
1116
1312
  return
1117
1313
  }
1118
1314
  if (message.kind === "bye") {
1119
- const peer = this.peers.get(message.from)
1120
1315
  if (peer) this.destroyPeer(peer, "peer-left")
1121
1316
  this.notify()
1122
1317
  return
1123
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
+ }
1124
1331
  if (message.kind === "description") {
1125
1332
  await this.onDescriptionSignal(message)
1126
1333
  return
@@ -1132,7 +1339,9 @@ export class SendSession {
1132
1339
  }
1133
1340
 
1134
1341
  private async onDescriptionSignal(message: DescriptionSignal) {
1135
- 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)
1136
1345
  if (!peer?.pc) return
1137
1346
  const offerCollision = message.description.type === "offer" && !peer.makingOffer && peer.pc.signalingState !== "stable"
1138
1347
  if (!peer.polite && offerCollision) return
@@ -1152,7 +1361,9 @@ export class SendSession {
1152
1361
  }
1153
1362
 
1154
1363
  private async onCandidateSignal(message: CandidateSignal) {
1155
- 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)
1156
1367
  if (!peer?.pc) return
1157
1368
  try {
1158
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"
@@ -43,6 +43,7 @@ export type VisiblePane = "peers" | "transfers" | "logs"
43
43
  export interface TuiState {
44
44
  session: SendSession
45
45
  sessionSeed: SessionSeed
46
+ peerSelectionByRoom: Map<string, Map<string, boolean>>
46
47
  snapshot: SessionSnapshot
47
48
  focusedId: string | null
48
49
  roomInput: string
@@ -75,6 +76,8 @@ export interface TuiActions {
75
76
  clearPeerSelection: TuiAction
76
77
  toggleHideTerminalPeers: TuiAction
77
78
  togglePeer: (peerId: string) => void
79
+ shareTurnWithPeer: (peerId: string) => void
80
+ shareTurnWithAllPeers: TuiAction
78
81
  toggleAutoOffer: TuiAction
79
82
  toggleAutoAccept: TuiAction
80
83
  toggleAutoSave: TuiAction
@@ -97,7 +100,13 @@ const NAME_INPUT_ID = "name-input"
97
100
  const DRAFT_INPUT_ID = "draft-input"
98
101
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
99
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
100
105
  const DEFAULT_WEB_URL = "https://send.rt.ht/"
106
+ const TRANSFER_DIRECTION_ARROW = {
107
+ out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
108
+ in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
109
+ } as const
101
110
 
102
111
  const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
103
112
  const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
@@ -146,6 +155,8 @@ export const createNoopTuiActions = (): TuiActions => ({
146
155
  clearPeerSelection: noop,
147
156
  toggleHideTerminalPeers: noop,
148
157
  togglePeer: noop,
158
+ shareTurnWithPeer: noop,
159
+ shareTurnWithAllPeers: noop,
149
160
  toggleAutoOffer: noop,
150
161
  toggleAutoAccept: noop,
151
162
  toggleAutoSave: noop,
@@ -180,6 +191,20 @@ const peerConnectionStatusKind = (status: string) => ({
180
191
  idle: "unknown",
181
192
  new: "unknown",
182
193
  }[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
194
+ const transferStatusKind = (status: TransferSnapshot["status"]) => ({
195
+ complete: "online",
196
+ sending: "online",
197
+ receiving: "online",
198
+ "awaiting-done": "online",
199
+ accepted: "busy",
200
+ queued: "busy",
201
+ offered: "busy",
202
+ pending: "busy",
203
+ cancelling: "busy",
204
+ rejected: "offline",
205
+ cancelled: "offline",
206
+ error: "offline",
207
+ }[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
183
208
  const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
184
209
  const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
185
210
  const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
@@ -298,8 +323,19 @@ const normalizeSessionSeed = (config: SessionConfig): SessionSeed => ({
298
323
  room: cleanRoom(config.room),
299
324
  })
300
325
 
301
- const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean) => new SendSession({
326
+ const roomPeerSelectionMemory = (peerSelectionByRoom: Map<string, Map<string, boolean>>, room: string) => {
327
+ const roomKey = cleanRoom(room)
328
+ let selectionMemory = peerSelectionByRoom.get(roomKey)
329
+ if (!selectionMemory) {
330
+ selectionMemory = new Map<string, boolean>()
331
+ peerSelectionByRoom.set(roomKey, selectionMemory)
332
+ }
333
+ return selectionMemory
334
+ }
335
+
336
+ const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean, peerSelectionMemory: Map<string, boolean>) => new SendSession({
302
337
  ...seed,
338
+ peerSelectionMemory,
303
339
  autoAcceptIncoming,
304
340
  autoSaveIncoming,
305
341
  })
@@ -435,11 +471,13 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
435
471
  const sessionSeed = normalizeSessionSeed(initialConfig)
436
472
  const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
437
473
  const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
438
- const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming)
474
+ const peerSelectionByRoom = new Map<string, Map<string, boolean>>()
475
+ const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming, roomPeerSelectionMemory(peerSelectionByRoom, sessionSeed.room))
439
476
  const focusState = deriveBootFocusState(sessionSeed.name)
440
477
  return {
441
478
  session,
442
479
  sessionSeed,
480
+ peerSelectionByRoom,
443
481
  snapshot: session.snapshot(),
444
482
  focusedId: null,
445
483
  roomInput: sessionSeed.room,
@@ -487,6 +525,32 @@ const ghostButton = (id: string, label: string, onPress?: TuiAction, options: {
487
525
  dsVariant: "ghost",
488
526
  })
489
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
+
490
554
  const denseSection = (options: DenseSectionOptions, children: readonly DenseSectionChild[]) => ui.box({
491
555
  ...(options.id === undefined ? {} : { id: options.id }),
492
556
  ...(options.key === undefined ? {} : { key: options.key }),
@@ -588,7 +652,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
588
652
  ]),
589
653
  ])
590
654
 
591
- const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection({
655
+ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: TuiActions) => denseSection({
592
656
  id: `peer-row-${peer.id}`,
593
657
  key: peer.id,
594
658
  }, [
@@ -607,9 +671,9 @@ const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection(
607
671
  }),
608
672
  ]),
609
673
  ui.box({ id: `peer-name-slot-${peer.id}`, flex: 1, minWidth: 0, border: "none" }, [
610
- ui.text(peer.displayName, {
611
- id: `peer-name-text-${peer.id}`,
612
- 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}`,
613
677
  }),
614
678
  ]),
615
679
  ui.row({ id: `peer-status-cluster-${peer.id}`, gap: 1, items: "center" }, [
@@ -639,9 +703,16 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
639
703
  const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
640
704
  const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
641
705
  const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
706
+ const canShareTurn = state.session.canShareTurn()
642
707
  return denseSection({
643
708
  id: "peers-card",
644
- 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
+ ]),
645
716
  flex: 1,
646
717
  actions: [
647
718
  actionButton("select-ready-peers", "All", actions.toggleSelectReadyPeers),
@@ -651,7 +722,7 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
651
722
  }, [
652
723
  ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
653
724
  peers.length
654
- ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, actions)))
725
+ ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, canShareTurn, actions)))
655
726
  : ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
656
727
  ]),
657
728
  ])
@@ -755,6 +826,7 @@ const transferPathLabel = (transfer: TransferSnapshot, peersById: Map<string, Pe
755
826
 
756
827
  const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
757
828
  const hasStarted = !!transfer.startedAt
829
+ const directionArrow = TRANSFER_DIRECTION_ARROW[transfer.direction]
758
830
  const facts = [
759
831
  renderTransferFact("Size", formatBytes(transfer.size)),
760
832
  renderTransferFact("Path", transferPathLabel(transfer, peersById)),
@@ -767,13 +839,18 @@ const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, Pe
767
839
 
768
840
  return denseSection({
769
841
  key: transfer.id,
770
- title: `${transfer.direction === "out" ? "" : "←"} ${transfer.name}`,
842
+ titleNode: ui.row({ id: `transfer-title-row-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
843
+ ui.row({ id: `transfer-title-main-${transfer.id}`, gap: 0, items: "center", wrap: true }, [
844
+ ui.text(directionArrow.glyph, { style: directionArrow.style }),
845
+ ui.text(` ${transfer.name}`, { variant: "heading" }),
846
+ ]),
847
+ ui.row({ id: `transfer-badges-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
848
+ ui.status(transferStatusKind(transfer.status), { label: transfer.status, showLabel: true }),
849
+ transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
850
+ ]),
851
+ ]),
771
852
  actions: transferActionButtons(transfer, actions),
772
853
  }, [
773
- ui.row({ gap: 1, wrap: true }, [
774
- tightTag(transfer.status, { variant: statusVariant(transfer.status), bare: true }),
775
- transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
776
- ]),
777
854
  ui.row({ gap: 0, wrap: true }, facts),
778
855
  ui.progress(transferProgress(transfer), { showPercent: true, label: `${percentFormat.format(transfer.progress)}%` }),
779
856
  ui.row({ gap: 0, wrap: true }, [
@@ -987,7 +1064,11 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
987
1064
  const requestStop = () => {
988
1065
  if (stopping) return
989
1066
  stopping = true
990
- void app.stop()
1067
+ try {
1068
+ process.kill(process.pid, "SIGINT")
1069
+ } catch {
1070
+ void app.stop()
1071
+ }
991
1072
  }
992
1073
 
993
1074
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1181,19 +1262,20 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1181
1262
  })
1182
1263
  commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
1183
1264
  void session.connect().catch(error => {
1184
- if (state.session !== session) return
1265
+ if (state.session !== session || stopping || cleanedUp || isSessionAbortedError(error)) return
1185
1266
  commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
1186
1267
  })
1187
1268
  }
1188
1269
 
1189
1270
  const replaceSession = (nextSeed: SessionSeed, text: string, options: { reseedBootFocus?: boolean } = {}) => {
1190
1271
  const previousSession = state.session
1191
- const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming)
1272
+ const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming, roomPeerSelectionMemory(state.peerSelectionByRoom, nextSeed.room))
1192
1273
  stopPreviewSession()
1193
1274
  commit(current => withNotice({
1194
1275
  ...current,
1195
1276
  session: nextSession,
1196
1277
  sessionSeed: nextSeed,
1278
+ peerSelectionByRoom: current.peerSelectionByRoom,
1197
1279
  snapshot: nextSession.snapshot(),
1198
1280
  roomInput: nextSeed.room,
1199
1281
  nameInput: visibleNameInput(nextSeed.name),
@@ -1270,7 +1352,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1270
1352
  setNameInput: value => commit(current => ({ ...current, nameInput: value })),
1271
1353
  toggleSelectReadyPeers: () => {
1272
1354
  let changed = 0
1273
- for (const peer of state.snapshot.peers) if (peer.presence === "active" && state.session.setPeerSelected(peer.id, peer.ready)) changed += 1
1355
+ for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
1274
1356
  commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
1275
1357
  maybeOfferDrafts()
1276
1358
  },
@@ -1284,6 +1366,29 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1284
1366
  state.session.togglePeerSelection(peerId)
1285
1367
  maybeOfferDrafts()
1286
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
+ },
1287
1392
  toggleAutoOffer: () => {
1288
1393
  commit(current => withNotice({ ...current, autoOfferOutgoing: !current.autoOfferOutgoing }, { text: !state.autoOfferOutgoing ? "Auto-offer on." : "Auto-offer off.", variant: !state.autoOfferOutgoing ? "success" : "warning" }))
1289
1394
  maybeOfferDrafts()
@@ -1461,17 +1566,14 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1461
1566
  await state.session.close()
1462
1567
  }
1463
1568
 
1464
- const onSignal = () => requestStop()
1465
- process.once("SIGINT", onSignal)
1466
- process.once("SIGTERM", onSignal)
1467
-
1468
1569
  try {
1469
1570
  bindSession(state.session)
1470
1571
  await app.run()
1471
1572
  } finally {
1472
- process.off("SIGINT", onSignal)
1473
- process.off("SIGTERM", onSignal)
1474
- await stop()
1475
- app.dispose()
1573
+ try {
1574
+ await stop()
1575
+ } finally {
1576
+ app.dispose()
1577
+ }
1476
1578
  }
1477
1579
  }
File without changes
File without changes
File without changes
File without changes
File without changes