@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 +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 +244 -33
- package/src/core/targeting.ts +0 -0
- package/src/tui/app.ts +128 -26
- 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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
360
|
-
this.iceServers = [...BASE_ICE_SERVERS, ...
|
|
361
|
-
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
|
|
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
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
760
|
+
unsubscribe = this.subscribe(() => {
|
|
646
761
|
if (!predicate()) return
|
|
647
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
964
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
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"
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1573
|
+
try {
|
|
1574
|
+
await stop()
|
|
1575
|
+
} finally {
|
|
1576
|
+
app.dispose()
|
|
1577
|
+
}
|
|
1476
1578
|
}
|
|
1477
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
|