fix(export): één download per webexport, en alleen melden wat we weten (#1902) #1906

Merged
brenno merged 10 commits from fix/webdownload-succesmelding-1902 into main 2026-09-01 00:16:49 +00:00
Owner

Closes #1902.

Wat er mis was

Op web is elke uitgang een browserdownload. De app las "er viel geen
uitzondering" als succes, en dat is geen bewijs: FilePicker.saveFile geeft op
web altijd null terug, ook wanneer het goed ging. Vier paden meldden
daardoor onvoorwaardelijk succes — deck-export, documentexport, .ocideck-pakket
en auditdossier.

De scherpste vorm zat bij een geredigeerde export. Die bood drie bestanden
achter elkaar aan (rapport, commitments, verificatiesleutels), en browsers
houden de tweede automatische download op rij tegen. De auteur hield een
geredigeerd rapport over — zonder iets om ook maar één redactie mee na te
trekken — en las "geëxporteerd naar". Dat is precies wat de comment in
export_service.dart wilde voorkomen.

Wat er nu gebeurt

  • Eén export, één download. Nieuw: lib/services/download_delivery.dart.
    Eén bestand gaat als zichzelf; meer dan één gaat als één ZIP, genoemd naar het
    hoofdbestand plus .zip. Dat geldt ook voor de sessie-export, die per dia een
    bestand aanbood.
  • Alleen melden wat we weten. Op web staat er aangeboden als download in
    plaats van geëxporteerd naar. De pagina kan niet zien of het bestand in de
    downloadmap aankwam — een download-anker meldt niets terug — en die grens hoort
    in de tekst te staan, niet alleen in de code.
  • Een geweigerde download is een fout. ExportFailure.downloadNotStarted
    voor de deck-export (de dienst levert de reden, de schil de zin — #576), null
    voor documentexport, pakket en dossier, met een melding in de schil. De
    sessie-export onderscheidt nu ook afbreken van mislukken: alleen het tweede
    krijgt een melding.
  • De poort ziet de nieuwe route. deliverAsDownload staat tussen de
    artefact-primitieven van check_audience_boundary. Daardoor kwam
    downloadDeckAsFile boven water: dat liep via downloadTextFile, wat geen
    primitief was, dus was er nooit gevraagd aan welke kant van de projectiegrens
    het stond. Nu geregistreerd als source.

Hoe het bewezen is

Twaalf nieuwe toetsen plus twee bij export_failure_text. De hele webtak lag
buiten bereik van de suite (kIsWeb is op de VM altijd false); twee
@visibleForTesting-haken maken hem meetbaar — de download-sink en de
platformtak.

Rood geproefd door de afleverlaag terug te zetten op het oude gedrag (elk bestand
apart, succes ongeacht de uitkomst): vier toetsen vallen om, op precies de twee
claims uit het issue — drie downloads in plaats van één, en een export die succes
meldt terwijl er niets vertrok.

Meegekomen

make check viel op drie plafonds; alle drie opgelost door te splitsen, niet
door de basislijn te verhogen:

  • ExportFormat/ExportResult naar lib/services/export_result.dart (data, geen
    dienst) — export_service.dart 515 → 450 regels, plafond mee omlaag naar 485.
  • De webaflevering uit ExportService.export naar een top-level functie —
    131 → 116 regels.
  • _flatMembers en de twee bestandsnaam-helpers uit FileService naar
    top-level; de naamexpressie stond op drie plekken letterlijk uitgeschreven.

deliversByDownload telt mee als platformpoort in check_conventions (de
productiewaarde ís kIsWeb; alleen een test kan hem omzetten).

Documentatie

USER_GUIDE zei dat de twee manifestbestanden op web "in dezelfde downloadmap"
belanden — precies wat er niet gebeurde. Bijgewerkt in USER_GUIDE, FILE_FORMAT en
hun nl-varianten, met de reden erbij. Drie nieuwe l10n-strings, 31 talen.

Twee dingen die het issue niet noemde

  • Stijlprofiel-export en het RFC 3161-tijdstempelverzoek deden hetzelfde.
    Die tweede is de vervelendste: hij bewaarde de nonce ná de export, en de
    comment daar zegt met zoveel woorden dat dat pas mag als het verzoek de deur
    uit is — anders blijft er een nonce achter voor een verzoek dat nooit
    verstuurd is. Na deze PR staat er in lib/ geen enkele
    FilePicker.saveFile-aanroep meer.
  • Onder wasm koos de downloadlaag stilzwijgend de lege stub.
    if (dart.library.html) is onwaar voor dart2wasm. Nagemeten in de glue-module
    van een echte --wasm-build: met dart.library.html staat .download er
    0× in en revokeObjectURL 1×, met dart.library.js_interop 1× en 2×. De
    ankercode was daar dus werkelijk weg. Wasm is nog geen bouwdoel (#1734); dit
    is precies de val die dat later stil zou maken.

Bewaker

Dit raakt een publieke belofte (de bewoording in de interface en twee
documenten) en de vorm waarin het werk van de gebruiker aankomt, dus de
bewaker heeft ernaar gekeken.

  • Meenemen naar ander gereedschap? Ja. Een ZIP is een open container die elk
    besturingssysteem uitpakt; de leden erin zijn onveranderd (.pdf,
    -redactie.json, .md). Geen OciDeck-specifieke omhulling, geen nieuwe
    partij om te vertrouwen — archive zat er al in en het pakken gebeurt in de
    tab zelf.
  • Het .md? Onaangeraakt. Dit is uitsluitend de afleverweg.
  • Als OciDeck stopt? De gedownloade ZIP blijft met elk uitpakprogramma te
    openen.

De botsing, hardop: gemak tegen integriteit. Eén los .pdf is prettiger dan
een ZIP die je moet uitpakken. Daarom gaat één bestand nog steeds als zichzelf —
alleen bij meer dan één wordt het een ZIP. Daar wint integriteit: de vorige vorm
liet het redactiemanifest stilzwijgend achter, en dan is een geredigeerd rapport
niet meer na te trekken. Veiligheid staat op 1. Ik zou van gedachten
veranderen
zodra browsers meerdere bestanden kunnen afleveren mét een
betrouwbare uitslag per bestand; dan is los beter dan een ZIP.

De bewoording is waarde 4: "geëxporteerd naar" beweerde meer dan de pagina kan
weten. De nieuwe zin is zwakker en waar.

Niet meegenomen

Dat de multi-download-poort van de browser dit werkelijk triggert is niet in
een echte browser nagemeten
; de repro-stappen staan in #1902. De code-analyse
staat wel vast, en de reparatie is hoe dan ook de goede kant op: één download kan
die poort niet raken.

🤖 Generated with Claude Code

Closes #1902. ## Wat er mis was Op web is elke uitgang een browserdownload. De app las "er viel geen uitzondering" als succes, en dat is geen bewijs: `FilePicker.saveFile` geeft op web **altijd `null`** terug, ook wanneer het goed ging. Vier paden meldden daardoor onvoorwaardelijk succes — deck-export, documentexport, `.ocideck`-pakket en auditdossier. De scherpste vorm zat bij een geredigeerde export. Die bood drie bestanden achter elkaar aan (rapport, commitments, verificatiesleutels), en browsers houden de tweede automatische download op rij tegen. De auteur hield een geredigeerd rapport over — zonder iets om ook maar één redactie mee na te trekken — en las "geëxporteerd naar". Dat is precies wat de comment in `export_service.dart` wilde voorkomen. ## Wat er nu gebeurt - **Eén export, één download.** Nieuw: `lib/services/download_delivery.dart`. Eén bestand gaat als zichzelf; meer dan één gaat als één ZIP, genoemd naar het hoofdbestand plus `.zip`. Dat geldt ook voor de sessie-export, die per dia een bestand aanbood. - **Alleen melden wat we weten.** Op web staat er *aangeboden als download* in plaats van *geëxporteerd naar*. De pagina kan niet zien of het bestand in de downloadmap aankwam — een download-anker meldt niets terug — en die grens hoort in de tekst te staan, niet alleen in de code. - **Een geweigerde download is een fout.** `ExportFailure.downloadNotStarted` voor de deck-export (de dienst levert de reden, de schil de zin — #576), `null` voor documentexport, pakket en dossier, met een melding in de schil. De sessie-export onderscheidt nu ook afbreken van mislukken: alleen het tweede krijgt een melding. - **De poort ziet de nieuwe route.** `deliverAsDownload` staat tussen de artefact-primitieven van `check_audience_boundary`. Daardoor kwam `downloadDeckAsFile` boven water: dat liep via `downloadTextFile`, wat geen primitief was, dus was er nooit gevraagd aan welke kant van de projectiegrens het stond. Nu geregistreerd als `source`. ## Hoe het bewezen is Twaalf nieuwe toetsen plus twee bij `export_failure_text`. De hele webtak lag buiten bereik van de suite (`kIsWeb` is op de VM altijd `false`); twee `@visibleForTesting`-haken maken hem meetbaar — de download-sink en de platformtak. Rood geproefd door de afleverlaag terug te zetten op het oude gedrag (elk bestand apart, succes ongeacht de uitkomst): vier toetsen vallen om, op precies de twee claims uit het issue — drie downloads in plaats van één, en een export die succes meldt terwijl er niets vertrok. ## Meegekomen `make check` viel op drie plafonds; alle drie opgelost door te splitsen, niet door de basislijn te verhogen: - `ExportFormat`/`ExportResult` naar `lib/services/export_result.dart` (data, geen dienst) — `export_service.dart` 515 → 450 regels, plafond mee omlaag naar 485. - De webaflevering uit `ExportService.export` naar een top-level functie — 131 → 116 regels. - `_flatMembers` en de twee bestandsnaam-helpers uit `FileService` naar top-level; de naamexpressie stond op drie plekken letterlijk uitgeschreven. `deliversByDownload` telt mee als platformpoort in `check_conventions` (de productiewaarde ís `kIsWeb`; alleen een test kan hem omzetten). ## Documentatie USER_GUIDE zei dat de twee manifestbestanden op web "in dezelfde downloadmap" belanden — precies wat er niet gebeurde. Bijgewerkt in USER_GUIDE, FILE_FORMAT en hun nl-varianten, met de reden erbij. Drie nieuwe l10n-strings, 31 talen. ## Twee dingen die het issue niet noemde - **Stijlprofiel-export en het RFC 3161-tijdstempelverzoek** deden hetzelfde. Die tweede is de vervelendste: hij bewaarde de nonce ná de export, en de comment daar zegt met zoveel woorden dat dat pas mag als het verzoek de deur uit is — anders blijft er een nonce achter voor een verzoek dat nooit verstuurd is. Na deze PR staat er in `lib/` geen enkele `FilePicker.saveFile`-aanroep meer. - **Onder wasm koos de downloadlaag stilzwijgend de lege stub.** `if (dart.library.html)` is onwaar voor dart2wasm. Nagemeten in de glue-module van een echte `--wasm`-build: met `dart.library.html` staat `.download` er 0× in en `revokeObjectURL` 1×, met `dart.library.js_interop` 1× en 2×. De ankercode was daar dus werkelijk weg. Wasm is nog geen bouwdoel (#1734); dit is precies de val die dat later stil zou maken. ## Bewaker Dit raakt een **publieke belofte** (de bewoording in de interface en twee documenten) en de **vorm waarin het werk van de gebruiker aankomt**, dus de bewaker heeft ernaar gekeken. - *Meenemen naar ander gereedschap?* Ja. Een ZIP is een open container die elk besturingssysteem uitpakt; de leden erin zijn onveranderd (`.pdf`, `-redactie.json`, `.md`). Geen OciDeck-specifieke omhulling, geen nieuwe partij om te vertrouwen — `archive` zat er al in en het pakken gebeurt in de tab zelf. - *Het `.md`?* Onaangeraakt. Dit is uitsluitend de afleverweg. - *Als OciDeck stopt?* De gedownloade ZIP blijft met elk uitpakprogramma te openen. **De botsing, hardop:** gemak tegen integriteit. Eén los `.pdf` is prettiger dan een ZIP die je moet uitpakken. Daarom gaat één bestand nog steeds als zichzelf — alleen bij meer dan één wordt het een ZIP. Daar wint integriteit: de vorige vorm liet het redactiemanifest stilzwijgend achter, en dan is een geredigeerd rapport niet meer na te trekken. Veiligheid staat op 1. **Ik zou van gedachten veranderen** zodra browsers meerdere bestanden kunnen afleveren mét een betrouwbare uitslag per bestand; dan is los beter dan een ZIP. De bewoording is waarde 4: "geëxporteerd naar" beweerde meer dan de pagina kan weten. De nieuwe zin is zwakker en waar. ## Niet meegenomen Dat de multi-download-poort van de browser dit werkelijk triggert is **niet in een echte browser nagemeten**; de repro-stappen staan in #1902. De code-analyse staat wel vast, en de reparatie is hoe dan ook de goede kant op: één download kan die poort niet raken. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Op web is elke uitgang een browserdownload, en de app las "geen uitzondering"
als succes: FilePicker.saveFile geeft daar altijd null terug, ook wanneer het
goed ging. Er viel dus niets te controleren, en vier paden meldden
onvoorwaardelijk succes (deck-export, documentexport, pakket, auditdossier).

De scherpste vorm zat bij een geredigeerde export: die bood drie bestanden
achter elkaar aan, en browsers houden de tweede automatische download op rij
tegen. De auteur hield het geredigeerde rapport over — zonder commitments,
zonder sleutels — en las dat het gelukt was.

- Nieuw: lib/services/download_delivery.dart. Eén export, één download; meer
  dan één bestand gaat als één ZIP. Twee @visibleForTesting-haken maken de
  webtak vanaf de VM meetbaar (kIsWeb is daar altijd false).
- Een geweigerde download levert nu een fout op: ExportFailure.downloadNotStarted
  voor de deck-export, null voor documentexport/pakket/dossier, met een melding
  in de schil.
- Eerlijke bewoording op web: "aangeboden als download" in plaats van
  "geëxporteerd naar" — de pagina kan niet zien of het bestand aankwam.
- De sessie-export (een bestand per dia) gaat om dezelfde reden als één ZIP, en
  onderscheidt nu afbreken van mislukken.
- deliverAsDownload staat tussen de artefact-primitieven van
  check_audience_boundary; daardoor kwam downloadDeckAsFile boven water, dat
  nooit geclassificeerd was omdat het via downloadTextFile liep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twaalf toetsen over download_delivery en de webtak van de export. Ze vallen om
op precies het gedrag van vóór deze fix: drie losse downloads in plaats van één
zip, een geweigerde download die toch een naam teruggaf, en een export die
succes meldde terwijl er niets vertrok.

Ook getoetst: de commitments reizen zonder salt mee en de sleutels wél — dat
onderscheid moet de zip overleven — en de webtak schrijft niets naar schijf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
USER_GUIDE zei dat de twee manifestbestanden op web "in dezelfde downloadmap"
belanden. Dat is precies wat er niet gebeurde: de browser hield ze tegen. Nu
staat er dat rapport en manifest samen als één ZIP aankomen, en waarom de
webversie "aangeboden als download" zegt in plaats van "geëxporteerd naar".

SOURCE_MAP krijgt download_delivery.dart; de regel over file_download.dart zegt
nu dat exportpaden er niet rechtstreeks langs gaan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
De fix uit #1902 duwde drie ratchets over hun grens. Alle drie hebben nu iets
minder in plaats van een ruimere basislijn:

- ExportFormat en ExportResult naar lib/services/export_result.dart. Het is data
  en geen dienst, en export_service.dart exporteert het bestand weer, dus geen
  enkele aanroeper verandert. 515 -> 450 regels; plafond mee omlaag naar 485,
  met lucht.
- De webaflevering uit ExportService.export naar een top-level functie: 131 ->
  116 regels. Hij gebruikt geen enkel veld van de dienst.
- _flatMembers en de twee bestandsnaam-helpers uit FileService naar top-level.
  De pakketnaam stond op drie plekken letterlijk uitgeschreven, de dossiernaam
  op twee.

deliversByDownload telt in check_conventions mee als platformpoort naast
kIsWeb/isWebPlatform/supportsLocalProjectFolders: het is dezelfde vraag in de
vorm waarin de exportpaden hem stellen, en de productiewaarde is kIsWeb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twee toetsen bij export_failure_text: het webkopje verschilt van het
schijfkopje, en een geweigerde download krijgt een echte zin in plaats van een
kopje "Technische melding:" met niets erachter.

FILE_FORMAT (en/nl) en USER_GUIDE.nl zeiden dat de twee manifestbestanden op web
in dezelfde downloadmap belanden. Dat is precies wat er niet gebeurde; nu staat
er dat ze samen als één ZIP aankomen, met de reden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
De poort zocht letterlijk `return writeDocumentExport(`; sinds #1902 vangt het
bewerkscherm de uitkomst op om een geweigerde download te kunnen melden. Anker
nu op `await writeDocumentExport(` — dezelfde aanroep, alleen wat ermee gebeurt
is anders.

Ook de twee expects vóór het snijden gezet in plaats van erna. De toets viel om
met "RangeError: Not in inclusive range 0..45989: -1" in plaats van te zeggen
dat de aanroep niet gevonden was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twee plekken van dezelfde klasse die het issue niet noemde. Beide riepen
FilePicker.saveFile aan en gingen door alsof het gelukt was.

- Stijlprofiel-export meldde "Stijlprofiel geëxporteerd" ongeacht de uitkomst.
  De uitkomst draagt nu downloadRefused, en de schil meldt dat — afbreken blijft
  stil, een weigering niet: daar heeft de gebruiker niets afgebroken.
- Het RFC 3161-tijdstempelverzoek bewaarde de nonce ná de export. De comment
  daar zegt met zoveel woorden dat dat pas mag als het verzoek de deur uit is,
  precies om te voorkomen dat er een nonce achterblijft voor een verzoek dat
  nooit verstuurd is. Op web wist dat pad niet dat het misging. Nu wordt er bij
  een geweigerde download niets bewaard en krijgt de gebruiker een melding.

Na deze twee staat er in lib/ geen enkele FilePicker.saveFile-aanroep meer:
alles loopt via deliverAsDownload, dat wél zegt of het aanbieden lukte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_SettingsDialogState zat exact op zijn plafond en de melding bij een geweigerde
download duwde hem eroverheen. De exportstroom staat nu top-level in hetzelfde
part-bestand, naast _importFailureText dat daar al zo stond; de methode in de
klasse is nog vijf regels doorgeefwerk. stillMounted blijft meegaan, zodat een
gesloten scherm nog steeds geen melding krijgt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
De per-bestand-dekkingsvloer viel op session_export.dart: de nieuwe webtak werd
door geen enkele test uitgevoerd. Nu wel, en niet als vulling — de twee toetsen
zeggen precies wat de fix belooft: twee bewerkte dia's vertrekken als één zip
(geen tweede download die de browser tegenhoudt) en het deck gaat daarna schoon
terug, en een geweigerde download laat de wijzigingen staan én meldt het.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(web): de downloadlaag koos onder wasm stilzwijgend de lege stub
All checks were successful
scans / scans (pull_request) Successful in 6m18s
static-gate / static-gate (pull_request) Successful in 10m9s
dfe4e8530b
`if (dart.library.html)` is onwaar onder dart2wasm. Op een wasm-build viel de
conditional daardoor terug op file_download_stub.dart, en dan is elke download
een lege huls — precies de fout uit #1902, maar dan voor het bouwdoel waar
#1734 naar kijkt.

Nagemeten, niet aangenomen. In de glue-module van een wasm-build:

  dart.library.js_interop -> revokeObjectURL 2x, `.download` 1x
  dart.library.html       -> revokeObjectURL 1x, `.download` 0x

De ankercode is daar dus werkelijk weg. De web-implementatie gebruikt alleen
dart:js_interop en package:web, die allebei op wasm werken; clipboard_html.dart,
presenter_fullscreen.dart en mermaid_render_service.dart gebruiken deze vorm al.

Beide bundels gebouwd en groen: `flutter build web --wasm` en `make build-web`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brenno force-pushed fix/webdownload-succesmelding-1902 from dfe4e8530b
All checks were successful
scans / scans (pull_request) Successful in 6m18s
static-gate / static-gate (pull_request) Successful in 10m9s
to 330842ca35
All checks were successful
scans / scans (pull_request) Successful in 6m20s
web-gate / web-gate (pull_request) Successful in 7m53s
static-gate / static-gate (pull_request) Successful in 9m31s
2026-09-01 00:06:27 +00:00
Compare
brenno merged commit 1948e97c2a into main 2026-09-01 00:16:49 +00:00
Sign in to join this conversation.
No description provided.