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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user