@shhhum/xftp-web 0.3.0 → 0.5.0

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.
Files changed (54) hide show
  1. package/README.md +15 -83
  2. package/dist/agent.d.ts +4 -6
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +79 -143
  5. package/dist/agent.js.map +1 -1
  6. package/dist/client.d.ts +0 -2
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +1 -6
  9. package/dist/client.js.map +1 -1
  10. package/dist/crypto/digest.d.ts +0 -1
  11. package/dist/crypto/digest.d.ts.map +1 -1
  12. package/dist/crypto/digest.js.map +1 -1
  13. package/dist/crypto/file.d.ts +0 -1
  14. package/dist/crypto/file.d.ts.map +1 -1
  15. package/dist/crypto/identity.d.ts +0 -1
  16. package/dist/crypto/keys.d.ts +0 -1
  17. package/dist/crypto/keys.js +0 -1
  18. package/dist/crypto/keys.js.map +1 -1
  19. package/dist/crypto/padding.d.ts +0 -1
  20. package/dist/crypto/secretbox.d.ts +0 -1
  21. package/dist/download.d.ts +0 -1
  22. package/dist/protocol/address.d.ts +0 -1
  23. package/dist/protocol/chunks.d.ts +0 -1
  24. package/dist/protocol/client.d.ts +0 -1
  25. package/dist/protocol/commands.d.ts +1 -10
  26. package/dist/protocol/commands.js +1 -15
  27. package/dist/protocol/commands.js.map +1 -1
  28. package/dist/protocol/description.d.ts +0 -1
  29. package/dist/protocol/encoding.d.ts +0 -1
  30. package/dist/protocol/handshake.d.ts +0 -1
  31. package/dist/protocol/transmission.d.ts +0 -1
  32. package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
  33. package/dist-web/assets/__vite-browser-external.js +1 -0
  34. package/dist-web/assets/index.css +1 -0
  35. package/dist-web/assets/index.js +1468 -0
  36. package/dist-web/crypto.worker.js +1413 -0
  37. package/dist-web/index.html +15 -0
  38. package/package.json +4 -5
  39. package/src/agent.ts +85 -156
  40. package/src/client.ts +1 -8
  41. package/src/crypto/digest.ts +2 -2
  42. package/src/crypto/keys.ts +0 -1
  43. package/src/protocol/commands.ts +2 -22
  44. package/web/crypto-backend.ts +122 -0
  45. package/web/crypto.worker.ts +313 -0
  46. package/web/download.ts +140 -0
  47. package/web/index.html +15 -0
  48. package/web/main.ts +30 -0
  49. package/web/progress.ts +52 -0
  50. package/web/servers.json +18 -0
  51. package/web/servers.ts +16 -0
  52. package/web/style.css +103 -0
  53. package/web/upload.ts +171 -0
  54. package/src/index.ts +0 -4
