@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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) => typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
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 || peer.presence !== "active") return false
432
- peer.selected = selected
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
- this.profile = localProfileFromResponse(await response.json())
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: true,
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 makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean) => new SendSession({
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 session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming)
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
- title: `${transfer.direction === "out" ? "" : "←"} ${transfer.name}`,
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" && state.session.setPeerSelected(peer.id, peer.ready)) changed += 1
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
  },