From b70dce2a9919729da848d3599990e51f1244d37b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 14 Nov 2026 18:46:24 +0800 Subject: [PATCH 1/3] perf(desktop): unblock frontend assets and defer archive readers --- src/apps/desktop/README.md | 5 + src/apps/desktop/src/frontend_workbench.rs | 355 +++++++++++++++++- src/apps/desktop/src/lib.rs | 13 +- src/apps/desktop/src/window_material.rs | 29 +- .../schema/AppearancePackageParser.ts | 3 +- .../src/infrastructure/visual-skin/archive.ts | 6 +- 6 files changed, 389 insertions(+), 22 deletions(-) diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index 3e6dc6ae5c..aa2f0ddf11 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -17,6 +17,11 @@ so newly exposed edges retain the page color while WebKit catches up. The sideba keeps its native material. Theme changes and sidebar geometry update this paint; translucent or image-backed scene surfaces retain their original composition. +To compare WebView rendering cost without the native sidebar material on macOS +and Windows, start the app with `OPENBITFUN_NATIVE_WINDOW_MATERIAL=0`. The main +window then uses the opaque fallback that Windows builds before 22H2 already use; +the default is unchanged. + ## Model selection and smart routing **Release availability:** smart mode is not available in this release. The GUI diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs index d9249e182d..ec4400bf26 100644 --- a/src/apps/desktop/src/frontend_workbench.rs +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -110,6 +110,16 @@ struct BundledFrontendSource { root: PathBuf, } +const NO_STORE_CACHE_CONTROL: &str = "no-store, max-age=0"; +const REVALIDATE_CACHE_CONTROL: &str = "private, no-cache"; + +struct ProtocolAsset { + bytes: Vec, + content_type: &'static str, + cache_control: &'static str, + etag: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BundledFrontendManifest { @@ -981,7 +991,7 @@ impl FrontendWorkbenchManager { return tauri::http::Response::builder() .status(tauri::http::StatusCode::OK) .header(tauri::http::header::CONTENT_TYPE, "text/css; charset=utf-8") - .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::CACHE_CONTROL, NO_STORE_CACHE_CONTROL) .body(BOOTSTRAP_THEME_CSS.to_vec()) .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())); } @@ -992,18 +1002,49 @@ impl FrontendWorkbenchManager { tauri::http::header::CONTENT_TYPE, "text/html; charset=utf-8", ) - .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::CACHE_CONTROL, NO_STORE_CACHE_CONTROL) .body(CONFIRMATION_HTML.to_vec()) .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())); } match self.read_protocol_asset(request_path) { - Ok((bytes, content_type)) => tauri::http::Response::builder() - .status(tauri::http::StatusCode::OK) - .header(tauri::http::header::CONTENT_TYPE, content_type) - .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") - .header(tauri::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(bytes) - .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), + Ok(asset) => { + let not_modified = matches!( + *request.method(), + tauri::http::Method::GET | tauri::http::Method::HEAD + ) && asset.etag.as_ref().is_some_and(|etag| { + request + .headers() + .get_all(tauri::http::header::IF_NONE_MATCH) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|value| { + value == "*" || value.strip_prefix("W/").unwrap_or(value) == etag + }) + }); + let mut response = tauri::http::Response::builder() + .status(if not_modified { + tauri::http::StatusCode::NOT_MODIFIED + } else { + tauri::http::StatusCode::OK + }) + .header(tauri::http::header::CONTENT_TYPE, asset.content_type) + .header(tauri::http::header::CACHE_CONTROL, asset.cache_control) + .header(tauri::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + if let Some(etag) = asset.etag { + response = response.header(tauri::http::header::ETAG, etag); + } + response + .body( + if not_modified || request.method() == tauri::http::Method::HEAD { + Vec::new() + } else { + asset.bytes + }, + ) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) + } Err(error) if matches!(request_path, "" | "/" | "/index.html") => { log::error!("Serving immutable frontend recovery page: error={error}"); tauri::http::Response::builder() @@ -1012,7 +1053,7 @@ impl FrontendWorkbenchManager { tauri::http::header::CONTENT_TYPE, "text/html; charset=utf-8", ) - .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::CACHE_CONTROL, NO_STORE_CACHE_CONTROL) .body(RECOVERY_HTML.to_vec()) .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) } @@ -1022,13 +1063,13 @@ impl FrontendWorkbenchManager { tauri::http::header::CONTENT_TYPE, "text/plain; charset=utf-8", ) - .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::CACHE_CONTROL, NO_STORE_CACHE_CONTROL) .body(error.into_bytes()) .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), } } - fn read_protocol_asset(&self, request_path: &str) -> Result<(Vec, &'static str), String> { + fn read_protocol_asset(&self, request_path: &str) -> Result { let decoded = urlencoding::decode(request_path) .map_err(|_| "Invalid frontend asset path encoding".to_string())?; let relative = decoded.trim_start_matches('/'); @@ -1056,6 +1097,7 @@ impl FrontendWorkbenchManager { }; validate_revision_id(&served_revision)?; let root = self.asset_root(&served_revision, relative_path)?; + let packaged_source = self.is_bundled_source_root(&root)?; let canonical_root = root .canonicalize() .map_err(|error| format!("Frontend root is unavailable: {error}"))?; @@ -1073,11 +1115,36 @@ impl FrontendWorkbenchManager { return Err("Frontend asset path escaped the active revision".to_string()); } let content_type = content_type_for(&canonical_candidate); + // Packaged assets and legacy full-tree customizations share URLs. Even + // hashed names must revalidate across activation, rollback and upgrade; + // a fresh immutable response would bypass the new revision entirely. + let cacheable = packaged_source + && canonical_candidate + .strip_prefix(&canonical_root) + .is_ok_and(is_content_hashed_bundle_asset); fs::read(&canonical_candidate) - .map(|bytes| (bytes, content_type)) + .map(|bytes| ProtocolAsset { + etag: cacheable.then(|| format!("\"{:x}\"", Sha256::digest(&bytes))), + bytes, + content_type, + cache_control: if cacheable { + REVALIDATE_CACHE_CONTROL + } else { + NO_STORE_CACHE_CONTROL + }, + }) .map_err(|error| format!("Failed to read frontend asset: {error}")) } + fn is_bundled_source_root(&self, root: &Path) -> Result { + Ok(self + .bundled_source + .lock() + .map_err(lock_error)? + .as_ref() + .is_some_and(|source| source.root == root)) + } + fn load_state(&self) -> FrontendWorkbenchState { let path = self.state_path(); match fs::read(&path) { @@ -1684,6 +1751,32 @@ fn validate_revision_id(value: &str) -> Result<(), String> { Ok(()) } +/// Vite emits build output as `assets/-.` with an 8-character +/// content hash. Public files copied into `assets//` keep their own names +/// and are not covered. +fn is_content_hashed_bundle_asset(relative: &Path) -> bool { + const HASH_LEN: usize = 8; + let mut components = relative.components(); + let (Some(Component::Normal(directory)), Some(Component::Normal(file)), None) = + (components.next(), components.next(), components.next()) + else { + return false; + }; + if directory != "assets" { + return false; + } + let Some((stem, extension)) = file.to_str().and_then(|file| file.rsplit_once('.')) else { + return false; + }; + let stem = stem.as_bytes(); + !extension.is_empty() + && stem.len() > HASH_LEN + && stem[stem.len() - HASH_LEN - 1] == b'-' + && stem[stem.len() - HASH_LEN..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + fn content_type_for(path: &Path) -> &'static str { match path.extension().and_then(|extension| extension.to_str()) { Some("html") => "text/html; charset=utf-8", @@ -1780,14 +1873,17 @@ mod tests { Some(revision) ); assert_eq!( - upgraded.read_protocol_asset("/assets/app.js").unwrap().0, + upgraded + .read_protocol_asset("/assets/app.js") + .unwrap() + .bytes, b"product v2" ); assert_eq!( upgraded .read_protocol_asset("/openbitfun-creation.css") .unwrap() - .0, + .bytes, b"/* user design */" ); assert!(upgraded.revision_dir(revision).is_dir()); @@ -2480,4 +2576,233 @@ mod tests { assert!(body.contains("--openbitfun-color-status-danger-content")); assert!(!body.contains("--openbitfun-appearance-token-")); } + + fn protocol_cache_control(manager: &FrontendWorkbenchManager, path: &str) -> String { + let request = tauri::http::Request::builder() + .uri(format!("openbitfun-ui://localhost{path}")) + .body(Vec::new()) + .expect("request"); + let response = manager.protocol_response(request); + assert_eq!(response.status(), tauri::http::StatusCode::OK, "{path}"); + response + .headers() + .get(tauri::http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("cache-control header") + .to_string() + } + + #[test] + fn hashed_bundle_asset_names_match_vite_output_only() { + for name in [ + "assets/index-BSfB8JR0.js", + "assets/macbook-air-hero--JR023m1.png", + "assets/pdf.worker.min-wgc6bjNh.mjs", + "assets/HarmonyOS_Sans_SC-BKicpBgU.ttf", + ] { + assert!(is_content_hashed_bundle_asset(Path::new(name)), "{name}"); + } + for name in [ + "index.html", + "assets/app.js", + "assets/README.md", + "assets/index-BSfB8JR0", + "assets/appearance/openbitfun-default-preview@4x.png", + "assets/appearance/preview-12345678.png", + "monaco-editor/vs/loader-12345678.js", + "openbitfun-creation.js", + ] { + assert!(!is_content_hashed_bundle_asset(Path::new(name)), "{name}"); + } + } + + #[test] + fn protocol_caches_only_hashed_assets_served_from_the_packaged_bundle() { + let root = tempfile::tempdir().expect("tempdir"); + let manager = overlay_manager(root.path(), "installed product"); + let packaged = root.path().join("packaged"); + fs::write(packaged.join("assets/index-BSfB8JR0.js"), "export {};").expect("chunk"); + fs::create_dir_all(packaged.join("assets/appearance")).expect("public directory"); + fs::write( + packaged.join("assets/appearance/preview-12345678.png"), + "png", + ) + .expect("public asset"); + + assert_eq!( + protocol_cache_control(&manager, "/assets/index-BSfB8JR0.js"), + REVALIDATE_CACHE_CONTROL + ); + for path in [ + "/index.html", + "/assets/app.js", + "/assets/appearance/preview-12345678.png", + ] { + assert_eq!( + protocol_cache_control(&manager, path), + NO_STORE_CACHE_CONTROL, + "{path}" + ); + } + + // Overlay customizations keep serving product chunks from the packaged + // bundle, while their own editable files must revalidate. + let overlay = "creative-overlay"; + let overlay_root = manager.revision_dir(overlay); + fs::create_dir_all(&overlay_root).expect("overlay root"); + fs::write(overlay_root.join(overrides::CSS), "/* edited */").expect("overlay css"); + fs::write(overlay_root.join(overrides::JS), "export default 1").expect("overlay js"); + overrides::write_manifest(&overlay_root).expect("overlay manifest"); + manager.state.lock().expect("state").active_revision = Some(overlay.to_string()); + assert_eq!( + protocol_cache_control(&manager, "/assets/index-BSfB8JR0.js"), + REVALIDATE_CACHE_CONTROL + ); + assert_eq!( + protocol_cache_control(&manager, &format!("/{}", overrides::JS)), + NO_STORE_CACHE_CONTROL + ); + + // Legacy full-tree customizations may rewrite a hashed file in place. + let legacy = "creative-legacy"; + write_frontend(&manager.revision_dir(legacy), "legacy"); + fs::write( + manager + .revision_dir(legacy) + .join("assets/index-BSfB8JR0.js"), + "export const edited = true;", + ) + .expect("edited chunk"); + manager.state.lock().expect("state").active_revision = Some(legacy.to_string()); + assert_eq!( + protocol_cache_control(&manager, "/assets/index-BSfB8JR0.js"), + NO_STORE_CACHE_CONTROL + ); + } + + fn conditional_protocol_response( + manager: &FrontendWorkbenchManager, + path: &str, + etag: &str, + ) -> tauri::http::Response> { + manager.protocol_response( + tauri::http::Request::builder() + .uri(format!("openbitfun-ui://localhost{path}")) + .header(tauri::http::header::IF_NONE_MATCH, etag) + .body(Vec::new()) + .expect("conditional request"), + ) + } + + #[test] + fn protocol_revalidates_packaged_bytes_and_never_caches_mutable_pages() { + let root = tempfile::tempdir().expect("tempdir"); + let manager = overlay_manager(root.path(), "installed product"); + let path = "/assets/index-BSfB8JR0.js"; + let file = root.path().join("packaged/assets/index-BSfB8JR0.js"); + fs::write(&file, "export const version = 1;").expect("chunk"); + + let first = conditional_protocol_response(&manager, path, "\"stale\""); + assert_eq!(first.status(), tauri::http::StatusCode::OK); + assert_eq!(first.body(), b"export const version = 1;"); + assert_eq!( + first.headers()[tauri::http::header::CACHE_CONTROL], + REVALIDATE_CACHE_CONTROL + ); + let etag = first.headers()[tauri::http::header::ETAG].to_str().unwrap(); + for condition in [ + etag.to_string(), + format!("W/{etag}"), + format!("\"stale\", {etag}"), + "*".to_string(), + ] { + let response = conditional_protocol_response(&manager, path, &condition); + assert_eq!(response.status(), tauri::http::StatusCode::NOT_MODIFIED); + assert!(response.body().is_empty()); + assert_eq!(response.headers()[tauri::http::header::ETAG], etag); + assert_eq!( + response.headers()[tauri::http::header::CACHE_CONTROL], + REVALIDATE_CACHE_CONTROL + ); + } + + // Mutable and bootstrap URLs always return their current content. + for page in [ + "/index.html", + "/bootstrap-theme.css", + "/frontend-update-confirm.html", + ] { + let response = conditional_protocol_response(&manager, page, "*"); + assert_eq!(response.status(), tauri::http::StatusCode::OK); + assert!(!response.body().is_empty()); + assert!(!response.headers().contains_key(tauri::http::header::ETAG)); + assert_eq!( + response.headers()[tauri::http::header::CACHE_CONTROL], + NO_STORE_CACHE_CONTROL + ); + } + + // A replacement with the same filename must not reuse earlier bytes. + fs::write(&file, "export const version = 2;").expect("replacement chunk"); + let replacement = conditional_protocol_response(&manager, path, etag); + assert_eq!(replacement.status(), tauri::http::StatusCode::OK); + assert_eq!(replacement.body(), b"export const version = 2;"); + assert_ne!(replacement.headers()[tauri::http::header::ETAG], etag); + fs::remove_file(file).expect("remove chunk"); + let missing = conditional_protocol_response(&manager, path, etag); + assert_eq!(missing.status(), tauri::http::StatusCode::NOT_FOUND); + assert_eq!( + missing.headers()[tauri::http::header::CACHE_CONTROL], + NO_STORE_CACHE_CONTROL + ); + } + + #[test] + fn cached_bundle_revalidates_during_legacy_preview_activation_and_rollback() { + let root = tempfile::tempdir().expect("tempdir"); + let manager = overlay_manager(root.path(), "installed product"); + let path = "/assets/index-BSfB8JR0.js"; + fs::write( + root.path().join("packaged/assets/index-BSfB8JR0.js"), + "original", + ) + .expect("chunk"); + let cached = conditional_protocol_response(&manager, path, "\"stale\""); + let etag = cached.headers()[tauri::http::header::ETAG] + .to_str() + .unwrap(); + let bundled = manager.state.lock().unwrap().active_revision.clone(); + let legacy = "creative-legacy"; + write_frontend(&manager.revision_dir(legacy), "legacy"); + fs::write( + manager + .revision_dir(legacy) + .join("assets/index-BSfB8JR0.js"), + "edited", + ) + .expect("legacy chunk"); + manager.state.lock().unwrap().pending = Some(PendingFrontendRevision { + revision_id: legacy.to_string(), + ..Default::default() + }); + + for phase in ["preview", "active"] { + let response = conditional_protocol_response(&manager, path, etag); + assert_eq!(response.status(), tauri::http::StatusCode::OK, "{phase}"); + assert_eq!(response.body(), b"edited", "{phase}"); + assert_eq!( + response.headers()[tauri::http::header::CACHE_CONTROL], + NO_STORE_CACHE_CONTROL + ); + assert!(!response.headers().contains_key(tauri::http::header::ETAG)); + let mut state = manager.state.lock().unwrap(); + state.pending = None; + state.active_revision = Some(legacy.to_string()); + } + manager.state.lock().unwrap().active_revision = bundled; + assert_eq!( + conditional_protocol_response(&manager, path, etag).status(), + tauri::http::StatusCode::NOT_MODIFIED + ); + } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index cb1ab01972..f86d694689 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -758,9 +758,18 @@ pub async fn run() { let mut builder = tauri::Builder::default(); let frontend_protocol_manager = Arc::clone(&frontend_workbench); - builder = builder.register_uri_scheme_protocol( + // The synchronous registration runs the handler inline on the WebView + // callback (the main thread on macOS and Windows). Every lazy chunk read, + // path canonicalization and revision lookup would then stall the UI, so the + // asset is resolved on the blocking pool and answered asynchronously. + builder = builder.register_asynchronous_uri_scheme_protocol( frontend_workbench::FRONTEND_PROTOCOL_SCHEME, - move |_context, request| frontend_protocol_manager.protocol_response(request), + move |_context, request, responder| { + let manager = Arc::clone(&frontend_protocol_manager); + tauri::async_runtime::spawn_blocking(move || { + responder.respond(manager.protocol_response(request)); + }); + }, ); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] diff --git a/src/apps/desktop/src/window_material.rs b/src/apps/desktop/src/window_material.rs index 939a1627ba..171f84c58f 100644 --- a/src/apps/desktop/src/window_material.rs +++ b/src/apps/desktop/src/window_material.rs @@ -8,7 +8,18 @@ const fn windows_native_sidebar_material_available(build: u32) -> bool { build >= WINDOWS_SYSTEM_BACKDROP_MIN_BUILD } +/// `OPENBITFUN_NATIVE_WINDOW_MATERIAL=0` selects the opaque window path on macOS +/// and Windows, for comparing WebView compositing cost without native material. +const NATIVE_WINDOW_MATERIAL_ENV: &str = "OPENBITFUN_NATIVE_WINDOW_MATERIAL"; + +fn native_window_material_allowed_by(value: Option<&std::ffi::OsStr>) -> bool { + value.is_none_or(|value| value != "0") +} + pub(crate) fn native_sidebar_material_available() -> bool { + if !native_window_material_allowed_by(std::env::var_os(NATIVE_WINDOW_MATERIAL_ENV).as_deref()) { + return false; + } #[cfg(target_os = "windows")] { // RtlGetVersion is independent of application compatibility manifests. @@ -92,10 +103,24 @@ mod tests { assert!(!windows_native_sidebar_material_available(0)); } + #[test] + fn native_window_material_env_only_opts_out_with_zero() { + use std::ffi::OsStr; + assert!(native_window_material_allowed_by(None)); + assert!(native_window_material_allowed_by(Some(OsStr::new("1")))); + assert!(native_window_material_allowed_by(Some(OsStr::new("")))); + assert!(!native_window_material_allowed_by(Some(OsStr::new("0")))); + } + #[cfg(target_os = "macos")] #[test] - fn macos_keeps_native_sidebar_material() { - assert!(native_sidebar_material_available()); + fn macos_keeps_native_sidebar_material_unless_disabled() { + assert_eq!( + native_sidebar_material_available(), + native_window_material_allowed_by( + std::env::var_os(NATIVE_WINDOW_MATERIAL_ENV).as_deref() + ) + ); } #[cfg(not(any(target_os = "windows", target_os = "macos")))] diff --git a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts index 83fb0def7a..71c9487fbf 100644 --- a/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts +++ b/src/web-ui/src/infrastructure/appearance/schema/AppearancePackageParser.ts @@ -1,4 +1,3 @@ -import JSZip from 'jszip'; import type { AppearanceRegistry } from '../registry/AppearanceRegistry'; import { assertValidAppearancePackage } from './AppearancePackageValidator'; import type { @@ -89,6 +88,8 @@ export class AppearancePackageParser { throw new Error(`Appearance archive must be between 1 byte and ${MAX_ARCHIVE_BYTES} bytes`); } this.assertSafeZipMetadata(source); + // Package import is a user action; keep the archive reader out of the startup bundle. + const { default: JSZip } = await import('jszip'); const zip = await JSZip.loadAsync(source, { checkCRC32: true }); const files = Object.values(zip.files).filter(file => !file.dir); if (files.length === 0 || files.length > MAX_ENTRIES) { diff --git a/src/web-ui/src/infrastructure/visual-skin/archive.ts b/src/web-ui/src/infrastructure/visual-skin/archive.ts index 49a89a8cb7..d09cfd70c4 100644 --- a/src/web-ui/src/infrastructure/visual-skin/archive.ts +++ b/src/web-ui/src/infrastructure/visual-skin/archive.ts @@ -1,4 +1,4 @@ -import { ZipReader, Uint8ArrayReader, type FileEntry } from '@zip.js/zip.js'; +import type { FileEntry, ZipReader } from '@zip.js/zip.js'; export const MAX_SKIN_EXPANDED_BYTES = 48 * 1024 * 1024; export const MAX_SKIN_MANIFEST_BYTES = 512 * 1024; @@ -12,7 +12,9 @@ export class SkinArchive { private constructor(private readonly reader: ZipReader) {} static async open(source: ArrayBuffer): Promise { - const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(source)), { + // Skin import is a user action; keep the archive reader out of the startup bundle. + const zip = await import('@zip.js/zip.js'); + const reader = new zip.ZipReader(new zip.Uint8ArrayReader(new Uint8Array(source)), { useWebWorkers: false, checkSignature: true, checkOverlappingEntry: true, }); const archive = new SkinArchive(reader); From 4a495166730ecc5151673f1e183216dc221dfec7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 14 Nov 2026 18:51:10 +0800 Subject: [PATCH 2/3] fix(desktop): avoid stale embedded relay asset caches --- src/apps/desktop/src/embedded_relay_host.rs | 93 ++++++++++++++++++-- src/apps/desktop/src/frontend_asset_cache.rs | 58 ++++++++++++ src/apps/desktop/src/frontend_workbench.rs | 52 +---------- src/apps/desktop/src/lib.rs | 1 + 4 files changed, 147 insertions(+), 57 deletions(-) create mode 100644 src/apps/desktop/src/frontend_asset_cache.rs diff --git a/src/apps/desktop/src/embedded_relay_host.rs b/src/apps/desktop/src/embedded_relay_host.rs index e3f43d7354..0d27f444d5 100644 --- a/src/apps/desktop/src/embedded_relay_host.rs +++ b/src/apps/desktop/src/embedded_relay_host.rs @@ -175,8 +175,26 @@ async fn static_cache_headers( ) -> axum::response::Response { let path = request.uri().path().to_string(); let mut response = next.run(request).await; + // Only successful build outputs have content-addressed URLs. Caching an + // unversioned file or a missing chunk for a year survives mobile upgrades. + let immutable = (response.status().is_success() + || response.status() == axum::http::StatusCode::NOT_MODIFIED) + && !path.ends_with(".html") + && !response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/html")) + && crate::frontend_asset_cache::is_content_hashed_bundle_asset(std::path::Path::new( + path.trim_start_matches('/'), + )); let headers = response.headers_mut(); - if path == "/" || path.ends_with(".html") { + if immutable { + headers.insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } else { headers.insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"), @@ -185,11 +203,6 @@ async fn static_cache_headers( axum::http::header::PRAGMA, axum::http::HeaderValue::from_static("no-cache"), ); - } else if path.starts_with("/assets/") { - headers.insert( - axum::http::header::CACHE_CONTROL, - axum::http::HeaderValue::from_static("public, max-age=31536000, immutable"), - ); } response } @@ -316,6 +329,13 @@ mod tests { .expect("test index should be written"); std::fs::write(static_dir.join("assets").join("app.js"), "test asset") .expect("test asset should be written"); + std::fs::write(static_dir.join("assets/app-12345678.js"), "hashed asset") + .expect("hashed asset should be written"); + std::fs::write( + static_dir.join("assets/page-12345678.html"), + "editable page", + ) + .expect("HTML asset should be written"); let host = DesktopEmbeddedRelayHost::default(); let port = start_on_free_port(&host, Some(static_dir.to_string_lossy().into_owned())).await; @@ -351,8 +371,67 @@ mod tests { .headers() .get(CACHE_CONTROL) .and_then(|v| v.to_str().ok()), - Some("public, max-age=31536000, immutable") + Some("no-cache, no-store, must-revalidate") + ); + + for (path, status, cache_control) in [ + ( + "/assets/app-12345678.js", + reqwest::StatusCode::OK, + "public, max-age=31536000, immutable", + ), + ( + "/assets/page-12345678.html", + reqwest::StatusCode::OK, + "no-cache, no-store, must-revalidate", + ), + ( + "/assets/missing-12345678.js", + reqwest::StatusCode::NOT_FOUND, + "no-cache, no-store, must-revalidate", + ), + ] { + let response = client + .get(format!("http://127.0.0.1:{port}{path}")) + .send() + .await + .expect("static request"); + assert_eq!(response.status(), status, "{path}"); + assert_eq!(response.headers()[CACHE_CONTROL], cache_control, "{path}"); + } + let hashed = client + .get(format!("http://127.0.0.1:{port}/assets/app-12345678.js")) + .send() + .await + .expect("hashed asset request"); + let revalidated = client + .get(format!("http://127.0.0.1:{port}/assets/app-12345678.js")) + .header( + reqwest::header::IF_MODIFIED_SINCE, + hashed.headers()[reqwest::header::LAST_MODIFIED].clone(), + ) + .send() + .await + .expect("hashed asset revalidation"); + assert_eq!(revalidated.status(), reqwest::StatusCode::NOT_MODIFIED); + assert_eq!( + revalidated.headers()[CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + drop(hashed); + drop(revalidated); + std::fs::write(static_dir.join("assets/app.js"), "upgraded asset") + .expect("unversioned asset should be replaced"); + let upgraded = client + .get(format!("http://127.0.0.1:{port}/assets/app.js")) + .send() + .await + .expect("upgraded asset request"); + assert_eq!( + upgraded.headers()[CACHE_CONTROL], + "no-cache, no-store, must-revalidate" ); + assert_eq!(upgraded.text().await.unwrap(), "upgraded asset"); drop(index); drop(asset); diff --git a/src/apps/desktop/src/frontend_asset_cache.rs b/src/apps/desktop/src/frontend_asset_cache.rs new file mode 100644 index 0000000000..8288a3de90 --- /dev/null +++ b/src/apps/desktop/src/frontend_asset_cache.rs @@ -0,0 +1,58 @@ +//! Build-output naming shared by the desktop frontend and embedded mobile web host. + +use std::path::{Component, Path}; + +/// Vite emits build output as `assets/-.` with an 8-character +/// content hash. Public files copied into `assets//` keep their own names +/// and are not covered. +pub(crate) fn is_content_hashed_bundle_asset(relative: &Path) -> bool { + const HASH_LEN: usize = 8; + let mut components = relative.components(); + let (Some(Component::Normal(directory)), Some(Component::Normal(file)), None) = + (components.next(), components.next(), components.next()) + else { + return false; + }; + if directory != "assets" { + return false; + } + let Some((stem, extension)) = file.to_str().and_then(|file| file.rsplit_once('.')) else { + return false; + }; + let stem = stem.as_bytes(); + !extension.is_empty() + && stem.len() > HASH_LEN + && stem[stem.len() - HASH_LEN - 1] == b'-' + && stem[stem.len() - HASH_LEN..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashed_bundle_asset_names_match_vite_output_only() { + for name in [ + "assets/index-BSfB8JR0.js", + "assets/macbook-air-hero--JR023m1.png", + "assets/pdf.worker.min-wgc6bjNh.mjs", + "assets/HarmonyOS_Sans_SC-BKicpBgU.ttf", + ] { + assert!(is_content_hashed_bundle_asset(Path::new(name)), "{name}"); + } + for name in [ + "index.html", + "assets/app.js", + "assets/README.md", + "assets/index-BSfB8JR0", + "assets/appearance/openbitfun-default-preview@4x.png", + "assets/appearance/preview-12345678.png", + "monaco-editor/vs/loader-12345678.js", + "openbitfun-creation.js", + ] { + assert!(!is_content_hashed_bundle_asset(Path::new(name)), "{name}"); + } + } +} diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs index ec4400bf26..b4c0c562ba 100644 --- a/src/apps/desktop/src/frontend_workbench.rs +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -2,6 +2,8 @@ mod overrides; +use crate::frontend_asset_cache::is_content_hashed_bundle_asset; + use std::fs; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -1751,32 +1753,6 @@ fn validate_revision_id(value: &str) -> Result<(), String> { Ok(()) } -/// Vite emits build output as `assets/-.` with an 8-character -/// content hash. Public files copied into `assets//` keep their own names -/// and are not covered. -fn is_content_hashed_bundle_asset(relative: &Path) -> bool { - const HASH_LEN: usize = 8; - let mut components = relative.components(); - let (Some(Component::Normal(directory)), Some(Component::Normal(file)), None) = - (components.next(), components.next(), components.next()) - else { - return false; - }; - if directory != "assets" { - return false; - } - let Some((stem, extension)) = file.to_str().and_then(|file| file.rsplit_once('.')) else { - return false; - }; - let stem = stem.as_bytes(); - !extension.is_empty() - && stem.len() > HASH_LEN - && stem[stem.len() - HASH_LEN - 1] == b'-' - && stem[stem.len() - HASH_LEN..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) -} - fn content_type_for(path: &Path) -> &'static str { match path.extension().and_then(|extension| extension.to_str()) { Some("html") => "text/html; charset=utf-8", @@ -2592,30 +2568,6 @@ mod tests { .to_string() } - #[test] - fn hashed_bundle_asset_names_match_vite_output_only() { - for name in [ - "assets/index-BSfB8JR0.js", - "assets/macbook-air-hero--JR023m1.png", - "assets/pdf.worker.min-wgc6bjNh.mjs", - "assets/HarmonyOS_Sans_SC-BKicpBgU.ttf", - ] { - assert!(is_content_hashed_bundle_asset(Path::new(name)), "{name}"); - } - for name in [ - "index.html", - "assets/app.js", - "assets/README.md", - "assets/index-BSfB8JR0", - "assets/appearance/openbitfun-default-preview@4x.png", - "assets/appearance/preview-12345678.png", - "monaco-editor/vs/loader-12345678.js", - "openbitfun-creation.js", - ] { - assert!(!is_content_hashed_bundle_asset(Path::new(name)), "{name}"); - } - } - #[test] fn protocol_caches_only_hashed_assets_served_from_the_packaged_bundle() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f86d694689..fbe03d1f6d 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -27,6 +27,7 @@ pub mod computer_use; pub mod crash_diagnostics; pub mod desktop_cards; mod embedded_relay_host; +mod frontend_asset_cache; pub mod frontend_workbench; pub mod logging; pub mod macos_menubar; From 8bb93e31a8339bcdf001434e65ef65fc6d4775b8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 14 Nov 2026 18:56:03 +0800 Subject: [PATCH 3/3] fix(desktop): preserve asset length in conditional responses --- src/apps/desktop/src/frontend_workbench.rs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs index b4c0c562ba..a8b224485e 100644 --- a/src/apps/desktop/src/frontend_workbench.rs +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -1032,6 +1032,8 @@ impl FrontendWorkbenchManager { tauri::http::StatusCode::OK }) .header(tauri::http::header::CONTENT_TYPE, asset.content_type) + // Wry otherwise derives this from the empty 304/HEAD body. + .header(tauri::http::header::CONTENT_LENGTH, asset.bytes.len()) .header(tauri::http::header::CACHE_CONTROL, asset.cache_control) .header(tauri::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); if let Some(etag) = asset.etag { @@ -2657,6 +2659,7 @@ mod tests { let first = conditional_protocol_response(&manager, path, "\"stale\""); assert_eq!(first.status(), tauri::http::StatusCode::OK); assert_eq!(first.body(), b"export const version = 1;"); + assert_eq!(first.headers()[tauri::http::header::CONTENT_LENGTH], "25"); assert_eq!( first.headers()[tauri::http::header::CACHE_CONTROL], REVALIDATE_CACHE_CONTROL @@ -2671,6 +2674,10 @@ mod tests { let response = conditional_protocol_response(&manager, path, &condition); assert_eq!(response.status(), tauri::http::StatusCode::NOT_MODIFIED); assert!(response.body().is_empty()); + assert_eq!( + response.headers()[tauri::http::header::CONTENT_LENGTH], + "25" + ); assert_eq!(response.headers()[tauri::http::header::ETAG], etag); assert_eq!( response.headers()[tauri::http::header::CACHE_CONTROL], @@ -2678,6 +2685,26 @@ mod tests { ); } + for (condition, status) in [ + ("\"stale\"", tauri::http::StatusCode::OK), + (etag, tauri::http::StatusCode::NOT_MODIFIED), + ] { + let response = manager.protocol_response( + tauri::http::Request::builder() + .method(tauri::http::Method::HEAD) + .uri(format!("openbitfun-ui://localhost{path}")) + .header(tauri::http::header::IF_NONE_MATCH, condition) + .body(Vec::new()) + .unwrap(), + ); + assert_eq!(response.status(), status); + assert!(response.body().is_empty()); + assert_eq!( + response.headers()[tauri::http::header::CONTENT_LENGTH], + "25" + ); + } + // Mutable and bootstrap URLs always return their current content. for page in [ "/index.html",