M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
3 changed files with 212 additions and 3 deletions
Showing only changes of commit c40263967d - Show all commits
+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(())
})
+18 -3
View File
@@ -92,13 +92,28 @@ fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<At
"SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC",
)?;
let rows = stmt.query_map([note_id], |r| {
let server_url: String = r.get(1)?;
let mime: String = r.get(3)?;
let sha256: Option<String> = 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()
+170
View File
@@ -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/<id>/attachments/<aid>`). 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<PathBuf> = OnceLock::new();
pub fn publish_root(root: PathBuf) {
let _ = SERVE_ROOT.set(root);
}
/// The URL an `<img>`/`<audio>`/`<a href>` should point at for these bytes.
///
/// **The two forms are not interchangeable.** A custom scheme is reachable as
/// `scheme://localhost/<path>` on Linux and macOS, but Windows and Android map it
/// onto `http://scheme.localhost/<path>`. Getting this wrong breaks exactly one
/// platform, silently, and CI cannot catch it — the runner is headless.
pub fn url_for(sha256: &str, mime: &str) -> String {
let query = urlencode(mime);
if cfg!(any(windows, target_os = "android")) {
format!("http://{BLOB_SCHEME}.localhost/{sha256}?mime={query}")
} else {
format!("{BLOB_SCHEME}://localhost/{sha256}?mime={query}")
}
}
/// Percent-encode the few characters a mime type can contain that don't belong in a
/// query value. Hand-rolled rather than adding a dependency for `/` and `+`.
fn urlencode(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 8);
for b in value.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn urldecode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The Content-Type to serve for a claimed mime.
///
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
/// arbitrary type would let an attachment claiming `text/html` run as a document
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
/// `image/` can name a scriptable type. Everything else is served as an opaque
/// download — the right treatment for an arbitrary file regardless.
fn content_type_for(mime: &str) -> String {
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
// A header value can't carry control characters, and a mime type has no business
// being long — both would only arrive from a malformed or hostile feed.
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
if familiar && printable {
mime.to_string()
} else {
"application/octet-stream".to_string()
}
}
/// Serve one request from the blob store. `path` is the URI path, `query` its query.
pub fn serve(path: &str, query: Option<&str>) -> (u16, String, Vec<u8>) {
let requested = path.trim_start_matches('/');
let Some(root) = SERVE_ROOT.get() else {
// A request before the store was published — nothing to serve yet.
return (503, "text/plain".into(), Vec::new());
};
let store = BlobStore {
root: root.clone(),
};
// `read` goes through `path`, which rejects anything that isn't a bare sha256 —
// so this handler inherits the traversal guard rather than re-implementing it.
let Some(bytes) = store.read(requested) else {
return (404, "text/plain".into(), Vec::new());
};
let claimed = query
.and_then(|q| q.split('&').find_map(|p| p.strip_prefix("mime=")))
.map(urldecode)
.unwrap_or_default();
(200, content_type_for(&claimed), bytes)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -141,6 +260,57 @@ mod tests {
assert!(store.has(HELLO), "should be found under the lowercase name");
}
#[test]
fn the_blob_url_carries_the_hash_and_the_mime() {
let url = url_for(HELLO, "image/png");
assert!(url.contains(HELLO), "the hash addresses the bytes: {url}");
assert!(url.contains("mime=image%2Fpng"), "mime encoded: {url}");
// The platform split is the whole risk of this feature, and CI is headless,
// so at least pin that the right branch was taken for THIS build.
if cfg!(any(windows, target_os = "android")) {
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
} else {
assert!(url.starts_with("tsblob://localhost/"), "{url}");
}
}
#[test]
fn url_encoding_round_trips_a_mime() {
assert_eq!(urldecode(&urlencode("image/svg+xml")), "image/svg+xml");
assert_eq!(urldecode(&urlencode("audio/mpeg")), "audio/mpeg");
// A malformed escape is left alone rather than eaten — the value still has to
// survive intact enough for `content_type_for` to reject it.
assert_eq!(urldecode("not-an-escape%ZZ"), "not-an-escape%ZZ");
}
#[test]
fn media_types_are_echoed_back() {
assert_eq!(content_type_for("image/png"), "image/png");
assert_eq!(content_type_for("audio/mpeg"), "audio/mpeg");
assert_eq!(content_type_for("application/pdf"), "application/pdf");
}
#[test]
fn a_scriptable_type_is_served_as_a_download() {
// This scheme is an origin of its own. An attachment claiming to be HTML
// must not be handed back as a document that can run there.
let opaque = "application/octet-stream";
assert_eq!(content_type_for("text/html"), opaque);
assert_eq!(content_type_for("application/javascript"), opaque);
assert_eq!(content_type_for(""), opaque);
// A control character can't reach a header value even under a safe family.
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
}
#[test]
fn serving_refuses_a_path_that_isnt_a_hash() {
// Delegated to `path`, so the traversal guard is the same one `store` uses.
publish_root(std::env::temp_dir().join("ts-blobs-serve-guard"));
let (status, _, body) = serve("/../../etc/passwd", None);
assert_eq!(status, 404);
assert!(body.is_empty());
}
#[test]
fn missing_blob_reads_as_none() {
let store = store("missing");