desktop: render synced attachments instead of broken images (task 2114)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s

A synced note carried the SERVER's relative attachment path
(/api/notes/<id>/attachments/<aid>). In the webview that resolves against the
app origin and 404s, so every synced image rendered broken even though the
bytes were already on disk from M10.7d. The absolute server URL wouldn't have
worked either: that route wants a bearer token the webview never sends, and it
would put an offline app on the network to show a file it already has.

The bytes now come off disk over a custom URI scheme, served straight from the
content-addressed blob store. The webview caches and range-requests them like
any other resource — which a data: URI would have thrown away — and the URL is
immutable-cacheable because a content address can never describe different
bytes.

Two things worth knowing about the shape of this:

The URL is rewritten in `load_attachments`, the single place the desktop
builds an attachment for the UI. NoteCard and NoteEditor are untouched, so
there's no second render site to drift.

The scheme's URL form is NOT the same on every platform: `scheme://localhost/`
on Linux and macOS, `http://scheme.localhost/` on Windows and Android. Getting
it wrong breaks exactly one channel, silently, and a headless CI runner can
never tell you.

The mime rides in the URL, and this scheme is an origin of its own, so an
attachment claiming to be text/html would run as a document there. Only media
families are echoed back; everything else is served as an opaque download,
which is the right treatment for an arbitrary file anyway. Path safety is
inherited rather than re-implemented — the handler reads through BlobStore,
which already refuses anything that isn't a bare sha256.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
2026-07-26 17:16:44 -04:00
co-authored by Claude Opus 5
parent d634801bd3
commit c40263967d
3 changed files with 212 additions and 3 deletions
+24
View File
@@ -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(())
})