Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 86 additions & 7 deletions src/apps/desktop/src/embedded_relay_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions src/apps/desktop/src/frontend_asset_cache.rs
Original file line number Diff line number Diff line change
@@ -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/<name>-<hash>.<ext>` with an 8-character
/// content hash. Public files copied into `assets/<dir>/` 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/[email protected]",
"assets/appearance/preview-12345678.png",
"monaco-editor/vs/loader-12345678.js",
"openbitfun-creation.js",
] {
assert!(!is_content_hashed_bundle_asset(Path::new(name)), "{name}");
}
}
}
Loading
Loading