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