feat: add native desktop download handling (#3933)

This commit is contained in:
Carlos Florêncio
2026-09-25 18:07:55 +01:00
committed by GitHub
parent f099a46dc7
commit 7d0c4ec2f1
37 changed files with 2035 additions and 67 deletions
@@ -15,6 +15,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const backendRsPath = resolve(__dirname, "../src-tauri/src/backend.rs");
const mainRsPath = resolve(__dirname, "../src-tauri/src/main.rs");
const shellRsPath = resolve(__dirname, "../src-tauri/src/shell.rs");
const tauriConfigPath = resolve(__dirname, "../src-tauri/tauri.conf.json");
async function withTempDir(run) {
const dir = await mkdtemp(join(tmpdir(), "wait-for-file-"));
@@ -125,6 +126,19 @@ test("generic app activation never consumes a pending notification route", async
assert.match(mainSource, /RunEvent::Reopen[\s\S]*activate_main_window/);
});
test("main window registers download handling before its configured WebView is created", async () => {
const [mainSource, configSource] = await Promise.all([
readFile(mainRsPath, "utf8"),
readFile(tauriConfigPath, "utf8"),
]);
const mainWindow = JSON.parse(configSource).app.windows.find(({ label }) => label === "main");
assert.ok(mainWindow, "the configured main window must exist");
assert.equal(mainWindow.create, false, "Tauri must leave creation to the configured callback builder");
assert.match(mainSource, /WebviewWindowBuilder::from_config[\s\S]*?\.on_download\([\s\S]*?\)\s*\.build\(\)/);
assert.match(mainSource, /downloads::handle_download_event/);
});
test("fullscreen uses the platform-native desktop accelerators", async () => {
const mainSource = await readFile(mainRsPath, "utf8");
+4 -4
View File
@@ -2904,9 +2904,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
@@ -2966,9 +2966,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
+582
View File
@@ -0,0 +1,582 @@
use std::{
collections::HashMap,
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use url::Url;
#[derive(Clone, Default)]
pub struct DownloadTracker {
pending: Arc<Mutex<HashMap<String, PendingDownload>>>,
next_attempt: Arc<AtomicU64>,
}
#[derive(Clone)]
enum PendingDownload {
Selecting { attempt: u64 },
Selected { attempt: u64, destination: PathBuf },
Transferring { destination: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadCompletion {
pub destination: PathBuf,
pub success: bool,
}
impl DownloadTracker {
#[cfg(test)]
pub fn begin(&self, url: &Url, destination: PathBuf) -> bool {
if destination.as_os_str().is_empty() {
return false;
}
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
if pending.contains_key(url.as_str()) {
return false;
}
pending.insert(
url.as_str().to_string(),
PendingDownload::Transferring { destination },
);
true
}
pub fn begin_selection(&self, url: &Url) -> Option<u64> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
if pending.contains_key(url.as_str()) {
return None;
}
let attempt = self.next_attempt.fetch_add(1, Ordering::Relaxed);
pending.insert(
url.as_str().to_string(),
PendingDownload::Selecting { attempt },
);
Some(attempt)
}
pub fn select_destination(&self, url: &Url, attempt: u64, destination: PathBuf) -> bool {
if destination.as_os_str().is_empty() {
return false;
}
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let Some(download) = pending.get_mut(url.as_str()) else {
return false;
};
if !matches!(download, PendingDownload::Selecting { attempt: current } if *current == attempt)
{
return false;
}
*download = PendingDownload::Selected {
attempt,
destination,
};
true
}
pub fn cancel_selection(&self, url: &Url, attempt: u64) -> bool {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let is_attempt = matches!(
pending.get(url.as_str()),
Some(PendingDownload::Selecting { attempt: current }) if *current == attempt
);
if is_attempt {
pending.remove(url.as_str());
}
is_attempt
}
pub fn expire_selection(&self, url: &Url, attempt: u64) -> bool {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let is_attempt = matches!(
pending.get(url.as_str()),
Some(PendingDownload::Selecting { attempt: current })
| Some(PendingDownload::Selected { attempt: current, .. })
if *current == attempt
);
if is_attempt {
pending.remove(url.as_str());
}
is_attempt
}
pub fn start_selected_transfer(&self, url: &Url) -> Option<PathBuf> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let download = pending.get_mut(url.as_str())?;
let PendingDownload::Selected { destination, .. } = download else {
return None;
};
let destination = destination.clone();
*download = PendingDownload::Transferring {
destination: destination.clone(),
};
Some(destination)
}
pub fn finish(&self, url: &Url, success: bool) -> Option<DownloadCompletion> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let destination = match pending.get(url.as_str())? {
PendingDownload::Transferring { destination } => destination.clone(),
_ => return None,
};
pending.remove(url.as_str());
Some(DownloadCompletion {
destination,
success,
})
}
#[cfg(test)]
fn pending_count(&self) -> usize {
self.pending
.lock()
.expect("download tracker mutex poisoned")
.len()
}
}
fn is_owned_download(
backend: &crate::backend::BackendState,
page_url: &Url,
download_url: &Url,
) -> bool {
backend.accepts_url(page_url.as_str()) && backend.accepts_url(download_url.as_str())
}
fn safe_suggested_filename(suggested_path: &std::path::Path) -> Option<String> {
let suggestion = suggested_path.to_string_lossy();
let name = suggestion.rsplit(['/', '\\']).next()?;
if name.is_empty() {
return None;
}
let cleaned: String = name
.chars()
.take(180)
.map(|character| {
if character.is_control() || "<>:\"|?*".contains(character) {
'_'
} else {
character
}
})
.collect();
let cleaned = cleaned.trim().trim_end_matches('.');
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
None
} else {
Some(cleaned.to_string())
}
}
#[cfg(feature = "desktop-runtime")]
mod runtime {
use super::{is_owned_download, safe_suggested_filename, DownloadTracker};
use crate::backend::BackendState;
use serde::Serialize;
use std::time::Duration;
use tauri::{webview::DownloadEvent, AppHandle, Emitter, Manager, WebviewWindow};
use tauri_plugin_dialog::DialogExt;
use url::Url;
const MAIN_WINDOW_LABEL: &str = "main";
const DOWNLOAD_FEEDBACK_EVENT: &str = "kandev-desktop-v1-download";
const DOWNLOAD_READY_EVENT: &str = "kandev-desktop-v1-download-ready";
const SELECTION_TIMEOUT: Duration = Duration::from_secs(30 * 60);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
enum DownloadStatus {
Selecting,
Started,
Saved,
Cancelled,
Failed,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadFeedback {
status: DownloadStatus,
url: String,
file_name: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadReady {
url: String,
file_name: String,
}
fn emit_feedback(window: &WebviewWindow, url: &Url, file_name: &str, status: DownloadStatus) {
let _ = window.emit(
DOWNLOAD_FEEDBACK_EVENT,
DownloadFeedback {
status,
url: url.as_str().to_string(),
file_name: file_name.to_string(),
},
);
}
fn emit_download_ready(window: &WebviewWindow, url: &Url, file_name: &str) {
let _ = window.emit(
DOWNLOAD_READY_EVENT,
DownloadReady {
url: url.as_str().to_string(),
file_name: file_name.to_string(),
},
);
}
pub fn handle_download_event(
app: &AppHandle,
backend: &BackendState,
tracker: &DownloadTracker,
event: DownloadEvent<'_>,
) -> bool {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return false;
};
match event {
DownloadEvent::Requested { url, destination } => {
let Ok(page_url) = window.url() else {
return false;
};
if !backend.accepts_url(page_url.as_str()) {
return false;
}
let Some(file_name) = safe_suggested_filename(destination) else {
emit_feedback(&window, &url, "download", DownloadStatus::Failed);
return false;
};
if backend.require_owned_origin(&window).is_err() {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
}
if !is_owned_download(backend, &page_url, &url) {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
}
if let Some(selected_path) = tracker.start_selected_transfer(&url) {
*destination = selected_path;
emit_feedback(&window, &url, &file_name, DownloadStatus::Started);
return true;
}
let Some(attempt) = tracker.begin_selection(&url) else {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
};
emit_feedback(&window, &url, &file_name, DownloadStatus::Selecting);
let callback_window = window.clone();
let callback_tracker = tracker.clone();
let callback_url = url.clone();
let callback_file_name = file_name.clone();
let timeout_tracker = tracker.clone();
let timeout_url = url.clone();
let timeout_window = window.clone();
let timeout_file_name = file_name.clone();
std::thread::spawn(move || {
std::thread::sleep(SELECTION_TIMEOUT);
if timeout_tracker.expire_selection(&timeout_url, attempt) {
emit_feedback(
&timeout_window,
&timeout_url,
&timeout_file_name,
DownloadStatus::Failed,
);
}
});
app.dialog()
.file()
.set_file_name(&file_name)
.set_parent(&window)
.save_file(move |selected| {
let Some(selected) = selected else {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Cancelled,
);
return;
};
let selected_path = match selected.into_path() {
Ok(path) => path,
Err(_) => {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Failed,
);
return;
}
};
if callback_tracker.select_destination(
&callback_url,
attempt,
selected_path,
) {
emit_download_ready(
&callback_window,
&callback_url,
&callback_file_name,
);
} else {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Failed,
);
}
});
false
}
DownloadEvent::Finished { url, success, .. } => {
if let Some(completion) = tracker.finish(&url, success) {
let file_name = completion
.destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("download");
let status = if completion.success {
DownloadStatus::Saved
} else {
DownloadStatus::Failed
};
if window
.url()
.is_ok_and(|page_url| backend.accepts_url(page_url.as_str()))
{
emit_feedback(&window, &url, file_name, status);
}
}
true
}
_ => true,
}
}
}
#[cfg(feature = "desktop-runtime")]
pub use runtime::handle_download_event;
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::BackendState;
use std::path::PathBuf;
use url::Url;
const OWNED_ORIGIN: &str = "http://127.0.0.1:38430";
fn owned_backend() -> BackendState {
let backend = BackendState::default();
backend.set_owned_origin(OWNED_ORIGIN).unwrap();
backend
}
#[test]
fn accepts_owned_http_and_blob_downloads_from_the_owned_page() {
let page = Url::parse("http://127.0.0.1:38430/settings/system/logs").unwrap();
let http = Url::parse("http://127.0.0.1:38430/api/logs.zip").unwrap();
let blob = Url::parse("blob:http://127.0.0.1:38430/1234-5678").unwrap();
let backend = owned_backend();
assert!(is_owned_download(&backend, &page, &http));
assert!(is_owned_download(&backend, &page, &blob));
}
#[test]
fn rejects_untrusted_pages_and_download_origins() {
let owned_page = Url::parse("http://127.0.0.1:38430/settings").unwrap();
let remote_page = Url::parse("https://example.com").unwrap();
let owned_download = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let remote_download = Url::parse("https://example.com/export.zip").unwrap();
let remote_blob = Url::parse("blob:https://example.com/1234").unwrap();
let backend = owned_backend();
assert!(!is_owned_download(&backend, &remote_page, &owned_download));
assert!(!is_owned_download(&backend, &owned_page, &remote_download));
assert!(!is_owned_download(&backend, &owned_page, &remote_blob));
}
#[test]
fn sanitizes_a_suggested_name_without_accepting_path_components() {
assert_eq!(
safe_suggested_filename(&PathBuf::from(r"C:\private\..\logs.zip")),
Some("logs.zip".to_string())
);
assert_eq!(
safe_suggested_filename(&PathBuf::from("/tmp/diagnostic\nlogs.zip")),
Some("diagnostic_logs.zip".to_string())
);
assert_eq!(safe_suggested_filename(&PathBuf::from("/tmp/..")), None);
}
#[test]
fn completion_uses_the_selected_destination_when_the_engine_omits_its_path() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let selected = PathBuf::from("/Users/example/Documents/export.zip");
assert!(tracker.begin(&url, selected.clone()));
assert_eq!(
tracker.finish(&url, true),
Some(DownloadCompletion {
destination: selected,
success: true,
})
);
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn failed_completion_releases_the_url_for_a_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let selected = PathBuf::from("/Users/example/Documents/export.zip");
assert!(tracker.begin(&url, selected.clone()));
assert_eq!(
tracker.finish(&url, false),
Some(DownloadCompletion {
destination: selected.clone(),
success: false,
})
);
assert!(tracker.begin(&url, selected));
}
#[test]
fn rejects_a_transfer_without_a_selected_destination() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
assert!(!tracker.begin(&url, PathBuf::new()));
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn rejects_an_empty_save_dialog_destination() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let attempt = tracker.begin_selection(&url).unwrap();
assert!(!tracker.select_destination(&url, attempt, PathBuf::new()));
assert!(tracker.cancel_selection(&url, attempt));
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn native_download_hook_uses_the_nonblocking_save_dialog_api() {
let source = include_str!("downloads.rs")
.split("#[cfg(test)]\nmod tests")
.next()
.unwrap();
assert!(source.contains(".save_file("));
assert!(!source.contains(".blocking_save_file("));
}
#[test]
fn selected_destination_is_used_for_the_retried_request_once() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let destination = PathBuf::from("/tmp/export.zip");
let attempt = tracker.begin_selection(&url).unwrap();
assert!(tracker.select_destination(&url, attempt, destination.clone()));
assert_eq!(
tracker.start_selected_transfer(&url),
Some(destination.clone())
);
assert_eq!(tracker.start_selected_transfer(&url), None);
assert_eq!(tracker.finish(&url, true).unwrap().destination, destination);
}
#[test]
fn cancelled_and_expired_selections_release_the_url_for_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let cancelled = tracker.begin_selection(&url).unwrap();
assert!(tracker.cancel_selection(&url, cancelled));
let expired = tracker.begin_selection(&url).unwrap();
assert!(tracker.select_destination(&url, expired, PathBuf::from("/tmp/export.zip")));
assert!(tracker.expire_selection(&url, expired));
assert_eq!(tracker.pending_count(), 0);
assert!(tracker.begin_selection(&url).is_some());
}
#[test]
fn an_old_selection_timeout_cannot_remove_a_newer_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let expired = tracker.begin_selection(&url).unwrap();
assert!(tracker.cancel_selection(&url, expired));
let retry = tracker.begin_selection(&url).unwrap();
assert!(!tracker.expire_selection(&url, expired));
assert!(tracker.select_destination(&url, retry, PathBuf::from("/tmp/retry.zip")));
}
#[test]
fn concurrent_requests_for_the_same_url_cannot_mix_destinations() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
assert!(tracker.begin(&url, PathBuf::from("/tmp/first.zip")));
assert!(!tracker.begin(&url, PathBuf::from("/tmp/second.zip")));
assert_eq!(
tracker.finish(&url, true).unwrap().destination,
PathBuf::from("/tmp/first.zip")
);
assert!(tracker.begin(&url, PathBuf::from("/tmp/second.zip")));
}
#[test]
fn simultaneous_http_and_blob_completions_keep_their_selected_destinations() {
let tracker = DownloadTracker::default();
let http = Url::parse("http://127.0.0.1:38430/logs.zip").unwrap();
let blob = Url::parse("blob:http://127.0.0.1:38430/1234-5678").unwrap();
let http_path = PathBuf::from("/tmp/logs.zip");
let blob_path = PathBuf::from("/tmp/chart.svg");
assert!(tracker.begin(&http, http_path.clone()));
assert!(tracker.begin(&blob, blob_path.clone()));
assert_eq!(tracker.finish(&blob, true).unwrap().destination, blob_path);
assert_eq!(tracker.finish(&http, true).unwrap().destination, http_path);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod backend;
pub mod downloads;
pub mod external_links;
pub mod folder_picker;
pub mod native_notifications;
+25 -4
View File
@@ -1,5 +1,5 @@
use kandev_desktop::{
backend, external_links, folder_picker,
backend, downloads, external_links, folder_picker,
native_notifications::{self, NativeNotificationState},
shell::{self, MenuAction, ZoomState},
updater::{self, UpdaterState},
@@ -8,6 +8,7 @@ use kandev_desktop::{
use std::thread;
use tauri::{
menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu},
webview::WebviewWindowBuilder,
Emitter, Manager, RunEvent, WindowEvent,
};
@@ -48,9 +49,29 @@ fn main() {
.menu(build_menu)
.on_menu_event(handle_menu_event)
.setup(|app| {
let window = app
.get_webview_window(MAIN_WINDOW_LABEL)
.expect("main window should exist");
let window_config = app
.config()
.app
.windows
.iter()
.find(|config| config.label == MAIN_WINDOW_LABEL)
.cloned()
.expect("main window config should exist");
let backend_state = app.state::<backend::BackendState>().inner().clone();
let download_tracker = downloads::DownloadTracker::default();
app.manage(download_tracker.clone());
let download_app = app.handle().clone();
let download_tracker_for_handler = download_tracker.clone();
let window = WebviewWindowBuilder::from_config(app, &window_config)?
.on_download(move |_webview, event| {
downloads::handle_download_event(
&download_app,
&backend_state,
&download_tracker_for_handler,
event,
)
})
.build()?;
let state_path = app.path().app_data_dir()?.join(WINDOW_STATE_FILE);
let window_state = WindowStateStore::new(state_path);
if let Err(err) = window_state.restore(&window) {
+1
View File
@@ -13,6 +13,7 @@
"windows": [
{
"label": "main",
"create": false,
"title": "Kandev",
"width": 1280,
"height": 900,