@elefunc/send 0.1.4 → 0.1.5
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/package.json +1 -1
- package/src/core/session.ts +45 -7
- package/src/tui/app.ts +48 -9
package/package.json
CHANGED
package/src/core/session.ts
CHANGED
|
@@ -157,6 +157,7 @@ export interface SessionConfig {
|
|
|
157
157
|
localId?: string
|
|
158
158
|
name?: string
|
|
159
159
|
saveDir?: string
|
|
160
|
+
peerSelectionMemory?: Map<string, boolean>
|
|
160
161
|
autoAcceptIncoming?: boolean
|
|
161
162
|
autoSaveIncoming?: boolean
|
|
162
163
|
turnUrls?: string[]
|
|
@@ -257,7 +258,12 @@ export const turnUsageState = (
|
|
|
257
258
|
return "idle"
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
const timeoutSignal = (ms: number
|
|
261
|
+
const timeoutSignal = (ms: number, base?: AbortSignal | null) => {
|
|
262
|
+
const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
|
|
263
|
+
if (!base) return timeout
|
|
264
|
+
if (!timeout) return base
|
|
265
|
+
return typeof AbortSignal.any === "function" ? AbortSignal.any([base, timeout]) : base
|
|
266
|
+
}
|
|
261
267
|
|
|
262
268
|
const normalizeCandidateType = (value: unknown) => typeof value === "string" ? value.toLowerCase() : ""
|
|
263
269
|
const validCandidateType = (value: string) => ["host", "srflx", "prflx", "relay"].includes(value)
|
|
@@ -335,6 +341,7 @@ export class SendSession {
|
|
|
335
341
|
private autoSaveIncoming: boolean
|
|
336
342
|
private readonly reconnectSocket: boolean
|
|
337
343
|
private readonly iceServers: RTCIceServer[]
|
|
344
|
+
private readonly peerSelectionMemory: Map<string, boolean>
|
|
338
345
|
private readonly peers = new Map<string, PeerState>()
|
|
339
346
|
private readonly transfers = new Map<string, TransferState>()
|
|
340
347
|
private readonly logs: LogEntry[] = []
|
|
@@ -346,6 +353,7 @@ export class SendSession {
|
|
|
346
353
|
private socketToken = 0
|
|
347
354
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
348
355
|
private peerStatsTimer: ReturnType<typeof setInterval> | null = null
|
|
356
|
+
private lifecycleAbortController: AbortController | null = null
|
|
349
357
|
private stopped = false
|
|
350
358
|
|
|
351
359
|
constructor(config: SessionConfig) {
|
|
@@ -353,6 +361,7 @@ export class SendSession {
|
|
|
353
361
|
this.room = cleanRoom(config.room)
|
|
354
362
|
this.name = cleanName(config.name ?? fallbackName)
|
|
355
363
|
this.saveDir = resolve(config.saveDir ?? resolve(process.cwd(), "downloads"))
|
|
364
|
+
this.peerSelectionMemory = config.peerSelectionMemory ?? new Map()
|
|
356
365
|
this.autoAcceptIncoming = !!config.autoAcceptIncoming
|
|
357
366
|
this.autoSaveIncoming = !!config.autoSaveIncoming
|
|
358
367
|
this.reconnectSocket = config.reconnectSocket ?? true
|
|
@@ -394,6 +403,8 @@ export class SendSession {
|
|
|
394
403
|
|
|
395
404
|
async connect(timeoutMs = 10_000) {
|
|
396
405
|
this.stopped = false
|
|
406
|
+
this.lifecycleAbortController?.abort()
|
|
407
|
+
this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
|
|
397
408
|
this.startPeerStatsPolling()
|
|
398
409
|
void this.loadLocalProfile()
|
|
399
410
|
void this.probePulse()
|
|
@@ -405,6 +416,9 @@ export class SendSession {
|
|
|
405
416
|
this.stopped = true
|
|
406
417
|
this.stopPeerStatsPolling()
|
|
407
418
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
419
|
+
this.reconnectTimer = null
|
|
420
|
+
this.lifecycleAbortController?.abort()
|
|
421
|
+
this.lifecycleAbortController = null
|
|
408
422
|
if (this.socket?.readyState === WebSocket.OPEN) this.sendSignal({ kind: "bye" })
|
|
409
423
|
const socket = this.socket
|
|
410
424
|
this.socket = null
|
|
@@ -428,8 +442,12 @@ export class SendSession {
|
|
|
428
442
|
|
|
429
443
|
setPeerSelected(peerId: string, selected: boolean) {
|
|
430
444
|
const peer = this.peers.get(peerId)
|
|
431
|
-
if (!peer
|
|
432
|
-
|
|
445
|
+
if (!peer) return false
|
|
446
|
+
const next = !!selected
|
|
447
|
+
const rememberedChanged = this.rememberPeerSelected(peerId, next)
|
|
448
|
+
if (peer.presence !== "active") return rememberedChanged
|
|
449
|
+
if (peer.selected === next && !rememberedChanged) return false
|
|
450
|
+
peer.selected = next
|
|
433
451
|
this.emit({ type: "peer", peer: this.peerSnapshot(peer) })
|
|
434
452
|
this.notify()
|
|
435
453
|
return true
|
|
@@ -862,13 +880,17 @@ export class SendSession {
|
|
|
862
880
|
|
|
863
881
|
private async loadLocalProfile() {
|
|
864
882
|
try {
|
|
865
|
-
const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000) })
|
|
883
|
+
const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000, this.lifecycleAbortController?.signal) })
|
|
866
884
|
if (!response.ok) throw new Error(`profile ${response.status}`)
|
|
867
|
-
|
|
885
|
+
const data = await response.json()
|
|
886
|
+
if (this.stopped) return
|
|
887
|
+
this.profile = localProfileFromResponse(data)
|
|
868
888
|
} catch (error) {
|
|
889
|
+
if (this.stopped) return
|
|
869
890
|
this.profile = localProfileFromResponse(null, `${error}`)
|
|
870
891
|
this.pushLog("profile:error", { error: `${error}` }, "error")
|
|
871
892
|
}
|
|
893
|
+
if (this.stopped) return
|
|
872
894
|
this.broadcastProfile()
|
|
873
895
|
this.notify()
|
|
874
896
|
}
|
|
@@ -878,13 +900,16 @@ export class SendSession {
|
|
|
878
900
|
this.pulse = { ...this.pulse, state: "checking", error: "" }
|
|
879
901
|
this.notify()
|
|
880
902
|
try {
|
|
881
|
-
const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500) })
|
|
903
|
+
const response = await fetch(PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
|
|
882
904
|
if (!response.ok) throw new Error(`pulse ${response.status}`)
|
|
905
|
+
if (this.stopped) return
|
|
883
906
|
this.pulse = { state: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
|
|
884
907
|
} catch (error) {
|
|
908
|
+
if (this.stopped) return
|
|
885
909
|
this.pulse = { state: "error", at: Date.now(), ms: 0, error: `${error}` }
|
|
886
910
|
this.pushLog("pulse:error", { error: `${error}` }, "error")
|
|
887
911
|
}
|
|
912
|
+
if (this.stopped) return
|
|
888
913
|
this.notify()
|
|
889
914
|
}
|
|
890
915
|
|
|
@@ -931,12 +956,23 @@ export class SendSession {
|
|
|
931
956
|
return !!peer && !!channel && peer.rtcEpoch === rtcEpoch && peer.dc === channel && channel.readyState === "open"
|
|
932
957
|
}
|
|
933
958
|
|
|
959
|
+
private peerSelected(peerId: string) {
|
|
960
|
+
return this.peerSelectionMemory.get(peerId) ?? true
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private rememberPeerSelected(peerId: string, selected: boolean) {
|
|
964
|
+
const next = !!selected
|
|
965
|
+
const previous = this.peerSelectionMemory.get(peerId)
|
|
966
|
+
this.peerSelectionMemory.set(peerId, next)
|
|
967
|
+
return previous !== next
|
|
968
|
+
}
|
|
969
|
+
|
|
934
970
|
private buildPeer(remoteId: string) {
|
|
935
971
|
const peer: PeerState = {
|
|
936
972
|
id: remoteId,
|
|
937
973
|
name: fallbackName,
|
|
938
974
|
presence: "active",
|
|
939
|
-
selected:
|
|
975
|
+
selected: this.peerSelected(remoteId),
|
|
940
976
|
polite: this.localId > remoteId,
|
|
941
977
|
pc: null,
|
|
942
978
|
dc: null,
|
|
@@ -962,9 +998,11 @@ export class SendSession {
|
|
|
962
998
|
|
|
963
999
|
private syncPeerPresence(remoteId: string, name?: string, profile?: PeerProfile, turnAvailable?: boolean) {
|
|
964
1000
|
const peer = this.peers.get(remoteId) ?? this.buildPeer(remoteId)
|
|
1001
|
+
const wasTerminal = peer.presence === "terminal"
|
|
965
1002
|
peer.lastSeenAt = Date.now()
|
|
966
1003
|
peer.presence = "active"
|
|
967
1004
|
peer.terminalReason = ""
|
|
1005
|
+
if (wasTerminal) peer.selected = this.peerSelected(remoteId)
|
|
968
1006
|
if (name != null) {
|
|
969
1007
|
peer.name = cleanName(name)
|
|
970
1008
|
for (const transfer of this.transfers.values()) if (transfer.peerId === remoteId) transfer.peerName = peer.name
|
package/src/tui/app.ts
CHANGED
|
@@ -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
|
|
@@ -98,6 +99,10 @@ const DRAFT_INPUT_ID = "draft-input"
|
|
|
98
99
|
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
99
100
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
100
101
|
const DEFAULT_WEB_URL = "https://send.rt.ht/"
|
|
102
|
+
const TRANSFER_DIRECTION_ARROW = {
|
|
103
|
+
out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
|
|
104
|
+
in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
|
|
105
|
+
} as const
|
|
101
106
|
|
|
102
107
|
const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
103
108
|
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
@@ -180,6 +185,20 @@ const peerConnectionStatusKind = (status: string) => ({
|
|
|
180
185
|
idle: "unknown",
|
|
181
186
|
new: "unknown",
|
|
182
187
|
}[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
|
|
188
|
+
const transferStatusKind = (status: TransferSnapshot["status"]) => ({
|
|
189
|
+
complete: "online",
|
|
190
|
+
sending: "online",
|
|
191
|
+
receiving: "online",
|
|
192
|
+
"awaiting-done": "online",
|
|
193
|
+
accepted: "busy",
|
|
194
|
+
queued: "busy",
|
|
195
|
+
offered: "busy",
|
|
196
|
+
pending: "busy",
|
|
197
|
+
cancelling: "busy",
|
|
198
|
+
rejected: "offline",
|
|
199
|
+
cancelled: "offline",
|
|
200
|
+
error: "offline",
|
|
201
|
+
}[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
|
|
183
202
|
const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
|
|
184
203
|
const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
|
|
185
204
|
const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
|
|
@@ -298,8 +317,19 @@ const normalizeSessionSeed = (config: SessionConfig): SessionSeed => ({
|
|
|
298
317
|
room: cleanRoom(config.room),
|
|
299
318
|
})
|
|
300
319
|
|
|
301
|
-
const
|
|
320
|
+
const roomPeerSelectionMemory = (peerSelectionByRoom: Map<string, Map<string, boolean>>, room: string) => {
|
|
321
|
+
const roomKey = cleanRoom(room)
|
|
322
|
+
let selectionMemory = peerSelectionByRoom.get(roomKey)
|
|
323
|
+
if (!selectionMemory) {
|
|
324
|
+
selectionMemory = new Map<string, boolean>()
|
|
325
|
+
peerSelectionByRoom.set(roomKey, selectionMemory)
|
|
326
|
+
}
|
|
327
|
+
return selectionMemory
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean, peerSelectionMemory: Map<string, boolean>) => new SendSession({
|
|
302
331
|
...seed,
|
|
332
|
+
peerSelectionMemory,
|
|
303
333
|
autoAcceptIncoming,
|
|
304
334
|
autoSaveIncoming,
|
|
305
335
|
})
|
|
@@ -435,11 +465,13 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
|
|
|
435
465
|
const sessionSeed = normalizeSessionSeed(initialConfig)
|
|
436
466
|
const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
|
|
437
467
|
const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
|
|
438
|
-
const
|
|
468
|
+
const peerSelectionByRoom = new Map<string, Map<string, boolean>>()
|
|
469
|
+
const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming, roomPeerSelectionMemory(peerSelectionByRoom, sessionSeed.room))
|
|
439
470
|
const focusState = deriveBootFocusState(sessionSeed.name)
|
|
440
471
|
return {
|
|
441
472
|
session,
|
|
442
473
|
sessionSeed,
|
|
474
|
+
peerSelectionByRoom,
|
|
443
475
|
snapshot: session.snapshot(),
|
|
444
476
|
focusedId: null,
|
|
445
477
|
roomInput: sessionSeed.room,
|
|
@@ -755,6 +787,7 @@ const transferPathLabel = (transfer: TransferSnapshot, peersById: Map<string, Pe
|
|
|
755
787
|
|
|
756
788
|
const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
|
|
757
789
|
const hasStarted = !!transfer.startedAt
|
|
790
|
+
const directionArrow = TRANSFER_DIRECTION_ARROW[transfer.direction]
|
|
758
791
|
const facts = [
|
|
759
792
|
renderTransferFact("Size", formatBytes(transfer.size)),
|
|
760
793
|
renderTransferFact("Path", transferPathLabel(transfer, peersById)),
|
|
@@ -767,13 +800,18 @@ const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, Pe
|
|
|
767
800
|
|
|
768
801
|
return denseSection({
|
|
769
802
|
key: transfer.id,
|
|
770
|
-
|
|
803
|
+
titleNode: ui.row({ id: `transfer-title-row-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
|
|
804
|
+
ui.row({ id: `transfer-title-main-${transfer.id}`, gap: 0, items: "center", wrap: true }, [
|
|
805
|
+
ui.text(directionArrow.glyph, { style: directionArrow.style }),
|
|
806
|
+
ui.text(` ${transfer.name}`, { variant: "heading" }),
|
|
807
|
+
]),
|
|
808
|
+
ui.row({ id: `transfer-badges-${transfer.id}`, gap: 1, items: "center", wrap: true }, [
|
|
809
|
+
ui.status(transferStatusKind(transfer.status), { label: transfer.status, showLabel: true }),
|
|
810
|
+
transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
|
|
811
|
+
]),
|
|
812
|
+
]),
|
|
771
813
|
actions: transferActionButtons(transfer, actions),
|
|
772
814
|
}, [
|
|
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
815
|
ui.row({ gap: 0, wrap: true }, facts),
|
|
778
816
|
ui.progress(transferProgress(transfer), { showPercent: true, label: `${percentFormat.format(transfer.progress)}%` }),
|
|
779
817
|
ui.row({ gap: 0, wrap: true }, [
|
|
@@ -1188,12 +1226,13 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1188
1226
|
|
|
1189
1227
|
const replaceSession = (nextSeed: SessionSeed, text: string, options: { reseedBootFocus?: boolean } = {}) => {
|
|
1190
1228
|
const previousSession = state.session
|
|
1191
|
-
const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming)
|
|
1229
|
+
const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming, roomPeerSelectionMemory(state.peerSelectionByRoom, nextSeed.room))
|
|
1192
1230
|
stopPreviewSession()
|
|
1193
1231
|
commit(current => withNotice({
|
|
1194
1232
|
...current,
|
|
1195
1233
|
session: nextSession,
|
|
1196
1234
|
sessionSeed: nextSeed,
|
|
1235
|
+
peerSelectionByRoom: current.peerSelectionByRoom,
|
|
1197
1236
|
snapshot: nextSession.snapshot(),
|
|
1198
1237
|
roomInput: nextSeed.room,
|
|
1199
1238
|
nameInput: visibleNameInput(nextSeed.name),
|
|
@@ -1270,7 +1309,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
|
|
|
1270
1309
|
setNameInput: value => commit(current => ({ ...current, nameInput: value })),
|
|
1271
1310
|
toggleSelectReadyPeers: () => {
|
|
1272
1311
|
let changed = 0
|
|
1273
|
-
for (const peer of state.snapshot.peers) if (peer.presence === "active" &&
|
|
1312
|
+
for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
|
|
1274
1313
|
commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
|
|
1275
1314
|
maybeOfferDrafts()
|
|
1276
1315
|
},
|