@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 +1 -1
- package/src/core/session.ts +56 -16
- package/src/index.ts +13 -6
- package/src/tui/app.ts +24 -3
package/package.json
CHANGED
package/src/core/session.ts
CHANGED
|
@@ -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
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
494
|
-
|
|
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
|
|
823
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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,
|