@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 +0 -0
- package/README.md +0 -0
- package/package.json +1 -1
- package/runtime/install.ts +0 -0
- package/runtime/rezi-checkbox-click.ts +0 -0
- package/runtime/rezi-files.ts +0 -0
- package/runtime/rezi-input-caret.ts +0 -0
- package/src/core/files.ts +0 -0
- package/src/core/paths.ts +0 -0
- package/src/core/protocol.ts +10 -1
- package/src/core/session.ts +200 -27
- package/src/core/targeting.ts +0 -0
- package/src/tui/app.ts +80 -17
- package/src/tui/file-search-protocol.ts +0 -0
- package/src/tui/file-search.ts +0 -0
- package/src/tui/file-search.worker.ts +0 -0
- package/src/types/bun-runtime.d.ts +0 -0
- package/src/types/bun-test.d.ts +0 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/runtime/install.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/runtime/rezi-files.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/core/files.ts
CHANGED
|
File without changes
|
package/src/core/paths.ts
CHANGED
|
File without changes
|
package/src/core/protocol.ts
CHANGED
|
@@ -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 => ({
|
package/src/core/session.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
369
|
-
this.iceServers = [...BASE_ICE_SERVERS, ...
|
|
370
|
-
this.turnAvailable =
|
|
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
|
-
|
|
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
|
-
|
|
760
|
+
unsubscribe = this.subscribe(() => {
|
|
664
761
|
if (!predicate()) return
|
|
665
|
-
|
|
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
|
|
1000
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
package/src/core/targeting.ts
CHANGED
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1573
|
+
try {
|
|
1574
|
+
await stop()
|
|
1575
|
+
} finally {
|
|
1576
|
+
app.dispose()
|
|
1577
|
+
}
|
|
1515
1578
|
}
|
|
1516
1579
|
}
|
|
File without changes
|
package/src/tui/file-search.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/types/bun-test.d.ts
CHANGED
|
File without changes
|