diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43a7d1d..db5927c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,6 +33,27 @@ pub fn run() { ]) .build(), ) + // Attachment bytes are served to the webview from the local blob store + // (M10.7f). Registered on the BUILDER because a scheme has to exist before + // the webview is created; the directory it reads from arrives later, in + // `setup`, via `blobs::publish_root`. + .register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| { + let (status, content_type, body) = + sync::blobs::serve(request.uri().path(), request.uri().query()); + tauri::http::Response::builder() + .status(status) + .header("Content-Type", content_type) + // The bytes are content-addressed: a given URL can never describe + // different bytes, so the webview may keep them indefinitely. + .header("Cache-Control", "public, max-age=31536000, immutable") + .body(body) + .unwrap_or_else(|_| { + tauri::http::Response::builder() + .status(500) + .body(Vec::new()) + .expect("a bodiless 500 always builds") + }) + }) .setup(|app| { use tauri::Manager; log_environment(app); @@ -51,6 +72,9 @@ pub fn run() { // synced image is readable with no network (M10.7d). let blobs = sync::blobs::BlobStore::new(dir.join("blobs"))?; log::info!("attachment store ready: {}", blobs.root().display()); + // Hand the directory to the URI-scheme handler registered below, which + // was built before this path could be resolved. + sync::blobs::publish_root(blobs.root().to_path_buf()); app.manage(blobs); Ok(()) }) diff --git a/desktop/src-tauri/src/local/store.rs b/desktop/src-tauri/src/local/store.rs index 644f5ce..5053426 100644 --- a/desktop/src-tauri/src/local/store.rs +++ b/desktop/src-tauri/src/local/store.rs @@ -92,13 +92,28 @@ fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result = r.get(5)?; Ok(Attachment { id: r.get(0)?, - url: r.get(1)?, + // Point at the LOCAL bytes, not the server's route. The stored url is the + // server's relative path, which resolves against the app origin in the + // webview and 404s — and even absolute it would need a bearer token the + // webview never sends. Rewriting here rather than at each render site + // means NoteCard and NoteEditor stay untouched and can't drift. + // + // Without a hash there's nothing to address the blob by (an older server + // that predates the sha256 column), so the original url is left alone: + // still broken, but no more broken than it already was. + url: match sha256.as_deref() { + Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime), + _ => server_url, + }, filename: r.get(2)?, - mime: r.get(3)?, + mime, size: r.get(4)?, - sha256: r.get(5)?, + sha256, }) })?; rows.collect() diff --git a/desktop/src-tauri/src/sync/blobs.rs b/desktop/src-tauri/src/sync/blobs.rs index cf016dc..c946be0 100644 --- a/desktop/src-tauri/src/sync/blobs.rs +++ b/desktop/src-tauri/src/sync/blobs.rs @@ -10,6 +10,7 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; /// A sha256 in lowercase hex, and nothing else. /// @@ -82,6 +83,124 @@ impl BlobStore { } } +// --- Serving blobs to the webview (M10.7f) ----------------------------------- +// +// A synced note's attachment `url` is the SERVER's relative path +// (`/api/notes//attachments/`). In the desktop webview that resolves +// against the app origin and 404s, and swapping in the absolute server URL wouldn't +// help either — that route needs a bearer token the webview won't send, and it would +// make an offline app fetch over the network to show a file it already has on disk. +// +// So the bytes are served locally, over a custom URI scheme, straight out of this +// store. The webview then caches and range-requests them like any other resource, +// which a `data:` URI would have thrown away. + +/// The scheme the webview fetches attachment bytes over. +pub const BLOB_SCHEME: &str = "tsblob"; + +/// The blob directory, published once the app has resolved its data dir. +/// +/// A `OnceLock` rather than Tauri's managed state because the scheme handler is +/// registered on the BUILDER, before `setup` has computed that path — and because +/// reading it this way keeps the handler independent of which Tauri 2.x minor +/// changed the handler's context argument. +static SERVE_ROOT: OnceLock = OnceLock::new(); + +pub fn publish_root(root: PathBuf) { + let _ = SERVE_ROOT.set(root); +} + +/// The URL an ``/`