Skip to content

Commit 52bfed7

Browse files
authored
feat(filesharing): progress bar during download/decrypt + per-file downloads (#241)
* feat(filesharing): progress bar during download/decrypt + per-file downloads Replaces the empty/spinner state during decryption with a labeled progress bar ("Downloading and decrypting locally…"). Wires up pg-js's new onDownloadProgress callback; falls back to an indeterminate animated bar when the server doesn't send Content-Length. Switches to pg-js's auto-unzip: result.files is now {name, blob}[] and result.download() triggers one browser download per file. The fallback Decrypt component's ~70-line in-component ZIP extractor is removed (pg-js auto-unwraps the data.bin single-entry case). * chore: point pg-js at feat branch for cross-repo CI (revert before merge) Temporary git-URL dep so this PR's CI can compile against the unpublished pg-js progress/auto-unzip types. Revert this commit (or bump to the released pg-js version) before merging. See: encryption4all/postguard-js#86 * chore: bump @e4a/pg-js to ^2.0.0 Replaces the temporary git-URL pin with the released version that ships the new onDownloadProgress callback, auto-unzip, and DecryptFileResult shape (encryption4all/postguard-js#86 merged as v2.0.0). * chore(decrypt-progress): drop dead class and orphan i18n key - Remove `class:indeterminate` directive on the bar track — no matching rule exists; the indeterminate visual lives on the inner `.bar-indeterminate` element. - Remove the now-unreferenced `filesharing.decryptpanel.decrypting` key from en.json and nl.json (the download page swapped its spinner+text for the DecryptionProgress component). * fix(decrypt-progress): add ARIA progressbar semantics for WCAG AA
1 parent 00b4187 commit 52bfed7

7 files changed

Lines changed: 161 additions & 129 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
},
7171
"dependencies": {
7272
"@deltablot/dropzone": "^7.4.3",
73-
"@e4a/pg-js": "^1.11.0",
73+
"@e4a/pg-js": "^2.0.0",
7474
"@iconify/svelte": "^5.2.1",
7575
"@privacybydesign/yivi-css": "^1.0.1",
7676
"country-flag-icons": "^1.6.17",

src/lib/components/fallback/Decrypt.svelte

Lines changed: 20 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { pg } from '$lib/postguard'
1414
1515
import YiviQRCode from '$lib/components/filesharing/YiviQRCode.svelte'
16+
import DecryptionProgress from '$lib/components/filesharing/DecryptionProgress.svelte'
1617
import Chip from '$lib/components/Chip.svelte'
1718
import { isMobile } from '$lib/browser-detect'
1819
@@ -44,6 +45,8 @@
4445
let { rightMode = $bindable(), readable, uuid, recipient } = $props()
4546
4647
let opened = $state()
48+
/** @type {number | undefined} */
49+
let decryptPct = $state(undefined)
4750
4851
function checkRecipients() {
4952
const recipients = [...policies.keys()].filter((k) => k)
@@ -86,24 +89,29 @@
8689
}
8790
8891
async function startDecryption() {
92+
decryptPct = undefined
8993
try {
9094
const result = await opened.decrypt({
9195
element: '#yivi-fallback',
9296
recipient: key,
9397
enableCache: true,
98+
/** @param {number | undefined} pct */
99+
onDownloadProgress: (pct) => {
100+
if (decryptState !== STATES.Decrypting) {
101+
decryptState = STATES.Decrypting
102+
}
103+
decryptPct = pct
104+
},
94105
})
95106
96-
decryptState = STATES.Decrypting
97-
98-
// pg-js wraps `data:`-mode uploads as a single-file zip
99-
// (`data.bin` = the raw bytes) before encrypting, because the
100-
// upload pipeline always works on Files. uuid-mode decrypts
101-
// therefore yield DecryptFileResult{blob, files} and not
102-
// DecryptDataResult{plaintext}. Unwrap the zip here so the
103-
// downstream parseMail sees real MIME bytes.
104-
const plaintext = result.plaintext
105-
? result.plaintext
106-
: await extractFromZip(result.blob, 'data.bin')
107+
// pg-js auto-unwraps `data:`-mode payloads (single-entry zip
108+
// with `data.bin`) into DecryptDataResult.plaintext. Email
109+
// fallback uploads are always `data:` mode, so this branch is
110+
// the expected one. If we ever receive a multi-file result
111+
// here, fall back to the first entry's bytes.
112+
const plaintext =
113+
result.plaintext ??
114+
new Uint8Array(await result.files[0].blob.arrayBuffer())
107115
108116
const outStream = new TextDecoder().decode(plaintext)
109117
decryptedMail = await email.parseMail(outStream)
@@ -114,82 +122,6 @@
114122
}
115123
}
116124
117-
/** Extract a single file from a ZIP blob and return its uncompressed
118-
* bytes. Reads via the central directory (where conflux — pg-js's zip
119-
* writer — records the real sizes) because the local file headers in
120-
* streaming-mode zips carry zeros. Supports stored (method 0) and
121-
* deflate (method 8); DecompressionStream('deflate-raw') is the right
122-
* decoder for ZIP-embedded deflate. */
123-
async function extractFromZip(blob, filename) {
124-
const buf = await blob.arrayBuffer()
125-
const view = new DataView(buf)
126-
const bytes = new Uint8Array(buf)
127-
const decoder = new TextDecoder('utf-8')
128-
129-
// Find EOCD (End of Central Directory): signature 0x06054b50,
130-
// located in the last ~64 KB of the file.
131-
let eocdOffset = -1
132-
for (
133-
let i = bytes.length - 22;
134-
i >= Math.max(0, bytes.length - 65557);
135-
i--
136-
) {
137-
if (view.getUint32(i, true) === 0x06054b50) {
138-
eocdOffset = i
139-
break
140-
}
141-
}
142-
if (eocdOffset === -1) throw new Error('ZIP EOCD record not found')
143-
144-
const cdOffset = view.getUint32(eocdOffset + 16, true)
145-
const numEntries = view.getUint16(eocdOffset + 10, true)
146-
147-
let pos = cdOffset
148-
for (let i = 0; i < numEntries; i++) {
149-
if (view.getUint32(pos, true) !== 0x02014b50) break // CDR signature
150-
151-
const method = view.getUint16(pos + 10, true)
152-
const compressedSize = view.getUint32(pos + 20, true)
153-
const nameLen = view.getUint16(pos + 28, true)
154-
const extraLen = view.getUint16(pos + 30, true)
155-
const commentLen = view.getUint16(pos + 32, true)
156-
const lfhOffset = view.getUint32(pos + 42, true)
157-
const name = decoder.decode(
158-
bytes.slice(pos + 46, pos + 46 + nameLen)
159-
)
160-
161-
if (name === filename) {
162-
// Re-parse the local file header to find the data start;
163-
// its name + extra fields can differ in length from the
164-
// CDR entry.
165-
const lfhNameLen = view.getUint16(lfhOffset + 26, true)
166-
const lfhExtraLen = view.getUint16(lfhOffset + 28, true)
167-
const dataStart = lfhOffset + 30 + lfhNameLen + lfhExtraLen
168-
const compressed = bytes.slice(
169-
dataStart,
170-
dataStart + compressedSize
171-
)
172-
173-
if (method === 0) return compressed
174-
if (method === 8) {
175-
const stream = new Blob([compressed])
176-
.stream()
177-
.pipeThrough(new DecompressionStream('deflate-raw'))
178-
return new Uint8Array(
179-
await new Response(stream).arrayBuffer()
180-
)
181-
}
182-
throw new Error(
183-
`Unsupported zip compression method ${method} for ${filename}`
184-
)
185-
}
186-
187-
pos += 46 + nameLen + extraLen + commentLen
188-
}
189-
190-
throw new Error(`File "${filename}" not found in zip`)
191-
}
192-
193125
async function storeMail(unparsed) {
194126
const hash = await decrypt.digestMessage(unparsed)
195127
const found = $emails.find((e) => e.hash === hash)
@@ -321,22 +253,7 @@
321253
/>
322254
</div>
323255
{:else if decryptState === STATES.Decrypting}
324-
<div class="spinner-wrapper">
325-
<svg class="spinner" viewBox="0 0 24 24" width="36" height="36">
326-
<circle
327-
class="spinner-circle"
328-
cx="12"
329-
cy="12"
330-
r="10"
331-
fill="none"
332-
stroke="currentColor"
333-
stroke-width="3"
334-
></circle>
335-
</svg>
336-
</div>
337-
<p class="status-text">
338-
{$_('fallback.decrypt.decrypting', { default: 'Decrypting...' })}
339-
</p>
256+
<DecryptionProgress percentage={decryptPct} />
340257
{:else if decryptState === STATES.Fail}
341258
<div class="decrypt-card error-card">
342259
<p class="error-text">
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<script lang="ts">
2+
import { _ } from 'svelte-i18n'
3+
4+
interface props {
5+
percentage: number | undefined
6+
}
7+
8+
let { percentage }: props = $props()
9+
10+
let determinate = $derived(typeof percentage === 'number')
11+
</script>
12+
13+
<div class="container">
14+
<p class="label" id="decryption-progress-label">
15+
{$_('filesharing.decryptpanel.downloadingAndDecrypting')}
16+
</p>
17+
{#if determinate}
18+
<div
19+
class="bar-track"
20+
role="progressbar"
21+
aria-labelledby="decryption-progress-label"
22+
aria-valuemin="0"
23+
aria-valuemax="100"
24+
aria-valuenow={Math.round(
25+
Math.max(0, Math.min(100, percentage ?? 0))
26+
)}
27+
>
28+
<div
29+
class="bar-fill"
30+
style="width: {Math.max(0, Math.min(100, percentage ?? 0))}%"
31+
></div>
32+
</div>
33+
{:else}
34+
<div
35+
class="bar-track"
36+
role="progressbar"
37+
aria-labelledby="decryption-progress-label"
38+
aria-valuemin="0"
39+
aria-valuemax="100"
40+
>
41+
<div class="bar-indeterminate"></div>
42+
</div>
43+
{/if}
44+
{#if determinate}
45+
<p class="percent">{Math.round(percentage ?? 0)}%</p>
46+
{/if}
47+
</div>
48+
49+
<style lang="scss">
50+
.container {
51+
display: flex;
52+
flex-direction: column;
53+
align-items: center;
54+
gap: 0.5rem;
55+
padding: 1.5rem 0;
56+
width: 100%;
57+
}
58+
59+
.label {
60+
margin: 0;
61+
color: var(--pg-text-secondary);
62+
font-family: var(--pg-font-family);
63+
font-size: var(--pg-font-size-md);
64+
text-align: center;
65+
}
66+
67+
.bar-track {
68+
position: relative;
69+
width: 100%;
70+
height: 8px;
71+
background: var(--pg-strong-background);
72+
border-radius: 4px;
73+
overflow: hidden;
74+
}
75+
76+
.bar-fill {
77+
height: 100%;
78+
background: var(--pg-text);
79+
border-radius: 4px;
80+
transition: width 0.15s ease-out;
81+
}
82+
83+
.bar-indeterminate {
84+
position: absolute;
85+
top: 0;
86+
left: 0;
87+
height: 100%;
88+
width: 40%;
89+
background: var(--pg-text);
90+
border-radius: 4px;
91+
animation: slide 1.4s ease-in-out infinite;
92+
}
93+
94+
@keyframes slide {
95+
0% {
96+
transform: translateX(-100%);
97+
}
98+
100% {
99+
transform: translateX(250%);
100+
}
101+
}
102+
103+
.percent {
104+
margin: 0;
105+
color: var(--pg-text-secondary);
106+
font-family: var(--pg-font-family);
107+
font-size: var(--pg-font-size-sm);
108+
}
109+
</style>

src/lib/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
"irmaInstructionQr": "Decrypt the files by verifying your e-mail address. Scan the QR code below with the free Yivi app on your phone.",
157157
"irmaInstructionMobile": "Decrypt the files by verifying your e-mail address. Please click the button below to open the Yivi-app.",
158158
"noIrma": "Don't have the Yivi-app yet?",
159-
"decrypting": "Your files are being downloaded and decrypted afterwards.",
159+
"downloadingAndDecrypting": "Downloading and decrypting locally…",
160160
"succes": "Successfully downloaded and decrypted",
161161
"askDownload": "Download",
162162
"askDownloadText": "Press the button below to start decrypting and downloading your files",

src/lib/locales/nl.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
"irmaInstructionQr": "Ontsleutel de bestanden door je e-mailadres te verifiëren. Scan de onderstaande QR-code met de gratis Yivi-app op je telefoon.",
157157
"irmaInstructionMobile": "Ontsleutel het bestand door je e-mailadres te tonen. Click daarvoor op de onderstaande knop om naar de identificatie app Yivi te gaan.",
158158
"noIrma": "Nog geen Yivi-app?",
159-
"decrypting": "Jouw bestand wordt gedownload en vervolgens ontsleuteld.",
159+
"downloadingAndDecrypting": "Bezig met downloaden en lokaal ontsleutelen…",
160160
"succes": "Succesvol gedownload en ontsleuteld",
161161
"askDownload": "Download",
162162
"askDownloadText": "Druk op onderstaande knop om de bestanden te ontsleutelen en downloaden.",

0 commit comments

Comments
 (0)