@elefunc/send 0.1.22 → 0.1.31

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.22",
3
+ "version": "0.1.31",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -103,6 +103,7 @@ interface IncomingDiskState {
103
103
  offset: number
104
104
  error: string
105
105
  closed: boolean
106
+ overwrite: boolean
106
107
  }
107
108
 
108
109
  export interface PeerSnapshot {
@@ -310,11 +311,41 @@ export class SessionAbortedError extends Error {
310
311
  export const isSessionAbortedError = (error: unknown): error is SessionAbortedError =>
311
312
  error instanceof SessionAbortedError || error instanceof Error && error.name === "SessionAbortedError"
312
313
 
313
- const timeoutSignal = (ms: number, base?: AbortSignal | null) => {
314
- const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined
315
- if (!base) return timeout
316
- if (!timeout) return base
317
- return typeof AbortSignal.any === "function" ? AbortSignal.any([base, timeout]) : base
314
+ const awaitAbortable = async <T>(promise: Promise<T>, signal?: AbortSignal | null) => {
315
+ if (!signal) return promise
316
+ if (signal.aborted) throw new SessionAbortedError()
317
+ return await new Promise<T>((resolve, reject) => {
318
+ const onAbort = () => {
319
+ signal.removeEventListener("abort", onAbort)
320
+ reject(new SessionAbortedError())
321
+ }
322
+ signal.addEventListener("abort", onAbort, { once: true })
323
+ promise.then(
324
+ value => {
325
+ signal.removeEventListener("abort", onAbort)
326
+ resolve(value)
327
+ },
328
+ error => {
329
+ signal.removeEventListener("abort", onAbort)
330
+ reject(error)
331
+ },
332
+ )
333
+ })
334
+ }
335
+
336
+ const fetchWithTimeout = async (input: string | URL | Request, init: RequestInit, ms: number, base?: AbortSignal | null) => {
337
+ if (typeof AbortController !== "function") return fetch(input, init)
338
+ const controller = new AbortController()
339
+ const timeout = setTimeout(() => controller.abort(new Error(`timed out after ${ms}ms`)), ms)
340
+ const onAbort = () => controller.abort(new SessionAbortedError())
341
+ if (base?.aborted) onAbort()
342
+ else base?.addEventListener("abort", onAbort, { once: true })
343
+ try {
344
+ return await fetch(input, { ...init, signal: controller.signal })
345
+ } finally {
346
+ clearTimeout(timeout)
347
+ base?.removeEventListener("abort", onAbort)
348
+ }
318
349
  }
319
350
 
320
351
  const normalizeCandidateType = (value: unknown) => typeof value === "string" ? value.toLowerCase() : ""
@@ -415,7 +446,7 @@ export class SendSession {
415
446
 
416
447
  private autoAcceptIncoming: boolean
417
448
  private autoSaveIncoming: boolean
418
- private readonly overwriteIncoming: boolean
449
+ private overwriteIncoming: boolean
419
450
  private readonly reconnectSocket: boolean
420
451
  private iceServers: RTCIceServer[]
421
452
  private extraTurnServers: RTCIceServer[]
@@ -490,9 +521,8 @@ export class SendSession {
490
521
  this.lifecycleAbortController = typeof AbortController === "function" ? new AbortController() : null
491
522
  this.startPeerStatsPolling()
492
523
  this.startPulsePolling()
493
- void this.loadLocalProfile()
494
- void this.probePulse()
495
- this.connectSocket()
524
+ await awaitAbortable(Promise.allSettled([this.loadLocalProfile(), this.probePulse()]), this.lifecycleAbortController?.signal) // Avoid the Bun Windows TUI TLS startup bug: https://github.com/oven-sh/bun/issues/28612
525
+ this.connectSocket() // Intentionally defer socket startup until after initial HTTPS calls: https://github.com/oven-sh/bun/issues/28612
496
526
  await this.waitFor(() => this.socketState === "open", timeoutMs, this.lifecycleAbortController?.signal)
497
527
  }
498
528
 
@@ -654,6 +684,14 @@ export class SendSession {
654
684
  return saved
655
685
  }
656
686
 
687
+ setOverwriteIncoming(enabled: boolean) {
688
+ const next = !!enabled
689
+ if (next === this.overwriteIncoming) return false
690
+ this.overwriteIncoming = next
691
+ this.notify()
692
+ return true
693
+ }
694
+
657
695
  cancelPendingOffers() {
658
696
  let cancelled = 0
659
697
  for (const transfer of this.transfers.values()) {
@@ -819,8 +857,9 @@ export class SendSession {
819
857
  }
820
858
 
821
859
  private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
822
- const finalPath = await incomingOutputPath(this.saveDir, fileName || "download", this.overwriteIncoming, this.reservedSavePaths)
823
- if (!this.overwriteIncoming) this.reservedSavePaths.add(finalPath)
860
+ const overwrite = this.overwriteIncoming
861
+ const finalPath = await incomingOutputPath(this.saveDir, fileName || "download", overwrite, this.reservedSavePaths)
862
+ if (!overwrite) this.reservedSavePaths.add(finalPath)
824
863
  for (let attempt = 0; ; attempt += 1) {
825
864
  const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
826
865
  try {
@@ -833,10 +872,11 @@ export class SendSession {
833
872
  offset: 0,
834
873
  error: "",
835
874
  closed: false,
875
+ overwrite,
836
876
  }
837
877
  } catch (error) {
838
878
  if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
839
- if (!this.overwriteIncoming) this.reservedSavePaths.delete(finalPath)
879
+ if (!overwrite) this.reservedSavePaths.delete(finalPath)
840
880
  throw error
841
881
  }
842
882
  }
@@ -906,12 +946,12 @@ export class SendSession {
906
946
  disk.closed = true
907
947
  await disk.handle.close()
908
948
  }
909
- if (!this.overwriteIncoming && await pathExists(finalPath)) {
949
+ if (!disk.overwrite && await pathExists(finalPath)) {
910
950
  this.reservedSavePaths.delete(finalPath)
911
951
  finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
912
952
  this.reservedSavePaths.add(finalPath)
913
953
  }
914
- if (this.overwriteIncoming) await replaceOutputPath(disk.tempPath, finalPath)
954
+ if (disk.overwrite) await replaceOutputPath(disk.tempPath, finalPath)
915
955
  else await rename(disk.tempPath, finalPath)
916
956
  transfer.savedPath = finalPath
917
957
  transfer.savedAt ||= Date.now()
@@ -1176,7 +1216,7 @@ export class SendSession {
1176
1216
 
1177
1217
  private async loadLocalProfile() {
1178
1218
  try {
1179
- const response = await fetch(PROFILE_URL, { cache: "no-store", signal: timeoutSignal(4000, this.lifecycleAbortController?.signal) })
1219
+ const response = await fetchWithTimeout(PROFILE_URL, { cache: "no-store" }, 4000, this.lifecycleAbortController?.signal)
1180
1220
  if (!response.ok) throw new Error(`profile ${response.status}`)
1181
1221
  const data = await response.json()
1182
1222
  if (this.stopped) return
@@ -1196,7 +1236,7 @@ export class SendSession {
1196
1236
  this.pulse = { ...this.pulse, state: "checking", error: "" }
1197
1237
  this.notify()
1198
1238
  try {
1199
- const response = await fetch(SIGNAL_PULSE_URL, { cache: "no-store", signal: timeoutSignal(3500, this.lifecycleAbortController?.signal) })
1239
+ const response = await fetchWithTimeout(SIGNAL_PULSE_URL, { cache: "no-store" }, 3500, this.lifecycleAbortController?.signal)
1200
1240
  if (!response.ok) throw new Error(`pulse ${response.status}`)
1201
1241
  if (this.stopped) return
1202
1242
  this.pulse = { state: "open", lastSettledState: "open", at: Date.now(), ms: performance.now() - startedAt, error: "" }
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ const TURN_OPTIONS = [
68
68
  ["--turn-username <value>", "custom TURN username"],
69
69
  ["--turn-credential <value>", "custom TURN credential"],
70
70
  ] as const
71
- const OVERWRITE_OPTION = ["--overwrite", "overwrite same-name saved files instead of creating copies"] as const
71
+ const OVERWRITE_OPTION = ["-o, --overwrite", "overwrite same-name saved files instead of creating copies"] as const
72
72
  const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory", { default: "." }] as const
73
73
  const TUI_TOGGLE_OPTIONS = [
74
74
  ["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0", { default: 1 }],
@@ -80,6 +80,13 @@ export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncom
80
80
  type CliOptionDefinition = readonly [flag: string, description: string, config?: { default?: unknown }]
81
81
  const addOptions = (command: CliCommand, definitions: readonly CliOptionDefinition[]) =>
82
82
  definitions.reduce((next, [flag, description, config]) => next.option(flag, description, config), command)
83
+ const normalizeCliOptions = (options: Record<string, unknown>) => {
84
+ const normalized = { ...options }
85
+ if (normalized.overwrite == null && normalized.o != null) normalized.overwrite = normalized.o
86
+ delete normalized.h
87
+ delete normalized.o
88
+ return normalized
89
+ }
83
90
  const withTrailingHelpLine = <T extends { outputHelp: () => void }>(target: T) => {
84
91
  const outputHelp = target.outputHelp.bind(target)
85
92
  target.outputHelp = () => {
@@ -323,7 +330,7 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
323
330
  ["--json", "print a json snapshot"],
324
331
  SAVE_DIR_OPTION,
325
332
  ...TURN_OPTIONS,
326
- ])).action(handlers.peers)
333
+ ])).action(options => handlers.peers(normalizeCliOptions(options)))
327
334
 
328
335
  withTrailingHelpLine(addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers").ignoreOptionDefaultValue(), [
329
336
  ...ROOM_SELF_OPTIONS,
@@ -332,7 +339,7 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
332
339
  ["--json", "emit ndjson events"],
333
340
  SAVE_DIR_OPTION,
334
341
  ...TURN_OPTIONS,
335
- ])).action(handlers.offer)
342
+ ])).action((files, options) => handlers.offer(files, normalizeCliOptions(options)))
336
343
 
337
344
  withTrailingHelpLine(addOptions(cli.command("accept", "receive and save files").ignoreOptionDefaultValue(), [
338
345
  ...ROOM_SELF_OPTIONS,
@@ -341,7 +348,7 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
341
348
  ["--once", "exit after the first saved incoming transfer"],
342
349
  ["--json", "emit ndjson events"],
343
350
  ...TURN_OPTIONS,
344
- ])).action(handlers.accept)
351
+ ])).action(options => handlers.accept(normalizeCliOptions(options)))
345
352
 
346
353
  withTrailingHelpLine(addOptions(cli.command("tui", "launch the interactive terminal UI").ignoreOptionDefaultValue(), [
347
354
  ...ROOM_SELF_OPTIONS,
@@ -350,7 +357,7 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
350
357
  SAVE_DIR_OPTION,
351
358
  OVERWRITE_OPTION,
352
359
  ...TURN_OPTIONS,
353
- ])).action(handlers.tui)
360
+ ])).action(options => handlers.tui(normalizeCliOptions(options)))
354
361
 
