feat(export): single-file offline keepsake viewer + guest download

Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the
extracted index.html is opened over file://. Browsers block external ES-module
scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows
a blank window. Build the viewer as one self-contained index.html (inlined
JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject
the data as window.__EXPORT_DATA__; the backend embeds the built viewer via
include_dir! and writes the data global into index.html when zipping.

Also surface the guest-facing download: a feed banner and the BottomNav Export
tab, both shown once the host releases the export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 17:28:54 +02:00
parent f243bfe89a
commit 3654aca18b
26 changed files with 271 additions and 92 deletions

View File

@@ -899,8 +899,11 @@ async fn run_html_export_inner(
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Write embedded viewer assets (index.html, _app/*, etc.)
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
// Write the embedded single-file viewer, injecting the export data as a
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
// index.html (file://), where a cross-origin fetch() of a sibling file is
// blocked — so the data must be inlined rather than fetched from data.json.
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?;
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
@@ -1301,22 +1304,43 @@ async fn maybe_broadcast_complete(
}
}
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
async fn write_dir_to_zip(
/// Write the embedded viewer into the ZIP, injecting the export data as a
/// `window.__EXPORT_DATA__` global into `index.html`. The keepsake is opened by
/// double-clicking `index.html` (file://), where browsers block a cross-origin
/// `fetch()` of a sibling `data.json` — so the data is inlined into the page.
/// (`data.json` is still written separately for the http-served case.)
async fn write_viewer_with_data(
dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>,
data_json: &str,
) -> Result<()> {
for file in dir.files() {
let path = file.path().to_string_lossy().to_string();
let contents = file.contents();
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
if path == "index.html" {
let html = std::str::from_utf8(file.contents())
.context("export-viewer index.html is not valid UTF-8")?;
// Escape `</` so a caption containing `</script>` can't break out of the tag.
let safe = data_json.replace("</", "<\\/");
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>");
let injected = match html.find("</head>") {
Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]),
None => format!("{script}{html}"),
};
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(injected.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
} else {
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(file.contents()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
}
for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?;
}
Ok(())
}