From 7d0c4ec2f19ddd27c401d474544988753774ce89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 25 Sep 2026 18:07:55 +0100 Subject: [PATCH] feat: add native desktop download handling (#3933) --- .../desktop/e2e/desktop-launch-smoke.test.mjs | 14 + apps/desktop/src-tauri/Cargo.lock | 8 +- apps/desktop/src-tauri/src/downloads.rs | 582 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/main.rs | 29 +- apps/desktop/src-tauri/tauri.conf.json | 1 + .../[id]/components/agent-memory-tab.tsx | 8 +- .../office/workspace/org/org-chart-canvas.tsx | 10 +- .../settings/export/export-preview.tsx | 8 +- apps/web/components/desktop-command-host.tsx | 47 ++ .../settings/system/log-viewer.test.tsx | 212 ++++++- .../components/settings/system/log-viewer.tsx | 204 +++++- .../tests/system/mobile-logs-bundle.spec.ts | 16 + .../web/lib/desktop/download-feedback.test.ts | 62 ++ apps/web/lib/desktop/download-feedback.ts | 28 + apps/web/lib/desktop/download-ready.test.ts | 36 ++ apps/web/lib/desktop/download-ready.ts | 9 + .../lib/desktop/external-links-usage.test.ts | 6 +- apps/web/lib/desktop/protocol.ts | 15 + apps/web/lib/utils/file-download.test.ts | 102 +++ apps/web/lib/utils/file-download.ts | 21 +- apps/web/lib/utils/native-blob-download.ts | 39 ++ apps/web/src/locales/en/settings.json | 6 + apps/web/src/locales/ja/settings.json | 6 + apps/web/src/locales/pseudo/settings.json | 6 + apps/web/src/locales/pt-pt/settings.json | 6 + apps/web/src/locales/zh-cn/settings.json | 6 + apps/web/src/locales/zh-hk/settings.json | 6 + apps/web/src/locales/zh-tw/settings.json | 6 + .../2026-09-25-native-desktop-downloads.md | 38 ++ docs/plans/native-desktop-downloads/plan.md | 137 +++++ .../task-01-desktop-save-handler.md | 114 ++++ .../task-02-export-feedback.md | 129 ++++ docs/public/desktop-app.md | 16 +- .../desktop/requirements/native-downloads.md | 51 ++ .../system-design/desktop-tauri-app.md | 10 +- .../desktop/system-design/native-downloads.md | 107 ++++ 37 files changed, 2035 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src-tauri/src/downloads.rs create mode 100644 apps/web/lib/desktop/download-feedback.test.ts create mode 100644 apps/web/lib/desktop/download-feedback.ts create mode 100644 apps/web/lib/desktop/download-ready.test.ts create mode 100644 apps/web/lib/desktop/download-ready.ts create mode 100644 apps/web/lib/utils/native-blob-download.ts create mode 100644 docs/decisions/2026-09-25-native-desktop-downloads.md create mode 100644 docs/plans/native-desktop-downloads/plan.md create mode 100644 docs/plans/native-desktop-downloads/task-01-desktop-save-handler.md create mode 100644 docs/plans/native-desktop-downloads/task-02-export-feedback.md create mode 100644 docs/specs/desktop/requirements/native-downloads.md create mode 100644 docs/specs/desktop/system-design/native-downloads.md diff --git a/apps/desktop/e2e/desktop-launch-smoke.test.mjs b/apps/desktop/e2e/desktop-launch-smoke.test.mjs index 2df7f9cce8..865e7ac65d 100644 --- a/apps/desktop/e2e/desktop-launch-smoke.test.mjs +++ b/apps/desktop/e2e/desktop-launch-smoke.test.mjs @@ -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"); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 948ffee10a..1747f725c9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -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", diff --git a/apps/desktop/src-tauri/src/downloads.rs b/apps/desktop/src-tauri/src/downloads.rs new file mode 100644 index 0000000000..bac6a67ec8 --- /dev/null +++ b/apps/desktop/src-tauri/src/downloads.rs @@ -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>>, + next_attempt: Arc, +} + +#[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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e1384d7d58..aaaaae7cd5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod backend; +pub mod downloads; pub mod external_links; pub mod folder_picker; pub mod native_notifications; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 7b36443fd7..67746a736d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -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::().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) { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9062f779c8..f8ba5aee68 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -13,6 +13,7 @@ "windows": [ { "label": "main", + "create": false, "title": "Kandev", "width": 1280, "height": 900, diff --git a/apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx index edfd1c5602..30cd6f2161 100644 --- a/apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { toast } from "@/lib/toast/sonner"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import * as officeApi from "@/lib/api/domains/office-api"; +import { triggerBlobDownload } from "@/lib/utils/file-download"; import { useTranslation } from "react-i18next"; type MemoryEntry = { @@ -79,12 +80,7 @@ function useMemoryActions(agent: AgentProfile) { const res = await officeApi.exportMemory(agent.id); const data = (res as { memory?: MemoryEntry[] }).memory ?? []; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${agent.name}-memory.json`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(blob, `${agent.name}-memory.json`); }, [agent.id, agent.name]); return { entries, handleDelete, handleClearAll, handleExport }; diff --git a/apps/web/app/office/workspace/org/org-chart-canvas.tsx b/apps/web/app/office/workspace/org/org-chart-canvas.tsx index fd2fbd035b..62ba463115 100644 --- a/apps/web/app/office/workspace/org/org-chart-canvas.tsx +++ b/apps/web/app/office/workspace/org/org-chart-canvas.tsx @@ -13,6 +13,7 @@ import { CARD_H, } from "./org-tree-layout"; import { useTranslation } from "react-i18next"; +import { triggerBlobDownload } from "@/lib/utils/file-download"; type OrgChartCanvasProps = { agents: AgentProfile[]; @@ -39,14 +40,7 @@ function exportSvg(svg: SVGSVGElement) { const serializer = new XMLSerializer(); const svgStr = serializer.serializeToString(svg); const blob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "org-chart.svg"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + triggerBlobDownload(blob, "org-chart.svg"); } export function OrgChartCanvas({ agents }: OrgChartCanvasProps) { diff --git a/apps/web/app/office/workspace/settings/export/export-preview.tsx b/apps/web/app/office/workspace/settings/export/export-preview.tsx index 5786688568..5796dcbf7e 100644 --- a/apps/web/app/office/workspace/settings/export/export-preview.tsx +++ b/apps/web/app/office/workspace/settings/export/export-preview.tsx @@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; import { ApiError } from "@/lib/api/client"; import * as officeApi from "@/lib/api/domains/office-api"; +import { triggerBlobDownload } from "@/lib/utils/file-download"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { ExportFileTree } from "./export-file-tree"; import { ExportFilePreview } from "./export-file-preview"; @@ -270,12 +271,7 @@ export function ExportPreview() { ) { return; } - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = "kandev-config-selected.zip"; - anchor.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); + triggerBlobDownload(blob, "kandev-config-selected.zip"); } catch (error) { setDownloadErrorKey( error instanceof ApiError && error.status === 409 diff --git a/apps/web/components/desktop-command-host.tsx b/apps/web/components/desktop-command-host.tsx index 9d44d8cf10..55f1cf3b72 100644 --- a/apps/web/components/desktop-command-host.tsx +++ b/apps/web/components/desktop-command-host.tsx @@ -3,6 +3,8 @@ import { useEffect, useMemo } from "react"; import { useRouter } from "@/lib/routing/client-router"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useTranslation } from "react-i18next"; +import { toast } from "@/lib/toast/sonner"; import { createDesktopV1Adapter, type DesktopV1Adapter } from "@/lib/desktop/adapter"; import { createDesktopCommandActions, @@ -16,6 +18,9 @@ import { } from "@/lib/desktop/external-links"; import { requestNewTaskCreation } from "@/lib/desktop/new-task-request"; import { createTauriEventTransport } from "@/lib/desktop/tauri-event-transport"; +import { listenForDesktopDownloadReady } from "@/lib/desktop/download-ready"; +import { desktopDownloadToastStatus } from "@/lib/desktop/download-feedback"; +import { completeNativeBlobDownload } from "@/lib/utils/native-blob-download"; import { desktopUpdater } from "@/lib/desktop/updater-client"; import type { DesktopUpdaterAdapter } from "@/lib/desktop/updater-adapter"; @@ -31,6 +36,7 @@ export function DesktopCommandHost({ updater?: DesktopUpdaterAdapter; }) { const router = useRouter(); + const { t } = useTranslation(); const actions = useMemo( () => createDesktopCommandActions({ @@ -80,6 +86,47 @@ export function DesktopCommandHost({ }; }, [adapter, router, updater]); + useEffect(() => { + if (!adapter.isAvailable()) return; + let disposed = false; + let stop: (() => void) | undefined; + void adapter + .listen("download", (feedback) => { + completeNativeBlobDownload(feedback); + const status = desktopDownloadToastStatus(feedback); + if (status === "saved") toast.success(t("settings:desktopDownloadSaved")); + if (status === "failed") toast.error(t("settings:desktopDownloadFailed")); + }) + .then( + (unlisten) => { + if (disposed) unlisten(); + else stop = unlisten; + }, + () => undefined, + ); + return () => { + disposed = true; + stop?.(); + }; + }, [adapter, t]); + + useEffect(() => { + if (!adapter.isAvailable()) return; + let disposed = false; + let stop: (() => void) | undefined; + void listenForDesktopDownloadReady(adapter).then( + (unlisten) => { + if (disposed) unlisten(); + else stop = unlisten; + }, + () => undefined, + ); + return () => { + disposed = true; + stop?.(); + }; + }, [adapter]); + useEffect(() => subscribeDesktopExternalLinks(document, externalLinks), [externalLinks]); return null; diff --git a/apps/web/components/settings/system/log-viewer.test.tsx b/apps/web/components/settings/system/log-viewer.test.tsx index 062a851e0e..803f0d8f8c 100644 --- a/apps/web/components/settings/system/log-viewer.test.tsx +++ b/apps/web/components/settings/system/log-viewer.test.tsx @@ -1,15 +1,26 @@ import { StrictMode } from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TauriEventInternals } from "@/lib/desktop/tauri-event-transport"; +import type { DesktopDownloadFeedback } from "@/lib/desktop/protocol"; import { LogViewer } from "./log-viewer"; const createMock = vi.fn(); const fetchMock = vi.fn(); const capabilitiesMock = vi.fn(); const sessionsMock = vi.fn(); -const downloadURLMock = vi.fn((..._args: unknown[]) => "/download/bundle-1"); +const DOWNLOAD_URL = "/download/bundle-1"; +const LISTEN_COMMAND_SUFFIX = "|listen"; +const DIAGNOSTIC_SAVE_PENDING = "Choose where to save the diagnostic ZIP."; +const DIAGNOSTIC_SAVE_FAILED = "The diagnostic ZIP could not be saved. Try again."; +const DIAGNOSTIC_BUNDLE_FILE_NAME = "kandev-diagnostic-logs.zip"; +const CUSTOMIZE_BUNDLE_TEST_ID = "customize-diagnostic-bundle"; +const CREATE_BUNDLE_TEST_ID = "create-custom-diagnostic-bundle"; +const BUNDLE_STATUS_TEST_ID = "diagnostic-bundle-status"; +const downloadURLMock = vi.fn((..._args: unknown[]) => DOWNLOAD_URL); const CHECKED_STATE = "checked"; const DATA_STATE_ATTRIBUTE = "data-state"; +let originalTauriInternals: PropertyDescriptor | undefined; vi.mock("@/lib/api/domains/system-api", () => ({ createDiagnosticBundle: (...args: unknown[]) => createMock(...args), @@ -20,6 +31,7 @@ vi.mock("@/lib/api/domains/system-api", () => ({ })); beforeEach(() => { + originalTauriInternals = Object.getOwnPropertyDescriptor(window, "__TAURI_INTERNALS__"); createMock.mockReset(); fetchMock.mockReset(); capabilitiesMock.mockReset(); @@ -36,10 +48,194 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.useRealTimers(); + if (originalTauriInternals) { + Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals); + } else { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + } vi.restoreAllMocks(); }); -describe("LogViewer", () => { +function installDesktopEvents({ failListener = false, failListenerAttempts = 0 } = {}) { + let onNativeEvent: ((event: { payload: unknown }) => void) | undefined; + let failedListenerAttempts = 0; + let listenerAttempts = 0; + const internals: TauriEventInternals = { + invoke: vi.fn(async (command: string) => { + if (command.endsWith(LISTEN_COMMAND_SUFFIX)) { + listenerAttempts += 1; + if (failListener || failedListenerAttempts < failListenerAttempts) { + failedListenerAttempts += 1; + throw new Error("listener failed"); + } + } + return command.endsWith(LISTEN_COMMAND_SUFFIX) ? 41 : undefined; + }), + transformCallback: vi.fn((callback) => { + onNativeEvent = callback; + return 7; + }), + }; + Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: internals }); + return { + internals, + emit: (payload: unknown) => onNativeEvent?.({ payload }), + listenerAttempts: () => listenerAttempts, + }; +} + +async function mountDesktopLogViewer(options?: { + failListener?: boolean; + failListenerAttempts?: number; +}) { + createMock.mockResolvedValue({ id: "bundle-1", status: "ready", warnings: [] }); + const desktop = installDesktopEvents(options); + render(); + await waitFor(() => + expect(desktop.internals.invoke).toHaveBeenCalledWith( + "plugin:event|listen", + expect.objectContaining({ event: "kandev-desktop-v1-download" }), + ), + ); + return desktop; +} + +function clickDesktopDownload() { + fireEvent.click(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID)); + fireEvent.click(screen.getByTestId(CREATE_BUNDLE_TEST_ID)); +} + +async function startDesktopDownload() { + clickDesktopDownload(); + await screen.findByText(DIAGNOSTIC_SAVE_PENDING); +} + +async function prepareDesktopDownload() { + const desktop = await mountDesktopLogViewer(); + await startDesktopDownload(); + return desktop; +} + +describe("LogViewer download feedback", () => { + it("reports a desktop bundle as saved only after native completion", async () => { + const desktop = await prepareDesktopDownload(); + expect(screen.queryByText("The diagnostic ZIP was saved.")).toBeNull(); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(true); + + desktop.emit({ + status: "saved", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + }); + + expect(await screen.findByText("The diagnostic ZIP was saved.")).toBeTruthy(); + }); + + it("keeps native cancellation neutral and the bundle action retryable", async () => { + const desktop = await prepareDesktopDownload(); + + desktop.emit({ + status: "cancelled", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + }); + + await waitFor(() => expect(screen.queryByTestId(BUNDLE_STATUS_TEST_ID)).toBeNull()); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(false); + }); + + it("shows a native save error and keeps the bundle action retryable", async () => { + const desktop = await prepareDesktopDownload(); + + desktop.emit({ + status: "failed", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + }); + + const status = await screen.findByTestId(BUNDLE_STATUS_TEST_ID); + expect(status.textContent).toContain(DIAGNOSTIC_SAVE_FAILED); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(false); + }); + + it("recovers with a retryable error when no native result arrives", async () => { + await mountDesktopLogViewer(); + const browserSetTimeout = window.setTimeout.bind(window); + vi.spyOn(window, "setTimeout").mockImplementation( + (handler, timeout, ...args) => + browserSetTimeout( + handler, + (timeout ?? 0) >= 600_000 ? 0 : (timeout ?? 0), + ...args, + ) as unknown as ReturnType, + ); + + await startDesktopDownload(); + + const status = await screen.findByTestId(BUNDLE_STATUS_TEST_ID); + expect(status.textContent).toContain(DIAGNOSTIC_SAVE_FAILED); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(false); + }); + + it("does not start the transfer when feedback listener setup keeps failing", async () => { + const desktop = await mountDesktopLogViewer({ failListener: true }); + + clickDesktopDownload(); + + const status = await screen.findByTestId(BUNDLE_STATUS_TEST_ID); + expect(status.textContent).toContain(DIAGNOSTIC_SAVE_FAILED); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(false); + expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled(); + expect(desktop.listenerAttempts()).toBe(2); + }); + + it("retries listener setup before starting a download after a transient listener failure", async () => { + const desktop = await mountDesktopLogViewer({ failListenerAttempts: 1 }); + + clickDesktopDownload(); + + await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce()); + expect(desktop.listenerAttempts()).toBe(2); + + desktop.emit({ + status: "saved", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + }); + expect(await screen.findByText("The diagnostic ZIP was saved.")).toBeTruthy(); + }); + + it("does not time out while the native Save dialog is open", async () => { + const desktop = await prepareDesktopDownload(); + vi.useFakeTimers(); + + desktop.emit({ + status: "selecting", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + } as DesktopDownloadFeedback); + act(() => vi.advanceTimersByTime(15 * 60 * 1_000)); + + expect(screen.getByText(DIAGNOSTIC_SAVE_PENDING)).toBeTruthy(); + expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID).hasAttribute("disabled")).toBe(true); + }); + + it("clears its result watchdog after a terminal native event", async () => { + const desktop = await prepareDesktopDownload(); + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + + desktop.emit({ + status: "saved", + url: new URL(DOWNLOAD_URL, window.location.href).href, + fileName: DIAGNOSTIC_BUNDLE_FILE_NAME, + }); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); +}); + +describe("LogViewer customizer", () => { it("uses one customizer with the standard backend and frontend defaults", async () => { createMock.mockResolvedValue({ id: "bundle-1", @@ -61,7 +257,7 @@ describe("LogViewer", () => { expect(screen.queryByText("Recent log output")).toBeNull(); expect(screen.queryByTestId("download-diagnostic-bundle")).toBeNull(); expect(screen.queryByTestId("download-diagnostic-bundle-with-acp")).toBeNull(); - fireEvent.click(screen.getByTestId("customize-diagnostic-bundle")); + fireEvent.click(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID)); expect( screen.getByRole("checkbox", { name: "Backend logs" }).getAttribute(DATA_STATE_ATTRIBUTE), ).toBe(CHECKED_STATE); @@ -71,7 +267,7 @@ describe("LogViewer", () => { expect( screen.getByRole("checkbox", { name: "Runtime index" }).getAttribute(DATA_STATE_ATTRIBUTE), ).not.toBe(CHECKED_STATE); - fireEvent.click(screen.getByTestId("create-custom-diagnostic-bundle")); + fireEvent.click(screen.getByTestId(CREATE_BUNDLE_TEST_ID)); await waitFor(() => expect(createMock).toHaveBeenCalledWith(["backend", "frontend"], [])); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("bundle-1"), { timeout: 1_500 }); @@ -105,14 +301,14 @@ describe("LogViewer", () => { }, ]); render(); - await waitFor(() => expect(screen.getByTestId("customize-diagnostic-bundle")).toBeTruthy()); + await waitFor(() => expect(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID)).toBeTruthy()); expect(screen.queryByTestId("download-diagnostic-bundle")).toBeNull(); expect(screen.queryByTestId("download-diagnostic-bundle-with-acp")).toBeNull(); - fireEvent.click(screen.getByTestId("customize-diagnostic-bundle")); + fireEvent.click(screen.getByTestId(CUSTOMIZE_BUNDLE_TEST_ID)); fireEvent.click(screen.getByRole("checkbox", { name: "ACP debug messages" })); expect((await screen.findAllByText("ACP debug messages")).length).toBeGreaterThanOrEqual(1); expect(screen.getByText(/can contain prompts/)).toBeTruthy(); - const create = screen.getByTestId("create-custom-diagnostic-bundle"); + const create = screen.getByTestId(CREATE_BUNDLE_TEST_ID); expect(create.hasAttribute("disabled")).toBe(true); const taskLink = await screen.findByTestId("acp-session-task-link-session-1"); expect(taskLink.textContent).toContain("Repair failing diagnostics"); diff --git a/apps/web/components/settings/system/log-viewer.tsx b/apps/web/components/settings/system/log-viewer.tsx index 41906daa87..0b6d6d5dee 100644 --- a/apps/web/components/settings/system/log-viewer.tsx +++ b/apps/web/components/settings/system/log-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; @@ -8,6 +8,11 @@ import { Card, CardContent, CardTitle } from "@kandev/ui/card"; import { Spinner } from "@kandev/ui/spinner"; import { IconAlertTriangle, IconDownload, IconFileZip } from "@tabler/icons-react"; import { ApiError } from "@/lib/api/client"; +import { createDesktopV1Adapter } from "@/lib/desktop/adapter"; +import type { DesktopDownloadFeedback } from "@/lib/desktop/protocol"; +import { createTauriEventTransport } from "@/lib/desktop/tauri-event-transport"; +import { DIAGNOSTIC_BUNDLE_FILE_NAME } from "@/lib/desktop/download-feedback"; +import { triggerUrlDownload } from "@/lib/utils/file-download"; import { buildDiagnosticBundleDownloadUrl, createDiagnosticBundle, @@ -23,9 +28,15 @@ import type { } from "@/lib/types/system"; import { BundleCustomizer } from "./bundle-customizer"; -type ViewState = "idle" | "collecting" | "preparing" | "partial" | "busy" | "error"; +type ViewState = "idle" | "collecting" | "preparing" | "saving" | "partial" | "busy" | "error"; +type ActiveDownload = { url: string; partial: boolean; warning: string; timeoutId?: number }; +type ActiveDownloadRef = { current: ActiveDownload | null }; + +const DESKTOP_DOWNLOAD_FEEDBACK_TIMEOUT_MS = 10 * 60 * 1_000; +const DESKTOP_DOWNLOAD_SELECTION_TIMEOUT_MS = 30 * 60 * 1_000; const DEFAULT_SOURCES: DiagnosticBundleSource[] = ["backend", "frontend"]; +const desktopDownloadAdapter = createDesktopV1Adapter(createTauriEventTransport()); // eslint-disable-next-line max-lines-per-function -- coordinates bundle polling and shared customizer state. export function LogViewer() { @@ -41,6 +52,48 @@ export function LogViewer() { const [selectedSessionIDs, setSelectedSessionIDs] = useState([]); const [customizerSubmitting, setCustomizerSubmitting] = useState(false); const mounted = useRef(true); + const activeDesktopDownload = useRef(null); + const desktopDownloadListenerReady = useRef(false); + const desktopDownloadListenerPromise = useRef | null>(null); + const desktopDownloadListenerStop = useRef<(() => void) | null>(null); + const desktopDownloadListenerGeneration = useRef(0); + + const ensureDesktopDownloadListener = useCallback((): Promise => { + if (!desktopDownloadAdapter.isAvailable()) return Promise.resolve(false); + if (desktopDownloadListenerReady.current) return Promise.resolve(true); + if (desktopDownloadListenerPromise.current) return desktopDownloadListenerPromise.current; + const generation = desktopDownloadListenerGeneration.current; + const promise = desktopDownloadAdapter + .listen("download", (feedback) => { + const active = activeDesktopDownload.current; + if (!mounted.current || !active || feedback.url !== active.url) return; + handleDesktopDownloadFeedback(feedback, active, { + setState, + setMessage, + t, + activeDesktopDownload, + }); + }) + .then( + (unlisten) => { + if (!mounted.current || generation !== desktopDownloadListenerGeneration.current) { + unlisten(); + return false; + } + desktopDownloadListenerReady.current = true; + desktopDownloadListenerStop.current = unlisten; + return true; + }, + () => false, + ) + .finally(() => { + if (desktopDownloadListenerPromise.current === promise) { + desktopDownloadListenerPromise.current = null; + } + }); + desktopDownloadListenerPromise.current = promise; + return promise; + }, [t]); useEffect(() => { mounted.current = true; @@ -49,9 +102,23 @@ export function LogViewer() { .catch(() => undefined); return () => { mounted.current = false; + clearActiveDesktopDownload(activeDesktopDownload); }; }, []); + useEffect(() => { + if (!desktopDownloadAdapter.isAvailable()) return; + desktopDownloadListenerGeneration.current += 1; + void ensureDesktopDownloadListener(); + return () => { + desktopDownloadListenerGeneration.current += 1; + desktopDownloadListenerReady.current = false; + desktopDownloadListenerPromise.current = null; + desktopDownloadListenerStop.current?.(); + desktopDownloadListenerStop.current = null; + }; + }, [ensureDesktopDownloadListener]); + useEffect(() => { if (!customizerOpen || !selectedSources.includes("acp") || sessionsLoaded) return; setSessionsLoading(true); @@ -80,11 +147,17 @@ export function LogViewer() { if (job.status !== "ready" && job.status !== "partial") { throw new Error(job.warnings?.[0] ?? t("settings:diagnosticBundlePrepareError")); } - setState(job.status === "partial" ? "partial" : "idle"); - setMessage(bundleMessage(job, t)); - triggerDownload(buildDiagnosticBundleDownloadUrl(job.id)); + await startBundleDownload(job, buildDiagnosticBundleDownloadUrl(job.id), { + activeDesktopDownload, + setState, + setMessage, + t, + ensureDesktopDownloadListener, + isMounted: () => mounted.current, + }); } catch (error) { if (!mounted.current) return; + clearActiveDesktopDownload(activeDesktopDownload); if (error instanceof ApiError && (error.status === 429 || error.status === 503)) { const retry = error.retryAfterSeconds ?? 5; setState("busy"); @@ -113,7 +186,7 @@ export function LogViewer() { }); }; - const pending = state === "collecting" || state === "preparing"; + const pending = state === "collecting" || state === "preparing" || state === "saving"; return (
@@ -254,9 +327,119 @@ async function prepareDiagnosticBundle( function buttonLabel(state: ViewState, t: (key: string) => string): string { if (state === "collecting") return t("settings:diagnosticCollectingFrontendLogs"); if (state === "preparing") return t("settings:diagnosticPreparingZip"); + if (state === "saving") return t("settings:saving"); return t("settings:diagnosticCustomize"); } +type DesktopDownloadActions = { + activeDesktopDownload: ActiveDownloadRef; + setState: (state: ViewState) => void; + setMessage: (message: string) => void; + t: (key: string) => string; +}; + +function clearActiveDesktopDownload(activeRef: ActiveDownloadRef): void { + const active = activeRef.current; + if (active?.timeoutId !== undefined) window.clearTimeout(active.timeoutId); + activeRef.current = null; +} + +function failActiveDesktopDownload(active: ActiveDownload, actions: DesktopDownloadActions): void { + if (actions.activeDesktopDownload.current !== active) return; + clearActiveDesktopDownload(actions.activeDesktopDownload); + actions.setState("error"); + actions.setMessage( + withPartialWarning(actions.t("settings:diagnosticSaveFailed"), active.warning), + ); +} + +function armDesktopDownloadFeedbackTimeout( + active: ActiveDownload, + actions: DesktopDownloadActions, + timeout = DESKTOP_DOWNLOAD_FEEDBACK_TIMEOUT_MS, +): void { + if (active.timeoutId !== undefined) window.clearTimeout(active.timeoutId); + active.timeoutId = window.setTimeout(() => failActiveDesktopDownload(active, actions), timeout); +} + +function handleDesktopDownloadFeedback( + feedback: DesktopDownloadFeedback, + active: ActiveDownload, + actions: DesktopDownloadActions, +): void { + if (feedback.status === "selecting") { + armDesktopDownloadFeedbackTimeout(active, actions, DESKTOP_DOWNLOAD_SELECTION_TIMEOUT_MS); + actions.setMessage(actions.t("settings:diagnosticSavePending")); + return; + } + if (feedback.status === "started") { + armDesktopDownloadFeedbackTimeout(active, actions); + actions.setMessage(actions.t("settings:diagnosticSavePending")); + return; + } + if (feedback.status === "cancelled") { + actions.setState(active.partial ? "partial" : "idle"); + actions.setMessage(""); + } else if (feedback.status === "saved") { + actions.setState(active.partial ? "partial" : "idle"); + actions.setMessage( + withPartialWarning(actions.t("settings:diagnosticSaveComplete"), active.warning), + ); + } else { + actions.setState("error"); + actions.setMessage( + withPartialWarning(actions.t("settings:diagnosticSaveFailed"), active.warning), + ); + } + clearActiveDesktopDownload(actions.activeDesktopDownload); +} + +async function startBundleDownload( + job: DiagnosticBundleJob, + downloadUrl: string, + actions: { + activeDesktopDownload: ActiveDownloadRef; + setState: (state: ViewState) => void; + setMessage: (message: string) => void; + t: (key: string) => string; + ensureDesktopDownloadListener: () => Promise; + isMounted: () => boolean; + }, +): Promise { + if (desktopDownloadAdapter.isAvailable()) { + if (!(await actions.ensureDesktopDownloadListener())) { + clearActiveDesktopDownload(actions.activeDesktopDownload); + actions.setState("error"); + actions.setMessage(actions.t("settings:diagnosticSaveFailed")); + return; + } + if (!actions.isMounted()) return; + const active: ActiveDownload = { + url: new URL(downloadUrl, window.location.href).href, + partial: job.status === "partial", + warning: bundleWarning(job, actions.t), + }; + actions.activeDesktopDownload.current = active; + armDesktopDownloadFeedbackTimeout(active, actions); + actions.setState("saving"); + actions.setMessage(actions.t("settings:diagnosticSavePending")); + } else { + clearActiveDesktopDownload(actions.activeDesktopDownload); + actions.setState(job.status === "partial" ? "partial" : "idle"); + actions.setMessage(bundleMessage(job, actions.t)); + } + triggerUrlDownload(downloadUrl, DIAGNOSTIC_BUNDLE_FILE_NAME); +} + +function bundleWarning(job: DiagnosticBundleJob, t: (key: string) => string): string { + if (job.status !== "partial") return ""; + return job.warnings?.length ? job.warnings.join(" ") : t("settings:diagnosticPartialZipWarning"); +} + +function withPartialWarning(message: string, warning: string): string { + return warning ? `${message} ${warning}` : message; +} + function bundleMessage( job: DiagnosticBundleJob, t: (key: string, options?: Record) => string, @@ -267,15 +450,6 @@ function bundleMessage( : t("settings:diagnosticPartialZipUnavailable"); } -function triggerDownload(url: string): void { - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = "kandev-diagnostic-logs.zip"; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); -} - function delay(milliseconds: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } diff --git a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts index 0c8f69064b..5fd10438b1 100644 --- a/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts +++ b/apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts @@ -58,4 +58,20 @@ test.describe("System Logs mobile", () => { ), ).toBe(false); }); + + test("downloads the diagnostic ZIP through the browser on mobile", async ({ testPage }) => { + test.setTimeout(45_000); + await testPage.setViewportSize({ width: 390, height: 844 }); + await testPage.goto("/settings/system/data-storage?tab=logs"); + await testPage.getByTestId("customize-diagnostic-bundle").tap(); + const drawer = testPage.getByTestId("diagnostic-bundle-drawer"); + await expect(drawer).toBeVisible(); + + const downloadPromise = testPage.waitForEvent("download"); + await drawer.getByTestId("create-custom-diagnostic-bundle").tap(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe("kandev-diagnostic-logs.zip"); + expect(await download.path()).not.toBeNull(); + }); }); diff --git a/apps/web/lib/desktop/download-feedback.test.ts b/apps/web/lib/desktop/download-feedback.test.ts new file mode 100644 index 0000000000..2fbde97ecc --- /dev/null +++ b/apps/web/lib/desktop/download-feedback.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DesktopDownloadFeedback } from "./protocol"; +import { desktopDownloadToastStatus } from "./download-feedback"; + +const event = (status: DesktopDownloadFeedback["status"], fileName: string) => ({ + status, + fileName, + url: "http://127.0.0.1:38430/export", +}); + +afterEach(() => { + window.history.replaceState({}, "", "/"); +}); + +describe("desktop download feedback", () => { + it("shows a global result for non-Logs exports", () => { + expect(desktopDownloadToastStatus(event("saved", "org-chart.svg"))).toBe("saved"); + expect(desktopDownloadToastStatus(event("failed", "agent-memory.json"))).toBe("failed"); + }); + + it("leaves cancellation neutral and routes Logs results to their current surface", () => { + window.history.replaceState({}, "", "/settings/system/data-storage?tab=logs"); + expect(desktopDownloadToastStatus(event("cancelled", "org-chart.svg"))).toBeNull(); + expect(desktopDownloadToastStatus(event("started", "org-chart.svg"))).toBeNull(); + expect( + desktopDownloadToastStatus({ + ...event("saved", "my-logs.zip"), + url: "http://127.0.0.1:38430/api/v1/system/logs/bundles/job-1/download", + }), + ).toBeNull(); + }); + + it("does not suppress an unrelated export with the diagnostic bundle filename", () => { + expect(desktopDownloadToastStatus(event("saved", "kandev-diagnostic-logs.zip"))).toBe("saved"); + expect(desktopDownloadToastStatus(event("failed", "kandev-diagnostic-logs.zip"))).toBe( + "failed", + ); + }); + + it("suppresses duplicate feedback when a Logs export is renamed in the Save dialog", () => { + window.history.replaceState({}, "", "/settings/system/data-storage?tab=logs"); + expect( + desktopDownloadToastStatus({ + ...event("saved", "my-private-logs.zip"), + url: "http://127.0.0.1:38430/api/v1/system/logs/bundles/job-1/download?token=secret", + }), + ).toBeNull(); + }); + + it("shows the global result if the user leaves Logs before the download finishes", () => { + const feedback = { + ...event("saved", "my-private-logs.zip"), + url: "http://127.0.0.1:38430/api/v1/system/logs/bundles/job-1/download", + }; + + window.history.pushState({}, "", "/settings/system/data-storage?tab=logs"); + expect(desktopDownloadToastStatus(feedback)).toBeNull(); + + window.history.pushState({}, "", "/settings"); + expect(desktopDownloadToastStatus(feedback)).toBe("saved"); + }); +}); diff --git a/apps/web/lib/desktop/download-feedback.ts b/apps/web/lib/desktop/download-feedback.ts new file mode 100644 index 0000000000..c36ac503e7 --- /dev/null +++ b/apps/web/lib/desktop/download-feedback.ts @@ -0,0 +1,28 @@ +import type { DesktopDownloadFeedback } from "./protocol"; + +export const DIAGNOSTIC_BUNDLE_FILE_NAME = "kandev-diagnostic-logs.zip"; + +export function desktopDownloadToastStatus( + feedback: DesktopDownloadFeedback, +): "saved" | "failed" | null { + if (isDiagnosticBundleDownload(feedback.url) && isLogsPage()) return null; + if (feedback.status === "saved" || feedback.status === "failed") return feedback.status; + return null; +} + +function isLogsPage(): boolean { + return ( + window.location.pathname === "/settings/system/data-storage" && + new URLSearchParams(window.location.search).get("tab") === "logs" + ); +} + +function isDiagnosticBundleDownload(downloadUrl: string): boolean { + try { + return /\/api\/v1\/system\/logs\/bundles\/[^/]+\/download\/?$/.test( + new URL(downloadUrl).pathname, + ); + } catch { + return false; + } +} diff --git a/apps/web/lib/desktop/download-ready.test.ts b/apps/web/lib/desktop/download-ready.test.ts new file mode 100644 index 0000000000..5a39d7b928 --- /dev/null +++ b/apps/web/lib/desktop/download-ready.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DesktopV1Adapter } from "./adapter"; +import { listenForDesktopDownloadReady } from "./download-ready"; + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("listenForDesktopDownloadReady", () => { + it("retries the selected URL using its native suggested name", async () => { + let onReady: ((payload: unknown) => void) | undefined; + const listen = vi.fn(async (_event: string, callback: (payload: unknown) => void) => { + onReady = callback; + return () => undefined; + }); + const adapter = { + isAvailable: () => true, + listen, + } as unknown as DesktopV1Adapter; + const clickedAnchors: HTMLAnchorElement[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + clickedAnchors.push(this); + }); + + await listenForDesktopDownloadReady(adapter); + onReady?.({ url: "/download/bundle.zip", fileName: "diagnostic.zip" }); + + expect(listen).toHaveBeenCalledWith("download-ready", expect.any(Function)); + expect(clickedAnchors).toHaveLength(1); + expect(clickedAnchors[0].href).toBe(new URL("/download/bundle.zip", window.location.href).href); + expect(clickedAnchors[0].download).toBe("diagnostic.zip"); + }); +}); diff --git a/apps/web/lib/desktop/download-ready.ts b/apps/web/lib/desktop/download-ready.ts new file mode 100644 index 0000000000..3f5cb8b918 --- /dev/null +++ b/apps/web/lib/desktop/download-ready.ts @@ -0,0 +1,9 @@ +import type { DesktopV1Adapter, DesktopUnlisten } from "./adapter"; +import { triggerUrlDownload } from "@/lib/utils/file-download"; + +export function listenForDesktopDownloadReady(adapter: DesktopV1Adapter): Promise { + if (!adapter.isAvailable()) return Promise.resolve(() => undefined); + return adapter.listen("download-ready", ({ url, fileName }) => { + triggerUrlDownload(url, fileName); + }); +} diff --git a/apps/web/lib/desktop/external-links-usage.test.ts b/apps/web/lib/desktop/external-links-usage.test.ts index 26695343be..8c0c3c371d 100644 --- a/apps/web/lib/desktop/external-links-usage.test.ts +++ b/apps/web/lib/desktop/external-links-usage.test.ts @@ -23,8 +23,10 @@ describe("programmatic external links", () => { it("uses the browser-owned download flow for selected Office exports", () => { const contents = source("../../app/office/workspace/settings/export/export-preview.tsx"); + const downloadHelper = source("../utils/file-download.ts"); - expect(contents).toContain("anchor.download"); - expect(contents).toContain("anchor.click()"); + expect(contents).toContain('triggerBlobDownload(blob, "kandev-config-selected.zip")'); + expect(downloadHelper).toContain("anchor.download = fileName"); + expect(downloadHelper).toContain("anchor.click()"); }); }); diff --git a/apps/web/lib/desktop/protocol.ts b/apps/web/lib/desktop/protocol.ts index e8bf397c55..b48f418c49 100644 --- a/apps/web/lib/desktop/protocol.ts +++ b/apps/web/lib/desktop/protocol.ts @@ -9,15 +9,30 @@ export const DESKTOP_NATIVE_EVENTS = { "open-settings": desktopEvent("open-settings"), "new-task": desktopEvent("new-task"), "check-for-updates": desktopEvent("check-for-updates"), + download: desktopEvent("download"), + "download-ready": desktopEvent("download-ready"), } as const; export type DesktopEventName = keyof typeof DESKTOP_NATIVE_EVENTS; +export type DesktopDownloadFeedback = { + status: "selecting" | "started" | "saved" | "cancelled" | "failed"; + url: string; + fileName: string; +}; + +export type DesktopDownloadReady = { + url: string; + fileName: string; +}; + export type DesktopEventPayloads = { "close-context": undefined; "open-settings": undefined; "new-task": undefined; "check-for-updates": undefined; + download: DesktopDownloadFeedback; + "download-ready": DesktopDownloadReady; }; export const DESKTOP_NATIVE_COMMANDS = { diff --git a/apps/web/lib/utils/file-download.test.ts b/apps/web/lib/utils/file-download.test.ts index 812e7e8ad0..c76a06430b 100644 --- a/apps/web/lib/utils/file-download.test.ts +++ b/apps/web/lib/utils/file-download.test.ts @@ -1,12 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { triggerBlobDownload, triggerFileDownload } from "./file-download"; +import { + completeNativeBlobDownload, + MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS, +} from "./native-blob-download"; const createObjectURLMock = vi.fn((_blob: Blob): string => "blob:mock"); const revokeObjectURLMock = vi.fn((_url: string): void => undefined); +const SAMPLE_BLOB_CONTENT = "bytes"; +const SAMPLE_DOWNLOAD_NAME = "report.zip"; const originalCreateObjectURL = URL.createObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL; +const originalTauriInternals = Object.getOwnPropertyDescriptor(window, "__TAURI_INTERNALS__"); beforeEach(() => { + vi.useFakeTimers(); URL.createObjectURL = createObjectURLMock as unknown as typeof URL.createObjectURL; URL.revokeObjectURL = revokeObjectURLMock as unknown as typeof URL.revokeObjectURL; createObjectURLMock.mockClear(); @@ -14,8 +22,15 @@ beforeEach(() => { }); afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); URL.createObjectURL = originalCreateObjectURL; URL.revokeObjectURL = originalRevokeObjectURL; + if (originalTauriInternals) { + Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals); + } else { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + } vi.restoreAllMocks(); }); @@ -48,6 +63,8 @@ describe("triggerFileDownload", () => { expect(blob).toBeInstanceOf(Blob); expect(blob.type).toContain("text/plain"); expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + vi.runOnlyPendingTimers(); expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); }); @@ -82,6 +99,91 @@ describe("triggerBlobDownload", () => { expect(createObjectURLMock).toHaveBeenCalledWith(blob); expect(click).toHaveBeenCalledTimes(1); expect(download()).toBe("kandev-automations.zip"); + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + vi.runOnlyPendingTimers(); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); + }); + + it("keeps native Blob URLs alive through delayed initiation and until a terminal event", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { invoke: vi.fn(), transformCallback: vi.fn() }, + }); + const { click } = spyAnchorClick(); + + triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME); + vi.advanceTimersByTime(120_000); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + + completeNativeBlobDownload({ + status: "started", + url: "blob:mock", + fileName: SAMPLE_DOWNLOAD_NAME, + }); + vi.advanceTimersByTime(60_000); + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + + completeNativeBlobDownload({ + status: "saved", + url: "blob:mock", + fileName: SAMPLE_DOWNLOAD_NAME, + }); + expect(revokeObjectURLMock).toHaveBeenCalledOnce(); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); + }); + + it.each(["cancelled", "failed"] as const)( + "revokes a native Blob URL after %s feedback", + (status) => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { invoke: vi.fn(), transformCallback: vi.fn() }, + }); + spyAnchorClick(); + + triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME); + completeNativeBlobDownload({ status, url: "blob:mock", fileName: SAMPLE_DOWNLOAD_NAME }); + + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); + }, + ); + + it("extends Blob lifetime while the native Save dialog is selecting a destination", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { invoke: vi.fn(), transformCallback: vi.fn() }, + }); + spyAnchorClick(); + + triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME); + vi.advanceTimersByTime(20 * 60 * 1_000); + completeNativeBlobDownload({ + status: "selecting", + url: "blob:mock", + fileName: SAMPLE_DOWNLOAD_NAME, + }); + vi.advanceTimersByTime(20 * 60 * 1_000); + + expect(revokeObjectURLMock).not.toHaveBeenCalled(); + completeNativeBlobDownload({ + status: "cancelled", + url: "blob:mock", + fileName: SAMPLE_DOWNLOAD_NAME, + }); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); + }); + + it("bounds cleanup if native completion feedback never arrives", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { invoke: vi.fn(), transformCallback: vi.fn() }, + }); + spyAnchorClick(); + + triggerBlobDownload(new Blob([SAMPLE_BLOB_CONTENT]), SAMPLE_DOWNLOAD_NAME); + vi.advanceTimersByTime(MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); }); }); diff --git a/apps/web/lib/utils/file-download.ts b/apps/web/lib/utils/file-download.ts index 577a57e149..03914960a7 100644 --- a/apps/web/lib/utils/file-download.ts +++ b/apps/web/lib/utils/file-download.ts @@ -1,3 +1,5 @@ +import { retainNativeBlobDownload, revokeNativeBlobDownload } from "./native-blob-download"; + export type TriggerFileDownloadParams = { fileName: string; content: string; @@ -20,6 +22,7 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer { function downloadBlobViaAnchor(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); + const nativeDownload = retainNativeBlobDownload(url); try { const anchor = document.createElement("a"); anchor.href = url; @@ -27,8 +30,13 @@ function downloadBlobViaAnchor(blob: Blob, fileName: string): void { document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); - } finally { - URL.revokeObjectURL(url); + } catch (error) { + if (nativeDownload) revokeNativeBlobDownload(url); + else URL.revokeObjectURL(url); + throw error; + } + if (!nativeDownload) { + window.setTimeout(() => URL.revokeObjectURL(url), 0); } } @@ -57,3 +65,12 @@ export function triggerFileDownload({ export function triggerBlobDownload(blob: Blob, fileName: string): void { downloadBlobViaAnchor(blob, fileName); } + +export function triggerUrlDownload(url: string, fileName: string): void { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +} diff --git a/apps/web/lib/utils/native-blob-download.ts b/apps/web/lib/utils/native-blob-download.ts new file mode 100644 index 0000000000..511bb08e93 --- /dev/null +++ b/apps/web/lib/utils/native-blob-download.ts @@ -0,0 +1,39 @@ +import { createTauriEventTransport } from "@/lib/desktop/tauri-event-transport"; +import type { DesktopDownloadFeedback } from "@/lib/desktop/protocol"; + +export const MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS = 30 * 60 * 1_000; + +const nativeDownloadTransport = createTauriEventTransport(); +const pendingNativeBlobDownloads = new Map(); + +export function retainNativeBlobDownload(url: string): boolean { + if (typeof window === "undefined" || !nativeDownloadTransport.isAvailable()) return false; + armNativeBlobDownloadCleanup(url); + return true; +} + +export function completeNativeBlobDownload(feedback: DesktopDownloadFeedback): void { + if (feedback.status === "selecting" || feedback.status === "started") { + if (pendingNativeBlobDownloads.has(feedback.url)) armNativeBlobDownloadCleanup(feedback.url); + return; + } + revokeNativeBlobDownload(feedback.url); +} + +export function revokeNativeBlobDownload(url: string): void { + const timeout = pendingNativeBlobDownloads.get(url); + if (timeout === undefined) return; + window.clearTimeout(timeout); + pendingNativeBlobDownloads.delete(url); + URL.revokeObjectURL(url); +} + +function armNativeBlobDownloadCleanup(url: string): void { + const previous = pendingNativeBlobDownloads.get(url); + if (previous !== undefined) window.clearTimeout(previous); + const timeout = window.setTimeout( + () => revokeNativeBlobDownload(url), + MAX_NATIVE_BLOB_DOWNLOAD_AGE_MS, + ); + pendingNativeBlobDownloads.set(url, timeout); +} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 871142ec1c..6749fd976d 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "Repository", "secretReferenceHidden": "A resource you cannot access", "deleteThisCustomLayoutAfterConfirmation": "Delete this custom layout after confirmation.", + "desktopDownloadFailed": "Kandev could not save this file. Try the download again.", + "desktopDownloadSaved": "Kandev saved the file.", "desktopNotifications": "Desktop Notifications", "desktopNotificationsDescription": "Notify this device when a selected agent turn, question, or Office event occurs.", "desktopOnlyWhileYouScrollPast": "Desktop only. While you scroll past your last prompt, stick a shortened copy to the top of the transcript with an expand toggle.", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "Open task {{title}} in a new tab", "diagnosticPartialZipDownloading": "A partial ZIP is downloading: {{warnings}}", "diagnosticPartialZipUnavailable": "A partial diagnostic ZIP is downloading; some frontend logs were unavailable.", + "diagnosticPartialZipWarning": "Some frontend logs were unavailable.", "diagnosticPreparingZip": "Preparing ZIP…", "diagnosticReviewBeforeSharing": "Review before sharing", "diagnosticReviewDescription": "This ZIP combines up to three days of backend logs with locally retained frontend console events from connected browsers. It may contain URLs, console arguments, stacks, paths, runtime metadata, and user-visible errors.", + "diagnosticSaveComplete": "The diagnostic ZIP was saved.", + "diagnosticSaveFailed": "The diagnostic ZIP could not be saved. Try again.", + "diagnosticSavePending": "Choose where to save the diagnostic ZIP.", "diagnosticSelectAllACPSessions": "Select all ACP sessions", "diagnosticSelectSessions": "Select ACP sessions", "diagnosticSelectedSessions_one": "{{count}} session selected", diff --git a/apps/web/src/locales/ja/settings.json b/apps/web/src/locales/ja/settings.json index 57a2423137..9cda9f5cdc 100644 --- a/apps/web/src/locales/ja/settings.json +++ b/apps/web/src/locales/ja/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "リポジトリ", "secretReferenceHidden": "アクセスできないリソース", "deleteThisCustomLayoutAfterConfirmation": "確認後、このカスタムレイアウトを削除します。", + "desktopDownloadFailed": "Kandev はこのファイルを保存できませんでした。ダウンロードをもう一度お試しください。", + "desktopDownloadSaved": "Kandev がファイルを保存しました。", "desktopNotifications": "デスクトップ通知", "desktopNotificationsDescription": "選択したエージェントのターン、質問、または Office イベントが発生したときにこのデバイスに通知します。", "desktopOnlyWhileYouScrollPast": "デスクトップのみ。最後のプロンプトを通過してスクロールすると、短縮版をトランスクリプト上部に固定表示し、展開トグルを付けます。", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "タスク {{title}} を新しいタブで開く", "diagnosticPartialZipDownloading": "部分ZIPをダウンロード中: {{warnings}}", "diagnosticPartialZipUnavailable": "部分診断ZIPをダウンロード中。一部フロントエンドログは利用不可。", + "diagnosticPartialZipWarning": "一部のフロントエンドログを利用できませんでした。", "diagnosticPreparingZip": "ZIP準備中…", "diagnosticReviewBeforeSharing": "共有前に確認", "diagnosticReviewDescription": "このZIPは、最大3日分バックエンドログと、接続中ブラウザからローカル保持したフロントエンドコンソールイベントをまとめる。URL、コンソール引数、スタック、パス、実行時メタデータ、ユーザーに見えるエラーを含む場合あり。", + "diagnosticSaveComplete": "診断 ZIP を保存しました。", + "diagnosticSaveFailed": "診断 ZIP を保存できませんでした。もう一度お試しください。", + "diagnosticSavePending": "診断 ZIP の保存先を選択してください。", "diagnosticSelectAllACPSessions": "すべてのACPセッションを選択", "diagnosticSelectSessions": "ACPセッションを選択", "diagnosticSelectedSessions_one": "{{count}}セッション選択済み", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index ec166a8d2c..d4e60cb530 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "Ŕēƥōśĩţōŕŷ", "secretReferenceHidden": "À ŕēśōũŕćē ŷōũ ćàńńōţ àććēśś", "deleteThisCustomLayoutAfterConfirmation": "Ďēĺēţē ţĥĩś ćũśţōḿ ĺàŷōũţ àƒţēŕ ćōńƒĩŕḿàţĩōń.", + "desktopDownloadFailed": "Ķàńďēv ćōũĺď ńōţ śàvē ţĥĩś ƒĩĺē. Ţŕŷ ţĥē ďōŵńĺōàď àĝàĩń.", + "desktopDownloadSaved": "Ķàńďēv śàvēď ţĥē ƒĩĺē.", "desktopNotifications": "Ďēśķţōƥ Ńōţĩƒĩćàţĩōńś", "desktopNotificationsDescription": "Ńōţĩƒŷ ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.", "desktopOnlyWhileYouScrollPast": "Ďēśķţōƥ ōńĺŷ. Ŵĥĩĺē ŷōũ śćŕōĺĺ ƥàśţ ŷōũŕ ĺàśţ ƥŕōḿƥţ, śţĩćķ à śĥōŕţēńēď ćōƥŷ ţō ţĥē ţōƥ ōƒ ţĥē ţŕàńśćŕĩƥţ ŵĩţĥ àń ēxƥàńď ţōĝĝĺē.", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "Ōƥēń ţàśķ {{title}} ĩń à ńēŵ ţàƀ", "diagnosticPartialZipDownloading": "À ƥàŕţĩàĺ ŹĨƤ ĩś ďōŵńĺōàďĩńĝ: {{warnings}}", "diagnosticPartialZipUnavailable": "À ƥàŕţĩàĺ ďĩàĝńōśţĩć ŹĨƤ ĩś ďōŵńĺōàďĩńĝ; śōḿē ƒŕōńţēńď ĺōĝś ŵēŕē ũńàvàĩĺàƀĺē.", + "diagnosticPartialZipWarning": "Śōḿē ƒŕōńţēńď ĺōĝś ŵēŕē ũńàvàĩĺàƀĺē.", "diagnosticPreparingZip": "Ƥŕēƥàŕĩńĝ ŹĨƤ…", "diagnosticReviewBeforeSharing": "Ŕēvĩēŵ ƀēƒōŕē śĥàŕĩńĝ", "diagnosticReviewDescription": "Ţĥĩś ŹĨƤ ćōḿƀĩńēś ũƥ ţō ţĥŕēē ďàŷś ōƒ ƀàćķēńď ĺōĝś ŵĩţĥ ĺōćàĺĺŷ ŕēţàĩńēď ƒŕōńţēńď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕś. Ĩţ ḿàŷ ćōńţàĩń ŨŔĹś, ćōńśōĺē àŕĝũḿēńţś, śţàćķś, ƥàţĥś, ŕũńţĩḿē ḿēţàďàţà, àńď ũśēŕ-vĩśĩƀĺē ēŕŕōŕś.", + "diagnosticSaveComplete": "Ţĥē ďĩàĝńōśţĩć ŹĨƤ ŵàś śàvēď.", + "diagnosticSaveFailed": "Ţĥē ďĩàĝńōśţĩć ŹĨƤ ćōũĺď ńōţ ƀē śàvēď. Ţŕŷ àĝàĩń.", + "diagnosticSavePending": "Çhōōśē ŵhēŕē ţō śàvē ţhē ďĩàĝńōśţĩć ŹĨƤ.", "diagnosticSelectAllACPSessions": "Śēĺēćţ àĺĺ ÀĆƤ śēśśĩōńś", "diagnosticSelectSessions": "Śēĺēćţ ÀĆƤ śēśśĩōńś", "diagnosticSelectedSessions_one": "{{count}} śēśśĩōń śēĺēćţēď", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 5eecfe5edf..db8cb97f35 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -149,6 +149,8 @@ "secretReferenceTypeRepository": "Repositório", "secretReferenceHidden": "Um recurso ao qual não tem acesso", "deleteThisCustomLayoutAfterConfirmation": "Eliminar este esquema personalizado após confirmação.", + "desktopDownloadFailed": "O Kandev não conseguiu guardar este ficheiro. Tente transferi-lo novamente.", + "desktopDownloadSaved": "O Kandev guardou o ficheiro.", "desktopNotifications": "Notificações no computador", "desktopNotificationsDescription": "Notificar este dispositivo quando ocorrer um turno de agente, uma pergunta ou um evento do Office selecionados.", "desktopOnlyWhileYouScrollPast": "Apenas no computador. Enquanto se desloca para além do seu último prompt, fixa uma cópia reduzida no topo da transcrição, com um botão para expandir.", @@ -176,9 +178,13 @@ "diagnosticOpenTask": "Abrir a tarefa {{title}} num novo separador", "diagnosticPartialZipDownloading": "Está a ser transferido um ZIP parcial: {{warnings}}", "diagnosticPartialZipUnavailable": "Está a ser transferido um ZIP de diagnóstico parcial; alguns registos do frontend não estavam disponíveis.", + "diagnosticPartialZipWarning": "Alguns registos do frontend não estavam disponíveis.", "diagnosticPreparingZip": "A preparar o ZIP…", "diagnosticReviewBeforeSharing": "Reveja antes de partilhar", "diagnosticReviewDescription": "Este ZIP combina até três dias de registos do backend com eventos de consola do frontend retidos localmente nos navegadores ligados. Pode conter URLs, argumentos de consola, pilhas de chamadas, caminhos, metadados de execução e erros visíveis para o utilizador.", + "diagnosticSaveComplete": "O ZIP de diagnóstico foi guardado.", + "diagnosticSaveFailed": "Não foi possível guardar o ZIP de diagnóstico. Tente novamente.", + "diagnosticSavePending": "Escolha onde pretende guardar o ZIP de diagnóstico.", "diagnosticSelectAllACPSessions": "Selecionar todas as sessões ACP", "diagnosticSelectSessions": "Selecionar sessões ACP", "diagnosticSelectedSessions_one": "{{count}} sessão selecionada", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index dbd41b5686..bb9fa94e0d 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "仓库", "secretReferenceHidden": "您无权访问的资源", "deleteThisCustomLayoutAfterConfirmation": "确认后删除此自定义布局。", + "desktopDownloadFailed": "Kandev 无法保存此文件。请重试下载。", + "desktopDownloadSaved": "Kandev 已保存此文件。", "desktopNotifications": "桌面通知", "desktopNotificationsDescription": "当选定的智能体回合、问题或办公室事件发生时通知此设备。", "desktopOnlyWhileYouScrollPast": "仅限桌面端。滚动越过最后一条提示时,将缩短后的副本固定到记录顶部,并提供展开开关。", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "在新标签页中打开任务 {{title}}", "diagnosticPartialZipDownloading": "正在下载部分诊断 ZIP:{{warnings}}", "diagnosticPartialZipUnavailable": "正在下载部分诊断 ZIP;部分前端日志不可用。", + "diagnosticPartialZipWarning": "部分前端日志不可用。", "diagnosticPreparingZip": "正在准备 ZIP…", "diagnosticReviewBeforeSharing": "分享前请检查", "diagnosticReviewDescription": "此 ZIP 包含最多三天的后端日志,以及所连接浏览器在本地保留的前端控制台事件。其中可能包含 URL、控制台参数、堆栈、路径、运行时元数据和用户可见错误。", + "diagnosticSaveComplete": "诊断 ZIP 已保存。", + "diagnosticSaveFailed": "无法保存诊断 ZIP。请重试。", + "diagnosticSavePending": "请选择诊断 ZIP 的保存位置。", "diagnosticSelectAllACPSessions": "选择所有 ACP 会话", "diagnosticSelectSessions": "选择 ACP 会话", "diagnosticSelectedSessions_one": "已选择 {{count}} 个会话", diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 1a29ef123a..cd4ee67902 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "儲存庫", "secretReferenceHidden": "您無權存取的資源", "deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。", + "desktopDownloadFailed": "Kandev 無法儲存此檔案。請重試下載。", + "desktopDownloadSaved": "Kandev 已儲存此檔案。", "desktopNotifications": "桌面通知", "desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此設備。", "desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}", "diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}", "diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。", + "diagnosticPartialZipWarning": "部分前端日誌不可用。", "diagnosticPreparingZip": "正在準備 ZIP…", "diagnosticReviewBeforeSharing": "分享前請檢查", "diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連接瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台參數、堆棧、路徑、執行時元數據和用戶可見錯誤。", + "diagnosticSaveComplete": "診斷 ZIP 已儲存。", + "diagnosticSaveFailed": "無法儲存診斷 ZIP。請重試。", + "diagnosticSavePending": "請選擇診斷 ZIP 的儲存位置。", "diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段", "diagnosticSelectSessions": "選擇 ACP 工作階段", "diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段", diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index c9f2f92271..0deaaeebe2 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -152,6 +152,8 @@ "secretReferenceTypeRepository": "儲存庫", "secretReferenceHidden": "您無權存取的資源", "deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。", + "desktopDownloadFailed": "Kandev 無法儲存此檔案。請重試下載。", + "desktopDownloadSaved": "Kandev 已儲存此檔案。", "desktopNotifications": "桌面通知", "desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此裝置。", "desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。", @@ -179,9 +181,13 @@ "diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}", "diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}", "diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。", + "diagnosticPartialZipWarning": "部分前端日誌無法使用。", "diagnosticPreparingZip": "正在準備 ZIP…", "diagnosticReviewBeforeSharing": "分享前請檢查", "diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連線瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台引數、堆疊、路徑、執行時後設資料和使用者可見錯誤。", + "diagnosticSaveComplete": "診斷 ZIP 已儲存。", + "diagnosticSaveFailed": "無法儲存診斷 ZIP。請重試。", + "diagnosticSavePending": "請選擇診斷 ZIP 的儲存位置。", "diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段", "diagnosticSelectSessions": "選擇 ACP 工作階段", "diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段", diff --git a/docs/decisions/2026-09-25-native-desktop-downloads.md b/docs/decisions/2026-09-25-native-desktop-downloads.md new file mode 100644 index 0000000000..38acea70cc --- /dev/null +++ b/docs/decisions/2026-09-25-native-desktop-downloads.md @@ -0,0 +1,38 @@ +# ADR-2026-09-25-native-desktop-downloads: Save desktop downloads through the WebView + +**Status:** accepted +**Date:** 2026-09-25 +**Area:** frontend, protocol + +## Context + +The desktop app displays the same SPA as the browser. Log bundles and other +exports use browser download links, while the desktop shell has no configured +destination or completion handler. A user can click Export and see no save +location or reliable result. File sources include authenticated HTTP responses +and locally generated object URLs. + +## Decision + +The desktop shell handles downloads from its owned, verified WebView with +Tauri's download callback. It offers a native Save panel and supplies the +user-selected destination to the WebView transfer. The shell does not expose a +general path-based write command to the SPA. Browser and phone downloads keep +their normal browser handling. Desktop updater transfers stay in their signed +update flow. + +## Consequences + +One destination policy covers current and future in-app HTTP and Blob exports +without copying entire files across the Tauri IPC boundary. The shell must +create its main WebView with the handler installed, correlate completion, and +verify platform behavior on macOS. Failed transfers need a visible result. + +## Alternatives considered + +- Add a save command to every export action: duplicates handling and risks + missing new downloads; large files would cross the IPC boundary. +- Keep WebView default downloads: does not offer a dependable destination or + completion experience in the installed app. +- Ask the backend to write the selected destination: crosses the backend's + storage boundary and makes browser and remote-backend cases ambiguous. diff --git a/docs/plans/native-desktop-downloads/plan.md b/docs/plans/native-desktop-downloads/plan.md new file mode 100644 index 0000000000..15ce978102 --- /dev/null +++ b/docs/plans/native-desktop-downloads/plan.md @@ -0,0 +1,137 @@ +--- +created: 2026-09-25 +status: in_progress +requirements: + - REQ-DESKTOP-NATIVE-DOWNLOADS-001 +system_design: + - ../../specs/desktop/system-design/native-downloads.md +legacy_specs: [] +--- + +# Implementation Plan: Native Desktop Downloads + +## Overview + +Give explicit in-app file downloads a native Save dialog and a dependable +result. First install the desktop transfer boundary, then align first-party +export feedback and object-URL lifetime with that boundary. + +## Initial diagnosis + +The log bundle is prepared, then `log-viewer.tsx` clicks an HTTP anchor. +At investigation time, `main.rs` had no Tauri download callback, so the shell +did not ask for a save destination or receive a completion result. The pinned +macOS WebView defaults to the Downloads folder silently. The implementation +now registers a callback; a packaged macOS reproduction is still needed to +verify that the reported click saves bytes to the selected destination. + +## Scope + +### In scope + +- Native Save dialog and completion handling for explicit HTTP and Blob + downloads from the owned desktop WebView. +- Diagnostic bundle feedback and object-URL lifetime corrections. +- Focused first-party download regression coverage across the audited sources. + +### Out of scope + +- Desktop updater transfers and external browser downloads. +- Changes to export contents, API permissions, and backend archive formats. + +## Technical approach + +Register `on_download` while constructing the configured main WebView in +`apps/desktop/src-tauri/src/main.rs`. Add a small desktop download module for +URL/origin decisions, safe suggested names, asynchronous Save dialog +selection, retry correlation, completion correlation, and failure signaling. +The initial WebView request opens the dialog and is rejected; a native event +asks the SPA to retry the same URL with the selected destination. Use the +installed `tauri-plugin-dialog`; keep bytes in the WebView's transfer path. +Preserve the existing startup and folder-picker boundaries. + +Review all direct object-URL callers found in the source audit. Give their +URLs through the native WebView terminal event, with bounded cleanup for an +abandoned attempt. Centralize that rule in `apps/web/lib/utils/file-download.ts`. +Make the System +Logs message reflect a requested save, cancellation, and actual completion in +desktop, while browser and phone continue their existing browser flow. Add +localized copy only if the existing catalog cannot express those states. +Update `docs/public/desktop-app.md`, whose external-link section currently +says downloads stay in the WebView, in the same implementation change. + +The audited first-party entry points are System Logs, System Backups, Office +task documents, task files and full chat text, automation ZIP, canvas +bundle/source, selected Office configuration, organization chart SVG, and +agent memory JSON. The native handler covers both transfer forms. Tests must +exercise representative HTTP and Blob sources, then check the remaining +callers for direct, early object-URL revocation. + +## ASCII UI preview + +`UI-01: Desktop export`, Settings > System > Logs, after bundle preparation. +The same native panel applies to other in-app downloads. + +```text +Current: Proposed: +Export logs bundle Export logs bundle + -> no Kandev save feedback -> macOS Save dialog + Name: kandev-diagnostic-logs.zip + Where: [user-selected folder] + [Cancel] [Save] + -> Saved / visible failure +``` + +`UI-02: Phone and browser` retain the current export action and browser-managed +download. The existing phone customizer drawer and touch target stay in place. +Native dialog appearance is owned by the operating system; the drawing only +specifies action order and states. `UI-01` maps to AC-001.1 through AC-001.4; +`UI-02` maps to AC-001.5. + +## Tests + +| Criterion | Evidence | +| ------------------ | ---------------------------------------------------------------------- | +| AC-001.1, AC-001.3 | Rust decision tests pass; packaged Save/Cancel check is pending | +| AC-001.2 | Packaged HTTP/Blob byte-hash check is pending; criterion is unverified | +| AC-001.4 | Rust completion/failure tests and LogViewer component tests pass | +| AC-001.5 | Browser and mobile Playwright download tests pass | + +## E2E tests + +`apps/desktop/e2e/desktop-launch-smoke.mjs` verifies startup and readiness. It +does not exercise downloads or native dialogs. The required native transfer +check needs a packaged macOS app and must verify Save, Cancel, suggested names, +and HTTP/Blob byte hashes before claiming AC-001.2. Exercise +`apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts`, +`apps/web/e2e/tests/task/file-tree-download.spec.ts`, and +`apps/web/e2e/tests/system/logs-page.spec.ts` so phone and browser behavior +remain covered. + +## Work orders + +- [ ] [Task 01: Handle downloads in the desktop shell](task-01-desktop-save-handler.md) (blocked on native transfer verification) +- [ ] [Task 02: Align export feedback and Blob lifetime](task-02-export-feedback.md) (implementation checks pass; package gate remains blocked) + +## Verification results + +The full Rust suite passed (79 tests), the Rust lockfile audit passed with its +existing allowed warnings, and the Linux desktop package built and passed its +startup smoke. The focused web suite passed (25 tests), along with type-check, +targeted lint, production build, localization checks, browser Logs E2E (3/3), +and mobile Logs E2E (3/3). The desktop smoke validates startup and readiness; +it does not exercise a native Save dialog or file transfer. Criterion +`AC-DESKTOP-NATIVE-DOWNLOADS-001.2` remains unverified. A packaged macOS +Save/Cancel and HTTP/Blob byte-hash run is required before this plan can be +completed, and this Linux host cannot run that WKWebView check. + +## Risks + +- WKWebView and Linux WebKit can differ in whether an anchor or object URL + raises a download event; the macOS packaged-app check is required. +- Tauri's macOS finished event can omit the path, so completion must use the + chosen destination and `success` flag. +- The asynchronous Save panel and retried transfer must be exercised in a + packaged macOS build. +- Bounded cleanup can expire an abandoned native Blob download; terminal + feedback normally releases its object URL sooner. diff --git a/docs/plans/native-desktop-downloads/task-01-desktop-save-handler.md b/docs/plans/native-desktop-downloads/task-01-desktop-save-handler.md new file mode 100644 index 0000000000..dac925a449 --- /dev/null +++ b/docs/plans/native-desktop-downloads/task-01-desktop-save-handler.md @@ -0,0 +1,114 @@ +--- +id: "01-desktop-save-handler" +title: "Handle downloads in the desktop shell" +status: blocked +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-DESKTOP-NATIVE-DOWNLOADS-001 +acceptance_criteria: + - AC-DESKTOP-NATIVE-DOWNLOADS-001.1 + - AC-DESKTOP-NATIVE-DOWNLOADS-001.2 + - AC-DESKTOP-NATIVE-DOWNLOADS-001.3 + - AC-DESKTOP-NATIVE-DOWNLOADS-001.4 +system_design: + - ../../specs/desktop/system-design/native-downloads.md +--- + +# Task 01: Handle downloads in the desktop shell + +## Summary + +Register a native Save dialog for downloads from the owned WebView. Preserve +the original response bytes and report completion or failure. + +## In scope + +- Construct the configured main WebView with a registered download callback. +- Check the owned origin, sanitize suggested filenames, handle Save/Cancel, + and correlate the finished event with the selected destination. +- Exercise both HTTP and Blob downloads in the desktop smoke harness. + +## Out of scope + +- Frontend export copy and object-URL caller changes. +- Updater package transfers and external links. + +## Acceptance + +1. An owned HTTP or Blob download opens a parented Save panel asynchronously + with the suggested filename, then retries the same URL and writes exact + bytes only after selection. +2. Cancel, untrusted origin, dialog error, and failed transfer do not claim + success and leave the user able to retry. +3. Existing desktop startup, capabilities, and folder selection still work. + +## ASCII UI preview + +`UI-01: Desktop export` from the [plan](plan.md#ascii-ui-preview): + +```text +Export logs bundle -> Save dialog -> [Cancel] or [Save] -> result +``` + +The Save dialog uses the operating system's layout. AC-001.1 through AC-001.4 +control its sequence and outcome. + +## Verification + +```bash +(cd apps/desktop/src-tauri && cargo test --features desktop-runtime) +(cd apps && pnpm --filter @kandev/desktop e2e) +``` + +Run the desktop smoke on macOS as a required platform check, with an HTTP +diagnostic ZIP and a generated Blob; record the app version, Save/Cancel +outcomes, and saved file hashes in Results. Linux success alone does not +confirm the macOS complaint is fixed. + +## Files likely touched + +- `apps/desktop/src-tauri/tauri.conf.json` +- `apps/desktop/src-tauri/src/main.rs` +- `apps/desktop/src-tauri/src/downloads.rs` (new) +- `apps/desktop/e2e/desktop-launch-smoke.mjs` +- `apps/desktop/e2e/desktop-launch-smoke.test.mjs` +- `apps/desktop/AGENTS.md` if the shell contract changes its guidance + +## Dependencies + +None. + +## Risks + +The asynchronous download callback and native dialog must cooperate on each +supported WebView. macOS may omit the finished path even after success. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/desktop/requirements/native-downloads.md) +- [System design](../../specs/desktop/system-design/native-downloads.md) +- [Native download decision](../../decisions/2026-09-25-native-desktop-downloads.md) + +## Results + +- `cargo test --features desktop-runtime downloads::tests`: all 13 download tests passed. +- `cargo test --features desktop-runtime`: all 79 Rust tests passed. +- `cargo fmt --check`: passed. +- `cargo audit`: passed after updating `rustls` to 0.23.45; eight existing allowed warnings remain. +- `node --test e2e/desktop-launch-smoke.test.mjs`: all 9 tests passed. +- `pnpm --filter @kandev/desktop e2e`: Linux desktop build and startup smoke passed. +- The startup smoke verifies backend readiness and WebView navigation. It does + not automate the native Save dialog or verify downloaded bytes. +- A packaged macOS Save/Cancel and HTTP/Blob byte check is still required. This + Linux host cannot verify WKWebView dialog behavior or saved-file hashes. +- The Save dialog is asynchronous on the WebView main thread. After selection, + the app retries the same URL with the selected destination; Rust tests cover + selection, retry, expiry, cancellation, and completion correlation. +- Status remains blocked until packaged native Save/Cancel and byte-preservation + evidence is recorded. AC-DESKTOP-NATIVE-DOWNLOADS-001.2 is unverified. diff --git a/docs/plans/native-desktop-downloads/task-02-export-feedback.md b/docs/plans/native-desktop-downloads/task-02-export-feedback.md new file mode 100644 index 0000000000..1eae51c40b --- /dev/null +++ b/docs/plans/native-desktop-downloads/task-02-export-feedback.md @@ -0,0 +1,129 @@ +--- +id: "02-export-feedback" +title: "Align export feedback and Blob lifetime" +status: blocked +wave: 2 +depends_on: + - "01-desktop-save-handler" +plan: "plan.md" +requirements: + - REQ-DESKTOP-NATIVE-DOWNLOADS-001 +acceptance_criteria: + - AC-DESKTOP-NATIVE-DOWNLOADS-001.2 + - AC-DESKTOP-NATIVE-DOWNLOADS-001.4 + - AC-DESKTOP-NATIVE-DOWNLOADS-001.5 +system_design: + - ../../specs/desktop/system-design/native-downloads.md +--- + +# Task 02: Align export feedback and Blob lifetime + +## Summary + +Keep generated object URLs alive through native completion, and make the +System Logs export report the real desktop save result. Browser and phone +downloads remain usable. + +## In scope + +- Correct direct Blob export callers with immediate URL revocation. +- Register Logs feedback before starting the HTTP request, pause the transfer + while the native Save panel is open, and report bounded retryable failures. +- Keep diagnostic toast suppression scoped to the Logs operation while its + page is active. +- Add focused browser and phone download coverage for the affected paths. + +## Out of scope + +- Changing ZIP, SVG, JSON, or task-file contents. +- Changing desktop updater behavior. + +## Acceptance + +1. System Logs does not claim a download or saved file before desktop + completion; cancel is neutral and failure is visible and retryable. +2. Every audited first-party object-URL export survives native download + initiation and retains browser download behavior. +3. Existing phone controls and browser download flows remain accessible. + +## ASCII UI preview + +`UI-01: Desktop export` and `UI-02: Phone and browser` from the +[plan](plan.md#ascii-ui-preview): + +```text +Desktop: Export -> Save dialog -> Saved / visible failure +Phone: Export -> existing browser-managed download +``` + +The phone's existing customizer drawer remains the entry point. AC-001.2, +AC-001.4, and AC-001.5 define the outcomes. + +## Verification + +```bash +(cd apps/web && pnpm exec vitest run components/settings/system/log-viewer.test.tsx lib/utils/file-download.test.ts lib/desktop/download-feedback.test.ts) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run tests/system/logs-page.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/system/mobile-logs-bundle.spec.ts) +(cd apps/web && pnpm run typecheck) +``` + +The desktop startup smoke does not exercise native downloads. Record a +packaged macOS run with Save/Cancel and HTTP/Blob byte hashes before marking +AC-001.2 complete. + +## Files likely touched + +- `apps/web/components/settings/system/log-viewer.tsx` +- `apps/web/components/settings/system/log-viewer.test.tsx` +- `apps/web/lib/utils/file-download.ts` +- `apps/web/lib/utils/file-download.test.ts` +- `apps/web/app/office/workspace/settings/export/export-preview.tsx` +- `apps/web/app/office/workspace/org/org-chart-canvas.tsx` +- `apps/web/app/office/agents/[id]/components/agent-memory-tab.tsx` +- `apps/web/e2e/tests/system/mobile-logs-bundle.spec.ts` +- `apps/web/e2e/tests/system/logs-page.spec.ts` +- `apps/web/e2e/tests/system/backups-page.spec.ts` +- `apps/web/src/locales/*` if new status copy is needed +- `docs/public/desktop-app.md` + +## Dependencies + +Task 01: the native result contract must exist before the UI consumes it. + +## Risks + +Tests must check a real browser download and native transfer, not merely that +an anchor was clicked. Avoid leaking object URLs after failed attempts. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/desktop/requirements/native-downloads.md) +- [System design](../../specs/desktop/system-design/native-downloads.md) +- Existing browser and mobile Playwright download tests + +## Results + +- `pnpm exec vitest run components/settings/system/log-viewer.test.tsx lib/utils/file-download.test.ts lib/desktop/download-feedback.test.ts lib/desktop/download-ready.test.ts`: all 25 tests passed. Coverage includes delayed native Blob initiation, Save-panel lifetime, terminal cleanup, bounded cleanup, no-result recovery, listener failure and retry, ready-URL replay, route-aware feedback, and an unrelated export using the diagnostic filename. +- `pnpm exec vitest run lib/desktop/external-links-usage.test.ts`: all 5 ownership assertions passed after changing the Logs download helper. +- Targeted ESLint, `pnpm run typecheck`, and the production Vite build passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` passed. +- `pnpm e2e:run tests/system/logs-page.spec.ts`: all 3 browser tests passed after a fresh production build. +- `pnpm e2e:run --project mobile-chrome tests/system/mobile-logs-bundle.spec.ts`: all 3 mobile tests passed after a fresh production build. +- `node --test scripts/validate-public-docs.test.mjs`: all 62 tests passed; the public-doc validator accepted all 47 pages. +- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all` passed. +- The Linux desktop package built and its startup smoke passed. It does not + automate the native Save dialog or verify downloaded bytes. +- The diagnostic listener is established before the HTTP anchor is clicked. + Save-panel selection and transfer completion have separate bounded timeouts; + a missing listener or terminal event leaves the export retryable. +- Packaged macOS Save/Cancel and byte-preservation evidence remains required + before AC-001.2 is verified. This Linux host cannot run that WKWebView check. +- Status remains blocked on the same native transfer gate as Task 01. The + frontend and browser/mobile checks are complete; AC-001.2 is unverified. diff --git a/docs/public/desktop-app.md b/docs/public/desktop-app.md index ab6f56c520..07e9f3f295 100644 --- a/docs/public/desktop-app.md +++ b/docs/public/desktop-app.md @@ -20,13 +20,13 @@ The [CLI](cli.md) is a better fit for headless machines, browser-only access, or Download artifacts from [GitHub Releases](https://github.com/kdlbs/kandev/releases). Each desktop filename begins with `kandev-desktop--`; use the installer format for your platform, not an updater archive. -| Platform label | Installer formats | In-app update support | -|---|---|---| -| `macos-arm64` | `.dmg` | Signed `.app.tar.gz` updater bundle | -| `macos-x64` | `.dmg` | Signed `.app.tar.gz` updater bundle | -| `linux-arm64` | AppImage, `.deb`, `.rpm` | AppImage installations only | -| `linux-x64` | AppImage, `.deb`, `.rpm` | AppImage installations only | -| `windows-x64` | NSIS `.exe` | Signed `.nsis.zip` updater bundle | +| Platform label | Installer formats | In-app update support | +| -------------- | ------------------------ | ----------------------------------- | +| `macos-arm64` | `.dmg` | Signed `.app.tar.gz` updater bundle | +| `macos-x64` | `.dmg` | Signed `.app.tar.gz` updater bundle | +| `linux-arm64` | AppImage, `.deb`, `.rpm` | AppImage installations only | +| `linux-x64` | AppImage, `.deb`, `.rpm` | AppImage installations only | +| `windows-x64` | NSIS `.exe` | Signed `.nsis.zip` updater bundle | Windows ARM64 is not a native release target. x64 emulation may run the installer on some ARM Windows systems, but that is dependency-bound and not covered by the release matrix. @@ -193,7 +193,7 @@ Linux `.deb` and `.rpm` installations must be updated through their package mana Kandev has no registered public URL scheme, file association, or command-line deep-link protocol. A second application launch only activates the existing window. Links and routes inside the Kandev UI still work normally, but an external `kandev://…` link is not a supported product path. -External `http`, `https`, and `mailto` destinations open through the system browser or mail client. The desktop bridge rejects URLs with embedded credentials, unsupported schemes, `localhost`/subdomains of `localhost`, loopback IPs, and unspecified IPs; Kandev routes, previews, downloads, and blob URLs remain in the WebView. RFC 1918/private-LAN hosts are not categorically blocked by this validator. +External `http`, `https`, and `mailto` destinations open in the system browser or mail client. The desktop bridge blocks links with embedded credentials and unsupported schemes. It keeps `localhost` names, loopback IPs, and unspecified IPs in the WebView. It does not block all private-LAN hosts. Kandev routes, previews, and downloads stay in the WebView. Downloads that you start inside the desktop app open the system Save dialog. Kandev reports whether the transfer succeeds. The WebView transfers the file data. Native notifications are limited to selected agent-turn-finished and agent-needs-an-answer events, plus session failures. Semantic session events diff --git a/docs/specs/desktop/requirements/native-downloads.md b/docs/specs/desktop/requirements/native-downloads.md new file mode 100644 index 0000000000..44f37030d8 --- /dev/null +++ b/docs/specs/desktop/requirements/native-downloads.md @@ -0,0 +1,51 @@ +--- +status: active +system: desktop +created: 2026-09-25 +owners: + - kandev +--- + +# Native Downloads Requirements + +## Overview + +The installed desktop app uses the shared web interface, but saving an export is +an operating-system action. The desktop system owns that boundary for all +first-party downloads started inside its window. The feature that creates each +file continues to own its contents and access rules. + +## Requirements + +### REQ-DESKTOP-NATIVE-DOWNLOADS-001: Save an in-app download + +**Intent:** A desktop user can choose where an explicitly requested file is +saved and can tell whether the save succeeded. + +#### Acceptance criteria + +- **AC-DESKTOP-NATIVE-DOWNLOADS-001.1:** When a user starts a file download in + the desktop app, the system shall present the operating system's Save dialog + with the file's suggested name before writing the file. +- **AC-DESKTOP-NATIVE-DOWNLOADS-001.2:** When the user selects a destination, + the system shall save the requested file there with its original bytes. This + applies to backend-served files and files generated in the interface, including + the diagnostic log bundle. +- **AC-DESKTOP-NATIVE-DOWNLOADS-001.3:** When the user cancels the Save dialog, + the system shall leave the destination unchanged and shall not report a + successful save. +- **AC-DESKTOP-NATIVE-DOWNLOADS-001.4:** When a requested desktop save fails, + the system shall show a visible failure and allow the user to try the action + again. It shall not claim that a file was saved before completion. +- **AC-DESKTOP-NATIVE-DOWNLOADS-001.5:** Browser and phone downloads shall retain + their browser-managed behavior and existing entry points. + +## Out of scope + +- Desktop updater package downloads, which are part of the signed update flow. +- Downloads opened in the external system browser. +- Changing export contents or permissions. + +## System design + +[Native downloads](../system-design/native-downloads.md). diff --git a/docs/specs/desktop/system-design/desktop-tauri-app.md b/docs/specs/desktop/system-design/desktop-tauri-app.md index ffafaaa8d3..7523802268 100644 --- a/docs/specs/desktop/system-design/desktop-tauri-app.md +++ b/docs/specs/desktop/system-design/desktop-tauri-app.md @@ -8,6 +8,7 @@ updated: 2026-08-27 owners: - tbd --- + # Tauri Desktop App System Design ## Purpose and boundaries @@ -16,8 +17,8 @@ This design defines the technical contract for `REQ-DESKTOP-DESKTOP-TAURI-APP-00 ## Requirement mapping -| Requirement | Design section | -| --- | --- | +| Requirement | Design section | +| ----------------------------------- | --------------------------------------------- | | `REQ-DESKTOP-DESKTOP-TAURI-APP-001` | [Desktop architecture](#desktop-architecture) | ## Desktop architecture @@ -198,8 +199,9 @@ unrestricted URL opening. Its directory command only returns a folder selected t system panel. External `http`, `https`, and `mailto` links open in the system browser/client. Internal loopback -navigation, downloads, and blob URLs remain in the WebView unless an existing workflow specifies -otherwise. +navigation and blob URLs remain in the WebView. In-app file downloads use the +[native download contract](native-downloads.md) for destination and completion; +the WebView still transfers their bytes. The launch boundary remains: diff --git a/docs/specs/desktop/system-design/native-downloads.md b/docs/specs/desktop/system-design/native-downloads.md new file mode 100644 index 0000000000..7edc433b3d --- /dev/null +++ b/docs/specs/desktop/system-design/native-downloads.md @@ -0,0 +1,107 @@ +--- +status: current +system: desktop +requirements: + - REQ-DESKTOP-NATIVE-DOWNLOADS-001 +created: 2026-09-25 +owners: + - kandev +--- + +# Native Downloads System Design + +## Purpose and boundaries + +The Tauri shell owns the destination and completion of downloads initiated by +its owned WebView. Existing backend routes and React export actions own the +file bytes, names, authentication, and feature-specific preparation. The web +browser and phone continue using their normal download behavior. + +## Requirement mapping + +| Requirement | Design section | +| ---------------------------------- | ---------------------------------------------------------------------------------------------- | +| `REQ-DESKTOP-NATIVE-DOWNLOADS-001` | [Desktop download flow](#desktop-download-flow), [Failure and recovery](#failure-and-recovery) | + +## Implementation and verification status + +`apps/desktop/src-tauri/src/main.rs` constructs the configured main WebView +with a download callback before navigation. The callback checks the owned +origin and uses the non-blocking native Save dialog. After selection, the SPA +retries the same URL through the WebView with the selected destination. The +shell reports selection, cancellation, and transfer completion. The SPA shows +diagnostic-bundle results and global results for other in-app downloads. + +The Linux desktop smoke checks startup and readiness only. It does not automate +the native Save panel or compare downloaded bytes. AC-DESKTOP-NATIVE-DOWNLOADS-001.2 +remains unverified until a packaged macOS run checks Save, Cancel, and the exact +bytes of HTTP and Blob downloads. + +## Desktop download flow + +Create the main WebView through `WebviewWindowBuilder::from_config` so its +`on_download` callback is registered before navigation. Disable automatic +creation of the same configured window. Preserve its configured label, size, +visibility, capabilities, and existing startup handoff. + +For `DownloadEvent::Requested`, check that the current WebView is the owned, +health-verified Kandev origin. Accept only a download from that same origin or +an object URL created by that origin. Use the engine's suggested destination +only for a safe suggested filename. Reserve the URL and open the existing +native dialog plugin's Save panel asynchronously, parented to the main window; +reject this first request while the user chooses a destination. On selection, +record the absolute destination and notify the SPA to retry the same URL. For +that retry, set the callback's destination and allow the WebView to transfer +the original response bytes. On cancellation, release the reservation and +reject the request. Expire abandoned selections after a bounded interval. Do +not accept a caller-supplied path or a new general filesystem command from the +SPA. The backend keeps enforcing authentication and authorization for HTTP +downloads. + +For `DownloadEvent::Finished`, treat `success` as authoritative. On macOS the +event's path can be absent even after success, so correlate completion with the +destination selected at request time. A failed transfer produces a visible +localized error in the SPA or an equivalent native error surface. The frontend +must not claim that the diagnostic ZIP is downloading or saved immediately +after invoking its anchor; it must reflect a pending or finished result in the +desktop path. Cancellation is neutral. Handle consecutive and simultaneous +requests without mixing their filenames or completion results. + +The source audit found these first-party entry points: + +| Source | Transfer form | +| ------------------------------------------------------------------------ | --------------------------------------- | +| System Logs bundle and System Backups | authenticated HTTP anchor | +| Office task documents | authenticated HTTP anchor | +| Task files, file viewer, and full chat text | object URL through `file-download.ts` | +| Automation ZIP and canvas bundle/source | fetched Blob through `file-download.ts` | +| Selected Office configuration, organization chart SVG, agent memory JSON | local Blob and object URL | + +Preserve desktop object URLs through the native terminal event. If completion +feedback does not arrive, use bounded cleanup for an abandoned attempt. Keep +the browser download helper's existing behavior. Do not divert the desktop +updater's signed package transfer or links opened in the system browser. + +## Failure and recovery + +Register the diagnostic result listener before triggering its download. If +listener setup fails, do not start the request; show a retryable error. A +missing terminal result has a bounded transfer timeout. While the Save panel is +open, use a longer bounded selection timeout so a user taking time to choose a +location does not receive a premature failure. If the user cancels or a +transfer fails, release the reservation and object URL without success copy. +Never silently write to a fallback directory. Test the browser path separately +to catch regressions in HTTP and Blob downloads. + +## Validation + +Rust tests cover origin and URL checks, safe suggested names, cancellation, +selection, and completion correlation. A desktop integration smoke covers both +HTTP and Blob sources, cancellation, and bytes at the chosen destination. Run +the full user flow on macOS because Linux WebKit and macOS WKWebView can differ. +Existing browser and mobile Playwright download tests cover unchanged web +behavior; add focused coverage where the source audit found an untested flow. + +## Related decisions + +- [ADR-2026-09-25-native-desktop-downloads](../../../decisions/2026-09-25-native-desktop-downloads.md)