355
362
  cli.help(sections => {
356
363
  const usage = sections.find(section => section.title === "Usage:")
@@ -391,7 +398,7 @@ export const runCli = async (argv = process.argv, handlers: CliHandlers = defaul
391
398
  printSubcommandHelp(argv, handlers, "tui")
392
399
  return
393
400
  }
394
- await handlers.tui(parsed.options)
401
+ await handlers.tui(normalizeCliOptions(parsed.options))
395
402
  return
396
403
  }
397
404
  await cli.runMatchedCommand()
package/src/tui/app.ts CHANGED
@@ -117,6 +117,7 @@ export interface TuiActions {
117
117
  toggleAutoOffer: TuiAction
118
118
  toggleAutoAccept: TuiAction
119
119
  toggleAutoSave: TuiAction
120
+ toggleOverwrite: TuiAction
120
121
  setDraftInput: (value: string, cursor?: number) => void
121
122
  addDrafts: TuiAction
122
123
  removeDraft: (draftId: string) => void
@@ -151,7 +152,7 @@ const ABOUT_BULLETS = [
151
152
  "• Join a room, see who is there, and filter or select exactly which peers to target before offering files.",
152
153
  "• File data does not travel through the signaling service; Send uses lightweight signaling to discover peers and negotiate WebRTC, then transfers directly peer-to-peer when possible, with TURN relay when needed.",
153
154
  "• Incoming transfers can be auto-accepted and auto-saved, and same-name files can either stay as numbered copies or overwrite the original when that mode is enabled.",
154
- "• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag.",
155
+ "• The CLI streams incoming saves straight to disk in the current save directory, with overwrite available through the CLI flag and the TUI Ctrl+O shortcut.",
155
156
  "• Other features include copyable web and CLI invites, rendered-peer filtering and selection, TURN sharing, and live connection insight like signaling state, RTT, data state, and path labels.",
156
157
  ] as const
157
158
  const TRANSFER_DIRECTION_ARROW = {
@@ -293,6 +294,7 @@ export const createNoopTuiActions = (): TuiActions => ({
293
294
  toggleAutoOffer: noop,
294
295
  toggleAutoAccept: noop,
295
296
  toggleAutoSave: noop,
297
+ toggleOverwrite: noop,
296
298
  setDraftInput: noop,
297
299
  addDrafts: noop,
298
300
  removeDraft: noop,
@@ -1261,8 +1263,12 @@ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection(
1261
1263
  ]),
1262
1264
  ])
1263
1265
 
1266
+ const footerKeycapWidth = (keycap: string) => keycap.length + 2
1267
+
1264
1268
  const renderFooterHint = (id: string, keycap: string, label: string) => ui.row({ id, gap: 0, items: "center" }, [
1265
- ui.kbd(keycap),
1269
+ ui.box({ id: `${id}-keycap`, width: footerKeycapWidth(keycap), border: "none" }, [
1270
+ ui.kbd(keycap),
1271
+ ]),
1266
1272
  ui.text(` ${label}`, { style: { dim: true } }),
1267
1273
  ])
1268
1274
 
@@ -1273,7 +1279,7 @@ const renderFooter = (state: TuiState) => ui.statusBar({
1273
1279
  ui.toolbar({ id: "footer-hints", gap: 3 }, [
1274
1280
  renderFooterHint("footer-hint-tab", "tab", "focus/accept"),
1275
1281
  renderFooterHint("footer-hint-enter", "enter", "accept/add"),
1276
- renderFooterHint("footer-hint-esc", "esc", "hide/reset"),
1282
+ renderFooterHint("footer-hint-ctrl-o", "ctrl+o", "overwrite"),
1277
1283
  renderFooterHint("footer-hint-ctrlc", "ctrl+c", "quit"),
1278
1284
  ]),
1279
1285
  ],
@@ -1822,6 +1828,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1822
1828
  error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
1823
1829
  )
1824
1830
  },
1831
+ toggleOverwrite: () => {
1832
+ const next = !state.overwriteIncoming
1833
+ state.session.setOverwriteIncoming(next)
1834
+ commit(current => withNotice({
1835
+ ...current,
1836
+ overwriteIncoming: next,
1837
+ sessionSeed: { ...current.sessionSeed, overwriteIncoming: next },
1838
+ }, { text: next ? "Overwrite on." : "Overwrite off.", variant: next ? "success" : "warning" }))
1839
+ },
1825
1840
  setDraftInput: (value, cursor) => updateDraftInput(value, cursor),
1826
1841
  addDrafts,
1827
1842
  removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
@@ -1971,6 +1986,12 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1971
1986
  commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
1972
1987
  },
1973
1988
  },
1989
+ "ctrl+o": {
1990
+ description: "Toggle overwrite mode",
1991
+ handler: () => {
1992
+ actions.toggleOverwrite()
1993
+ },
1994
+ },
1974
1995
  enter: {
1975
1996
  description: "Commit focused input",
1976
1997
  when: ctx => ctx.focusedId === ROOM_INPUT_ID || ctx.focusedId === NAME_INPUT_ID || ctx.focusedId === DRAFT_INPUT_ID,