Skip to content

Commit 6209578

Browse files
committed
xftp-web: progress ring overhaul
Rewrite progress ring with smooth lerp animation, green checkmark on completion, theme reactivity via MutationObserver, and per-phase color variables (encrypt/upload/download/decrypt). Show honest per-phase progress: each phase animates 0-100% independently with a ring color change between phases. Add decrypt progress callback from the web worker so the decryption phase tracks real chunk processing instead of showing an indeterminate spinner. Snap immediately on phase reset (0) and completion (1) to avoid lingering partial progress. Clean up animation and observers via destroy() in finally blocks.
1 parent dd6bc68 commit 6209578

File tree

6 files changed

+153
-28
lines changed

6 files changed

+153
-28
lines changed

xftp-web/web/crypto-backend.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export interface CryptoBackend {
1010
dhSecret: Uint8Array, nonce: Uint8Array,
1111
body: Uint8Array, digest: Uint8Array, chunkNo: number
1212
): Promise<void>
13-
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
13+
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array},
14+
onProgress?: (done: number, total: number) => void
1415
): Promise<{header: FileHeader, content: Uint8Array}>
1516
cleanup(): Promise<void>
1617
}
@@ -118,12 +119,15 @@ class WorkerBackend implements CryptoBackend {
118119
)
119120
}
120121

121-
async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
122+
async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array},
123+
onProgress?: (done: number, total: number) => void
122124
): Promise<{header: FileHeader, content: Uint8Array}> {
125+
this.progressCb = onProgress ?? null
123126
const resp = await this.send({
124127
type: 'verifyAndDecrypt',
125128
size: params.size, digest: params.digest, key: params.key, nonce: params.nonce
126129
})
130+
this.progressCb = null
127131
return {header: resp.header, content: new Uint8Array(resp.content)}
128132
}
129133

