@elefunc/send 0.1.30 → 0.1.32

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.30",
3
+ "version": "0.1.32",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "send": "./src/index.ts"
13
13
  },
14
14
  "files": [
15
+ "scripts",
15
16
  "src",
16
17
  "runtime",
17
18
  "README.md",
@@ -19,6 +20,8 @@
19
20
  "package.json"
20
21
  ],
21
22
  "scripts": {
23
+ "build:standalone": "bun run ./scripts/build-standalone.ts",
24
+ "build:standalone_all": "bun run ./scripts/build-standalone-all.ts",
22
25
  "start": "bun run ./src/index.ts",
23
26
  "test": "bun test",
24
27
  "fix:rezi": "bun run ./dia/rezi-fix.ts",
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
7
+ const outRoot = join(packageRoot, "out")
8
+
9
+ export const standaloneCompileTargets = [
10
+ "bun-darwin-x64",
11
+ "bun-darwin-x64-baseline",
12
+ "bun-darwin-x64-modern",
13
+ "bun-darwin-arm64",
14
+ "bun-linux-x64",
15
+ "bun-linux-x64-baseline",
16
+ "bun-linux-x64-modern",
17
+ "bun-linux-arm64",
18
+ "bun-linux-x64-musl",
19
+ "bun-linux-x64-musl-baseline",
20
+ "bun-linux-x64-musl-modern",
21
+ "bun-linux-arm64-musl",
22
+ "bun-windows-x64",
23
+ "bun-windows-x64-baseline",
24
+ "bun-windows-x64-modern",
25
+ "bun-windows-arm64",
26
+ ] as const
27
+
28
+ export type StandaloneCompileTarget = (typeof standaloneCompileTargets)[number]
29
+
30
+ export const standaloneTargetToBasename = (target: StandaloneCompileTarget) => `send-${target.replace(/^bun-/, "")}`
31
+
32
+ export const standaloneTargetToArtifactName = (target: StandaloneCompileTarget) => {
33
+ const basename = standaloneTargetToBasename(target)
34
+ return target.includes("windows") ? `${basename}.exe` : basename
35
+ }
36
+
37
+ const runBuild = (target: StandaloneCompileTarget, outfile: string) => {
38
+ const proc = spawnSync(
39
+ process.execPath,
40
+ ["run", "./scripts/build-standalone.ts", "--outfile", outfile, "--target", target],
41
+ {
42
+ cwd: packageRoot,
43
+ stdio: "inherit",
44
+ },
45
+ )
46
+ if (proc.error) throw proc.error
47
+ if (proc.status !== 0) throw new Error(`build:standalone failed for ${target} with exit code ${proc.status}`)
48
+ }
49
+
50
+ const main = () => {
51
+ const args = process.argv.slice(2)
52
+ if (args.length > 0) throw new Error(`build:standalone_all does not accept arguments: ${args.join(" ")}`)
53
+
54
+ mkdirSync(outRoot, { recursive: true })
55
+
56
+ for (const [index, target] of standaloneCompileTargets.entries()) {
57
+ const basename = standaloneTargetToBasename(target)
58
+ const artifactName = standaloneTargetToArtifactName(target)
59
+ const outfile = join(outRoot, basename)
60
+ const artifactPath = join(outRoot, artifactName)
61
+ rmSync(outfile, { force: true })
62
+ if (artifactPath !== outfile) rmSync(artifactPath, { force: true })
63
+ console.log(`[${index + 1}/${standaloneCompileTargets.length}] Building ${target} -> out/${artifactName}`)
64
+ runBuild(target, outfile)
65
+ if (!existsSync(artifactPath)) throw new Error(`Expected artifact was not created for ${target}: ${artifactPath}`)
66
+ }
67
+
68
+ console.log(`Built ${standaloneCompileTargets.length} standalone binaries in ${outRoot}`)
69
+ }
70
+
71
+ if (import.meta.main) main()
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { dirname, join, relative, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { buildRuntimeArchive, renderStandaloneBootstrapSource } from "./standalone-lib"
7
+
8
+ type BuildOptions = {
9
+ keepTemp: boolean
10
+ outfile: string
11
+ target?: string
12
+ }
13
+
14
+ type PackageJsonShape = {
15
+ name: string
16
+ version: string
17
+ }
18
+
19
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
20
+
21
+ const toImportSpecifier = (fromDir: string, targetPath: string) => {
22
+ const relativePath = relative(fromDir, targetPath).replaceAll("\\", "/")
23
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`
24
+ }
25
+
26
+ export const parseBuildArgs = (argv: readonly string[]): BuildOptions => {
27
+ let keepTemp = false
28
+ let outfile = resolve(packageRoot, "out/send")
29
+ let target: string | undefined
30
+ for (let index = 0; index < argv.length; index++) {
31
+ const arg = argv[index]
32
+ if (arg === "--keep-temp") {
33
+ keepTemp = true
34
+ continue
35
+ }
36
+ if (arg === "--outfile") {
37
+ const value = argv[index + 1]
38
+ if (!value) throw new Error("Missing value for --outfile")
39
+ outfile = resolve(packageRoot, value)
40
+ index += 1
41
+ continue
42
+ }
43
+ if (arg === "--target") {
44
+ const value = argv[index + 1]
45
+ if (!value) throw new Error("Missing value for --target")
46
+ target = value
47
+ index += 1
48
+ continue
49
+ }
50
+ throw new Error(`Unknown argument: ${arg}`)
51
+ }
52
+ return { keepTemp, outfile, target }
53
+ }
54
+
55
+ const copyIfPresent = (sourcePath: string, targetPath: string) => {
56
+ if (!existsSync(sourcePath)) return
57
+ mkdirSync(dirname(targetPath), { recursive: true })
58
+ cpSync(sourcePath, targetPath, { recursive: true })
59
+ }
60
+
61
+ const runBun = (args: string[], cwd: string) => {
62
+ const proc = spawnSync(process.execPath, args, {
63
+ cwd,
64
+ stdio: "inherit",
65
+ })
66
+ if (proc.error) throw proc.error
67
+ if (proc.status !== 0) throw new Error(`bun ${args.join(" ")} failed with exit code ${proc.status}`)
68
+ }
69
+
70
+ const loadPackageJson = async () => JSON.parse(await Bun.file(join(packageRoot, "package.json")).text()) as PackageJsonShape
71
+
72
+ const main = async () => {
73
+ const options = parseBuildArgs(process.argv.slice(2))
74
+ const packageJson = await loadPackageJson()
75
+ const workspaceRoot = mkdtempSync(join(tmpdir(), "send-standalone-build-"))
76
+ const stageRoot = join(workspaceRoot, "stage")
77
+ const generatedRoot = join(workspaceRoot, "generated")
78
+ mkdirSync(stageRoot, { recursive: true })
79
+ mkdirSync(generatedRoot, { recursive: true })
80
+
81
+ copyIfPresent(join(packageRoot, "LICENSE"), join(stageRoot, "LICENSE"))
82
+ copyIfPresent(join(packageRoot, "README.md"), join(stageRoot, "README.md"))
83
+ copyIfPresent(join(packageRoot, "package.json"), join(stageRoot, "package.json"))
84
+ copyIfPresent(join(packageRoot, "runtime"), join(stageRoot, "runtime"))
85
+ copyIfPresent(join(packageRoot, "src"), join(stageRoot, "src"))
86
+ copyIfPresent(join(packageRoot, "tsconfig.json"), join(stageRoot, "tsconfig.json"))
87
+
88
+ try {
89
+ runBun(["install", "--production"], stageRoot)
90
+
91
+ const runtimeArchive = buildRuntimeArchive(stageRoot, join(generatedRoot, "runtime.bin"))
92
+ const bootstrapPath = join(generatedRoot, "bootstrap.ts")
93
+ writeFileSync(
94
+ bootstrapPath,
95
+ renderStandaloneBootstrapSource({
96
+ archiveImportPath: toImportSpecifier(generatedRoot, runtimeArchive.archivePath),
97
+ entrypointRelativePath: "src/index.ts",
98
+ helperImportPath: toImportSpecifier(generatedRoot, join(packageRoot, "scripts/standalone-lib.ts")),
99
+ packageName: packageJson.name,
100
+ packageVersion: packageJson.version,
101
+ runtimeHash: runtimeArchive.hash,
102
+ }),
103
+ )
104
+
105
+ mkdirSync(dirname(options.outfile), { recursive: true })
106
+ const buildArgs = ["build", "--compile", bootstrapPath, "--outfile", options.outfile]
107
+ if (options.target) buildArgs.push("--target", options.target)
108
+ runBun(buildArgs, packageRoot)
109
+
110
+ console.log(`Built ${options.outfile}`)
111
+ console.log(`Embedded ${runtimeArchive.fileCount} files (${runtimeArchive.totalBytes} bytes) from ${workspaceRoot}`)
112
+ } finally {
113
+ if (!options.keepTemp) rmSync(workspaceRoot, { recursive: true, force: true })
114
+ }
115
+ }
116
+
117
+ if (import.meta.main) await main()
@@ -0,0 +1,247 @@
1
+ import { spawnSync, type SpawnSyncOptionsWithBufferEncoding, type SpawnSyncReturns } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs"
14
+ import { tmpdir } from "node:os"
15
+ import { basename, dirname, join, relative, resolve, sep } from "node:path"
16
+
17
+ const ARCHIVE_MAGIC = Buffer.from("SENDRT1\0", "utf8")
18
+ const READY_FILE = ".send-standalone-ready.json"
19
+ const encoder = new TextEncoder()
20
+ const decoder = new TextDecoder()
21
+ const ROOT_FILES = new Set(["LICENSE", "README.md", "package.json", "tsconfig.json"])
22
+
23
+ export type RuntimeArchiveFile = {
24
+ absolutePath: string
25
+ relativePath: string
26
+ size: number
27
+ }
28
+
29
+ export type RuntimeArchiveInfo = {
30
+ archivePath: string
31
+ fileCount: number
32
+ hash: string
33
+ totalBytes: number
34
+ }
35
+
36
+ export type EnsureExtractedRuntimeOptions = {
37
+ archivePath: string
38
+ packageName: string
39
+ packageVersion: string
40
+ runtimeHash: string
41
+ }
42
+
43
+ export type RunExtractedRuntimeOptions = {
44
+ args?: readonly string[]
45
+ entrypointRelativePath: string
46
+ env?: NodeJS.ProcessEnv
47
+ execPath?: string
48
+ runtimeRoot: string
49
+ stdio?: SpawnSyncOptionsWithBufferEncoding["stdio"]
50
+ }
51
+
52
+ export type LaunchStandaloneRuntimeOptions = EnsureExtractedRuntimeOptions & {
53
+ args?: readonly string[]
54
+ entrypointRelativePath: string
55
+ }
56
+
57
+ const normalizeRelativePath = (path: string) => path.replaceAll("\\", "/").replace(/^\.\//, "")
58
+
59
+ const writeUInt32 = (value: number) => {
60
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) throw new Error(`Expected an unsigned 32-bit integer, got ${value}`)
61
+ const buffer = Buffer.allocUnsafe(4)
62
+ buffer.writeUInt32LE(value, 0)
63
+ return buffer
64
+ }
65
+
66
+ const readUInt32 = (bytes: Uint8Array, offset: number) => {
67
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 4)
68
+ return view.getUint32(0, true)
69
+ }
70
+
71
+ const safePackageDir = (packageName: string) => packageName.replace(/^@/, "").replace(/[\\/]+/g, "-")
72
+
73
+ const resolveExtractTarget = (outputRoot: string, relativePath: string) => {
74
+ const target = resolve(outputRoot, relativePath)
75
+ const normalizedRoot = resolve(outputRoot)
76
+ const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`
77
+ if (target !== normalizedRoot && !target.startsWith(rootPrefix)) throw new Error(`Refusing to extract outside runtime root: ${relativePath}`)
78
+ return target
79
+ }
80
+
81
+ export const shouldIncludeRuntimePath = (relativePath: string) => {
82
+ const normalized = normalizeRelativePath(relativePath)
83
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return false
84
+ if (normalized === "bun.lock" || normalized === "bun.lockb") return false
85
+ if (normalized.startsWith("node_modules/.bin/")) return false
86
+ if (ROOT_FILES.has(normalized)) return true
87
+ return normalized.startsWith("node_modules/") || normalized.startsWith("runtime/") || normalized.startsWith("src/")
88
+ }
89
+
90
+ export const collectRuntimeFiles = (root: string): RuntimeArchiveFile[] => {
91
+ const files: RuntimeArchiveFile[] = []
92
+ const walk = (dir: string) => {
93
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
94
+ const absolutePath = join(dir, entry.name)
95
+ if (entry.isSymbolicLink()) continue
96
+ if (entry.isDirectory()) {
97
+ walk(absolutePath)
98
+ continue
99
+ }
100
+ if (!entry.isFile()) continue
101
+ const relativePath = normalizeRelativePath(relative(root, absolutePath))
102
+ if (!shouldIncludeRuntimePath(relativePath)) continue
103
+ files.push({
104
+ absolutePath,
105
+ relativePath,
106
+ size: statSync(absolutePath).size,
107
+ })
108
+ }
109
+ }
110
+ walk(resolve(root))
111
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath))
112
+ return files
113
+ }
114
+
115
+ export const buildRuntimeArchive = (root: string, archivePath: string): RuntimeArchiveInfo => {
116
+ const files = collectRuntimeFiles(root)
117
+ const chunks: Buffer[] = [ARCHIVE_MAGIC, writeUInt32(files.length)]
118
+ const hash = createHash("sha256")
119
+ let totalBytes = ARCHIVE_MAGIC.byteLength + 4
120
+ for (const file of files) {
121
+ const pathBytes = Buffer.from(file.relativePath, "utf8")
122
+ const data = readFileSync(file.absolutePath)
123
+ hash.update(pathBytes)
124
+ hash.update("\0")
125
+ hash.update(data)
126
+ chunks.push(writeUInt32(pathBytes.byteLength), pathBytes, writeUInt32(data.byteLength), data)
127
+ totalBytes += 8 + pathBytes.byteLength + data.byteLength
128
+ }
129
+ mkdirSync(dirname(archivePath), { recursive: true })
130
+ writeFileSync(archivePath, Buffer.concat(chunks, totalBytes))
131
+ return {
132
+ archivePath,
133
+ fileCount: files.length,
134
+ hash: hash.digest("hex").slice(0, 12),
135
+ totalBytes,
136
+ }
137
+ }
138
+
139
+ export const extractRuntimeArchive = async (archivePath: string, outputRoot: string) => {
140
+ const bytes = new Uint8Array(await Bun.file(archivePath).arrayBuffer())
141
+ if (bytes.byteLength < ARCHIVE_MAGIC.byteLength + 4) throw new Error(`Standalone runtime archive is too small: ${archivePath}`)
142
+ if (Buffer.compare(Buffer.from(bytes.subarray(0, ARCHIVE_MAGIC.byteLength)), ARCHIVE_MAGIC) !== 0) {
143
+ throw new Error(`Unsupported standalone runtime archive header in ${archivePath}`)
144
+ }
145
+ let offset = ARCHIVE_MAGIC.byteLength
146
+ const fileCount = readUInt32(bytes, offset)
147
+ offset += 4
148
+ for (let index = 0; index < fileCount; index++) {
149
+ if (offset + 8 > bytes.byteLength) throw new Error(`Standalone runtime archive truncated before file ${index + 1}`)
150
+ const pathLength = readUInt32(bytes, offset)
151
+ offset += 4
152
+ if (offset + pathLength > bytes.byteLength) throw new Error(`Standalone runtime archive truncated while reading file ${index + 1} path`)
153
+ const relativePath = decoder.decode(bytes.subarray(offset, offset + pathLength))
154
+ offset += pathLength
155
+ if (!shouldIncludeRuntimePath(relativePath)) throw new Error(`Unexpected runtime archive path: ${relativePath}`)
156
+ const fileSize = readUInt32(bytes, offset)
157
+ offset += 4
158
+ if (offset + fileSize > bytes.byteLength) throw new Error(`Standalone runtime archive truncated while reading ${relativePath}`)
159
+ const target = resolveExtractTarget(outputRoot, relativePath)
160
+ mkdirSync(dirname(target), { recursive: true })
161
+ await Bun.write(target, bytes.subarray(offset, offset + fileSize))
162
+ offset += fileSize
163
+ }
164
+ if (offset !== bytes.byteLength) throw new Error(`Standalone runtime archive has ${bytes.byteLength - offset} trailing bytes`)
165
+ return fileCount
166
+ }
167
+
168
+ export const ensureExtractedRuntime = async (options: EnsureExtractedRuntimeOptions) => {
169
+ const runtimeBase = join(tmpdir(), safePackageDir(options.packageName))
170
+ const runtimeRoot = join(runtimeBase, `${options.packageVersion}-${options.runtimeHash}`)
171
+ const readyPath = join(runtimeRoot, READY_FILE)
172
+ if (existsSync(readyPath)) return runtimeRoot
173
+ mkdirSync(runtimeBase, { recursive: true })
174
+ if (existsSync(runtimeRoot)) rmSync(runtimeRoot, { recursive: true, force: true })
175
+ const tempRoot = mkdtempSync(join(runtimeBase, `${basename(runtimeRoot)}.tmp-`))
176
+ try {
177
+ const fileCount = await extractRuntimeArchive(options.archivePath, tempRoot)
178
+ writeFileSync(
179
+ join(tempRoot, READY_FILE),
180
+ JSON.stringify(
181
+ {
182
+ files: fileCount,
183
+ packageName: options.packageName,
184
+ runtimeHash: options.runtimeHash,
185
+ version: options.packageVersion,
186
+ },
187
+ null,
188
+ 2,
189
+ ),
190
+ )
191
+ try {
192
+ renameSync(tempRoot, runtimeRoot)
193
+ } catch (error) {
194
+ if (existsSync(readyPath)) {
195
+ rmSync(tempRoot, { recursive: true, force: true })
196
+ return runtimeRoot
197
+ }
198
+ throw error
199
+ }
200
+ } catch (error) {
201
+ rmSync(tempRoot, { recursive: true, force: true })
202
+ throw error
203
+ }
204
+ return runtimeRoot
205
+ }
206
+
207
+ export const runExtractedRuntime = (options: RunExtractedRuntimeOptions): SpawnSyncReturns<Buffer> => {
208
+ const entrypointPath = join(options.runtimeRoot, options.entrypointRelativePath)
209
+ return spawnSync(options.execPath ?? process.execPath, ["run", entrypointPath, ...(options.args ?? process.argv.slice(2))], {
210
+ cwd: process.cwd(),
211
+ env: { ...(options.env ?? process.env), BUN_BE_BUN: "1" },
212
+ stdio: options.stdio ?? "inherit",
213
+ })
214
+ }
215
+
216
+ export const launchStandaloneRuntime = async (options: LaunchStandaloneRuntimeOptions) => {
217
+ const runtimeRoot = await ensureExtractedRuntime(options)
218
+ const result = runExtractedRuntime({
219
+ args: options.args,
220
+ entrypointRelativePath: options.entrypointRelativePath,
221
+ runtimeRoot,
222
+ })
223
+ if (result.error) throw result.error
224
+ if (result.signal) process.kill(process.pid, result.signal)
225
+ return result.status ?? 1
226
+ }
227
+
228
+ export const renderStandaloneBootstrapSource = (options: {
229
+ archiveImportPath: string
230
+ entrypointRelativePath: string
231
+ helperImportPath: string
232
+ packageName: string
233
+ packageVersion: string
234
+ runtimeHash: string
235
+ }) => `import runtimeArchive from ${JSON.stringify(options.archiveImportPath)} with { type: "file" }
236
+ import { launchStandaloneRuntime } from ${JSON.stringify(options.helperImportPath)}
237
+
238
+ const exitCode = await launchStandaloneRuntime({
239
+ archivePath: runtimeArchive,
240
+ packageName: ${JSON.stringify(options.packageName)},
241
+ packageVersion: ${JSON.stringify(options.packageVersion)},
242
+ runtimeHash: ${JSON.stringify(options.runtimeHash)},
243
+ entrypointRelativePath: ${JSON.stringify(options.entrypointRelativePath)},
244
+ })
245
+
246
+ process.exit(exitCode)
247
+ `
@@ -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 {
@@ -445,7 +446,7 @@ export class SendSession {
445
446
 
446
447
  private autoAcceptIncoming: boolean
447
448
  private autoSaveIncoming: boolean
448
- private readonly overwriteIncoming: boolean
449
+ private overwriteIncoming: boolean
449
450
  private readonly reconnectSocket: boolean
450
451
  private iceServers: RTCIceServer[]
451
452
  private extraTurnServers: RTCIceServer[]
@@ -683,6 +684,14 @@ export class SendSession {
683
684
  return saved
684
685
  }
685
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
+
686
695
  cancelPendingOffers() {
687
696
  let cancelled = 0
688
697
  for (const transfer of this.transfers.values()) {
@@ -848,8 +857,9 @@ export class SendSession {
848
857
  }
849
858
 
850
859
  private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
851
- const finalPath = await incomingOutputPath(this.saveDir, fileName || "download", this.overwriteIncoming, this.reservedSavePaths)
852
- 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)
853
863
  for (let attempt = 0; ; attempt += 1) {
854
864
  const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
855
865
  try {
@@ -862,10 +872,11 @@ export class SendSession {
862
872
  offset: 0,
863
873
  error: "",
864
874
  closed: false,
875
+ overwrite,
865
876
  }
866
877
  } catch (error) {
867
878
  if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
868
- if (!this.overwriteIncoming) this.reservedSavePaths.delete(finalPath)
879
+ if (!overwrite) this.reservedSavePaths.delete(finalPath)
869
880
  throw error
870
881
  }
871
882
  }
@@ -935,12 +946,12 @@ export class SendSession {
935
946
  disk.closed = true
936
947
  await disk.handle.close()
937
948
  }
938
- if (!this.overwriteIncoming && await pathExists(finalPath)) {
949
+ if (!disk.overwrite && await pathExists(finalPath)) {
939
950
  this.reservedSavePaths.delete(finalPath)
940
951
  finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
941
952
  this.reservedSavePaths.add(finalPath)
942
953
  }
943
- if (this.overwriteIncoming) await replaceOutputPath(disk.tempPath, finalPath)
954
+ if (disk.overwrite) await replaceOutputPath(disk.tempPath, finalPath)
944
955
  else await rename(disk.tempPath, finalPath)
945
956
  transfer.savedPath = finalPath
946
957
  transfer.savedAt ||= Date.now()
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,