From c9f07c5896849a9a3fe2d75f700caeacd262d328 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 19 Feb 2026 12:48:24 +0000 Subject: [PATCH 01/14] prepare package.json for npm publishing Remove private flag, add description/license/repository/publishConfig, rename postinstall to pretest, add prepublishOnly, set files and main. --- xftp-web/package.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/xftp-web/package.json b/xftp-web/package.json index 67610689d..5a15ecb9e 100644 --- a/xftp-web/package.json +++ b/xftp-web/package.json @@ -1,12 +1,22 @@ { "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": "https://github.com/simplex-chat/simplexmq", + "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", From a7b431c1fe843018a8ec348df4291715f456c7cf Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 19 Feb 2026 12:48:28 +0000 Subject: [PATCH 02/14] stable output filenames in production build --- xftp-web/vite.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts index 539ff18c1..36f19b2c8 100644 --- a/xftp-web/vite.config.ts +++ b/xftp-web/vite.config.ts @@ -92,12 +92,17 @@ export default defineConfig(({mode}) => { chunkSizeWarningLimit: 1200, rollupOptions: { external: ['node:http2', 'url'], + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name][extname]', + }, }, }, server: httpsConfig ? {https: httpsConfig} : {}, preview: {host: true, https: false}, define, - worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url']}}, + worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url'], output: {entryFileNames: '[name].js'}}}, plugins, } }) From c88701ee645d5060b26360b461a651024daa3350 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 19 Feb 2026 12:55:24 +0000 Subject: [PATCH 03/14] fix repository url format, expand files array --- xftp-web/package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/xftp-web/package.json b/xftp-web/package.json index 5a15ecb9e..5989ebfbd 100644 --- a/xftp-web/package.json +++ b/xftp-web/package.json @@ -5,12 +5,17 @@ "license": "AGPL-3.0-only", "repository": { "type": "git", - "url": "https://github.com/simplex-chat/simplexmq", + "url": "git+https://github.com/simplex-chat/simplexmq.git", "directory": "xftp-web" }, "type": "module", "main": "dist-web/index.html", - "files": ["src", "web", "dist", "dist-web"], + "files": [ + "src", + "web", + "dist", + "dist-web" + ], "publishConfig": { "access": "public" }, From bfac6f5095a6887acaa3f5174ed1ae4ac0e16315 Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 14:24:20 +0000 Subject: [PATCH 04/14] embeddable component: scoped CSS, dark mode, i18n, events, share - worker output to assets/ for single-directory deployment - scoped all CSS under #app, removed global resets - dark mode via .dark ancestor class - progress ring reads colors from CSS custom properties - i18n via window.__XFTP_I18N__ with t() helper - configurable mount element via data-xftp-app attribute - optional hashchange listener (data-no-hashchange) - completion events: xftp:upload-complete, xftp:download-complete - enhanced file-too-large error mentioning SimpleX app - native share button via navigator.share --- xftp-web/vite.config.ts | 2 +- xftp-web/web/crypto-backend.ts | 3 +- xftp-web/web/download.ts | 26 +++++----- xftp-web/web/i18n.ts | 9 ++++ xftp-web/web/main.ts | 19 ++++++-- xftp-web/web/progress.ts | 14 ++++-- xftp-web/web/style.css | 89 ++++++++++++++++++++++------------ xftp-web/web/upload.ts | 55 +++++++++++++-------- 8 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 xftp-web/web/i18n.ts diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts index 36f19b2c8..1bb8a0624 100644 --- a/xftp-web/vite.config.ts +++ b/xftp-web/vite.config.ts @@ -102,7 +102,7 @@ export default defineConfig(({mode}) => { server: httpsConfig ? {https: httpsConfig} : {}, preview: {host: true, https: false}, define, - worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url'], output: {entryFileNames: '[name].js'}}}, + worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url'], output: {entryFileNames: 'assets/[name].js'}}}, plugins, } }) diff --git a/xftp-web/web/crypto-backend.ts b/xftp-web/web/crypto-backend.ts index e1ed52b84..348182476 100644 --- a/xftp-web/web/crypto-backend.ts +++ b/xftp-web/web/crypto-backend.ts @@ -1,4 +1,5 @@ import type {FileHeader} from '../src/crypto/file.js' +import {t} from './i18n.js' export interface CryptoBackend { encrypt(data: Uint8Array, fileName: string, @@ -134,7 +135,7 @@ class WorkerBackend implements CryptoBackend { export function createCryptoBackend(): CryptoBackend { if (typeof Worker === 'undefined') { - throw new Error('Web Workers required — update your browser') + throw new Error(t('workersRequired', 'Web Workers required \u2014 update your browser')) } return new WorkerBackend() } diff --git a/xftp-web/web/download.ts b/xftp-web/web/download.ts index 25443cf35..e2d47039c 100644 --- a/xftp-web/web/download.ts +++ b/xftp-web/web/download.ts @@ -1,5 +1,6 @@ import {createCryptoBackend} from './crypto-backend.js' import {createProgressRing} from './progress.js' +import {t} from './i18n.js' import { newXFTPAgent, closeXFTPAgent, decodeDescriptionURI, downloadFileRaw @@ -11,30 +12,30 @@ export function initDownload(app: HTMLElement, hash: string) { try { fd = decodeDescriptionURI(hash) } catch (err: any) { - app.innerHTML = `