xftp-web/web/crypto.worker.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,16 @@ async function handleVerifyAndDecrypt(
155155
// Read chunks — from memory (fallback) or OPFS
156156
const chunks: Uint8Array[] = []
157157
let totalSize = 0
158+
const chunkCount = useMemory ? memoryChunks.size : chunkMeta.size
159+
const totalSteps = chunkCount * 2 + 1 // read + hash + decrypt
160+
let step = 0
158161
if (useMemory) {
159162
const sorted = [...memoryChunks.entries()].sort((a, b) => a[0] - b[0])
160163
for (const [chunkNo, data] of sorted) {
161164
console.log(`[WORKER-DBG] verify memory chunk=${chunkNo} size=${data.length}`)
162165
chunks.push(data)
163166
totalSize += data.length
167+
self.postMessage({id, type: 'progress', done: ++step, total: totalSteps})
164168
}
165169
} else {
166170
// Close write handle, reopen as read
@@ -180,6 +184,7 @@ async function handleVerifyAndDecrypt(
180184
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))}`)
181185
chunks.push(buf)
182186
totalSize += meta.size
187+
self.postMessage({id, type: 'progress', done: ++step, total: totalSteps})
183188
}
184189
readHandle.close()
185190
}
@@ -197,6 +202,7 @@ async function handleVerifyAndDecrypt(
197202
for (let off = 0; off < chunk.length; off += SEG) {
198203
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
199204
}
205+
self.postMessage({id, type: 'progress', done: ++step, total: totalSteps})
200206
}
201207
const actualDigest = sodium.crypto_hash_sha512_final(state)
202208
if (!digestEqual(actualDigest, digest)) {

xftp-web/web/download.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,22 +78,22 @@ export function initDownload(app: HTMLElement, hash: string) {
7878
)
7979
}, {
8080
onProgress: (downloaded, total) => {
81-
ring.update(downloaded / total * 0.8)
81+
ring.update(downloaded / total, '--xftp-ring-download')
8282
}
8383
})
8484

85+
ring.update(0, '--xftp-ring-decrypt')
8586
statusText.textContent = t('decrypting', 'Decrypting\u2026')
86-
ring.update(0.85)
8787

8888
const {header, content} = await backend.verifyAndDecrypt({
8989
size: resolvedFd.size,
9090
digest: resolvedFd.digest,
9191
key: resolvedFd.key,
9292
nonce: resolvedFd.nonce
93+
}, (done, total) => {
94+
ring.update(done / total, '--xftp-ring-decrypt')
9395
})
9496

95-
ring.update(0.95)
96-
9797
// Sanitize filename and trigger browser save
9898
const fileName = sanitizeFileName(header.fileName)
9999
const blob = new Blob([content.buffer as ArrayBuffer])
@@ -116,6 +116,7 @@ export function initDownload(app: HTMLElement, hash: string) {
116116
if (err instanceof XFTPPermanentError) retryBtn.hidden = true
117117
else retryBtn.hidden = false
118118
} finally {
119+
ring.destroy()
119120
await backend.cleanup().catch(() => {})
120121
closeXFTPAgent(agent)
121122
}

xftp-web/web/progress.ts

Lines changed: 122 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ const SIZE = 120
22
const LINE_WIDTH = 8
33
const RADIUS = (SIZE - LINE_WIDTH) / 2
44
const CENTER = SIZE / 2
5+
const LERP_SPEED = 0.12
56

67
export interface ProgressRing {
78
canvas: HTMLCanvasElement
8-
update(fraction: number): void
9+
update(fraction: number, fgVar?: string): void
10+
setIndeterminate(on: boolean): void
11+
destroy(): void
912
}
1013

1114
export function createProgressRing(): ProgressRing {
@@ -18,39 +21,138 @@ export function createProgressRing(): ProgressRing {
1821
const ctx = canvas.getContext('2d')!
1922
ctx.scale(devicePixelRatio, devicePixelRatio)
2023

21-
function draw(fraction: number) {
24+
let displayed = 0
25+
let target = 0
26+
let animId = 0
27+
let spinAngle = 0
28+
let spinning = false
29+
let currentFgVar = '--xftp-ring-fg'
30+
31+
function getColors() {
2232
const appEl = document.querySelector('[data-xftp-app]') ?? document.getElementById('app')
2333
const s = appEl ? getComputedStyle(appEl) : null
24-
const bgColor = s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0'
25-
const fgColor = s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6'
26-
const textColor = s?.getPropertyValue('--xftp-ring-text').trim() || '#333'
34+
return {
35+
bg: s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0',
36+
fg: s?.getPropertyValue(currentFgVar).trim() || s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6',
37+
text: s?.getPropertyValue('--xftp-ring-text').trim() || '#333',
38+
done: s?.getPropertyValue('--xftp-ring-done').trim() || '#16a34a',
39+
}
40+
}
2741

28-
ctx.clearRect(0, 0, SIZE, SIZE)
29-
// Background arc
42+
function drawBgRing(c: ReturnType<typeof getColors>, color?: string) {
3043
ctx.beginPath()
3144
ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
32-
ctx.strokeStyle = bgColor
45+
ctx.strokeStyle = color ?? c.bg
3346
ctx.lineWidth = LINE_WIDTH
3447
ctx.lineCap = 'round'
3548
ctx.stroke()
36-
// Foreground arc
37-
if (fraction > 0) {
49+
}
50+
51+
function render(fraction: number) {
52+
const c = getColors()
53+
ctx.clearRect(0, 0, SIZE, SIZE)
54+
drawBgRing(c, fraction >= 1 ? c.done : undefined)
55+
56+
if (fraction > 0 && fraction < 1) {
3857
ctx.beginPath()
3958
ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
40-
ctx.strokeStyle = fgColor
59+
ctx.strokeStyle = c.fg
4160
ctx.lineWidth = LINE_WIDTH
4261
ctx.lineCap = 'round'
4362
ctx.stroke()
4463
}
45-
// Percentage text
46-
const pct = Math.round(fraction * 100)
47-
ctx.fillStyle = textColor
48-
ctx.font = '600 20px system-ui, sans-serif'
49-
ctx.textAlign = 'center'
50-
ctx.textBaseline = 'middle'
51-
ctx.fillText(pct + '%', CENTER, CENTER)
64+
65+
if (fraction >= 1) {
66+
ctx.strokeStyle = c.done
67+
ctx.lineWidth = 5
68+
ctx.lineCap = 'round'
69+
ctx.lineJoin = 'round'
70+
ctx.beginPath()
71+
ctx.moveTo(CENTER - 18, CENTER + 2)
72+
ctx.lineTo(CENTER - 4, CENTER + 16)
73+
ctx.lineTo(CENTER + 22, CENTER - 14)
74+
ctx.stroke()
75+
} else {
76+
const pct = Math.round(fraction * 100)
77+
ctx.fillStyle = c.text
78+
ctx.font = '600 20px system-ui, sans-serif'
79+
ctx.textAlign = 'center'
80+
ctx.textBaseline = 'middle'
81+
ctx.fillText(pct + '%', CENTER, CENTER)
82+
}
83+
}
84+
85+
function tick() {
86+
if (spinning) return
87+
const diff = target - displayed
88+
if (Math.abs(diff) < 0.002) {
89+
displayed = target
90+
render(displayed)
91+
animId = 0
92+
return
93+
}
94+
displayed += diff * LERP_SPEED
95+
render(displayed)
96+
animId = requestAnimationFrame(tick)
5297
}
5398

54-
draw(0)
55-
return {canvas, update: draw}
99+
function startTick() {
100+
if (!animId && !spinning) { animId = requestAnimationFrame(tick) }
101+
}
102+
103+
function stopAnim() {
104+
if (animId) { cancelAnimationFrame(animId); animId = 0 }
105+
spinning = false
106+
}
107+
108+
function spinFrame() {
109+
const c = getColors()
110+
ctx.clearRect(0, 0, SIZE, SIZE)
111+
drawBgRing(c)
112+
ctx.beginPath()
113+
ctx.arc(CENTER, CENTER, RADIUS, spinAngle, spinAngle + Math.PI * 0.75)
114+
ctx.strokeStyle = c.fg
115+
ctx.lineWidth = LINE_WIDTH
116+
ctx.lineCap = 'round'
117+
ctx.stroke()
118+
spinAngle += 0.06
119+
if (spinning) animId = requestAnimationFrame(spinFrame)
120+
}
121+
122+
function redraw() {
123+
if (spinning) return
124+
render(displayed)
125+
}
126+
127+
const mql = matchMedia('(prefers-color-scheme: dark)')
128+
mql.addEventListener('change', redraw)
129+
const observer = new MutationObserver(redraw)
130+
observer.observe(document.documentElement, {attributes: true, attributeFilter: ['class']})
131+
132+
render(0)
133+
return {
134+
canvas,
135+
update(fraction: number, fgVar?: string) {
136+
stopAnim()
137+
if (fgVar) currentFgVar = fgVar
138+
// Snap immediately on phase reset (0) and completion (1)
139+
if ((fraction === 0 && target > 0) || fraction >= 1) {
140+
displayed = fraction
141+
target = fraction
142+
render(fraction)
143+
return
144+
}
145+
target = fraction
146+
startTick()
147+
},
148+
setIndeterminate(on: boolean) {
149+
stopAnim()
150+
if (on) { spinning = true; spinFrame() }
151+
},
152+
destroy() {
153+
stopAnim()
154+
mql.removeEventListener('change', redraw)
155+
observer.disconnect()
156+
},
157+
}
56158
}

xftp-web/web/style.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
max-width: 480px;
66
padding: 16px;
77
box-sizing: border-box;
8+
--xftp-ring-encrypt: #f59e0b;
9+
--xftp-ring-upload: #3b82f6;
10+
--xftp-ring-download: #06b6d4;
11+
--xftp-ring-decrypt: #8b5cf6;
812
}
913

1014
:is(#app, [data-xftp-app]) .card {
@@ -99,7 +103,12 @@
99103
color: #e5e7eb;
100104
--xftp-ring-bg: #374151;
101105
--xftp-ring-fg: #60a5fa;
106+
--xftp-ring-encrypt: #fbbf24;
107+
--xftp-ring-upload: #60a5fa;
108+
--xftp-ring-download: #22d3ee;
109+
--xftp-ring-decrypt: #a78bfa;
102110
--xftp-ring-text: #e5e7eb;
111+
--xftp-ring-done: #4ade80;
103112
}
104113
.dark :is(#app, [data-xftp-app]) .card {
105114
background: #1f2937;

xftp-web/web/upload.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function initUpload(app: HTMLElement) {
122122

123123
cancelBtn.onclick = () => {
124124
aborted = true
125+
ring.destroy()
125126
backend.cleanup().catch(() => {})
126127
closeXFTPAgent(agent)
127128
showStage(dropZone)
@@ -132,10 +133,11 @@ export function initUpload(app: HTMLElement) {
132133
if (aborted) return
133134

134135
const encrypted = await backend.encrypt(fileData, file.name, (done, total) => {
135-
ring.update(done / total * 0.3)
136+
ring.update(done / total, '--xftp-ring-encrypt')
136137
})
137138
if (aborted) return
138139

140+
ring.update(0, '--xftp-ring-upload')
139141
statusText.textContent = t('uploading', 'Uploading\u2026')
140142
const metadata: EncryptedFileMetadata = {
141143
digest: encrypted.digest,
@@ -147,7 +149,7 @@ export function initUpload(app: HTMLElement) {
147149
const result = await uploadFile(agent, servers, metadata, {
148150
readChunk: (off, sz) => backend.readChunk(off, sz),
149151
onProgress: (uploaded, total) => {
150-
ring.update(0.3 + (uploaded / total) * 0.7)
152+
ring.update(uploaded / total, '--xftp-ring-upload')
151153
}
152154
})
153155
if (aborted) return
@@ -174,6 +176,7 @@ export function initUpload(app: HTMLElement) {
174176
else retryBtn.hidden = false
175177
}
176178
} finally {
179+
ring.destroy()
177180
await backend.cleanup().catch(() => {})
178181
closeXFTPAgent(agent)
179182
}

0 commit comments

Comments
 (0)