@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 +4 -1
- package/scripts/build-standalone-all.ts +71 -0
- package/scripts/build-standalone.ts +117 -0
- package/scripts/standalone-lib.ts +247 -0
- package/src/core/session.ts +17 -6
- package/src/tui/app.ts +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elefunc/send",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
`
|
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 {
|
|
@@ -445,7 +446,7 @@ export class SendSession {
|
|
|
445
446
|
|
|
446
447
|
private autoAcceptIncoming: boolean
|
|
447
448
|
private autoSaveIncoming: boolean
|
|
448
|
-
private
|
|
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
|
|
852
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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.
|
|
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,
|