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
4 changes: 2 additions & 2 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ mcp-bin = []
# tauri's eyes — with a tauri child attached, `is_webview_window()` turns
# false, `get_webview_window("main")` returns None and every command taking a
# `tauri::WebviewWindow` argument fails — and it needs no `unstable` feature.
# Only macOS and Windows compile the child surface; Linux uses owned windows
# (wry's child build is X11-only and cannot be positioned on Wayland).
# Linux builds into a GTK Fixed overlay instead of build_as_child, supporting
# both X11 and Wayland while preserving the workspace's WebviewWindow identity.
browser-child = ["dep:tauri-runtime-wry", "dep:webview2-com", "dep:windows"]
# Dev-only puppet driven by a JSON control file (see src/browser/smoke.rs).
browser-smoke = []
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/src/browser/doc_guest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ pub fn doc_label(tab_id: &str) -> String {
}

/// Whether this build can host document guests: the embedded surface with a
/// per-webview scheme handler. Linux has no embedded surface, and the inline
/// preview stays in place there.
/// per-webview scheme handler. Linux has the surface but not the handler: its
/// views are built related to a view of another context, so a scheme wry's
/// builder registers lands on a context they do not use. The inline preview
/// stays in place there.
pub fn supported() -> bool {
cfg!(all(
feature = "browser-child",
Expand Down
113 changes: 104 additions & 9 deletions src-tauri/src/browser/downloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,42 @@ impl BrowserDownloads {
}
}

/// Finish the oldest in-flight download of `url` (the only identity the
/// completion callback carries) and return the updated record.
/// Finish an in-flight download of `url` and return the updated record:
/// the one written to `path` when the engine names the file (Windows
/// reports where it landed, Linux the destination it was given), else the
/// oldest one still running. The address is all macOS's completion
/// carries, and two transfers of one address can end in either order.
fn finish(&self, url: &str, success: bool, path: Option<PathBuf>) -> Option<BrowserDownload> {
let mut entries = self.lock();
let entry = entries
.iter_mut()
.find(|d| d.url == url && d.state == DownloadState::Started)?;
let running = |d: &BrowserDownload| d.url == url && d.state == DownloadState::Started;
let path = path.filter(|path| !path.as_os_str().is_empty());
let index = path
.as_deref()
.and_then(|path| {
entries
.iter()
.position(|d| running(d) && Path::new(&d.path) == path)
})
.or_else(|| entries.iter().position(running))?;
let entry = &mut entries[index];
entry.state = if success {
DownloadState::Completed
} else {
DownloadState::Failed
};
// Windows / Linux report where the file actually landed; macOS does
// not, and keeps the path chosen when the download was requested.
// What this transfer was holding, whatever the record says next.
let reserved = PathBuf::from(&entry.path);
// A file that landed somewhere else than it was told is followed
// there; macOS reports no path and keeps the one chosen at request.
if let Some(path) = path {
if success && !path.as_os_str().is_empty() {
if success {
entry.file_name = file_name_of(&path);
entry.path = path.to_string_lossy().to_string();
}
}
let done = entry.clone();
drop(entries);
self.reserved().remove(Path::new(&done.path));
self.reserved().remove(&reserved);
Some(done)
}

Expand Down Expand Up @@ -309,6 +322,23 @@ pub fn requested(app: &AppHandle, tab_id: &str, url: &str, destination: &mut Pat
true
}

/// One source binding per native view. The context resolves the source at
/// download start, then retains these callbacks only for that transfer.
#[cfg(target_os = "linux")]
pub fn linux_handlers(app: &AppHandle, tab_id: &str) -> super::shim::linux::DownloadHandlers {
let started_app = app.clone();
let tab_id = tab_id.to_string();
let finished_app = app.clone();
super::shim::linux::DownloadHandlers {
started: std::sync::Arc::new(move |url, path| {
requested(&started_app, &tab_id, url, path)
}),
finished: std::sync::Arc::new(move |url, destination, success| {
finished(&finished_app, url, Some(destination.to_path_buf()), success)
}),
}
}

/// The engine finished (or gave up on) a download.
pub fn finished(app: &AppHandle, url: &str, path: Option<PathBuf>, success: bool) {
let Some(downloads) = app.try_state::<BrowserDownloads>() else {
Expand Down Expand Up @@ -494,6 +524,71 @@ mod tests {
assert!(downloads.list().is_empty());
}

/// Two transfers of one address end in either order. Where the engine
/// says which file it was writing (Windows reports the path, Linux knows
/// the destination it was given), that picks the record — and the
/// reservation released is that transfer's own. Only an address alone
/// (macOS) falls back to the oldest one still running.
#[test]
fn a_reported_path_finishes_its_own_download() {
const URL: &str = "https://example.com/report.pdf";
let dir = tempfile::tempdir().unwrap();
let downloads = BrowserDownloads::default();
let first = downloads.reserve(dir.path(), "report.pdf");
let second = downloads.reserve(dir.path(), "report.pdf");
for (id, path) in [("dl-1", &first), ("dl-2", &second)] {
downloads.push(BrowserDownload {
id: id.into(),
tab_id: "t1".into(),
url: URL.into(),
file_name: file_name_of(path),
path: path.to_string_lossy().to_string(),
state: DownloadState::Started,
});
}

// The second one fails while the first is still running.
let done = downloads.finish(URL, false, Some(second.clone())).unwrap();
assert_eq!(done.id, "dl-2");
assert_eq!(done.state, DownloadState::Failed);
let first_record = downloads
.list()
.into_iter()
.find(|d| d.id == "dl-1")
.unwrap();
assert_eq!(first_record.state, DownloadState::Started);
// `report.pdf` is still being written; only `report (1).pdf` is free.
assert_eq!(downloads.reserve(dir.path(), "report.pdf"), second);

let done = downloads.finish(URL, true, Some(first.clone())).unwrap();
assert_eq!(done.id, "dl-1");
assert_eq!(done.state, DownloadState::Completed);
}

/// A file the engine wrote somewhere else than it was told: the record
/// follows it, and the name it had reserved is free again rather than
/// held for the rest of the session.
#[test]
fn a_file_that_landed_elsewhere_releases_its_reservation() {
const URL: &str = "https://example.com/report.pdf";
let dir = tempfile::tempdir().unwrap();
let downloads = BrowserDownloads::default();
let reserved = downloads.reserve(dir.path(), "report.pdf");
downloads.push(BrowserDownload {
id: "dl-1".into(),
tab_id: "t1".into(),
url: URL.into(),
file_name: file_name_of(&reserved),
path: reserved.to_string_lossy().to_string(),
state: DownloadState::Started,
});
let landed = dir.path().join("report-final.pdf");
let done = downloads.finish(URL, true, Some(landed.clone())).unwrap();
assert_eq!(done.path, landed.to_string_lossy());
assert_eq!(done.file_name, "report-final.pdf");
assert_eq!(downloads.reserve(dir.path(), "report.pdf"), reserved);
}

#[test]
fn windows_device_names_cannot_survive() {
assert_eq!(safe_file_name("CON"), "_CON");
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! next to the chat: on macOS / Windows a wry child webview embedded in the
//! workspace window at the bounds of a placeholder element (see Cargo.toml's
//! `browser-child` note for why wry is driven directly rather than through
//! tauri's `add_child`), on Linux (and as
//! the fallback everywhere) an owned top-level window. The frontend only ever
//! tauri's `add_child`), on Linux a GTK overlay within that same window.
//! An owned top-level window remains an explicit fallback. The frontend only ever
//! talks to it through the `browser_*` commands and the `browser://*` events;
//! the page itself has no Tauri IPC at all — `browser-*` labels are absent from
//! every capability on purpose, so a page script cannot reach any command.
Expand Down Expand Up @@ -98,7 +98,7 @@ pub mod surface;
#[cfg(all(
feature = "browser-child",
feature = "tauri-runtime",
any(target_os = "macos", target_os = "windows")
any(target_os = "macos", target_os = "windows", target_os = "linux")
))]
pub mod surface_child;
#[cfg(feature = "tauri-runtime")]
Expand Down
27 changes: 26 additions & 1 deletion src-tauri/src/browser/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use tauri::Url;
/// local filesystem.
/// How far back a page-initiated new-window request may look for a user
/// gesture before it counts as an unsolicited popup. Both surfaces ask it:
/// the embedded one on macOS and Windows, the owned window on Linux.
/// embedded tabs on all desktop platforms and the owned-window fallback.
pub const POPUP_GESTURE_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);

pub fn navigation_allowed(url: &Url) -> bool {
Expand Down Expand Up @@ -46,6 +46,13 @@ pub fn subframe_navigation_allowed(url: &Url) -> bool {
}
}

/// Called only for a refused scheme. WebKitGTK also reports subframes but
/// does not identify them, so keep their wider schemes quiet without allowing
/// those schemes as top-level navigations.
pub fn report_scheme_denial(url: &Url, main_frame: bool) -> bool {
main_frame && !(cfg!(target_os = "linux") && subframe_navigation_allowed(url))
}

/// The initial URL handed to `browser_open_tab` must already be a web page;
/// `about:blank` is accepted so an empty tab can be opened explicitly.
pub fn open_url_allowed(url: &Url) -> bool {
Expand Down Expand Up @@ -521,6 +528,24 @@ mod tests {
}
}

/// A refused scheme is announced only for the page's own navigation. On
/// Linux every frame is asked as if it were the top one, so a scheme a
/// frame may hold is refused quietly there: an iframe a page composes
/// must not put a "blocked" notice over the page. A scheme no frame may
/// hold is still announced.
#[test]
fn refused_schemes_are_announced_for_the_page_alone() {
let frame_scheme = u("data:text/html,<b>x</b>");
let local = u("file:///etc/passwd");
assert!(!report_scheme_denial(&frame_scheme, false));
assert!(!report_scheme_denial(&local, false));
assert!(report_scheme_denial(&local, true));
assert_eq!(
report_scheme_denial(&frame_scheme, true),
!cfg!(target_os = "linux")
);
}

#[test]
fn open_url_is_stricter_than_navigation() {
assert!(open_url_allowed(&u("https://example.com")));
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/browser/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,8 @@ enum ProbeSurface {
#[cfg(all(target_os = "windows", feature = "browser-child"))]
Child(crate::browser::surface_child::ChildHandle),
/// Linux, and Windows without the embedded surface: a window that is
/// never shown, as the profile's tabs are owned windows there.
/// never shown, in the profile's own context (on Linux the one its
/// embedded tabs share too).
#[cfg(not(any(target_os = "macos", all(target_os = "windows", feature = "browser-child"))))]
Window(tauri::WebviewWindow),
}
Expand Down
Loading