Invalid or corrupted link.

` + app.innerHTML = `

${t('invalidLink', 'Invalid or corrupted link.')}

` return } const size = fd.redirect ? fd.redirect.size : fd.size app.innerHTML = `
-

SimpleX File Transfer

+

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

-

File available (~${formatSize(size)})

- +

${t('fileAvailable', 'File available (~%size%)').replace('%size%', formatSize(size))}

+
-

This file is encrypted — the server never sees file contents.

-

The decryption key is in the link's hash fragment, which your browser never sends to any server.

-

For maximum security, use the SimpleX app.

+

${t('dlSecurityNote1', 'This file is encrypted \u2014 the server never sees file contents.')}

+

${t('dlSecurityNote2', 'The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server.')}

+

${t('dlSecurityNote3', 'For maximum security, use the SimpleX app.')}

` @@ -65,7 +66,7 @@ export function initDownload(app: HTMLElement, hash: string) { const ring = createProgressRing() progressContainer.innerHTML = '' progressContainer.appendChild(ring.canvas) - statusText.textContent = 'Downloading…' + statusText.textContent = t('downloading', 'Downloading\u2026') const backend = createCryptoBackend() const agent = newXFTPAgent() @@ -81,7 +82,7 @@ export function initDownload(app: HTMLElement, hash: string) { } }) - statusText.textContent = 'Decrypting…' + statusText.textContent = t('decrypting', 'Decrypting\u2026') ring.update(0.85) const {header, content} = await backend.verifyAndDecrypt({ @@ -107,7 +108,8 @@ export function initDownload(app: HTMLElement, hash: string) { setTimeout(() => URL.revokeObjectURL(url), 1000) ring.update(1) - statusText.textContent = 'Download complete' + statusText.textContent = t('downloadComplete', 'Download complete') + app.dispatchEvent(new CustomEvent('xftp:download-complete', {detail: {fileName}, bubbles: true})) } catch (err: any) { const msg = err?.message ?? String(err) showError(msg) diff --git a/xftp-web/web/i18n.ts b/xftp-web/web/i18n.ts new file mode 100644 index 000000000..832233465 --- /dev/null +++ b/xftp-web/web/i18n.ts @@ -0,0 +1,9 @@ +declare global { + interface Window { + __XFTP_I18N__?: Record + } +} + +export function t(key: string, fallback: string): string { + return window.__XFTP_I18N__?.[key] ?? fallback +} diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts index 69fd8eba5..d0afdbf8b 100644 --- a/xftp-web/web/main.ts +++ b/xftp-web/web/main.ts @@ -1,17 +1,24 @@ import sodium from 'libsodium-wrappers-sumo' import {initUpload} from './upload.js' import {initDownload} from './download.js' +import {t} from './i18n.js' + +function getAppElement(): HTMLElement | null { + return (document.querySelector('[data-xftp-app]') as HTMLElement | null) ?? document.getElementById('app') +} async function main() { await sodium.ready initApp() - // Handle hash changes (SPA navigation) - window.addEventListener('hashchange', initApp) + const app = getAppElement() + if (!app?.hasAttribute('data-no-hashchange')) { + window.addEventListener('hashchange', initApp) + } } function initApp() { - const app = document.getElementById('app')! + const app = getAppElement()! const hash = window.location.hash.slice(1) if (hash) { @@ -21,10 +28,12 @@ function initApp() { } } +;(window as any).__xftp_initApp = 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..16dbe7ee8 100644 --- a/xftp-web/web/progress.ts +++ b/xftp-web/web/progress.ts @@ -2,8 +2,6 @@ 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 @@ -21,11 +19,17 @@ export function createProgressRing(): ProgressRing { ctx.scale(devicePixelRatio, devicePixelRatio) function draw(fraction: number) { + const appEl = document.querySelector('[data-xftp-app]') ?? document.getElementById('app') + const s = appEl ? getComputedStyle(appEl) : null + const bgColor = s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0' + const fgColor = s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6' + const textColor = s?.getPropertyValue('--xftp-ring-text').trim() || '#333' + ctx.clearRect(0, 0, SIZE, SIZE) // Background arc ctx.beginPath() ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI) - ctx.strokeStyle = BG_COLOR + ctx.strokeStyle = bgColor ctx.lineWidth = LINE_WIDTH ctx.lineCap = 'round' ctx.stroke() @@ -33,14 +37,14 @@ export function createProgressRing(): ProgressRing { 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.strokeStyle = fgColor ctx.lineWidth = LINE_WIDTH ctx.lineCap = 'round' ctx.stroke() } // Percentage text const pct = Math.round(fraction * 100) - ctx.fillStyle = '#333' + ctx.fillStyle = textColor ctx.font = '600 20px system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' diff --git a/xftp-web/web/style.css b/xftp-web/web/style.css index 3c5654a0e..44abdfc3f 100644 --- a/xftp-web/web/style.css +++ b/xftp-web/web/style.css @@ -1,22 +1,13 @@ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -body { +#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; } -.card { +#app .card { background: #fff; border-radius: 12px; padding: 32px 24px; @@ -24,28 +15,28 @@ body { text-align: center; } -h1 { +#app h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; } -.stage { margin-top: 16px; } +#app .stage { margin-top: 16px; } /* Drop zone */ -.drop-zone { +#app .drop-zone { border: 2px dashed #ccc; border-radius: 8px; padding: 32px 16px; transition: border-color .15s, background .15s; } -.drop-zone.drag-over { +#app .drop-zone.drag-over { border-color: #3b82f6; background: #eff6ff; } /* Buttons */ -.btn { +#app .btn { display: inline-block; padding: 10px 24px; border: none; @@ -57,25 +48,25 @@ h1 { cursor: pointer; transition: background .15s; } -.btn:hover { background: #2563eb; } -.btn-secondary { background: #6b7280; } -.btn-secondary:hover { background: #4b5563; } +#app .btn:hover { background: #2563eb; } +#app .btn-secondary { background: #6b7280; } +#app .btn-secondary:hover { background: #4b5563; } /* Hints */ -.hint { color: #999; font-size: .85rem; margin-top: 8px; } -.expiry { margin-top: 12px; } +#app .hint { color: #999; font-size: .85rem; margin-top: 8px; } +#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; } +#app .progress-ring { display: block; margin: 0 auto 12px; } +#app #upload-status, #app #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; } /* Share link row */ -.link-row { +#app .link-row { display: flex; gap: 8px; margin-top: 12px; } -.link-row input { +#app .link-row input { flex: 1; padding: 8px 10px; border: 1px solid #ccc; @@ -85,11 +76,11 @@ h1 { } /* Messages */ -.success { color: #16a34a; font-weight: 600; } -.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; } +#app .success { color: #16a34a; font-weight: 600; } +#app .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; } /* Security note */ -.security-note { +#app .security-note { margin-top: 20px; padding: 12px; background: #f0fdf4; @@ -98,6 +89,40 @@ 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; } +#app .security-note p + p { margin-top: 6px; } +#app .security-note a { color: #3b82f6; text-decoration: none; } +#app .security-note a:hover { text-decoration: underline; } + +/* ── Dark mode ─────────────────────────────────── */ +.dark #app { + color: #e5e7eb; + --xftp-ring-bg: #374151; + --xftp-ring-fg: #60a5fa; + --xftp-ring-text: #e5e7eb; +} +.dark #app .card { + background: #1f2937; + box-shadow: 0 1px 3px rgba(0,0,0,.4); +} +.dark #app .drop-zone { border-color: #4b5563; } +.dark #app .drop-zone.drag-over { + border-color: #60a5fa; + background: rgba(59,130,246,.15); +} +.dark #app .btn-secondary { background: #4b5563; } +.dark #app .btn-secondary:hover { background: #374151; } +.dark #app .hint { color: #9ca3af; } +.dark #app #upload-status, +.dark #app #dl-status { color: #9ca3af; } +.dark #app .link-row input { + background: #374151; + border-color: #4b5563; + color: #e5e7eb; +} +.dark #app .success { color: #4ade80; } +.dark #app .error { color: #f87171; } +.dark #app .security-note { + background: rgba(34,197,94,.1); + color: #d1d5db; +} +.dark #app .security-note a { color: #60a5fa; } diff --git a/xftp-web/web/upload.ts b/xftp-web/web/upload.ts index 0faa70c0a..ddfe978c3 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 @@ -12,35 +13,35 @@ const MAX_SIZE = 100 * 1024 * 1024 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 +58,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 @@ -90,11 +101,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,7 +113,7 @@ export function initUpload(app: HTMLElement) { const ring = createProgressRing() progressContainer.innerHTML = '' progressContainer.appendChild(ring.canvas) - statusText.textContent = 'Encrypting…' + statusText.textContent = t('encrypting', 'Encrypting\u2026') const backend = createCryptoBackend() const agent = newXFTPAgent() @@ -123,7 +134,7 @@ export function initUpload(app: HTMLElement) { }) if (aborted) return - statusText.textContent = 'Uploading…' + statusText.textContent = t('uploading', 'Uploading\u2026') const metadata: EncryptedFileMetadata = { digest: encrypted.digest, key: encrypted.key, @@ -142,12 +153,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) From 86a2bd52d611a627403d57a9c98b23688a9232ba Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 14:30:12 +0000 Subject: [PATCH 05/14] deferred init and runtime server configuration - data-defer-init attribute skips auto-initialization - window.__XFTP_SERVERS__ overrides baked-in server list --- xftp-web/web/main.ts | 4 +++- xftp-web/web/servers.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts index d0afdbf8b..64e63dfd3 100644 --- a/xftp-web/web/main.ts +++ b/xftp-web/web/main.ts @@ -9,9 +9,11 @@ function getAppElement(): HTMLElement | null { async function main() { await sodium.ready - initApp() const app = getAppElement() + if (!app?.hasAttribute('data-defer-init')) { + initApp() + } if (!app?.hasAttribute('data-no-hashchange')) { window.addEventListener('hashchange', initApp) } 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) } From c20e796d57fc02fa16dd5b59fe276ef59af48171 Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 14:36:26 +0000 Subject: [PATCH 06/14] use relative base path for relocatable build output --- xftp-web/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts index 1bb8a0624..d0288aff6 100644 --- a/xftp-web/vite.config.ts +++ b/xftp-web/vite.config.ts @@ -84,6 +84,7 @@ export default defineConfig(({mode}) => { } : undefined return { + base: './', root: 'web', build: { outDir: resolve(__dirname, 'dist-web'), From ff22546c139c51b68757ecb4508af4fdacd8d2dc Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 16:46:53 +0000 Subject: [PATCH 07/14] xftp-web: retry resets to default state, use innerHTML for errors --- xftp-web/web/download.ts | 4 ++-- xftp-web/web/upload.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/xftp-web/web/download.ts b/xftp-web/web/download.ts index e2d47039c..c1c4f83ee 100644 --- a/xftp-web/web/download.ts +++ b/xftp-web/web/download.ts @@ -54,12 +54,12 @@ export function initDownload(app: HTMLElement, hash: string) { } function showError(msg: string) { - errorMsg.textContent = msg + errorMsg.innerHTML = msg showStage(errorStage) } dlBtn.addEventListener('click', startDownload) - retryBtn.addEventListener('click', startDownload) + retryBtn.addEventListener('click', () => showStage(readyStage)) async function startDownload() { showStage(progressStage) diff --git a/xftp-web/web/upload.ts b/xftp-web/web/upload.ts index ddfe978c3..61362e0df 100644 --- a/xftp-web/web/upload.ts +++ b/xftp-web/web/upload.ts @@ -83,7 +83,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) { @@ -92,7 +94,7 @@ export function initUpload(app: HTMLElement) { } function showError(msg: string) { - errorMsg.textContent = msg + errorMsg.innerHTML = msg showStage(errorStage) } From 3d58e76f40ad58a1a86fec7f3b5a6db56c0de2da Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 17:37:43 +0000 Subject: [PATCH 08/14] xftp-web: only enter download mode for valid XFTP URIs in hash --- xftp-web/web/main.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts index 64e63dfd3..e9481c487 100644 --- a/xftp-web/web/main.ts +++ b/xftp-web/web/main.ts @@ -1,6 +1,7 @@ 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 { @@ -15,15 +16,22 @@ async function main() { initApp() } if (!app?.hasAttribute('data-no-hashchange')) { - window.addEventListener('hashchange', initApp) + window.addEventListener('hashchange', () => { + const hash = window.location.hash.slice(1) + if (!hash || isXFTPHash(hash)) initApp() + }) } } +function isXFTPHash(hash: string): boolean { + try { decodeDescriptionURI(hash); return true } catch { return false } +} + function initApp() { const app = getAppElement()! const hash = window.location.hash.slice(1) - if (hash) { + if (hash && isXFTPHash(hash)) { initDownload(app, hash) } else { initUpload(app) From dea5297f00065eccd411e23404f4cc55421dcfda Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 21:23:06 +0000 Subject: [PATCH 09/14] xftp-web: render UI before WASM is ready Move sodium.ready await after UI initialization so the upload/download interface appears instantly. WASM is only needed when user triggers an actual upload or download. Dispatch xftp:ready event once WASM loads. --- xftp-web/web/main.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts index e9481c487..41b18b067 100644 --- a/xftp-web/web/main.ts +++ b/xftp-web/web/main.ts @@ -8,9 +8,11 @@ function getAppElement(): HTMLElement | null { return (document.querySelector('[data-xftp-app]') as HTMLElement | null) ?? document.getElementById('app') } -async function main() { - await sodium.ready +const wasmReady = sodium.ready +async function main() { + // 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() @@ -21,6 +23,8 @@ async function main() { if (!hash || isXFTPHash(hash)) initApp() }) } + await wasmReady + app?.dispatchEvent(new CustomEvent('xftp:ready', {bubbles: true})) } function isXFTPHash(hash: string): boolean { @@ -38,7 +42,7 @@ function initApp() { } } -;(window as any).__xftp_initApp = initApp +;(window as any).__xftp_initApp = async () => { await wasmReady; initApp() } main().catch(err => { const app = getAppElement() From 87ceaeb8b3997085b6645b2f6b977d926abee11d Mon Sep 17 00:00:00 2001 From: shum Date: Fri, 20 Feb 2026 21:23:13 +0000 Subject: [PATCH 10/14] xftp-web: CLS placeholder HTML and embedder CSS selectors Add placeholder HTML to index.html so the page renders a styled card before JS executes, preventing layout shift. Use a