webpage implementation (not tested)

This commit is contained in:
Evgeny @ SimpleX Chat
2026-02-05 09:59:00 +00:00
parent 3eee58ad31
commit 97773f0f30
21 changed files with 1156 additions and 74 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
dist-web/
package-lock.json
+10 -1
View File
@@ -9,15 +9,24 @@
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs",
"build": "tsc",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:browser": "vitest --run"
"test:browser": "vitest --run",
"dev": "vite --mode development",
"build:local": "vite build --mode development",
"build:prod": "vite build --mode production",
"preview": "vite preview",
"preview:prod": "vite build --mode production && vite preview",
"check:web": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.worker.json --noEmit",
"test:page": "playwright test test/page.spec.ts"
},
"devDependencies": {
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/node": "^20.0.0",
"@types/pako": "^2.0.3",
"@vitest/browser": "^3.0.0",
"@playwright/test": "^1.50.0",
"playwright": "^1.50.0",
"typescript": "^5.4.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
"dependencies": {
+19
View File
@@ -0,0 +1,19 @@
import {defineConfig} from '@playwright/test'
export default defineConfig({
testDir: './test',
testMatch: '**/*.spec.ts',
timeout: 60_000,
use: {
ignoreHTTPSErrors: true,
launchOptions: {
args: ['--ignore-certificate-errors']
}
},
webServer: {
command: 'npx vite build --mode development && npx vite preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI
},
globalSetup: './test/globalSetup.ts'
})
+124 -68
View File
@@ -15,11 +15,11 @@ import {
} from "./protocol/description.js"
import type {FileInfo} from "./protocol/commands.js"
import {
getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk,
getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw,
ackXFTPChunk, deleteXFTPChunk, type XFTPClientAgent
} from "./client.js"
export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent} from "./client.js"
import {processDownloadedFile} from "./download.js"
import {processDownloadedFile, decryptReceivedChunk} from "./download.js"
import type {XFTPServer} from "./protocol/address.js"
import {formatXFTPServer, parseXFTPServer} from "./protocol/address.js"
import {concatBytes} from "./protocol/encoding.js"
@@ -38,14 +38,17 @@ interface SentChunk {
server: XFTPServer
}
export interface EncryptedFileInfo {
encData: Uint8Array
export interface EncryptedFileMetadata {
digest: Uint8Array // SHA-512 of encData
key: Uint8Array // 32B SbKey
nonce: Uint8Array // 24B CbNonce
chunkSizes: number[]
}
export interface EncryptedFileInfo extends EncryptedFileMetadata {
encData: Uint8Array
}
export interface UploadResult {
rcvDescription: FileDescription
sndDescription: FileDescription
@@ -93,13 +96,25 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr
const DEFAULT_REDIRECT_THRESHOLD = 400
export interface UploadOptions {
onProgress?: (uploaded: number, total: number) => void
redirectThreshold?: number
readChunk?: (offset: number, size: number) => Promise<Uint8Array>
}
export async function uploadFile(
agent: XFTPClientAgent,
server: XFTPServer,
encrypted: EncryptedFileInfo,
onProgress?: (uploaded: number, total: number) => void,
redirectThreshold?: number
encrypted: EncryptedFileMetadata,
options?: UploadOptions
): Promise<UploadResult> {
const {onProgress, redirectThreshold, readChunk: readChunkOpt} = options ?? {}
const readChunk: (offset: number, size: number) => Promise<Uint8Array> = readChunkOpt
? readChunkOpt
: ('encData' in encrypted
? (off, sz) => Promise.resolve((encrypted as EncryptedFileInfo).encData.subarray(off, off + sz))
: () => { throw new Error("uploadFile: readChunk required when encData is absent") })
const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)
const specs = prepareChunkSpecs(encrypted.chunkSizes)
const client = await getXFTPServerClient(agent, server)
const sentChunks: SentChunk[] = []
@@ -109,7 +124,7 @@ export async function uploadFile(
const chunkNo = i + 1
const sndKp = generateEd25519KeyPair()
const rcvKp = generateEd25519KeyPair()
const chunkData = encrypted.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize)
const chunkDigest = getChunkDigest(chunkData)
const fileInfo: FileInfo = {
sndKey: encodePubKeyEd25519(sndKp.publicKey),
@@ -126,7 +141,7 @@ export async function uploadFile(
chunkSize: spec.chunkSize, digest: chunkDigest, server
})
uploaded += spec.chunkSize
onProgress?.(uploaded, encrypted.encData.length)
onProgress?.(uploaded, total)
}
const rcvDescription = buildDescription("recipient", encrypted, sentChunks)
const sndDescription = buildDescription("sender", encrypted, sentChunks)
@@ -142,7 +157,7 @@ export async function uploadFile(
function buildDescription(
party: "recipient" | "sender",
enc: EncryptedFileInfo,
enc: EncryptedFileMetadata,
chunks: SentChunk[]
): FileDescription {
const defChunkSize = enc.chunkSizes[0]
@@ -223,61 +238,111 @@ async function uploadRedirectDescription(
// ── Download ────────────────────────────────────────────────────
export interface RawDownloadedChunk {
chunkNo: number
dhSecret: Uint8Array
nonce: Uint8Array
body: Uint8Array
digest: Uint8Array
}
export interface DownloadRawOptions {
onProgress?: (downloaded: number, total: number) => void
concurrency?: number
}
export async function downloadFileRaw(
agent: XFTPClientAgent,
fd: FileDescription,
onRawChunk: (chunk: RawDownloadedChunk) => Promise<void>,
options?: DownloadRawOptions
): Promise<FileDescription> {
const err = validateFileDescription(fd)
if (err) throw new Error("downloadFileRaw: " + err)
const {onProgress, concurrency = 1} = options ?? {}
// Resolve redirect on main thread (redirect data is small)
if (fd.redirect !== null) {
fd = await resolveRedirect(agent, fd)
}
const resolvedFd = fd
// Pre-connect to avoid race condition under concurrency
const servers = new Set(resolvedFd.chunks.map(c => c.replicas[0]?.server).filter(Boolean) as string[])
for (const s of servers) {
await getXFTPServerClient(agent, parseXFTPServer(s))
}
// Sliding-window parallel download
let downloaded = 0
const queue = resolvedFd.chunks.slice()
let idx = 0
async function worker() {
while (idx < queue.length) {
const i = idx++
const chunk = queue[i]
const replica = chunk.replicas[0]
if (!replica) throw new Error("downloadFileRaw: chunk has no replicas")
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
const raw = await downloadXFTPChunkRaw(client, kp.privateKey, replica.replicaId)
await onRawChunk({
chunkNo: chunk.chunkNo,
dhSecret: raw.dhSecret,
nonce: raw.nonce,
body: raw.body,
digest: chunk.digest
})
downloaded += chunk.chunkSize
onProgress?.(downloaded, resolvedFd.size)
}
}
const workers = Array.from({length: Math.min(concurrency, queue.length)}, () => worker())
await Promise.all(workers)
return resolvedFd
}
export async function ackFileChunks(
agent: XFTPClientAgent, fd: FileDescription
): Promise<void> {
for (const chunk of fd.chunks) {
const replica = chunk.replicas[0]
if (!replica) continue
try {
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
} catch (_) {}
}
}
export async function downloadFile(
agent: XFTPClientAgent,
fd: FileDescription,
onProgress?: (downloaded: number, total: number) => void
): Promise<DownloadResult> {
const err = validateFileDescription(fd)
if (err) throw new Error("downloadFile: " + err)
if (fd.redirect !== null) {
return downloadWithRedirect(agent, fd, onProgress)
}
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
let downloaded = 0
for (const chunk of fd.chunks) {
const replica = chunk.replicas[0]
if (!replica) throw new Error("downloadFile: chunk has no replicas")
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest)
plaintextChunks[chunk.chunkNo - 1] = data
downloaded += chunk.chunkSize
onProgress?.(downloaded, fd.size)
}
// Verify file size
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
if (totalSize !== fd.size) throw new Error("downloadFile: file size mismatch")
// Verify file digest (SHA-512 of encrypted file data)
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
const chunks: Uint8Array[] = []
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
chunks[raw.chunkNo - 1] = decryptReceivedChunk(
raw.dhSecret, raw.nonce, raw.body, raw.digest
)
}, {onProgress})
const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch")
const digest = sha512(combined)
if (!digestEqual(digest, fd.digest)) throw new Error("downloadFile: file digest mismatch")
// Decrypt
const result = processDownloadedFile(fd, plaintextChunks)
// ACK all chunks (best-effort)
for (const chunk of fd.chunks) {
const replica = chunk.replicas[0]
if (!replica) continue
try {
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
} catch (_) {}
}
if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch")
const result = processDownloadedFile(resolvedFd, chunks)
await ackFileChunks(agent, resolvedFd)
return result
}
async function downloadWithRedirect(
async function resolveRedirect(
agent: XFTPClientAgent,
fd: FileDescription,
onProgress?: (downloaded: number, total: number) => void
): Promise<DownloadResult> {
fd: FileDescription
): Promise<FileDescription> {
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
for (const chunk of fd.chunks) {
const replica = chunk.replicas[0]
if (!replica) throw new Error("downloadWithRedirect: chunk has no replicas")
if (!replica) throw new Error("resolveRedirect: chunk has no replicas")
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
@@ -285,28 +350,19 @@ async function downloadWithRedirect(
plaintextChunks[chunk.chunkNo - 1] = data
}
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
if (totalSize !== fd.size) throw new Error("downloadWithRedirect: redirect file size mismatch")
if (totalSize !== fd.size) throw new Error("resolveRedirect: redirect file size mismatch")
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
const digest = sha512(combined)
if (!digestEqual(digest, fd.digest)) throw new Error("downloadWithRedirect: redirect file digest mismatch")
if (!digestEqual(digest, fd.digest)) throw new Error("resolveRedirect: redirect file digest mismatch")
const {content: yamlBytes} = processDownloadedFile(fd, plaintextChunks)
const innerFd = decodeFileDescription(new TextDecoder().decode(yamlBytes))
const innerErr = validateFileDescription(innerFd)
if (innerErr) throw new Error("downloadWithRedirect: inner description invalid: " + innerErr)
if (innerFd.size !== fd.redirect!.size) throw new Error("downloadWithRedirect: redirect size mismatch")
if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("downloadWithRedirect: redirect digest mismatch")
if (innerErr) throw new Error("resolveRedirect: inner description invalid: " + innerErr)
if (innerFd.size !== fd.redirect!.size) throw new Error("resolveRedirect: redirect size mismatch")
if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("resolveRedirect: redirect digest mismatch")
// ACK redirect chunks (best-effort)
for (const chunk of fd.chunks) {
const replica = chunk.replicas[0]
if (!replica) continue
try {
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
const seed = decodePrivKeyEd25519(replica.replicaKey)
const kp = ed25519KeyPairFromSeed(seed)
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
} catch (_) {}
}
return downloadFile(agent, innerFd, onProgress)
await ackFileChunks(agent, fd)
return innerFd
}
// ── Delete ──────────────────────────────────────────────────────
+17 -4
View File
@@ -207,15 +207,28 @@ export async function uploadXFTPChunk(
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
}
export async function downloadXFTPChunk(
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
): Promise<Uint8Array> {
export interface RawChunkResponse {
dhSecret: Uint8Array
nonce: Uint8Array
body: Uint8Array
}
export async function downloadXFTPChunkRaw(
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array
): Promise<RawChunkResponse> {
const {publicKey, privateKey} = generateX25519KeyPair()
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd)
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
const dhSecret = dh(response.rcvDhKey, privateKey)
return decryptReceivedChunk(dhSecret, response.nonce, body, digest ?? null)
return {dhSecret, nonce: response.nonce, body}
}
export async function downloadXFTPChunk(
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
): Promise<Uint8Array> {
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId)
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
}
export async function deleteXFTPChunk(
+1 -1
View File
@@ -91,7 +91,7 @@ export type FileParty = "recipient" | "sender"
export interface FileDescription {
party: FileParty
size: number // total file size in bytes
digest: Uint8Array // SHA-256 file digest
digest: Uint8Array // SHA-512 file digest
key: Uint8Array // SbKey (32 bytes)
nonce: Uint8Array // CbNonce (24 bytes)
chunkSize: number // default chunk size in bytes
+42
View File
@@ -0,0 +1,42 @@
import {test, expect} from '@playwright/test'
const PAGE_URL = 'http://localhost:4173'
test('page upload + download round-trip', async ({page}) => {
// Upload page
await page.goto(PAGE_URL)
await expect(page.locator('#drop-zone')).toBeVisible()
// Create a small test file
const content = 'Hello SimpleX ' + Date.now()
const fileName = 'test-file.txt'
const buffer = Buffer.from(content, 'utf-8')
// Set file via hidden input
const fileInput = page.locator('#file-input')
await fileInput.setInputFiles({name: fileName, mimeType: 'text/plain', buffer})
// Wait for upload to complete
const shareLink = page.locator('[data-testid="share-link"]')
await expect(shareLink).toBeVisible({timeout: 30_000})
// Extract the hash from the share link
const linkValue = await shareLink.inputValue()
const hash = new URL(linkValue).hash
// Navigate to download page
await page.goto(PAGE_URL + hash)
await expect(page.locator('#dl-btn')).toBeVisible()
// Start download and wait for completion
const downloadPromise = page.waitForEvent('download')
await page.locator('#dl-btn').click()
const download = await downloadPromise
// Verify downloaded file
expect(download.suggestedFilename()).toBe(fileName)
const downloadedContent = (await download.path()) !== null
? (await import('fs')).readFileSync(await download.path()!, 'utf-8')
: ''
expect(downloadedContent).toBe(content)
})
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": [],
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"]
},
"include": ["web/**/*.ts", "src/**/*.ts"],
"exclude": ["web/crypto.worker.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": [],
"moduleResolution": "bundler",
"lib": ["ES2022", "WebWorker"]
},
"include": ["web/crypto.worker.ts", "src/**/*.ts"]
}
+51
View File
@@ -0,0 +1,51 @@
import {defineConfig, type Plugin} from 'vite'
import {readFileSync} from 'fs'
import {createHash} from 'crypto'
import presets from './web/servers.json'
function parseHost(addr: string): string {
const m = addr.match(/@(.+)$/)
if (!m) throw new Error('bad server address: ' + addr)
const host = m[1].split(',')[0]
return host.includes(':') ? host : host + ':443'
}
function cspPlugin(servers: string[]): Plugin {
const origins = servers.map(s => 'https://' + parseHost(s)).join(' ')
return {
name: 'csp-connect-src',
transformIndexHtml: {
order: 'pre',
handler(html, ctx) {
if (ctx.server) {
return html.replace(/<meta\s[^>]*?Content-Security-Policy[\s\S]*?>/i, '')
}
return html.replace('__CSP_CONNECT_SRC__', origins)
}
}
}
}
export default defineConfig(({mode}) => {
const define: Record<string, string> = {}
let servers: string[]
if (mode === 'development') {
const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8')
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64')
const fp = createHash('sha256').update(der).digest('base64')
.replace(/\+/g, '-').replace(/\//g, '_')
servers = [`xftp://${fp}@localhost:7000`]
define['__XFTP_SERVERS__'] = JSON.stringify(servers)
} else {
servers = [...presets.simplex, ...presets.flux]
}
return {
root: 'web',
build: {outDir: '../dist-web', target: 'esnext'},
define,
worker: {format: 'es' as const},
plugins: [cspPlugin(servers)],
}
})
+2
View File
@@ -9,6 +9,8 @@ const fingerprint = createHash('sha256').update(der).digest('base64').replace(/\
const serverAddr = `xftp://${fingerprint}@localhost:7000`
export default defineConfig({
esbuild: {target: 'esnext'},
optimizeDeps: {esbuildOptions: {target: 'esnext'}},
define: {
'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr)
},
+112
View File
@@ -0,0 +1,112 @@
import type {FileHeader} from '../src/crypto/file.js'
export interface CryptoBackend {
encrypt(data: Uint8Array, fileName: string,
onProgress?: (done: number, total: number) => void
): Promise<EncryptResult>
readChunk(offset: number, size: number): Promise<Uint8Array>
decryptAndStoreChunk(
dhSecret: Uint8Array, nonce: Uint8Array,
body: Uint8Array, digest: Uint8Array, chunkNo: number
): Promise<void>
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
): Promise<{header: FileHeader, content: Uint8Array}>
cleanup(): Promise<void>
}
export interface EncryptResult {
digest: Uint8Array
key: Uint8Array
nonce: Uint8Array
chunkSizes: number[]
}
type PendingRequest = {resolve: (value: any) => void, reject: (reason: any) => void}
class WorkerBackend implements CryptoBackend {
private worker: Worker
private pending = new Map<number, PendingRequest>()
private nextId = 1
private progressCb: ((done: number, total: number) => void) | null = null
constructor() {
this.worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), {type: 'module'})
this.worker.onmessage = (e) => this.handleMessage(e.data)
}
private handleMessage(msg: {id: number, type: string, [k: string]: any}) {
if (msg.type === 'progress') {
this.progressCb?.(msg.done, msg.total)
return
}
const p = this.pending.get(msg.id)
if (!p) return
this.pending.delete(msg.id)
if (msg.type === 'error') {
p.reject(new Error(msg.message))
} else {
p.resolve(msg)
}
}
private send(msg: Record<string, any>, transfer?: Transferable[]): Promise<any> {
const id = this.nextId++
return new Promise((resolve, reject) => {
this.pending.set(id, {resolve, reject})
this.worker.postMessage({...msg, id}, transfer ?? [])
})
}
private toTransferable(data: Uint8Array): ArrayBuffer {
if (data.byteOffset !== 0 || data.byteLength !== data.buffer.byteLength) {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
}
return data.buffer as ArrayBuffer
}
async encrypt(data: Uint8Array, fileName: string,
onProgress?: (done: number, total: number) => void): Promise<EncryptResult> {
this.progressCb = onProgress ?? null
const buf = this.toTransferable(data)
const resp = await this.send({type: 'encrypt', data: buf, fileName}, [buf])
this.progressCb = null
return {digest: resp.digest, key: resp.key, nonce: resp.nonce, chunkSizes: resp.chunkSizes}
}
async readChunk(offset: number, size: number): Promise<Uint8Array> {
const resp = await this.send({type: 'readChunk', offset, size})
return new Uint8Array(resp.data)
}
async decryptAndStoreChunk(
dhSecret: Uint8Array, nonce: Uint8Array,
body: Uint8Array, digest: Uint8Array, chunkNo: number
): Promise<void> {
const buf = this.toTransferable(body)
await this.send(
{type: 'decryptAndStoreChunk', dhSecret, nonce, body: buf, chunkDigest: digest, chunkNo},
[buf]
)
}
async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
): Promise<{header: FileHeader, content: Uint8Array}> {
const resp = await this.send({
type: 'verifyAndDecrypt',
size: params.size, digest: params.digest, key: params.key, nonce: params.nonce
})
return {header: resp.header, content: new Uint8Array(resp.content)}
}
async cleanup(): Promise<void> {
await this.send({type: 'cleanup'})
this.worker.terminate()
}
}
export function createCryptoBackend(): CryptoBackend {
if (typeof Worker === 'undefined') {
throw new Error('Web Workers required — update your browser')
}
return new WorkerBackend()
}
+225
View File
@@ -0,0 +1,225 @@
import sodium from 'libsodium-wrappers-sumo'
import {encryptFile, encodeFileHeader, decryptChunks} from '../src/crypto/file.js'
import {sha512} from '../src/crypto/digest.js'
import {prepareChunkSizes, fileSizeLen, authTagSize} from '../src/protocol/chunks.js'
import {concatBytes} from '../src/protocol/encoding.js'
import {decryptReceivedChunk} from '../src/download.js'
// ── OPFS session management ─────────────────────────────────────
const SESSION_DIR = `session-${Date.now()}-${crypto.randomUUID()}`
let uploadReadHandle: FileSystemSyncAccessHandle | null = null
let downloadWriteHandle: FileSystemSyncAccessHandle | null = null
const chunkMeta = new Map<number, {offset: number, size: number}>()
let currentDownloadOffset = 0
let sessionDir: FileSystemDirectoryHandle | null = null
async function getSessionDir(): Promise<FileSystemDirectoryHandle> {
if (!sessionDir) {
const root = await navigator.storage.getDirectory()
sessionDir = await root.getDirectoryHandle(SESSION_DIR, {create: true})
}
return sessionDir
}
async function sweepStale() {
const root = await navigator.storage.getDirectory()
const oneHourAgo = Date.now() - 3600_000
for await (const [name] of (root as any).entries()) {
if (!name.startsWith('session-')) continue
const parts = name.split('-')
const ts = parseInt(parts[1], 10)
if (!isNaN(ts) && ts < oneHourAgo) {
try { await root.removeEntry(name, {recursive: true}) } catch (_) {}
}
}
}
// ── Message handlers ────────────────────────────────────────────
async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) {
const source = new Uint8Array(data)
const key = new Uint8Array(32)
const nonce = new Uint8Array(24)
crypto.getRandomValues(key)
crypto.getRandomValues(nonce)
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
const fileSize = BigInt(fileHdr.length + source.length)
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
const chunkSizes = prepareChunkSizes(payloadSize)
const encSize = BigInt(chunkSizes.reduce((a: number, b: number) => a + b, 0))
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)
self.postMessage({id, type: 'progress', done: 50, total: 100})
const digest = sha512(encData)
self.postMessage({id, type: 'progress', done: 80, total: 100})
// Write to OPFS
const dir = await getSessionDir()
const fileHandle = await dir.getFileHandle('upload.bin', {create: true})
const writeHandle = await fileHandle.createSyncAccessHandle()
writeHandle.write(encData)
writeHandle.flush()
writeHandle.close()
// Reopen as persistent read handle
uploadReadHandle = await fileHandle.createSyncAccessHandle()
self.postMessage({id, type: 'progress', done: 100, total: 100})
self.postMessage({id, type: 'encrypted', digest, key, nonce, chunkSizes})
}
function handleReadChunk(id: number, offset: number, size: number) {
if (!uploadReadHandle) {
self.postMessage({id, type: 'error', message: 'No upload file open'})
return
}
const buf = new Uint8Array(size)
uploadReadHandle.read(buf, {at: offset})
const ab = buf.buffer as ArrayBuffer
self.postMessage({id, type: 'chunk', data: ab}, [ab])
}
async function handleDecryptAndStore(
id: number, dhSecret: Uint8Array, nonce: Uint8Array,
body: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number
) {
const bodyArr = new Uint8Array(body)
const decrypted = decryptReceivedChunk(dhSecret, nonce, bodyArr, chunkDigest)
if (!downloadWriteHandle) {
const dir = await getSessionDir()
const fileHandle = await dir.getFileHandle('download.bin', {create: true})
downloadWriteHandle = await fileHandle.createSyncAccessHandle()
}
const offset = currentDownloadOffset
currentDownloadOffset += decrypted.length
chunkMeta.set(chunkNo, {offset, size: decrypted.length})
downloadWriteHandle.write(decrypted, {at: offset})
downloadWriteHandle.flush()
self.postMessage({id, type: 'stored'})
}
async function handleVerifyAndDecrypt(
id: number, size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array
) {
// Close write handle, reopen as read
if (downloadWriteHandle) {
downloadWriteHandle.flush()
downloadWriteHandle.close()
downloadWriteHandle = null
}
const dir = await getSessionDir()
const fileHandle = await dir.getFileHandle('download.bin')
const readHandle = await fileHandle.createSyncAccessHandle()
// Read chunks ordered by chunkNo
const sortedEntries = [...chunkMeta.entries()].sort((a, b) => a[0] - b[0])
const chunks: Uint8Array[] = []
for (const [, meta] of sortedEntries) {
const buf = new Uint8Array(meta.size)
readHandle.read(buf, {at: meta.offset})
chunks.push(buf)
}
readHandle.close()
// Verify size
const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
if (combined.length !== size) {
self.postMessage({id, type: 'error', message: `File size mismatch: ${combined.length} !== ${size}`})
return
}
// Verify SHA-512 digest
const actualDigest = sha512(combined)
if (!digestEqual(actualDigest, digest)) {
self.postMessage({id, type: 'error', message: 'File digest mismatch'})
return
}
// File-level decrypt
const result = decryptChunks(BigInt(size), chunks, key, nonce)
// Clean up download file
try { await dir.removeEntry('download.bin') } catch (_) {}
chunkMeta.clear()
currentDownloadOffset = 0
const contentBuf = result.content.buffer.slice(
result.content.byteOffset,
result.content.byteOffset + result.content.byteLength
)
self.postMessage(
{id, type: 'decrypted', header: result.header, content: contentBuf},
[contentBuf]
)
}
async function handleCleanup(id: number) {
if (uploadReadHandle) {
uploadReadHandle.close()
uploadReadHandle = null
}
if (downloadWriteHandle) {
downloadWriteHandle.close()
downloadWriteHandle = null
}
chunkMeta.clear()
currentDownloadOffset = 0
try {
const root = await navigator.storage.getDirectory()
await root.removeEntry(SESSION_DIR, {recursive: true})
} catch (_) {}
sessionDir = null
self.postMessage({id, type: 'cleaned'})
}
// ── Message dispatch ────────────────────────────────────────────
self.onmessage = async (e: MessageEvent) => {
const msg = e.data
try {
switch (msg.type) {
case 'encrypt':
await handleEncrypt(msg.id, msg.data, msg.fileName)
break
case 'readChunk':
handleReadChunk(msg.id, msg.offset, msg.size)
break
case 'decryptAndStoreChunk':
await handleDecryptAndStore(msg.id, msg.dhSecret, msg.nonce, msg.body, msg.chunkDigest, msg.chunkNo)
break
case 'verifyAndDecrypt':
await handleVerifyAndDecrypt(msg.id, msg.size, msg.digest, msg.key, msg.nonce)
break
case 'cleanup':
await handleCleanup(msg.id)
break
default:
self.postMessage({id: msg.id, type: 'error', message: `Unknown message type: ${msg.type}`})
}
} catch (err: any) {
self.postMessage({id: msg.id, type: 'error', message: err?.message ?? String(err)})
}
}
// ── Helpers ─────────────────────────────────────────────────────
function digestEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
return diff === 0
}
// ── Init ────────────────────────────────────────────────────────
;(async () => {
await sodium.ready
await sweepStale()
})()
+137
View File
@@ -0,0 +1,137 @@
import {createCryptoBackend} from './crypto-backend.js'
import {createProgressRing} from './progress.js'
import {
newXFTPAgent, closeXFTPAgent,
decodeDescriptionURI, downloadFileRaw, ackFileChunks
} from '../src/agent.js'
export function initDownload(app: HTMLElement, hash: string) {
let fd: ReturnType<typeof decodeDescriptionURI>
try {
fd = decodeDescriptionURI(hash)
} catch (err: any) {
app.innerHTML = `<div class="card"><p class="error">Invalid or corrupted link.</p></div>`
return
}
const size = fd.redirect ? fd.redirect.size : fd.size
app.innerHTML = `
<div class="card">
<h1>SimpleX File Transfer</h1>
<div id="dl-ready" class="stage">
<p>File available (~${formatSize(size)})</p>
<button id="dl-btn" class="btn">Download</button>
<div class="security-note">
<p>This file is encrypted — the server never sees file contents.</p>
<p>The decryption key is in the link's hash fragment, which your browser never sends to any server.</p>
<p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
</div>
</div>
<div id="dl-progress" class="stage" hidden>
<div id="dl-progress-container"></div>
<p id="dl-status">Downloading…</p>
</div>
<div id="dl-error" class="stage" hidden>
<p class="error" id="dl-error-msg"></p>
<button id="dl-retry-btn" class="btn">Retry</button>
</div>
</div>`
const readyStage = document.getElementById('dl-ready')!
const progressStage = document.getElementById('dl-progress')!
const errorStage = document.getElementById('dl-error')!
const progressContainer = document.getElementById('dl-progress-container')!
const statusText = document.getElementById('dl-status')!
const dlBtn = document.getElementById('dl-btn')!
const errorMsg = document.getElementById('dl-error-msg')!
const retryBtn = document.getElementById('dl-retry-btn')!
function showStage(stage: HTMLElement) {
for (const s of [readyStage, progressStage, errorStage]) s.hidden = true
stage.hidden = false
}
function showError(msg: string) {
errorMsg.textContent = msg
showStage(errorStage)
}
dlBtn.addEventListener('click', startDownload)
retryBtn.addEventListener('click', startDownload)
async function startDownload() {
showStage(progressStage)
const ring = createProgressRing()
progressContainer.innerHTML = ''
progressContainer.appendChild(ring.canvas)
statusText.textContent = 'Downloading…'
const backend = createCryptoBackend()
const agent = newXFTPAgent()
try {
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
await backend.decryptAndStoreChunk(
raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo
)
}, {
onProgress: (downloaded, total) => {
ring.update(downloaded / total * 0.8)
},
concurrency: 3
})
statusText.textContent = 'Decrypting…'
ring.update(0.85)
const {header, content} = await backend.verifyAndDecrypt({
size: resolvedFd.size,
digest: resolvedFd.digest,
key: resolvedFd.key,
nonce: resolvedFd.nonce
})
ring.update(0.95)
// ACK (best-effort)
ackFileChunks(agent, resolvedFd).catch(() => {})
// Sanitize filename and trigger browser save
const fileName = sanitizeFileName(header.fileName)
const blob = new Blob([content.buffer as ArrayBuffer])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
ring.update(1)
statusText.textContent = 'Download complete'
} catch (err: any) {
showError(err?.message ?? String(err))
} finally {
await backend.cleanup().catch(() => {})
closeXFTPAgent(agent)
}
}
}
function sanitizeFileName(name: string): string {
let s = name
// Strip path separators
s = s.replace(/[/\\]/g, '')
// Replace null/control characters
s = s.replace(/[\x00-\x1f\x7f]/g, '_')
// Strip Unicode bidi override characters
s = s.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
// Limit length
if (s.length > 255) s = s.slice(0, 255)
return s || 'download'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src __CSP_CONNECT_SRC__;">
<title>SimpleX File Transfer</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
import sodium from 'libsodium-wrappers-sumo'
import {initUpload} from './upload.js'
import {initDownload} from './download.js'
async function main() {
await sodium.ready
const app = document.getElementById('app')!
const hash = window.location.hash.slice(1)
if (hash) {
initDownload(app, hash)
} else {
initUpload(app)
}
}
main().catch(err => {
const app = document.getElementById('app')
if (app) {
app.innerHTML = `<div class="error"><p>Failed to initialize: ${err.message}</p></div>`
}
console.error(err)
})
+52
View File
@@ -0,0 +1,52 @@
const SIZE = 120
const LINE_WIDTH = 8
const RADIUS = (SIZE - LINE_WIDTH) / 2
const CENTER = SIZE / 2
const BG_COLOR = '#e0e0e0'
const FG_COLOR = '#3b82f6'
export interface ProgressRing {
canvas: HTMLCanvasElement
update(fraction: number): void
}
export function createProgressRing(): ProgressRing {
const canvas = document.createElement('canvas')
canvas.width = SIZE * devicePixelRatio
canvas.height = SIZE * devicePixelRatio
canvas.style.width = SIZE + 'px'
canvas.style.height = SIZE + 'px'
canvas.className = 'progress-ring'
const ctx = canvas.getContext('2d')!
ctx.scale(devicePixelRatio, devicePixelRatio)
function draw(fraction: number) {
ctx.clearRect(0, 0, SIZE, SIZE)
// Background arc
ctx.beginPath()
ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
ctx.strokeStyle = BG_COLOR
ctx.lineWidth = LINE_WIDTH
ctx.lineCap = 'round'
ctx.stroke()
// Foreground arc
if (fraction > 0) {
ctx.beginPath()
ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
ctx.strokeStyle = FG_COLOR
ctx.lineWidth = LINE_WIDTH
ctx.lineCap = 'round'
ctx.stroke()
}
// Percentage text
const pct = Math.round(fraction * 100)
ctx.fillStyle = '#333'
ctx.font = '600 20px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(pct + '%', CENTER, CENTER)
}
draw(0)
return {canvas, update: draw}
}
+18
View File
@@ -0,0 +1,18 @@
{
"simplex": [
"xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im",
"xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im",
"xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im",
"xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im",
"xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im",
"xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im"
],
"flux": [
"xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com",
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com",
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com",
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com",
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com",
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com"
]
}
+16
View File
@@ -0,0 +1,16 @@
import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
import presets from './servers.json'
declare const __XFTP_SERVERS__: string[]
const serverAddresses: string[] = typeof __XFTP_SERVERS__ !== 'undefined'
? __XFTP_SERVERS__
: [...presets.simplex, ...presets.flux]
export function getServers(): XFTPServer[] {
return serverAddresses.map(parseXFTPServer)
}
export function pickRandomServer(servers: XFTPServer[]): XFTPServer {
return servers[Math.floor(Math.random() * servers.length)]
}
+103
View File
@@ -0,0 +1,103 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#app {
width: 100%;
max-width: 480px;
padding: 16px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 32px 24px;
box-shadow: 0 1px 3px rgba(0,0,0,.1);
text-align: center;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 24px;
}
.stage { margin-top: 16px; }
/* Drop zone */
.drop-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 32px 16px;
transition: border-color .15s, background .15s;
}
.drop-zone.drag-over {
border-color: #3b82f6;
background: #eff6ff;
}
/* Buttons */
.btn {
display: inline-block;
padding: 10px 24px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
font-size: .9rem;
font-weight: 500;
cursor: pointer;
transition: background .15s;
}
.btn:hover { background: #2563eb; }
.btn-secondary { background: #6b7280; }
.btn-secondary:hover { background: #4b5563; }
/* Hints */
.hint { color: #999; font-size: .85rem; margin-top: 8px; }
.expiry { margin-top: 12px; }
/* Progress */
.progress-ring { display: block; margin: 0 auto 12px; }
#upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
/* Share link row */
.link-row {
display: flex;
gap: 8px;
margin-top: 12px;
}
.link-row input {
flex: 1;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 6px;
font-size: .85rem;
background: #f9fafb;
}
/* Messages */
.success { color: #16a34a; font-weight: 600; }
.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
/* Security note */
.security-note {
margin-top: 20px;
padding: 12px;
background: #f0fdf4;
border-radius: 6px;
font-size: .8rem;
color: #555;
text-align: left;
}
.security-note p + p { margin-top: 6px; }
.security-note a { color: #3b82f6; text-decoration: none; }
.security-note a:hover { text-decoration: underline; }
+164
View File
@@ -0,0 +1,164 @@
import {createCryptoBackend} from './crypto-backend.js'
import {getServers, pickRandomServer} from './servers.js'
import {createProgressRing} from './progress.js'
import {
newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI,
type EncryptedFileMetadata
} from '../src/agent.js'
const MAX_SIZE = 100 * 1024 * 1024
export function initUpload(app: HTMLElement) {
app.innerHTML = `
<div class="card">
<h1>SimpleX File Transfer</h1>
<div id="drop-zone" class="drop-zone">
<p>Drag & drop a file here</p>
<p class="hint">or</p>
<label class="btn" for="file-input">Choose file</label>
<input id="file-input" type="file" hidden>
<p class="hint">Max 100 MB</p>
</div>
<div id="upload-progress" class="stage" hidden>
<div id="progress-container"></div>
<p id="upload-status">Encrypting…</p>
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
<div id="upload-complete" class="stage" hidden>
<p class="success">File uploaded</p>
<div class="link-row">
<input id="share-link" data-testid="share-link" readonly>
<button id="copy-btn" class="btn">Copy</button>
</div>
<p class="hint expiry">Files are typically available for 48 hours.</p>
<div class="security-note">
<p>Your file was encrypted in the browser before upload — the server never sees file contents.</p>
<p>The link contains the decryption key in the hash fragment, which the browser never sends to any server.</p>
<p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
</div>
</div>
<div id="upload-error" class="stage" hidden>
<p class="error" id="error-msg"></p>
<button id="retry-btn" class="btn">Retry</button>
</div>
</div>`
const dropZone = document.getElementById('drop-zone')!
const fileInput = document.getElementById('file-input') as HTMLInputElement
const progressStage = document.getElementById('upload-progress')!
const completeStage = document.getElementById('upload-complete')!
const errorStage = document.getElementById('upload-error')!
const progressContainer = document.getElementById('progress-container')!
const statusText = document.getElementById('upload-status')!
const cancelBtn = document.getElementById('cancel-btn')!
const shareLink = document.getElementById('share-link') as HTMLInputElement
const copyBtn = document.getElementById('copy-btn')!
const errorMsg = document.getElementById('error-msg')!
const retryBtn = document.getElementById('retry-btn')!
let aborted = false
let pendingFile: File | null = null
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over') })
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'))
dropZone.addEventListener('drop', e => {
e.preventDefault()
dropZone.classList.remove('drag-over')
const f = e.dataTransfer?.files[0]
if (f) startUpload(f)
})
fileInput.addEventListener('change', () => {
if (fileInput.files?.[0]) startUpload(fileInput.files[0])
})
retryBtn.addEventListener('click', () => {
if (pendingFile) startUpload(pendingFile)
})
function showStage(stage: HTMLElement) {
for (const s of [dropZone, progressStage, completeStage, errorStage]) s.hidden = true
stage.hidden = false
}
function showError(msg: string) {
errorMsg.textContent = msg
showStage(errorStage)
}
async function startUpload(file: File) {
pendingFile = file
aborted = false
if (file.size > MAX_SIZE) {
showError(`File too large (${formatSize(file.size)}). Maximum is 100 MB.`)
return
}
if (file.size === 0) {
showError('File is empty.')
return
}
showStage(progressStage)
const ring = createProgressRing()
progressContainer.innerHTML = ''
progressContainer.appendChild(ring.canvas)
statusText.textContent = 'Encrypting…'
const backend = createCryptoBackend()
const agent = newXFTPAgent()
cancelBtn.onclick = () => {
aborted = true
backend.cleanup().catch(() => {})
closeXFTPAgent(agent)
showStage(dropZone)
}
try {
const fileData = new Uint8Array(await file.arrayBuffer())
if (aborted) return
const encrypted = await backend.encrypt(fileData, file.name, (done, total) => {
ring.update(done / total * 0.3)
})
if (aborted) return
statusText.textContent = 'Uploading…'
const metadata: EncryptedFileMetadata = {
digest: encrypted.digest,
key: encrypted.key,
nonce: encrypted.nonce,
chunkSizes: encrypted.chunkSizes
}
const servers = getServers()
const server = pickRandomServer(servers)
const result = await uploadFile(agent, server, metadata, {
readChunk: (off, sz) => backend.readChunk(off, sz),
onProgress: (uploaded, total) => {
ring.update(0.3 + (uploaded / total) * 0.7)
}
})
if (aborted) return
const url = window.location.origin + window.location.pathname + '#' + result.uri
shareLink.value = url
showStage(completeStage)
copyBtn.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
copyBtn.textContent = 'Copied!'
setTimeout(() => { copyBtn.textContent = 'Copy' }, 2000)
})
}
} catch (err: any) {
if (!aborted) showError(err?.message ?? String(err))
} finally {
await backend.cleanup().catch(() => {})
closeXFTPAgent(agent)
}
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}