diff --git a/xftp-web/package.json b/xftp-web/package.json index 67610689d..5989ebfbd 100644 --- a/xftp-web/package.json +++ b/xftp-web/package.json @@ -1,12 +1,27 @@ { "name": "xftp-web", "version": "0.1.0", - "private": true, + "description": "XFTP file transfer protocol client for web/browser environments", + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/simplex-chat/simplexmq.git", + "directory": "xftp-web" + }, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist-web/index.html", + "files": [ + "src", + "web", + "dist", + "dist-web" + ], + "publishConfig": { + "access": "public" + }, "scripts": { - "postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs && npx playwright install chromium", + "prepublishOnly": "npm run build && npm run build:prod", + "pretest": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs && npx playwright install chromium", "build": "tsc", "test": "vitest", "dev": "npx tsx test/runSetup.ts && vite --mode development", diff --git a/xftp-web/src/crypto/digest.ts b/xftp-web/src/crypto/digest.ts index 95bf2679c..9df449cc6 100644 --- a/xftp-web/src/crypto/digest.ts +++ b/xftp-web/src/crypto/digest.ts @@ -14,12 +14,20 @@ export function sha512(data: Uint8Array): Uint8Array { // Streaming SHA-512 over multiple chunks -- avoids copying large data into WASM memory at once. // Internally segments chunks larger than 4MB to limit peak WASM memory usage. -export function sha512Streaming(chunks: Iterable): Uint8Array { +export function sha512Streaming( + chunks: Iterable, + onProgress?: (done: number, total: number) => void, + totalBytes?: number +): Uint8Array { const SEG = 4 * 1024 * 1024 const state = sodium.crypto_hash_sha512_init() as unknown as sodium.StateAddress + let done = 0 for (const chunk of chunks) { for (let off = 0; off < chunk.length; off += SEG) { - sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length))) + const end = Math.min(off + SEG, chunk.length) + sodium.crypto_hash_sha512_update(state, chunk.subarray(off, end)) + done += end - off + onProgress?.(done, totalBytes ?? done) } } return sodium.crypto_hash_sha512_final(state) diff --git a/xftp-web/src/crypto/file.ts b/xftp-web/src/crypto/file.ts index 0088a680b..ab166969a 100644 --- a/xftp-web/src/crypto/file.ts +++ b/xftp-web/src/crypto/file.ts @@ -2,9 +2,11 @@ // Operates on in-memory Uint8Array (no file I/O needed for browser). import {Decoder, concatBytes, encodeInt64, encodeString, decodeString, encodeMaybe, decodeMaybe} from "../protocol/encoding.js" -import {sbInit, sbEncryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js" +import {sbInit, sbEncryptChunk, sbDecryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js" +import {unPadLazy} from "./padding.js" const AUTH_TAG_SIZE = 16n +const PROGRESS_SEG = 256 * 1024 // -- FileHeader @@ -54,14 +56,24 @@ export function encryptFile( key: Uint8Array, nonce: Uint8Array, fileSize: bigint, - encSize: bigint + encSize: bigint, + onProgress?: (done: number, total: number) => void ): Uint8Array { const state = sbInit(key, nonce) const lenStr = encodeInt64(fileSize) const padLen = Number(encSize - AUTH_TAG_SIZE - fileSize - 8n) if (padLen < 0) throw new Error("encryptFile: encSize too small") const hdr = sbEncryptChunk(state, concatBytes(lenStr, fileHdr)) - const encSource = sbEncryptChunk(state, source) + // Process source in segments for progress reporting. + // sbEncryptChunk is streaming — segments produce identical output to a single call. + const encSource = new Uint8Array(source.length) + for (let off = 0; off < source.length; off += PROGRESS_SEG) { + const end = Math.min(off + PROGRESS_SEG, source.length) + const seg = sbEncryptChunk(state, source.subarray(off, end)) + encSource.set(seg, off) + onProgress?.(end, source.length) + } + if (source.length === 0) onProgress?.(0, 0) const padding = new Uint8Array(padLen) padding.fill(0x23) // '#' const encPad = sbEncryptChunk(state, padding) @@ -82,13 +94,37 @@ export function decryptChunks( encSize: bigint, chunks: Uint8Array[], key: Uint8Array, - nonce: Uint8Array + nonce: Uint8Array, + onProgress?: (done: number, total: number) => void ): {header: FileHeader, content: Uint8Array} { if (chunks.length === 0) throw new Error("decryptChunks: empty chunks") const paddedLen = encSize - AUTH_TAG_SIZE const data = chunks.length === 1 ? chunks[0] : concatBytes(...chunks) - const {valid, content} = sbDecryptTailTag(key, nonce, paddedLen, data) - if (!valid) throw new Error("decryptChunks: invalid auth tag") + if (!onProgress) { + const {valid, content} = sbDecryptTailTag(key, nonce, paddedLen, data) + if (!valid) throw new Error("decryptChunks: invalid auth tag") + const {header, rest} = parseFileHeader(content) + return {header, content: rest} + } + // Segmented decrypt for progress reporting. + // sbDecryptChunk is streaming — segments produce identical output to a single call. + const pLen = Number(paddedLen) + const cipher = data.subarray(0, pLen) + const providedTag = data.subarray(pLen) + const state = sbInit(key, nonce) + const plaintext = new Uint8Array(pLen) + for (let off = 0; off < pLen; off += PROGRESS_SEG) { + const end = Math.min(off + PROGRESS_SEG, pLen) + const seg = sbDecryptChunk(state, cipher.subarray(off, end)) + plaintext.set(seg, off) + onProgress(end, pLen) + } + if (pLen === 0) onProgress(0, 0) + const computedTag = sbAuth(state) + let diff = providedTag.length === 16 ? 0 : 1 + for (let i = 0; i < computedTag.length; i++) diff |= providedTag[i] ^ computedTag[i] + if (diff !== 0) throw new Error("decryptChunks: invalid auth tag") + const content = unPadLazy(plaintext) const {header, rest} = parseFileHeader(content) return {header, content: rest} } diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts index 539ff18c1..13b3caa83 100644 --- a/xftp-web/vite.config.ts +++ b/xftp-web/vite.config.ts @@ -27,7 +27,15 @@ function cspPlugin(servers: string[], isDev: boolean): Plugin { if (isDev) { return html.replace(/]*?Content-Security-Policy[\s\S]*?>/i, '') } - return html.replace('__CSP_CONNECT_SRC__', origins) + // Auto-compute SHA-256 hashes for inline scripts (CSP script-src) + const scriptHashes: string[] = [] + html.replace(/ diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts index 69fd8eba5..41b18b067 100644 --- a/xftp-web/web/main.ts +++ b/xftp-web/web/main.ts @@ -1,30 +1,53 @@ import sodium from 'libsodium-wrappers-sumo' import {initUpload} from './upload.js' import {initDownload} from './download.js' +import {decodeDescriptionURI} from '../src/agent.js' +import {t} from './i18n.js' + +function getAppElement(): HTMLElement | null { + return (document.querySelector('[data-xftp-app]') as HTMLElement | null) ?? document.getElementById('app') +} + +const wasmReady = sodium.ready async function main() { - await sodium.ready - initApp() + // Render UI immediately — no WASM needed for HTML + event listeners. + // WASM is only used later when user triggers upload/download. + const app = getAppElement() + if (!app?.hasAttribute('data-defer-init')) { + initApp() + } + if (!app?.hasAttribute('data-no-hashchange')) { + window.addEventListener('hashchange', () => { + const hash = window.location.hash.slice(1) + if (!hash || isXFTPHash(hash)) initApp() + }) + } + await wasmReady + app?.dispatchEvent(new CustomEvent('xftp:ready', {bubbles: true})) +} - // Handle hash changes (SPA navigation) - window.addEventListener('hashchange', initApp) +function isXFTPHash(hash: string): boolean { + try { decodeDescriptionURI(hash); return true } catch { return false } } function initApp() { - const app = document.getElementById('app')! + const app = getAppElement()! const hash = window.location.hash.slice(1) - if (hash) { + if (hash && isXFTPHash(hash)) { initDownload(app, hash) } else { initUpload(app) } } +;(window as any).__xftp_initApp = async () => { await wasmReady; initApp() } + main().catch(err => { - const app = document.getElementById('app') + const app = getAppElement() if (app) { - app.innerHTML = `

Failed to initialize: ${err.message}

` + app.innerHTML = `

${t('initError', 'Failed to initialize: %error%').replace('%error%', err.message)}

` } console.error(err) }) diff --git a/xftp-web/web/progress.ts b/xftp-web/web/progress.ts index 2fa292f27..9872b74fd 100644 --- a/xftp-web/web/progress.ts +++ b/xftp-web/web/progress.ts @@ -2,12 +2,14 @@ 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' +const LERP_SPEED = 0.12 export interface ProgressRing { canvas: HTMLCanvasElement update(fraction: number): void + fillTo(fraction: number, durationMs: number): Promise + setIndeterminate(on: boolean): void + destroy(): void } export function createProgressRing(): ProgressRing { @@ -20,33 +22,160 @@ export function createProgressRing(): ProgressRing { const ctx = canvas.getContext('2d')! ctx.scale(devicePixelRatio, devicePixelRatio) - function draw(fraction: number) { - ctx.clearRect(0, 0, SIZE, SIZE) - // Background arc + let displayed = 0 + let target = 0 + let animId = 0 + let spinAngle = 0 + let spinning = false + let fillResolve: (() => void) | null = null + function getColors() { + const appEl = document.querySelector('[data-xftp-app]') ?? document.getElementById('app') + const s = appEl ? getComputedStyle(appEl) : null + return { + bg: s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0', + fg: s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6', + text: s?.getPropertyValue('--xftp-ring-text').trim() || '#333', + done: s?.getPropertyValue('--xftp-ring-done').trim() || '#16a34a', + } + } + + function drawBgRing(c: ReturnType, color?: string) { ctx.beginPath() ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI) - ctx.strokeStyle = BG_COLOR + ctx.strokeStyle = color ?? c.bg ctx.lineWidth = LINE_WIDTH ctx.lineCap = 'round' ctx.stroke() - // Foreground arc - if (fraction > 0) { + } + + function render(fraction: number) { + const c = getColors() + ctx.clearRect(0, 0, SIZE, SIZE) + drawBgRing(c, fraction >= 1 ? c.done : undefined) + + if (fraction > 0 && fraction < 1) { ctx.beginPath() ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction) - ctx.strokeStyle = FG_COLOR + ctx.strokeStyle = c.fg 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) + + if (fraction >= 1) { + ctx.strokeStyle = c.done + ctx.lineWidth = 5 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.beginPath() + ctx.moveTo(CENTER - 18, CENTER + 2) + ctx.lineTo(CENTER - 4, CENTER + 16) + ctx.lineTo(CENTER + 22, CENTER - 14) + ctx.stroke() + } else { + const pct = Math.round(fraction * 100) + ctx.fillStyle = c.text + ctx.font = '600 20px system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(pct + '%', CENTER, CENTER) + } + } + + function tick() { + if (spinning) return + const diff = target - displayed + if (Math.abs(diff) < 0.002) { + displayed = target + render(displayed) + animId = 0 + return + } + displayed += diff * LERP_SPEED + render(displayed) + animId = requestAnimationFrame(tick) } - draw(0) - return {canvas, update: draw} + function startTick() { + if (!animId && !spinning) { animId = requestAnimationFrame(tick) } + } + + function stopAnim() { + if (animId) { cancelAnimationFrame(animId); animId = 0 } + spinning = false + if (fillResolve) { fillResolve(); fillResolve = null } + } + + function spinFrame() { + const c = getColors() + ctx.clearRect(0, 0, SIZE, SIZE) + drawBgRing(c) + ctx.beginPath() + ctx.arc(CENTER, CENTER, RADIUS, spinAngle, spinAngle + Math.PI * 0.75) + ctx.strokeStyle = c.fg + ctx.lineWidth = LINE_WIDTH + ctx.lineCap = 'round' + ctx.stroke() + spinAngle += 0.06 + if (spinning) animId = requestAnimationFrame(spinFrame) + } + + function redraw() { + if (spinning) return + render(displayed) + } + + const mql = matchMedia('(prefers-color-scheme: dark)') + mql.addEventListener('change', redraw) + const observer = new MutationObserver(redraw) + observer.observe(document.documentElement, {attributes: true, attributeFilter: ['class']}) + + render(0) + return { + canvas, + update(fraction: number) { + stopAnim() + // Snap immediately on phase reset (0) and completion (1) + if ((fraction === 0 && target > 0) || fraction >= 1) { + displayed = fraction + target = fraction + render(fraction) + return + } + target = fraction + startTick() + }, + fillTo(fraction: number, durationMs: number): Promise { + stopAnim() + const from = displayed + const start = performance.now() + return new Promise(resolve => { + fillResolve = resolve + function frame() { + const t = Math.min(1, (performance.now() - start) / durationMs) + const eased = 1 - (1 - t) * (1 - t) // ease-out + displayed = from + (fraction - from) * eased + target = displayed + render(displayed) + if (t < 1) { + animId = requestAnimationFrame(frame) + } else { + animId = 0 + fillResolve = null + resolve() + } + } + animId = requestAnimationFrame(frame) + }) + }, + setIndeterminate(on: boolean) { + stopAnim() + if (on) { spinning = true; spinFrame() } + }, + destroy() { + stopAnim() + mql.removeEventListener('change', redraw) + observer.disconnect() + }, + } } diff --git a/xftp-web/web/servers.ts b/xftp-web/web/servers.ts index b9a67e5bf..6939ade5f 100644 --- a/xftp-web/web/servers.ts +++ b/xftp-web/web/servers.ts @@ -8,5 +8,6 @@ declare const __XFTP_SERVERS__: string[] const serverAddresses: string[] = __XFTP_SERVERS__ export function getServers(): XFTPServer[] { - return serverAddresses.map(parseXFTPServer) + const addrs = (window as any).__XFTP_SERVERS__ ?? serverAddresses + return addrs.map(parseXFTPServer) } diff --git a/xftp-web/web/style.css b/xftp-web/web/style.css index 3c5654a0e..e5ba53f03 100644 --- a/xftp-web/web/style.css +++ b/xftp-web/web/style.css @@ -1,22 +1,14 @@ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -body { +#app, [data-xftp-app] { 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; + box-sizing: border-box; + --xftp-ring-fg: #3b82f6; } -.card { +:is(#app, [data-xftp-app]) .card { background: #fff; border-radius: 12px; padding: 32px 24px; @@ -24,28 +16,28 @@ body { text-align: center; } -h1 { +:is(#app, [data-xftp-app]) h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; } -.stage { margin-top: 16px; } +:is(#app, [data-xftp-app]) .stage { margin-top: 16px; } /* Drop zone */ -.drop-zone { +:is(#app, [data-xftp-app]) .drop-zone { border: 2px dashed #ccc; border-radius: 8px; padding: 32px 16px; transition: border-color .15s, background .15s; } -.drop-zone.drag-over { +:is(#app, [data-xftp-app]) .drop-zone.drag-over { border-color: #3b82f6; background: #eff6ff; } /* Buttons */ -.btn { +:is(#app, [data-xftp-app]) .btn { display: inline-block; padding: 10px 24px; border: none; @@ -57,25 +49,26 @@ h1 { cursor: pointer; transition: background .15s; } -.btn:hover { background: #2563eb; } -.btn-secondary { background: #6b7280; } -.btn-secondary:hover { background: #4b5563; } +:is(#app, [data-xftp-app]) .btn:hover { background: #2563eb; } +:is(#app, [data-xftp-app]) .btn-secondary { background: #6b7280; } +:is(#app, [data-xftp-app]) .btn-secondary:hover { background: #4b5563; } /* Hints */ -.hint { color: #999; font-size: .85rem; margin-top: 8px; } -.expiry { margin-top: 12px; } +:is(#app, [data-xftp-app]) .hint { color: #999; font-size: .85rem; margin-top: 8px; } +:is(#app, [data-xftp-app]) .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; } +:is(#app, [data-xftp-app]) .progress-ring { display: block; margin: 0 auto 12px; } +:is(#app, [data-xftp-app]) #upload-status, +:is(#app, [data-xftp-app]) #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; } /* Share link row */ -.link-row { +:is(#app, [data-xftp-app]) .link-row { display: flex; gap: 8px; margin-top: 12px; } -.link-row input { +:is(#app, [data-xftp-app]) .link-row input { flex: 1; padding: 8px 10px; border: 1px solid #ccc; @@ -85,11 +78,11 @@ h1 { } /* Messages */ -.success { color: #16a34a; font-weight: 600; } -.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; } +:is(#app, [data-xftp-app]) .success { color: #16a34a; font-weight: 600; } +:is(#app, [data-xftp-app]) .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; } /* Security note */ -.security-note { +:is(#app, [data-xftp-app]) .security-note { margin-top: 20px; padding: 12px; background: #f0fdf4; @@ -98,6 +91,41 @@ h1 { 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; } +:is(#app, [data-xftp-app]) .security-note p + p { margin-top: 6px; } +:is(#app, [data-xftp-app]) .security-note a { color: #3b82f6; text-decoration: none; } +:is(#app, [data-xftp-app]) .security-note a:hover { text-decoration: underline; } + +/* ── Dark mode ─────────────────────────────────── */ +.dark :is(#app, [data-xftp-app]) { + color: #e5e7eb; + --xftp-ring-bg: #374151; + --xftp-ring-fg: #60a5fa; + --xftp-ring-text: #e5e7eb; + --xftp-ring-done: #4ade80; +} +.dark :is(#app, [data-xftp-app]) .card { + background: #1f2937; + box-shadow: 0 1px 3px rgba(0,0,0,.4); +} +.dark :is(#app, [data-xftp-app]) .drop-zone { border-color: #4b5563; } +.dark :is(#app, [data-xftp-app]) .drop-zone.drag-over { + border-color: #60a5fa; + background: rgba(59,130,246,.15); +} +.dark :is(#app, [data-xftp-app]) .btn-secondary { background: #4b5563; } +.dark :is(#app, [data-xftp-app]) .btn-secondary:hover { background: #374151; } +.dark :is(#app, [data-xftp-app]) .hint { color: #9ca3af; } +.dark :is(#app, [data-xftp-app]) #upload-status, +.dark :is(#app, [data-xftp-app]) #dl-status { color: #9ca3af; } +.dark :is(#app, [data-xftp-app]) .link-row input { + background: #374151; + border-color: #4b5563; + color: #e5e7eb; +} +.dark :is(#app, [data-xftp-app]) .success { color: #4ade80; } +.dark :is(#app, [data-xftp-app]) .error { color: #f87171; } +.dark :is(#app, [data-xftp-app]) .security-note { + background: rgba(34,197,94,.1); + color: #d1d5db; +} +.dark :is(#app, [data-xftp-app]) .security-note a { color: #60a5fa; } diff --git a/xftp-web/web/upload.ts b/xftp-web/web/upload.ts index 0faa70c0a..a3a60cdca 100644 --- a/xftp-web/web/upload.ts +++ b/xftp-web/web/upload.ts @@ -1,6 +1,7 @@ import {createCryptoBackend} from './crypto-backend.js' import {getServers} from './servers.js' import {createProgressRing} from './progress.js' +import {t} from './i18n.js' import { newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI, type EncryptedFileMetadata @@ -8,39 +9,42 @@ import { import {XFTPPermanentError} from '../src/client.js' const MAX_SIZE = 100 * 1024 * 1024 +const ENCRYPT_WEIGHT = 0.15 +const ENCRYPT_MIN_FILE_SIZE = 100 * 1024 +const ENCRYPT_MIN_DISPLAY_MS = 1000 export function initUpload(app: HTMLElement) { app.innerHTML = `
-

SimpleX File Transfer

+

${t('title', 'SimpleX File Transfer')}

-

Drag & drop a file here

-

or

- +

${t('dropZone', 'Drag & drop a file here')}

+

${t('dropZoneHint', 'or')}

+ -

Max 100 MB

+

${t('maxSizeHint', 'Max 100 MB')}

` @@ -57,6 +61,16 @@ export function initUpload(app: HTMLElement) { const errorMsg = document.getElementById('error-msg')! const retryBtn = document.getElementById('retry-btn')! + const shareBtn = typeof navigator.share === 'function' + ? (() => { + const btn = document.createElement('button') + btn.className = 'btn btn-secondary' + btn.textContent = t('share', 'Share') + shareLink.parentElement!.appendChild(btn) + return btn + })() + : null + let aborted = false let pendingFile: File | null = null @@ -72,7 +86,9 @@ export function initUpload(app: HTMLElement) { if (fileInput.files?.[0]) startUpload(fileInput.files[0]) }) retryBtn.addEventListener('click', () => { - if (pendingFile) startUpload(pendingFile) + pendingFile = null + fileInput.value = '' + showStage(dropZone) }) function showStage(stage: HTMLElement) { @@ -81,7 +97,7 @@ export function initUpload(app: HTMLElement) { } function showError(msg: string) { - errorMsg.textContent = msg + errorMsg.innerHTML = msg showStage(errorStage) } @@ -90,11 +106,11 @@ export function initUpload(app: HTMLElement) { aborted = false if (file.size > MAX_SIZE) { - showError(`File too large (${formatSize(file.size)}). Maximum is 100 MB.`) + showError(t('fileTooLarge', 'File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB.').replace('%size%', formatSize(file.size))) return } if (file.size === 0) { - showError('File is empty.') + showError(t('fileEmpty', 'File is empty.')) return } @@ -102,28 +118,43 @@ export function initUpload(app: HTMLElement) { const ring = createProgressRing() progressContainer.innerHTML = '' progressContainer.appendChild(ring.canvas) - statusText.textContent = 'Encrypting…' + + const showEncrypt = file.size >= ENCRYPT_MIN_FILE_SIZE + const encryptWeight = showEncrypt ? ENCRYPT_WEIGHT : 0 + statusText.textContent = showEncrypt + ? t('encrypting', 'Encrypting\u2026') + : t('uploading', 'Uploading\u2026') const backend = createCryptoBackend() const agent = newXFTPAgent() cancelBtn.onclick = () => { aborted = true + ring.destroy() backend.cleanup().catch(() => {}) closeXFTPAgent(agent) showStage(dropZone) } try { + const encryptStart = performance.now() 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) + ring.update((done / total) * encryptWeight) }) if (aborted) return - statusText.textContent = 'Uploading…' + if (showEncrypt) { + const elapsed = performance.now() - encryptStart + if (elapsed < ENCRYPT_MIN_DISPLAY_MS) { + await ring.fillTo(encryptWeight, ENCRYPT_MIN_DISPLAY_MS - elapsed) + if (aborted) return + } + statusText.textContent = t('uploading', 'Uploading\u2026') + } + const metadata: EncryptedFileMetadata = { digest: encrypted.digest, key: encrypted.key, @@ -134,7 +165,7 @@ export function initUpload(app: HTMLElement) { const result = await uploadFile(agent, servers, metadata, { readChunk: (off, sz) => backend.readChunk(off, sz), onProgress: (uploaded, total) => { - ring.update(0.3 + (uploaded / total) * 0.7) + ring.update(encryptWeight + (uploaded / total) * (1 - encryptWeight)) } }) if (aborted) return @@ -142,12 +173,16 @@ export function initUpload(app: HTMLElement) { const url = window.location.origin + window.location.pathname + '#' + result.uri shareLink.value = url showStage(completeStage) + app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true})) copyBtn.onclick = () => { navigator.clipboard.writeText(url).then(() => { - copyBtn.textContent = 'Copied!' - setTimeout(() => { copyBtn.textContent = 'Copy' }, 2000) + copyBtn.textContent = t('copied', 'Copied!') + setTimeout(() => { copyBtn.textContent = t('copy', 'Copy') }, 2000) }) } + if (shareBtn) { + shareBtn.onclick = () => navigator.share({url}).catch(() => {}) + } } catch (err: any) { if (!aborted) { const msg = err?.message ?? String(err) @@ -157,6 +192,7 @@ export function initUpload(app: HTMLElement) { else retryBtn.hidden = false } } finally { + ring.destroy() await backend.cleanup().catch(() => {}) closeXFTPAgent(agent) }