@@ -0,0 +1,313 @@
1
+ import sodium from 'libsodium-wrappers-sumo'
2
+ import {encryptFile, encodeFileHeader, decryptChunks} from '../src/crypto/file.js'
3
+ import {sha512Streaming} from '../src/crypto/digest.js'
4
+ import {prepareChunkSizes, fileSizeLen, authTagSize} from '../src/protocol/chunks.js'
5
+ import {decryptReceivedChunk} from '../src/download.js'
6
+
7
+ // ── OPFS session management ─────────────────────────────────────
8
+
9
+ const SESSION_DIR = `session-${Date.now()}-${crypto.randomUUID()}`
10
+ let uploadReadHandle: FileSystemSyncAccessHandle | null = null
11
+ let downloadWriteHandle: FileSystemSyncAccessHandle | null = null
12
+ const chunkMeta = new Map<number, {offset: number, size: number}>()
13
+ let currentDownloadOffset = 0
14
+ let sessionDir: FileSystemDirectoryHandle | null = null
15
+ let useMemory = false
16
+ const memoryChunks = new Map<number, Uint8Array>()
17
+
18
+ async function getSessionDir(): Promise<FileSystemDirectoryHandle> {
19
+ if (!sessionDir) {
20
+ const root = await navigator.storage.getDirectory()
21
+ sessionDir = await root.getDirectoryHandle(SESSION_DIR, {create: true})
22
+ }
23
+ return sessionDir
24
+ }
25
+
26
+ async function sweepStale() {
27
+ const root = await navigator.storage.getDirectory()
28
+ const oneHourAgo = Date.now() - 3600_000
29
+ for await (const [name] of (root as any).entries()) {
30
+ if (!name.startsWith('session-')) continue
31
+ const parts = name.split('-')
32
+ const ts = parseInt(parts[1], 10)
33
+ if (!isNaN(ts) && ts < oneHourAgo) {
34
+ try { await root.removeEntry(name, {recursive: true}) } catch (_) {}
35
+ }
36
+ }
37
+ }
38
+
39
+ // ── Message handlers ────────────────────────────────────────────
40
+
41
+ async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) {
42
+ const source = new Uint8Array(data)
43
+ const key = new Uint8Array(32)
44
+ const nonce = new Uint8Array(24)
45
+ crypto.getRandomValues(key)
46
+ crypto.getRandomValues(nonce)
47
+ const fileHdr = encodeFileHeader({fileName, fileExtra: null})
48
+ const fileSize = BigInt(fileHdr.length + source.length)
49
+ const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
50
+ const chunkSizes = prepareChunkSizes(payloadSize)
51
+ const encSize = BigInt(chunkSizes.reduce((a: number, b: number) => a + b, 0))
52
+ const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)
53
+
54
+ self.postMessage({id, type: 'progress', done: 50, total: 100})
55
+
56
+ const digest = sha512Streaming([encData])
57
+ console.log(`[WORKER-DBG] encrypt: encData.len=${encData.length} digest=${_whex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`)
58
+
59
+ self.postMessage({id, type: 'progress', done: 80, total: 100})
60
+
61
+ // Write to OPFS
62
+ const dir = await getSessionDir()
63
+ const fileHandle = await dir.getFileHandle('upload.bin', {create: true})
64
+ const writeHandle = await fileHandle.createSyncAccessHandle()
65
+ const written = writeHandle.write(encData)
66
+ if (written !== encData.length) throw new Error(`OPFS upload write: ${written}/${encData.length}`)
67
+ writeHandle.flush()
68
+ writeHandle.close()
69
+
70
+ // Reopen as persistent read handle
71
+ uploadReadHandle = await fileHandle.createSyncAccessHandle()
72
+
73
+ self.postMessage({id, type: 'progress', done: 100, total: 100})
74
+ self.postMessage({id, type: 'encrypted', digest, key, nonce, chunkSizes})
75
+ }
76
+
77
+ function handleReadChunk(id: number, offset: number, size: number) {
78
+ if (!uploadReadHandle) {
79
+ self.postMessage({id, type: 'error', message: 'No upload file open'})
80
+ return
81
+ }
82
+ const buf = new Uint8Array(size)
83
+ uploadReadHandle.read(buf, {at: offset})
84
+ const ab = buf.buffer as ArrayBuffer
85
+ self.postMessage({id, type: 'chunk', data: ab}, [ab])
86
+ }
87
+
88
+ async function handleDecryptAndStore(
89
+ id: number, dhSecret: Uint8Array, nonce: Uint8Array,
90
+ body: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number
91
+ ) {
92
+ const bodyArr = new Uint8Array(body)
93
+ console.log(`[WORKER-DBG] store chunk=${chunkNo} body.len=${bodyArr.length} nonce=${_whex(nonce, 24)} dhSecret=${_whex(dhSecret)} digest=${_whex(chunkDigest, 32)} body[0..8]=${_whex(bodyArr)} body[-8..]=${_whex(bodyArr.slice(-8))}`)
94
+ const decrypted = decryptReceivedChunk(dhSecret, nonce, bodyArr, chunkDigest)
95
+ console.log(`[WORKER-DBG] decrypted chunk=${chunkNo} len=${decrypted.length} [0..8]=${_whex(decrypted)} [-8..]=${_whex(decrypted.slice(-8))}`)
96
+
97
+ if (useMemory) {
98
+ memoryChunks.set(chunkNo, decrypted)
99
+ self.postMessage({id, type: 'stored'})
100
+ return
101
+ }
102
+
103
+ if (!downloadWriteHandle) {
104
+ const dir = await getSessionDir()
105
+ const fileHandle = await dir.getFileHandle('download.bin', {create: true})
106
+ downloadWriteHandle = await fileHandle.createSyncAccessHandle()
107
+ }
108
+
109
+ const offset = currentDownloadOffset
110
+ currentDownloadOffset += decrypted.length
111
+ chunkMeta.set(chunkNo, {offset, size: decrypted.length})
112
+ const written = downloadWriteHandle.write(decrypted, {at: offset})
113
+ console.log(`[WORKER-DBG] OPFS write chunk=${chunkNo} offset=${offset} size=${decrypted.length} written=${written}`)
114
+
115
+ if (written !== decrypted.length) {
116
+ console.warn(`[WORKER] OPFS write failed chunk=${chunkNo}: ${written}/${decrypted.length}, falling back to in-memory storage`)
117
+ // Migrate previously written chunks from OPFS to memory
118
+ for (const [cn, meta] of chunkMeta.entries()) {
119
+ if (cn === chunkNo) continue
120
+ const buf = new Uint8Array(meta.size)
121
+ downloadWriteHandle.read(buf, {at: meta.offset})
122
+ memoryChunks.set(cn, buf)
123
+ }
124
+ downloadWriteHandle.close()
125
+ downloadWriteHandle = null
126
+ try {
127
+ const dir = await getSessionDir()
128
+ await dir.removeEntry('download.bin')
129
+ } catch (_) {}
130
+ chunkMeta.clear()
131
+ currentDownloadOffset = 0
132
+ memoryChunks.set(chunkNo, decrypted)
133
+ useMemory = true
134
+ self.postMessage({id, type: 'stored'})
135
+ return
136
+ }
137
+
138
+ downloadWriteHandle.flush()
139
+
140
+ // Verify: read back and compare first/last 8 bytes
141
+ const verifyBuf = new Uint8Array(Math.min(8, decrypted.length))
142
+ downloadWriteHandle.read(verifyBuf, {at: offset})
143
+ const verifyEnd = new Uint8Array(Math.min(8, decrypted.length))
144
+ downloadWriteHandle.read(verifyEnd, {at: offset + decrypted.length - verifyEnd.length})
145
+ console.log(`[WORKER-DBG] OPFS verify chunk=${chunkNo} readBack[0..8]=${_whex(verifyBuf)} readBack[-8..]=${_whex(verifyEnd)} expected[0..8]=${_whex(decrypted)} expected[-8..]=${_whex(decrypted.slice(-8))}`)
146
+
147
+ self.postMessage({id, type: 'stored'})
148
+ }
149
+
150
+ async function handleVerifyAndDecrypt(
151
+ id: number, size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array
152
+ ) {
153
+ console.log(`[WORKER-DBG] verify: expectedSize=${size} expectedDigest=${_whex(digest, 64)} useMemory=${useMemory} chunkMeta.size=${chunkMeta.size} memoryChunks.size=${memoryChunks.size}`)
154
+
155
+ // Read chunks — from memory (fallback) or OPFS
156
+ const chunks: Uint8Array[] = []
157
+ let totalSize = 0
158
+ if (useMemory) {
159
+ const sorted = [...memoryChunks.entries()].sort((a, b) => a[0] - b[0])
160
+ for (const [chunkNo, data] of sorted) {
161
+ console.log(`[WORKER-DBG] verify memory chunk=${chunkNo} size=${data.length}`)
162
+ chunks.push(data)
163
+ totalSize += data.length
164
+ }
165
+ } else {
166
+ // Close write handle, reopen as read
167
+ if (downloadWriteHandle) {
168
+ downloadWriteHandle.flush()
169
+ downloadWriteHandle.close()
170
+ downloadWriteHandle = null
171
+ }
172
+ const dir = await getSessionDir()
173
+ const fileHandle = await dir.getFileHandle('download.bin')
174
+ const readHandle = await fileHandle.createSyncAccessHandle()
175
+ console.log(`[WORKER-DBG] verify: OPFS file size=${readHandle.getSize()}`)
176
+ const sortedEntries = [...chunkMeta.entries()].sort((a, b) => a[0] - b[0])
177
+ for (const [chunkNo, meta] of sortedEntries) {
178
+ const buf = new Uint8Array(meta.size)
179
+ const bytesRead = readHandle.read(buf, {at: meta.offset})
180
+ console.log(`[WORKER-DBG] verify read chunk=${chunkNo} offset=${meta.offset} size=${meta.size} bytesRead=${bytesRead} [0..8]=${_whex(buf)} [-8..]=${_whex(buf.slice(-8))}`)
181
+ chunks.push(buf)
182
+ totalSize += meta.size
183
+ }
184
+ readHandle.close()
185
+ }
186
+
187
+ if (totalSize !== size) {
188
+ self.postMessage({id, type: 'error', message: `File size mismatch: ${totalSize} !== ${size}`})
189
+ return
190
+ }
191
+
192
+ // Compute per-chunk SHA-512 incrementally to find divergence point
193
+ const state = sodium.crypto_hash_sha512_init()
194
+ for (let i = 0; i < chunks.length; i++) {
195
+ const chunk = chunks[i]
196
+ const SEG = 4 * 1024 * 1024
197
+ for (let off = 0; off < chunk.length; off += SEG) {
198
+ sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
199
+ }
200
+ }
201
+ const actualDigest = sodium.crypto_hash_sha512_final(state)
202
+ if (!digestEqual(actualDigest, digest)) {
203
+ console.error(`[WORKER-DBG] DIGEST MISMATCH: expected=${_whex(digest, 64)} actual=${_whex(actualDigest, 64)} chunks=${chunks.length} totalSize=${totalSize}`)
204
+ // Log per-chunk incremental hash to find divergence
205
+ const state2 = sodium.crypto_hash_sha512_init()
206
+ for (let i = 0; i < chunks.length; i++) {
207
+ const chunk = chunks[i]
208
+ const SEG = 4 * 1024 * 1024
209
+ for (let off = 0; off < chunk.length; off += SEG) {
210
+ sodium.crypto_hash_sha512_update(state2, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
211
+ }
212
+ // snapshot incremental hash (create temp copy of state)
213
+ const chunkDigest = sha512Streaming([chunk])
214
+ console.error(`[WORKER-DBG] chunk[${i}] size=${chunk.length} sha512=${_whex(chunkDigest, 32)}… [0..8]=${_whex(chunk)} [-8..]=${_whex(chunk.slice(-8))}`)
215
+ }
216
+ self.postMessage({id, type: 'error', message: 'File digest mismatch'})
217
+ return
218
+ }
219
+ console.log(`[WORKER-DBG] verify: digest OK`)
220
+
221
+ // File-level decrypt
222
+ const result = decryptChunks(BigInt(size), chunks, key, nonce)
223
+
224
+ // Clean up download state
225
+ if (!useMemory) {
226
+ const dir = await getSessionDir()
227
+ try { await dir.removeEntry('download.bin') } catch (_) {}
228
+ }
229
+ chunkMeta.clear()
230
+ memoryChunks.clear()
231
+ currentDownloadOffset = 0
232
+ useMemory = false
233
+
234
+ const contentBuf = result.content.buffer.slice(
235
+ result.content.byteOffset,
236
+ result.content.byteOffset + result.content.byteLength
237
+ )
238
+ self.postMessage(
239
+ {id, type: 'decrypted', header: result.header, content: contentBuf},
240
+ [contentBuf]
241
+ )
242
+ }
243
+
244
+ async function handleCleanup(id: number) {
245
+ if (uploadReadHandle) {
246
+ uploadReadHandle.close()
247
+ uploadReadHandle = null
248
+ }
249
+ if (downloadWriteHandle) {
250
+ downloadWriteHandle.close()
251
+ downloadWriteHandle = null
252
+ }
253
+ chunkMeta.clear()
254
+ memoryChunks.clear()
255
+ currentDownloadOffset = 0
256
+ useMemory = false
257
+ try {
258
+ const root = await navigator.storage.getDirectory()
259
+ await root.removeEntry(SESSION_DIR, {recursive: true})
260
+ } catch (_) {}
261
+ sessionDir = null
262
+ self.postMessage({id, type: 'cleaned'})
263
+ }
264
+
265
+ // ── Message dispatch ────────────────────────────────────────────
266
+
267
+ self.onmessage = async (e: MessageEvent) => {
268
+ await initPromise
269
+ const msg = e.data
270
+ try {
271
+ switch (msg.type) {
272
+ case 'encrypt':
273
+ await handleEncrypt(msg.id, msg.data, msg.fileName)
274
+ break
275
+ case 'readChunk':
276
+ handleReadChunk(msg.id, msg.offset, msg.size)
277
+ break
278
+ case 'decryptAndStoreChunk':
279
+ await handleDecryptAndStore(msg.id, msg.dhSecret, msg.nonce, msg.body, msg.chunkDigest, msg.chunkNo)
280
+ break
281
+ case 'verifyAndDecrypt':
282
+ await handleVerifyAndDecrypt(msg.id, msg.size, msg.digest, msg.key, msg.nonce)
283
+ break
284
+ case 'cleanup':
285
+ await handleCleanup(msg.id)
286
+ break
287
+ default:
288
+ self.postMessage({id: msg.id, type: 'error', message: `Unknown message type: ${msg.type}`})
289
+ }
290
+ } catch (err: any) {
291
+ self.postMessage({id: msg.id, type: 'error', message: err?.message ?? String(err)})
292
+ }
293
+ }
294
+
295
+ // ── Helpers ─────────────────────────────────────────────────────
296
+
297
+ function _whex(b: Uint8Array, n = 8): string {
298
+ return Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, '0')).join('')
299
+ }
300
+
301
+ function digestEqual(a: Uint8Array, b: Uint8Array): boolean {
302
+ if (a.length !== b.length) return false
303
+ let diff = 0
304
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
305
+ return diff === 0
306
+ }
307
+
308
+ // ── Init ────────────────────────────────────────────────────────
309
+
310
+ const initPromise = (async () => {
311
+ await sodium.ready
312
+ await sweepStale()
313
+ })()
@@ -0,0 +1,140 @@
1
+ import {createCryptoBackend} from './crypto-backend.js'
2
+ import {createProgressRing} from './progress.js'
3
+ import {
4
+ newXFTPAgent, closeXFTPAgent,
5
+ decodeDescriptionURI, downloadFileRaw
6
+ } from '../src/agent.js'
7
+ import {XFTPPermanentError} from '../src/client.js'
8
+
9
+ export function initDownload(app: HTMLElement, hash: string) {
10
+ let fd: ReturnType<typeof decodeDescriptionURI>
11
+ try {
12
+ fd = decodeDescriptionURI(hash)
13
+ } catch (err: any) {
14
+ app.innerHTML = `<div class="card"><p class="error">Invalid or corrupted link.</p></div>`
15
+ return
16
+ }
17
+
18
+ const size = fd.redirect ? fd.redirect.size : fd.size
19
+ app.innerHTML = `
20
+ <div class="card">
21
+ <h1>SimpleX File Transfer</h1>
22
+ <div id="dl-ready" class="stage">
23
+ <p>File available (~${formatSize(size)})</p>
24
+ <button id="dl-btn" class="btn">Download</button>
25
+ <div class="security-note">
26
+ <p>This file is encrypted — the server never sees file contents.</p>
27
+ <p>The decryption key is in the link's hash fragment, which your browser never sends to any server.</p>
28
+ <p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
29
+ </div>
30
+ </div>
31
+ <div id="dl-progress" class="stage" hidden>
32
+ <div id="dl-progress-container"></div>
33
+ <p id="dl-status">Downloading…</p>
34
+ </div>
35
+ <div id="dl-error" class="stage" hidden>
36
+ <p class="error" id="dl-error-msg"></p>
37
+ <button id="dl-retry-btn" class="btn">Retry</button>
38
+ </div>
39
+ </div>`
40
+
41
+ const readyStage = document.getElementById('dl-ready')!
42
+ const progressStage = document.getElementById('dl-progress')!
43
+ const errorStage = document.getElementById('dl-error')!
44
+ const progressContainer = document.getElementById('dl-progress-container')!
45
+ const statusText = document.getElementById('dl-status')!
46
+ const dlBtn = document.getElementById('dl-btn')!
47
+ const errorMsg = document.getElementById('dl-error-msg')!
48
+ const retryBtn = document.getElementById('dl-retry-btn')!
49
+
50
+ function showStage(stage: HTMLElement) {
51
+ for (const s of [readyStage, progressStage, errorStage]) s.hidden = true
52
+ stage.hidden = false
53
+ }
54
+
55
+ function showError(msg: string) {
56
+ errorMsg.textContent = msg
57
+ showStage(errorStage)
58
+ }
59
+
60
+ dlBtn.addEventListener('click', startDownload)
61
+ retryBtn.addEventListener('click', startDownload)
62
+
63
+ async function startDownload() {
64
+ showStage(progressStage)
65
+ const ring = createProgressRing()
66
+ progressContainer.innerHTML = ''
67
+ progressContainer.appendChild(ring.canvas)
68
+ statusText.textContent = 'Downloading…'
69
+
70
+ const backend = createCryptoBackend()
71
+ const agent = newXFTPAgent()
72
+
73
+ try {
74
+ const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
75
+ await backend.decryptAndStoreChunk(
76
+ raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo
77
+ )
78
+ }, {
79
+ onProgress: (downloaded, total) => {
80
+ ring.update(downloaded / total * 0.8)
81
+ }
82
+ })
83
+
84
+ statusText.textContent = 'Decrypting…'
85
+ ring.update(0.85)
86
+
87
+ const {header, content} = await backend.verifyAndDecrypt({
88
+ size: resolvedFd.size,
89
+ digest: resolvedFd.digest,
90
+ key: resolvedFd.key,
91
+ nonce: resolvedFd.nonce
92
+ })
93
+
94
+ ring.update(0.95)
95
+
96
+ // Sanitize filename and trigger browser save
97
+ const fileName = sanitizeFileName(header.fileName)
98
+ const blob = new Blob([content.buffer as ArrayBuffer])
99
+ const url = URL.createObjectURL(blob)
100
+ const a = document.createElement('a')
101
+ a.href = url
102
+ a.download = encodeURIComponent(fileName)
103
+ a.style.display = 'none'
104
+ document.body.appendChild(a)
105
+ a.click()
106
+ document.body.removeChild(a)
107
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
108
+
109
+ ring.update(1)
110
+ statusText.textContent = 'Download complete'
111
+ } catch (err: any) {
112
+ const msg = err?.message ?? String(err)
113
+ showError(msg)
114
+ if (err instanceof XFTPPermanentError) retryBtn.hidden = true
115
+ else retryBtn.hidden = false
116
+ } finally {
117
+ await backend.cleanup().catch(() => {})
118
+ closeXFTPAgent(agent)
119
+ }
120
+ }
121
+ }
122
+
123
+ function sanitizeFileName(name: string): string {
124
+ let s = name
125
+ // Strip path separators
126
+ s = s.replace(/[/\\]/g, '')
127
+ // Replace null/control characters
128
+ s = s.replace(/[\x00-\x1f\x7f]/g, '_')
129
+ // Strip Unicode bidi override characters
130
+ s = s.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
131
+ // Limit length
132
+ if (s.length > 255) s = s.slice(0, 255)
133
+ return s || 'download'
134
+ }
135
+
136
+ function formatSize(bytes: number): string {
137
+ if (bytes < 1024) return bytes + ' B'
138
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
139
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
140
+ }
package/web/index.html ADDED
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="Content-Security-Policy"
7
+ content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' __CSP_CONNECT_SRC__;">
8
+ <title>SimpleX File Transfer</title>
9
+ <link rel="stylesheet" href="./style.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ <script type="module" src="./main.ts"></script>
14
+ </body>
15
+ </html>
package/web/main.ts ADDED
@@ -0,0 +1,30 @@
1
+ import sodium from 'libsodium-wrappers-sumo'
2
+ import {initUpload} from './upload.js'
3
+ import {initDownload} from './download.js'
4
+
5
+ async function main() {
6
+ await sodium.ready
7
+ initApp()
8
+
9
+ // Handle hash changes (SPA navigation)
10
+ window.addEventListener('hashchange', initApp)
11
+ }
12
+
13
+ function initApp() {
14
+ const app = document.getElementById('app')!
15
+ const hash = window.location.hash.slice(1)
16
+
17
+ if (hash) {
18
+ initDownload(app, hash)
19
+ } else {
20
+ initUpload(app)
21
+ }
22
+ }
23
+
24
+ main().catch(err => {
25
+ const app = document.getElementById('app')
26
+ if (app) {
27
+ app.innerHTML = `<div class="error"><p>Failed to initialize: ${err.message}</p></div>`
28
+ }
29
+ console.error(err)
30
+ })
@@ -0,0 +1,52 @@
1
+ const SIZE = 120
2
+ const LINE_WIDTH = 8
3
+ const RADIUS = (SIZE - LINE_WIDTH) / 2
4
+ const CENTER = SIZE / 2
5
+ const BG_COLOR = '#e0e0e0'
6
+ const FG_COLOR = '#3b82f6'
7
+
8
+ export interface ProgressRing {
9
+ canvas: HTMLCanvasElement
10
+ update(fraction: number): void
11
+ }
12
+
13
+ export function createProgressRing(): ProgressRing {
14
+ const canvas = document.createElement('canvas')
15
+ canvas.width = SIZE * devicePixelRatio
16
+ canvas.height = SIZE * devicePixelRatio
17
+ canvas.style.width = SIZE + 'px'
18
+ canvas.style.height = SIZE + 'px'
19
+ canvas.className = 'progress-ring'
20
+ const ctx = canvas.getContext('2d')!
21
+ ctx.scale(devicePixelRatio, devicePixelRatio)
22
+
23
+ function draw(fraction: number) {
24
+ ctx.clearRect(0, 0, SIZE, SIZE)
25
+ // Background arc
26
+ ctx.beginPath()
27
+ ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
28
+ ctx.strokeStyle = BG_COLOR
29
+ ctx.lineWidth = LINE_WIDTH
30
+ ctx.lineCap = 'round'
31
+ ctx.stroke()
32
+ // Foreground arc
33
+ if (fraction > 0) {
34
+ ctx.beginPath()
35
+ ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
36
+ ctx.strokeStyle = FG_COLOR
37
+ ctx.lineWidth = LINE_WIDTH
38
+ ctx.lineCap = 'round'
39
+ ctx.stroke()
40
+ }
41
+ // Percentage text
42
+ const pct = Math.round(fraction * 100)
43
+ ctx.fillStyle = '#333'
44
+ ctx.font = '600 20px system-ui, sans-serif'
45
+ ctx.textAlign = 'center'
46
+ ctx.textBaseline = 'middle'
47
+ ctx.fillText(pct + '%', CENTER, CENTER)
48
+ }
49
+
50
+ draw(0)
51
+ return {canvas, update: draw}
52
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "simplex": [
3
+ "xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im",
4
+ "xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im",
5
+ "xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im",
6
+ "xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im",
7
+ "xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im",
8
+ "xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im"
9
+ ],
10
+ "flux": [
11
+ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com",
12
+ "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com",
13
+ "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com",
14
+ "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com",
15
+ "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com",
16
+ "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com"
17
+ ]
18
+ }
package/web/servers.ts ADDED
@@ -0,0 +1,16 @@
1
+ import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
2
+
3
+ // __XFTP_SERVERS__ is injected at build time by vite.config.ts
4
+ // In development mode: test server from globalSetup
5
+ // In production mode: preset servers from servers.json
6
+ declare const __XFTP_SERVERS__: string[]
7
+
8
+ const serverAddresses: string[] = __XFTP_SERVERS__
9
+
10
+ export function getServers(): XFTPServer[] {
11
+ return serverAddresses.map(parseXFTPServer)
12
+ }
13
+
14
+ export function pickRandomServer(servers: XFTPServer[]): XFTPServer {
15
+ return servers[Math.floor(Math.random() * servers.length)]
16
+ }
package/web/style.css ADDED
@@ -0,0 +1,103 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ body {
4
+ font-family: system-ui, -apple-system, sans-serif;
5
+ background: #f5f5f5;
6
+ color: #333;
7
+ min-height: 100vh;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ }
12
+
13
+ #app {
14
+ width: 100%;
15
+ max-width: 480px;
16
+ padding: 16px;
17
+ }
18
+
19
+ .card {
20
+ background: #fff;
21
+ border-radius: 12px;
22
+ padding: 32px 24px;
23
+ box-shadow: 0 1px 3px rgba(0,0,0,.1);
24
+ text-align: center;
25
+ }
26
+
27
+ h1 {
28
+ font-size: 1.25rem;
29
+ font-weight: 600;
30
+ margin-bottom: 24px;
31
+ }
32
+
33
+ .stage { margin-top: 16px; }
34
+
35
+ /* Drop zone */
36
+ .drop-zone {
37
+ border: 2px dashed #ccc;
38
+ border-radius: 8px;
39
+ padding: 32px 16px;
40
+ transition: border-color .15s, background .15s;
41
+ }
42
+ .drop-zone.drag-over {
43
+ border-color: #3b82f6;
44
+ background: #eff6ff;
45
+ }
46
+
47
+ /* Buttons */
48
+ .btn {
49
+ display: inline-block;
50
+ padding: 10px 24px;
51
+ border: none;
52
+ border-radius: 6px;
53
+ background: #3b82f6;
54
+ color: #fff;
55
+ font-size: .9rem;
56
+ font-weight: 500;
57
+ cursor: pointer;
58
+ transition: background .15s;
59
+ }
60
+ .btn:hover { background: #2563eb; }
61
+ .btn-secondary { background: #6b7280; }
62
+ .btn-secondary:hover { background: #4b5563; }
63
+
64
+ /* Hints */
65
+ .hint { color: #999; font-size: .85rem; margin-top: 8px; }
66
+ .expiry { margin-top: 12px; }
67
+
68
+ /* Progress */
69
+ .progress-ring { display: block; margin: 0 auto 12px; }
70
+ #upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
71
+
72
+ /* Share link row */
73
+ .link-row {
74
+ display: flex;
75
+ gap: 8px;
76
+ margin-top: 12px;
77
+ }
78
+ .link-row input {
79
+ flex: 1;
80
+ padding: 8px 10px;
81
+ border: 1px solid #ccc;
82
+ border-radius: 6px;
83
+ font-size: .85rem;
84
+ background: #f9fafb;
85
+ }
86
+
87
+ /* Messages */
88
+ .success { color: #16a34a; font-weight: 600; }
89
+ .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
90
+
91
+ /* Security note */
92
+ .security-note {
93
+ margin-top: 20px;
94
+ padding: 12px;
95
+ background: #f0fdf4;
96
+ border-radius: 6px;
97
+ font-size: .8rem;
98
+ color: #555;
99
+ text-align: left;
100
+ }
101
+ .security-note p + p { margin-top: 6px; }
102
+ .security-note a { color: #3b82f6; text-decoration: none; }
103
+ .security-note a:hover { text-decoration: underline; }