Skip to content

Commit 3254beb

Browse files
committed
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).
1 parent 00b4187 commit 3254beb

5 files changed

Lines changed: 139 additions & 122 deletions

File tree

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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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">
15+
{$_('filesharing.decryptpanel.downloadingAndDecrypting')}
16+
</p>
17+
<div class="bar-track" class:indeterminate={!determinate}>
18+
{#if determinate}
19+
<div
20+
class="bar-fill"
21+
style="width: {Math.max(0, Math.min(100, percentage ?? 0))}%"
22+
></div>
23+
{:else}
24+
<div class="bar-indeterminate"></div>
25+
{/if}
26+
</div>
27+
{#if determinate}
28+
<p class="percent">{Math.round(percentage ?? 0)}%</p>
29+
{/if}
30+
</div>
31+
32+
<style lang="scss">
33+
.container {
34+
display: flex;
35+
flex-direction: column;
36+
align-items: center;
37+
gap: 0.5rem;
38+
padding: 1.5rem 0;
39+
width: 100%;
40+
}
41+
42+
.label {
43+
margin: 0;
44+
color: var(--pg-text-secondary);
45+
font-family: var(--pg-font-family);
46+
font-size: var(--pg-font-size-md);
47+
text-align: center;
48+
}
49+
50+
.bar-track {
51+
position: relative;
52+
width: 100%;
53+
height: 8px;
54+
background: var(--pg-strong-background);
55+
border-radius: 4px;
56+
overflow: hidden;
57+
}
58+
59+
.bar-fill {
60+
height: 100%;
61+
background: var(--pg-text);
62+
border-radius: 4px;
63+
transition: width 0.15s ease-out;
64+
}
65+
66+
.bar-indeterminate {
67+
position: absolute;
68+
top: 0;
69+
left: 0;
70+
height: 100%;
71+
width: 40%;
72+
background: var(--pg-text);
73+
border-radius: 4px;
74+
animation: slide 1.4s ease-in-out infinite;
75+
}
76+
77+
@keyframes slide {
78+
0% {
79+
transform: translateX(-100%);
80+
}
81+
100% {
82+
transform: translateX(250%);
83+
}
84+
}
85+
86+
.percent {
87+
margin: 0;
88+
color: var(--pg-text-secondary);
89+
font-family: var(--pg-font-family);
90+
font-size: var(--pg-font-size-sm);
91+
}
92+
</style>

src/lib/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
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?",
159159
"decrypting": "Your files are being downloaded and decrypted afterwards.",
160+
"downloadingAndDecrypting": "Downloading and decrypting locally…",
160161
"succes": "Successfully downloaded and decrypted",
161162
"askDownload": "Download",
162163
"askDownloadText": "Press the button below to start decrypting and downloading your files",

src/lib/locales/nl.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
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?",
159159
"decrypting": "Jouw bestand wordt gedownload en vervolgens ontsleuteld.",
160+
"downloadingAndDecrypting": "Bezig met downloaden en lokaal ontsleutelen…",
160161
"succes": "Succesvol gedownload en ontsleuteld",
161162
"askDownload": "Download",
162163
"askDownloadText": "Druk op onderstaande knop om de bestanden te ontsleutelen en downloaden.",

src/routes/(app)/download/+page.svelte

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
} from '@e4a/pg-js'
1616
import YiviQRCode from '$lib/components/filesharing/YiviQRCode.svelte'
1717
import FileList from '$lib/components/filesharing/FileList.svelte'
18+
import DecryptionProgress from '$lib/components/filesharing/DecryptionProgress.svelte'
1819
import { isMobile } from '$lib/browser-detect'
1920
import Chip from '$lib/components/Chip.svelte'
2021
import HelpToggle from '$lib/components/HelpToggle.svelte'
@@ -39,6 +40,7 @@
3940
let key = $state('')
4041
let senderIdentity: FriendlySender | null = $state(null)
4142
let fileList: string[] = $state([])
43+
let decryptPct: number | undefined = $state(undefined)
4244
4345
let opened: Awaited<ReturnType<typeof pg.open>> | null = null
4446
@@ -107,6 +109,7 @@
107109
108110
async function startDecryption() {
109111
downloadState = 'Ready'
112+
decryptPct = undefined
110113
retryStatus.set(null)
111114
await tick()
112115
@@ -117,13 +120,21 @@
117120
element: '#yivi-download',
118121
recipient: key,
119122
enableCache: true,
123+
onDownloadProgress: (pct) => {
124+
// Yivi scan happens before any bytes flow — the first
125+
// progress tick is our cue to flip into Decrypting.
126+
if (downloadState !== 'Decrypting') {
127+
downloadState = 'Decrypting'
128+
}
129+
decryptPct = pct
130+
},
120131
})) as DecryptFileResult
121132
122133
senderIdentity = result.sender
123-
fileList = result.files
134+
fileList = result.files.map((f) => f.name)
124135
125-
// Trigger automatic download
126-
result.download('files.zip')
136+
// Trigger automatic download (one browser download per file)
137+
result.download()
127138
128139
retryStatus.set(null)
129140
downloadState = 'Done'
@@ -269,22 +280,17 @@
269280
</div>
270281
{/if}
271282
{:else if downloadState === 'Decrypting'}
272-
<div class="spinner-wrapper">
273-
<svg class="spinner" viewBox="0 0 24 24" width="36" height="36">
274-
<circle
275-
class="spinner-circle"
276-
cx="12"
277-
cy="12"
278-
r="10"
279-
fill="none"
280-
stroke="currentColor"
281-
stroke-width="3"
282-
></circle>
283-
</svg>
284-
</div>
285-
<p class="description">
286-
{$_('filesharing.decryptpanel.decrypting')}
287-
</p>
283+
<DecryptionProgress percentage={decryptPct} />
284+
{#if $retryStatus}
285+
<p class="retry-status" role="status">
286+
{$_('filesharing.encryptPanel.retrying', {
287+
values: {
288+
attempt: $retryStatus.attempt + 1,
289+
max: $retryStatus.maxAttempts,
290+
},
291+
})}
292+
</p>
293+
{/if}
288294
{:else if downloadState === 'Done'}
289295
<div class="success-banner">
290296
<svg

0 commit comments

Comments
 (0)