Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changes/asset-protocol-404-missing-subresources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"tauri": patch:bug
"tauri-cli": patch:bug
"@tauri-apps/cli": patch:bug
"tauri-utils": patch:enhance
---

The `tauri://` asset protocol and the CLI's built-in dev server now return `404` (`text/plain`, naming the requested path) when a request that is not a navigation does not match any asset, instead of serving `index.html` with `200 text/html`. Navigations still resolve to the SPA `index.html` fallback so the frontend router can react to any URL, and that fallback now logs a warning. Requests are classified by `Sec-Fetch-Dest` where the webview sends it and by the `Accept` header otherwise; without either header the request counts as a navigation, so the fallback is preserved.
98 changes: 87 additions & 11 deletions crates/tauri-cli/src/dev/builtin_dev_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use axum::{
extract::{ws, State, WebSocketUpgrade},
http::{header, StatusCode, Uri},
http::{header, HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response},
};
use std::{
Expand Down Expand Up @@ -79,7 +79,7 @@ pub fn start<P: AsRef<Path>>(dir: P, ip: IpAddr, port: Option<u16>) -> crate::Re
Ok(address)
}

async fn handler(uri: Uri, state: State<ServerState>) -> impl IntoResponse {
async fn handler(uri: Uri, headers: HeaderMap, state: State<ServerState>) -> impl IntoResponse {
// Frontend files should not contain query parameters. This seems to be how Vite handles it.
let uri = uri.path();

Expand All @@ -89,27 +89,50 @@ async fn handler(uri: Uri, state: State<ServerState>) -> impl IntoResponse {
uri.strip_prefix('/').unwrap_or(uri)
};

let bytes = fs_read_scoped(state.dir.join(uri), &state.dir)
.or_else(|_| fs_read_scoped(state.dir.join(format!("{uri}.html")), &state.dir))
.or_else(|_| fs_read_scoped(state.dir.join(format!("{uri}/index.html")), &state.dir))
.or_else(|_| std::fs::read(state.dir.join("index.html")));

match bytes {
Ok(mut bytes) => {
match resolve_asset(
&state.dir,
uri,
tauri_utils::request::is_navigation(&headers),
) {
Some(mut bytes) => {
let mime_type = MimeType::parse_with_fallback(&bytes, uri, MimeType::OctetStream);
if mime_type == MimeType::Html.to_string() {
bytes = inject_address(bytes, &state.address);
}
(StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes)
}
Err(_) => (
None => (
StatusCode::NOT_FOUND,
[(header::CONTENT_TYPE, "text/plain".into())],
vec![],
format!("asset not found: /{}", uri.trim_start_matches('/')).into_bytes(),
),
}
}

/// Resolves a request path against the dist directory, mirroring the fallback
/// chain of the `tauri://` protocol: exact path, `{path}.html`,
/// `{path}/index.html`, then the SPA `index.html` fallback.
/// Only navigations fall back, so a missing subresource is answered with a 404
/// instead of an HTML document.
fn resolve_asset(dir: &Path, uri: &str, allow_html_fallback: bool) -> Option<Vec<u8>> {
let exact = fs_read_scoped(dir.join(uri), dir).ok();

if !allow_html_fallback {
return exact;
}

exact
.or_else(|| fs_read_scoped(dir.join(format!("{uri}.html")), dir).ok())
.or_else(|| fs_read_scoped(dir.join(format!("{uri}/index.html")), dir).ok())
.or_else(|| {
let bytes = std::fs::read(dir.join("index.html")).ok();
if bytes.is_some() {
log::warn!("asset `/{uri}` not found; serving `index.html` instead (SPA fallback)");
}
bytes
})
}

async fn ws_handler(ws: WebSocketUpgrade, state: State<ServerState>) -> Response {
ws.on_upgrade(move |mut ws| async move {
let mut rx = state.tx.subscribe();
Expand Down Expand Up @@ -167,3 +190,56 @@ fn watch<F: Fn() + Send + 'static>(dir: PathBuf, handler: F) {
}
});
}

#[cfg(test)]
mod tests {
use super::resolve_asset;

fn dist() -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("tauri-cli-dev-server-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("docs")).unwrap();
// the server canonicalizes the dist dir on startup (`start`); do the same
// here so the scope check holds on platforms where the temp dir contains
// symlinks (macOS `/var`) or short names (Windows)
let dir = dunce::canonicalize(&dir).unwrap();
std::fs::write(dir.join("index.html"), "<html>index</html>").unwrap();
std::fs::write(dir.join("about.html"), "<html>about</html>").unwrap();
std::fs::write(dir.join("docs/index.html"), "<html>docs</html>").unwrap();
std::fs::write(dir.join("app.js"), "console.log('app')").unwrap();
dir
}

#[test]
fn resolves_assets_like_the_tauri_protocol() {
let dir = dist();

// exact hits
assert_eq!(
resolve_asset(&dir, "app.js", false).as_deref(),
Some(b"console.log('app')" as &[u8])
);
// navigations keep the html fallbacks
assert_eq!(
resolve_asset(&dir, "about", true).as_deref(),
Some(b"<html>about</html>" as &[u8])
);
assert_eq!(
resolve_asset(&dir, "docs", true).as_deref(),
Some(b"<html>docs</html>" as &[u8])
);
assert_eq!(
resolve_asset(&dir, "route", true).as_deref(),
Some(b"<html>index</html>" as &[u8])
);
// missing subresources do not fall back
assert_eq!(
resolve_asset(&dir, "reports/2024.pdf", true).as_deref(),
Some(b"<html>index</html>" as &[u8])
);
assert_eq!(resolve_asset(&dir, "missing.js", false), None);
assert_eq!(resolve_asset(&dir, "assets/missing.png", false), None);

std::fs::remove_dir_all(dir).unwrap();
}
}
1 change: 1 addition & 0 deletions crates/tauri-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod io;
pub mod mime_type;
pub mod platform;
pub mod plugin;
pub mod request;
/// Prepare application resources and sidecars.
#[cfg(feature = "resources")]
pub mod resources;
Expand Down
99 changes: 99 additions & 0 deletions crates/tauri-utils/src/request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

//! Helpers to interpret requests served by Tauri.

use http::{header::ACCEPT, HeaderMap};

const SEC_FETCH_DEST: &str = "sec-fetch-dest";

/// Whether the request is a document navigation, i.e. the webview is loading a
/// page rather than a subresource of one.
///
/// A navigation may resolve to the SPA `index.html` fallback, so the frontend
/// router can react to any URL.
/// A subresource (script, style, image, font, `fetch`) must not, because
/// serving an HTML document in its place fails with a misleading error that
/// names neither the URL nor the cause.
///
/// Chromium based webviews send [`Sec-Fetch-Dest`], which answers this
/// directly.
/// WebKit does not send fetch metadata for custom protocols, but its `Accept`
/// header names `text/html` for navigations and never for subresources, which
/// send `*/*`, `image/...` or `text/css` instead.
/// When neither header is present nothing is assumed and the request counts as
/// a navigation, preserving the fallback.
///
/// [`Sec-Fetch-Dest`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest
pub fn is_navigation(headers: &HeaderMap) -> bool {
if let Some(destination) = headers.get(SEC_FETCH_DEST).and_then(|v| v.to_str().ok()) {
return matches!(
destination.trim().to_ascii_lowercase().as_str(),
"document" | "iframe" | "frame"
);
}

match headers.get(ACCEPT).and_then(|v| v.to_str().ok()) {
Some(accept) => accept.to_ascii_lowercase().contains("text/html"),
None => true,
}
}

#[cfg(test)]
mod tests {
use super::*;

fn headers(entries: &[(&str, &str)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (name, value) in entries {
headers.insert(
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
}
headers
}

#[test]
fn fetch_metadata_answers_directly() {
for destination in ["document", "iframe", "frame", "DOCUMENT"] {
assert!(is_navigation(&headers(&[("sec-fetch-dest", destination)])));
}
for destination in ["script", "style", "image", "font", "empty", "worker"] {
assert!(!is_navigation(&headers(&[("sec-fetch-dest", destination)])));
}
}

#[test]
fn fetch_metadata_wins_over_accept() {
assert!(!is_navigation(&headers(&[
("sec-fetch-dest", "script"),
("accept", "text/html,*/*"),
])));
}

#[test]
fn accept_distinguishes_webkit_requests() {
// values observed on WebKitGTK 2.52.3 for `tauri://` requests
assert!(is_navigation(&headers(&[(
"accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
)])));
assert!(!is_navigation(&headers(&[("accept", "*/*")])));
assert!(!is_navigation(&headers(&[(
"accept",
"image/webp,image/avif,image/jxl,video/*;q=0.8,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"
)])));
assert!(!is_navigation(&headers(&[(
"accept",
"text/css,*/*;q=0.1"
)])));
}

#[test]
fn without_evidence_the_fallback_is_kept() {
assert!(is_navigation(&headers(&[])));
assert!(is_navigation(&headers(&[("user-agent", "whatever")])));
}
}
3 changes: 2 additions & 1 deletion crates/tauri/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ impl<R: Runtime> AssetResolver<R> {
}
}

self.manager.get_asset(path, use_https_scheme).ok()
// there is no request to classify here, so the historical fallback applies
self.manager.get_asset(path, use_https_scheme, true).ok()
}

/// Iterate on all assets.
Expand Down
Loading
Loading