feat: add native desktop download handling (#3933)

This commit is contained in:
Carlos Florêncio
2026-09-25 18:07:55 +01:00
committed by GitHub
parent f099a46dc7
commit 7d0c4ec2f1
37 changed files with 2035 additions and 67 deletions
@@ -15,6 +15,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const backendRsPath = resolve(__dirname, "../src-tauri/src/backend.rs"); const backendRsPath = resolve(__dirname, "../src-tauri/src/backend.rs");
const mainRsPath = resolve(__dirname, "../src-tauri/src/main.rs"); const mainRsPath = resolve(__dirname, "../src-tauri/src/main.rs");
const shellRsPath = resolve(__dirname, "../src-tauri/src/shell.rs"); const shellRsPath = resolve(__dirname, "../src-tauri/src/shell.rs");
const tauriConfigPath = resolve(__dirname, "../src-tauri/tauri.conf.json");
async function withTempDir(run) { async function withTempDir(run) {
const dir = await mkdtemp(join(tmpdir(), "wait-for-file-")); 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/); 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 () => { test("fullscreen uses the platform-native desktop accelerators", async () => {
const mainSource = await readFile(mainRsPath, "utf8"); const mainSource = await readFile(mainRsPath, "utf8");
+4 -4
View File
@@ -2904,9 +2904,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.42" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -2966,9 +2966,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
+582
View File
@@ -0,0 +1,582 @@
use std::{
collections::HashMap,
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use url::Url;
#[derive(Clone, Default)]
pub struct DownloadTracker {
pending: Arc<Mutex<HashMap<String, PendingDownload>>>,
next_attempt: Arc<AtomicU64>,
}
#[derive(Clone)]
enum PendingDownload {
Selecting { attempt: u64 },
Selected { attempt: u64, destination: PathBuf },
Transferring { destination: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadCompletion {
pub destination: PathBuf,
pub success: bool,
}
impl DownloadTracker {
#[cfg(test)]
pub fn begin(&self, url: &Url, destination: PathBuf) -> bool {
if destination.as_os_str().is_empty() {
return false;
}
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
if pending.contains_key(url.as_str()) {
return false;
}
pending.insert(
url.as_str().to_string(),
PendingDownload::Transferring { destination },
);
true
}
pub fn begin_selection(&self, url: &Url) -> Option<u64> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
if pending.contains_key(url.as_str()) {
return None;
}
let attempt = self.next_attempt.fetch_add(1, Ordering::Relaxed);
pending.insert(
url.as_str().to_string(),
PendingDownload::Selecting { attempt },
);
Some(attempt)
}
pub fn select_destination(&self, url: &Url, attempt: u64, destination: PathBuf) -> bool {
if destination.as_os_str().is_empty() {
return false;
}
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let Some(download) = pending.get_mut(url.as_str()) else {
return false;
};
if !matches!(download, PendingDownload::Selecting { attempt: current } if *current == attempt)
{
return false;
}
*download = PendingDownload::Selected {
attempt,
destination,
};
true
}
pub fn cancel_selection(&self, url: &Url, attempt: u64) -> bool {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let is_attempt = matches!(
pending.get(url.as_str()),
Some(PendingDownload::Selecting { attempt: current }) if *current == attempt
);
if is_attempt {
pending.remove(url.as_str());
}
is_attempt
}
pub fn expire_selection(&self, url: &Url, attempt: u64) -> bool {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let is_attempt = matches!(
pending.get(url.as_str()),
Some(PendingDownload::Selecting { attempt: current })
| Some(PendingDownload::Selected { attempt: current, .. })
if *current == attempt
);
if is_attempt {
pending.remove(url.as_str());
}
is_attempt
}
pub fn start_selected_transfer(&self, url: &Url) -> Option<PathBuf> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let download = pending.get_mut(url.as_str())?;
let PendingDownload::Selected { destination, .. } = download else {
return None;
};
let destination = destination.clone();
*download = PendingDownload::Transferring {
destination: destination.clone(),
};
Some(destination)
}
pub fn finish(&self, url: &Url, success: bool) -> Option<DownloadCompletion> {
let mut pending = self
.pending
.lock()
.expect("download tracker mutex poisoned");
let destination = match pending.get(url.as_str())? {
PendingDownload::Transferring { destination } => destination.clone(),
_ => return None,
};
pending.remove(url.as_str());
Some(DownloadCompletion {
destination,
success,
})
}
#[cfg(test)]
fn pending_count(&self) -> usize {
self.pending
.lock()
.expect("download tracker mutex poisoned")
.len()
}
}
fn is_owned_download(
backend: &crate::backend::BackendState,
page_url: &Url,
download_url: &Url,
) -> bool {
backend.accepts_url(page_url.as_str()) && backend.accepts_url(download_url.as_str())
}
fn safe_suggested_filename(suggested_path: &std::path::Path) -> Option<String> {
let suggestion = suggested_path.to_string_lossy();
let name = suggestion.rsplit(['/', '\\']).next()?;
if name.is_empty() {
return None;
}
let cleaned: String = name
.chars()
.take(180)
.map(|character| {
if character.is_control() || "<>:\"|?*".contains(character) {
'_'
} else {
character
}
})
.collect();
let cleaned = cleaned.trim().trim_end_matches('.');
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
None
} else {
Some(cleaned.to_string())
}
}
#[cfg(feature = "desktop-runtime")]
mod runtime {
use super::{is_owned_download, safe_suggested_filename, DownloadTracker};
use crate::backend::BackendState;
use serde::Serialize;
use std::time::Duration;
use tauri::{webview::DownloadEvent, AppHandle, Emitter, Manager, WebviewWindow};
use tauri_plugin_dialog::DialogExt;
use url::Url;
const MAIN_WINDOW_LABEL: &str = "main";
const DOWNLOAD_FEEDBACK_EVENT: &str = "kandev-desktop-v1-download";
const DOWNLOAD_READY_EVENT: &str = "kandev-desktop-v1-download-ready";
const SELECTION_TIMEOUT: Duration = Duration::from_secs(30 * 60);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
enum DownloadStatus {
Selecting,
Started,
Saved,
Cancelled,
Failed,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadFeedback {
status: DownloadStatus,
url: String,
file_name: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadReady {
url: String,
file_name: String,
}
fn emit_feedback(window: &WebviewWindow, url: &Url, file_name: &str, status: DownloadStatus) {
let _ = window.emit(
DOWNLOAD_FEEDBACK_EVENT,
DownloadFeedback {
status,
url: url.as_str().to_string(),
file_name: file_name.to_string(),
},
);
}
fn emit_download_ready(window: &WebviewWindow, url: &Url, file_name: &str) {
let _ = window.emit(
DOWNLOAD_READY_EVENT,
DownloadReady {
url: url.as_str().to_string(),
file_name: file_name.to_string(),
},
);
}
pub fn handle_download_event(
app: &AppHandle,
backend: &BackendState,
tracker: &DownloadTracker,
event: DownloadEvent<'_>,
) -> bool {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return false;
};
match event {
DownloadEvent::Requested { url, destination } => {
let Ok(page_url) = window.url() else {
return false;
};
if !backend.accepts_url(page_url.as_str()) {
return false;
}
let Some(file_name) = safe_suggested_filename(destination) else {
emit_feedback(&window, &url, "download", DownloadStatus::Failed);
return false;
};
if backend.require_owned_origin(&window).is_err() {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
}
if !is_owned_download(backend, &page_url, &url) {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
}
if let Some(selected_path) = tracker.start_selected_transfer(&url) {
*destination = selected_path;
emit_feedback(&window, &url, &file_name, DownloadStatus::Started);
return true;
}
let Some(attempt) = tracker.begin_selection(&url) else {
emit_feedback(&window, &url, &file_name, DownloadStatus::Failed);
return false;
};
emit_feedback(&window, &url, &file_name, DownloadStatus::Selecting);
let callback_window = window.clone();
let callback_tracker = tracker.clone();
let callback_url = url.clone();
let callback_file_name = file_name.clone();
let timeout_tracker = tracker.clone();
let timeout_url = url.clone();
let timeout_window = window.clone();
let timeout_file_name = file_name.clone();
std::thread::spawn(move || {
std::thread::sleep(SELECTION_TIMEOUT);
if timeout_tracker.expire_selection(&timeout_url, attempt) {
emit_feedback(
&timeout_window,
&timeout_url,
&timeout_file_name,
DownloadStatus::Failed,
);
}
});
app.dialog()
.file()
.set_file_name(&file_name)
.set_parent(&window)
.save_file(move |selected| {
let Some(selected) = selected else {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Cancelled,
);
return;
};
let selected_path = match selected.into_path() {
Ok(path) => path,
Err(_) => {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Failed,
);
return;
}
};
if callback_tracker.select_destination(
&callback_url,
attempt,
selected_path,
) {
emit_download_ready(
&callback_window,
&callback_url,
&callback_file_name,
);
} else {
callback_tracker.cancel_selection(&callback_url, attempt);
emit_feedback(
&callback_window,
&callback_url,
&callback_file_name,
DownloadStatus::Failed,
);
}
});
false
}
DownloadEvent::Finished { url, success, .. } => {
if let Some(completion) = tracker.finish(&url, success) {
let file_name = completion
.destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("download");
let status = if completion.success {
DownloadStatus::Saved
} else {
DownloadStatus::Failed
};
if window
.url()
.is_ok_and(|page_url| backend.accepts_url(page_url.as_str()))
{
emit_feedback(&window, &url, file_name, status);
}
}
true
}
_ => true,
}
}
}
#[cfg(feature = "desktop-runtime")]
pub use runtime::handle_download_event;
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::BackendState;
use std::path::PathBuf;
use url::Url;
const OWNED_ORIGIN: &str = "http://127.0.0.1:38430";
fn owned_backend() -> BackendState {
let backend = BackendState::default();
backend.set_owned_origin(OWNED_ORIGIN).unwrap();
backend
}
#[test]
fn accepts_owned_http_and_blob_downloads_from_the_owned_page() {
let page = Url::parse("http://127.0.0.1:38430/settings/system/logs").unwrap();
let http = Url::parse("http://127.0.0.1:38430/api/logs.zip").unwrap();
let blob = Url::parse("blob:http://127.0.0.1:38430/1234-5678").unwrap();
let backend = owned_backend();
assert!(is_owned_download(&backend, &page, &http));
assert!(is_owned_download(&backend, &page, &blob));
}
#[test]
fn rejects_untrusted_pages_and_download_origins() {
let owned_page = Url::parse("http://127.0.0.1:38430/settings").unwrap();
let remote_page = Url::parse("https://example.com").unwrap();
let owned_download = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let remote_download = Url::parse("https://example.com/export.zip").unwrap();
let remote_blob = Url::parse("blob:https://example.com/1234").unwrap();
let backend = owned_backend();
assert!(!is_owned_download(&backend, &remote_page, &owned_download));
assert!(!is_owned_download(&backend, &owned_page, &remote_download));
assert!(!is_owned_download(&backend, &owned_page, &remote_blob));
}
#[test]
fn sanitizes_a_suggested_name_without_accepting_path_components() {
assert_eq!(
safe_suggested_filename(&PathBuf::from(r"C:\private\..\logs.zip")),
Some("logs.zip".to_string())
);
assert_eq!(
safe_suggested_filename(&PathBuf::from("/tmp/diagnostic\nlogs.zip")),
Some("diagnostic_logs.zip".to_string())
);
assert_eq!(safe_suggested_filename(&PathBuf::from("/tmp/..")), None);
}
#[test]
fn completion_uses_the_selected_destination_when_the_engine_omits_its_path() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let selected = PathBuf::from("/Users/example/Documents/export.zip");
assert!(tracker.begin(&url, selected.clone()));
assert_eq!(
tracker.finish(&url, true),
Some(DownloadCompletion {
destination: selected,
success: true,
})
);
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn failed_completion_releases_the_url_for_a_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let selected = PathBuf::from("/Users/example/Documents/export.zip");
assert!(tracker.begin(&url, selected.clone()));
assert_eq!(
tracker.finish(&url, false),
Some(DownloadCompletion {
destination: selected.clone(),
success: false,
})
);
assert!(tracker.begin(&url, selected));
}
#[test]
fn rejects_a_transfer_without_a_selected_destination() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
assert!(!tracker.begin(&url, PathBuf::new()));
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn rejects_an_empty_save_dialog_destination() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let attempt = tracker.begin_selection(&url).unwrap();
assert!(!tracker.select_destination(&url, attempt, PathBuf::new()));
assert!(tracker.cancel_selection(&url, attempt));
assert_eq!(tracker.pending_count(), 0);
}
#[test]
fn native_download_hook_uses_the_nonblocking_save_dialog_api() {
let source = include_str!("downloads.rs")
.split("#[cfg(test)]\nmod tests")
.next()
.unwrap();
assert!(source.contains(".save_file("));
assert!(!source.contains(".blocking_save_file("));
}
#[test]
fn selected_destination_is_used_for_the_retried_request_once() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let destination = PathBuf::from("/tmp/export.zip");
let attempt = tracker.begin_selection(&url).unwrap();
assert!(tracker.select_destination(&url, attempt, destination.clone()));
assert_eq!(
tracker.start_selected_transfer(&url),
Some(destination.clone())
);
assert_eq!(tracker.start_selected_transfer(&url), None);
assert_eq!(tracker.finish(&url, true).unwrap().destination, destination);
}
#[test]
fn cancelled_and_expired_selections_release_the_url_for_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let cancelled = tracker.begin_selection(&url).unwrap();
assert!(tracker.cancel_selection(&url, cancelled));
let expired = tracker.begin_selection(&url).unwrap();
assert!(tracker.select_destination(&url, expired, PathBuf::from("/tmp/export.zip")));
assert!(tracker.expire_selection(&url, expired));
assert_eq!(tracker.pending_count(), 0);
assert!(tracker.begin_selection(&url).is_some());
}
#[test]
fn an_old_selection_timeout_cannot_remove_a_newer_retry() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
let expired = tracker.begin_selection(&url).unwrap();
assert!(tracker.cancel_selection(&url, expired));
let retry = tracker.begin_selection(&url).unwrap();
assert!(!tracker.expire_selection(&url, expired));
assert!(tracker.select_destination(&url, retry, PathBuf::from("/tmp/retry.zip")));
}
#[test]
fn concurrent_requests_for_the_same_url_cannot_mix_destinations() {
let tracker = DownloadTracker::default();
let url = Url::parse("http://127.0.0.1:38430/export.zip").unwrap();
assert!(tracker.begin(&url, PathBuf::from("/tmp/first.zip")));
assert!(!tracker.begin(&url, PathBuf::from("/tmp/second.zip")));
assert_eq!(
tracker.finish(&url, true).unwrap().destination,
PathBuf::from("/tmp/first.zip")
);
assert!(tracker.begin(&url, PathBuf::from("/tmp/second.zip")));
}
#[test]
fn simultaneous_http_and_blob_completions_keep_their_selected_destinations() {
let tracker = DownloadTracker::default();
let http = Url::parse("http://127.0.0.1:38430/logs.zip").unwrap();
let blob = Url::parse("blob:http://127.0.0.1:38430/1234-5678").unwrap();
let http_path = PathBuf::from("/tmp/logs.zip");
let blob_path = PathBuf::from("/tmp/chart.svg");
assert!(tracker.begin(&http, http_path.clone()));
assert!(tracker.begin(&blob, blob_path.clone()));
assert_eq!(tracker.finish(&blob, true).unwrap().destination, blob_path);
assert_eq!(tracker.finish(&http, true).unwrap().destination, http_path);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod backend; pub mod backend;
pub mod downloads;
pub mod external_links; pub mod external_links;
pub mod folder_picker; pub mod folder_picker;
pub mod native_notifications; pub mod native_notifications;
+25 -4
View File
@@ -1,5 +1,5 @@
use kandev_desktop::{ use kandev_desktop::{
backend, external_links, folder_picker, backend, downloads, external_links, folder_picker,
native_notifications::{self, NativeNotificationState}, native_notifications::{self, NativeNotificationState},
shell::{self, MenuAction, ZoomState}, shell::{self, MenuAction, ZoomState},
updater::{self, UpdaterState}, updater::{self, UpdaterState},
@@ -8,6 +8,7 @@ use kandev_desktop::{
use std::thread; use std::thread;
use tauri::{ use tauri::{
menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}, menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu},
webview::WebviewWindowBuilder,
Emitter, Manager, RunEvent, WindowEvent, Emitter, Manager, RunEvent, WindowEvent,
}; };
@@ -48,9 +49,29 @@ fn main() {
.menu(build_menu) .menu(build_menu)
.on_menu_event(handle_menu_event) .on_menu_event(handle_menu_event)
.setup(|app| { .setup(|app| {
let window = app let window_config = app
.get_webview_window(MAIN_WINDOW_LABEL) .config()
.expect("main window should exist"); .app
.windows
.iter()
.find(|config| config.label == MAIN_WINDOW_LABEL)
.cloned()
.expect("main window config should exist");
let backend_state = app.state::<backend::BackendState>().inner().clone();
let download_tracker = downloads::DownloadTracker::default();
app.manage(download_tracker.clone());
let download_app = app.handle().clone();
let download_tracker_for_handler = download_tracker.clone();
let window = WebviewWindowBuilder::from_config(app, &window_config)?
.on_download(move |_webview, event| {
downloads::handle_download_event(
&download_app,
&backend_state,
&download_tracker_for_handler,
event,
)
})
.build()?;
let state_path = app.path().app_data_dir()?.join(WINDOW_STATE_FILE); let state_path = app.path().app_data_dir()?.join(WINDOW_STATE_FILE);
let window_state = WindowStateStore::new(state_path); let window_state = WindowStateStore::new(state_path);
if let Err(err) = window_state.restore(&window) { if let Err(err) = window_state.restore(&window) {
+1
View File
@@ -13,6 +13,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"create": false,
"title": "Kandev", "title": "Kandev",
"width": 1280, "width": 1280,
"height": 900, "height": 900,
@@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { toast } from "@/lib/toast/sonner"; import { toast } from "@/lib/toast/sonner";
import type { AgentProfile } from "@/lib/state/slices/office/types"; import type { AgentProfile } from "@/lib/state/slices/office/types";
import * as officeApi from "@/lib/api/domains/office-api"; import * as officeApi from "@/lib/api/domains/office-api";
import { triggerBlobDownload } from "@/lib/utils/file-download";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type MemoryEntry = { type MemoryEntry = {
@@ -79,12 +80,7 @@ function useMemoryActions(agent: AgentProfile) {
const res = await officeApi.exportMemory(agent.id); const res = await officeApi.exportMemory(agent.id);
const data = (res as { memory?: MemoryEntry[] }).memory ?? []; const data = (res as { memory?: MemoryEntry[] }).memory ?? [];
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob); triggerBlobDownload(blob, `${agent.name}-memory.json`);
const a = document.createElement("a");
a.href = url;
a.download = `${agent.name}-memory.json`;
a.click();
URL.revokeObjectURL(url);
}, [agent.id, agent.name]); }, [agent.id, agent.name]);
return { entries, handleDelete, handleClearAll, handleExport }; return { entries, handleDelete, handleClearAll, handleExport };
@@ -13,6 +13,7 @@ import {
CARD_H, CARD_H,
} from "./org-tree-layout"; } from "./org-tree-layout";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { triggerBlobDownload } from "@/lib/utils/file-download";
type OrgChartCanvasProps = { type OrgChartCanvasProps = {
agents: AgentProfile[]; agents: AgentProfile[];
@@ -39,14 +40,7 @@ function exportSvg(svg: SVGSVGElement) {
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svg); const svgStr = serializer.serializeToString(svg);
const blob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" }); const blob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob); triggerBlobDownload(blob, "org-chart.svg");
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);
} }
export function OrgChartCanvas({ agents }: OrgChartCanvasProps) { export function OrgChartCanvas({ agents }: OrgChartCanvasProps) {
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider"; import { useAppStore } from "@/components/state-provider";
import { ApiError } from "@/lib/api/client"; import { ApiError } from "@/lib/api/client";
import * as officeApi from "@/lib/api/domains/office-api"; 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 { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { ExportFileTree } from "./export-file-tree"; import { ExportFileTree } from "./export-file-tree";
import { ExportFilePreview } from "./export-file-preview"; import { ExportFilePreview } from "./export-file-preview";
@@ -270,12 +271,7 @@ export function ExportPreview() {
) { ) {
return; return;
} }
const url = URL.createObjectURL(blob); triggerBlobDownload(blob, "kandev-config-selected.zip");
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "kandev-config-selected.zip";
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
} catch (error) { } catch (error) {
setDownloadErrorKey( setDownloadErrorKey(
error instanceof ApiError && error.status === 409 error instanceof ApiError && error.status === 409
@@ -3,6 +3,8 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useRouter } from "@/lib/routing/client-router"; import { useRouter } from "@/lib/routing/client-router";
import { useDockviewStore } from "@/lib/state/dockview-store"; 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 { createDesktopV1Adapter, type DesktopV1Adapter } from "@/lib/desktop/adapter";
import { import {
createDesktopCommandActions, createDesktopCommandActions,
@@ -16,6 +18,9 @@ import {
} from "@/lib/desktop/external-links"; } from "@/lib/desktop/external-links";
import { requestNewTaskCreation } from "@/lib/desktop/new-task-request"; import { requestNewTaskCreation } from "@/lib/desktop/new-task-request";
import { createTauriEventTransport } from "@/lib/desktop/tauri-event-transport"; 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 { desktopUpdater } from "@/lib/desktop/updater-client";
import type { DesktopUpdaterAdapter } from "@/lib/desktop/updater-adapter"; import type { DesktopUpdaterAdapter } from "@/lib/desktop/updater-adapter";
@@ -31,6 +36,7 @@ export function DesktopCommandHost({
updater?: DesktopUpdaterAdapter; updater?: DesktopUpdaterAdapter;
}) { }) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation();
const actions = useMemo( const actions = useMemo(
() => () =>
createDesktopCommandActions({ createDesktopCommandActions({
@@ -80,6 +86,47 @@ export function DesktopCommandHost({
}; };
}, [adapter, router, updater]); }, [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]); useEffect(() => subscribeDesktopExternalLinks(document, externalLinks), [externalLinks]);
return null; return null;
@@ -1,15 +1,26 @@
import { StrictMode } from "react"; 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 { 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"; import { LogViewer } from "./log-viewer";
const createMock = vi.fn(); const createMock = vi.fn();
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const capabilitiesMock = vi.fn(); const capabilitiesMock = vi.fn();
const sessionsMock = 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 CHECKED_STATE = "checked";
const DATA_STATE_ATTRIBUTE = "data-state"; const DATA_STATE_ATTRIBUTE = "data-state";
let originalTauriInternals: PropertyDescriptor | undefined;
vi.mock("@/lib/api/domains/system-api", () => ({ vi.mock("@/lib/api/domains/system-api", () => ({
createDiagnosticBundle: (...args: unknown[]) => createMock(...args), createDiagnosticBundle: (...args: unknown[]) => createMock(...args),
@@ -20,6 +31,7 @@ vi.mock("@/lib/api/domains/system-api", () => ({
})); }));
beforeEach(() => { beforeEach(() => {
originalTauriInternals = Object.getOwnPropertyDescriptor(window, "__TAURI_INTERNALS__");
createMock.mockReset(); createMock.mockReset();
fetchMock.mockReset(); fetchMock.mockReset();
capabilitiesMock.mockReset(); capabilitiesMock.mockReset();
@@ -36,10 +48,194 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.useRealTimers();
if (originalTauriInternals) {
Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
} else {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
}
vi.restoreAllMocks(); 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(<LogViewer />);
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<typeof setTimeout>,
);
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 () => { it("uses one customizer with the standard backend and frontend defaults", async () => {
createMock.mockResolvedValue({ createMock.mockResolvedValue({
id: "bundle-1", id: "bundle-1",
@@ -61,7 +257,7 @@ describe("LogViewer", () => {
expect(screen.queryByText("Recent log output")).toBeNull(); expect(screen.queryByText("Recent log output")).toBeNull();
expect(screen.queryByTestId("download-diagnostic-bundle")).toBeNull(); expect(screen.queryByTestId("download-diagnostic-bundle")).toBeNull();
expect(screen.queryByTestId("download-diagnostic-bundle-with-acp")).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( expect(
screen.getByRole("checkbox", { name: "Backend logs" }).getAttribute(DATA_STATE_ATTRIBUTE), screen.getByRole("checkbox", { name: "Backend logs" }).getAttribute(DATA_STATE_ATTRIBUTE),
).toBe(CHECKED_STATE); ).toBe(CHECKED_STATE);
@@ -71,7 +267,7 @@ describe("LogViewer", () => {
expect( expect(
screen.getByRole("checkbox", { name: "Runtime index" }).getAttribute(DATA_STATE_ATTRIBUTE), screen.getByRole("checkbox", { name: "Runtime index" }).getAttribute(DATA_STATE_ATTRIBUTE),
).not.toBe(CHECKED_STATE); ).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(createMock).toHaveBeenCalledWith(["backend", "frontend"], []));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("bundle-1"), { timeout: 1_500 }); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith("bundle-1"), { timeout: 1_500 });
@@ -105,14 +301,14 @@ describe("LogViewer", () => {
}, },
]); ]);
render(<LogViewer />); render(<LogViewer />);
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")).toBeNull();
expect(screen.queryByTestId("download-diagnostic-bundle-with-acp")).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" })); fireEvent.click(screen.getByRole("checkbox", { name: "ACP debug messages" }));
expect((await screen.findAllByText("ACP debug messages")).length).toBeGreaterThanOrEqual(1); expect((await screen.findAllByText("ACP debug messages")).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/can contain prompts/)).toBeTruthy(); 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); expect(create.hasAttribute("disabled")).toBe(true);
const taskLink = await screen.findByTestId("acp-session-task-link-session-1"); const taskLink = await screen.findByTestId("acp-session-task-link-session-1");
expect(taskLink.textContent).toContain("Repair failing diagnostics"); expect(taskLink.textContent).toContain("Repair failing diagnostics");
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button"; 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 { Spinner } from "@kandev/ui/spinner";
import { IconAlertTriangle, IconDownload, IconFileZip } from "@tabler/icons-react"; import { IconAlertTriangle, IconDownload, IconFileZip } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client"; 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 { import {
buildDiagnosticBundleDownloadUrl, buildDiagnosticBundleDownloadUrl,
createDiagnosticBundle, createDiagnosticBundle,
@@ -23,9 +28,15 @@ import type {
} from "@/lib/types/system"; } from "@/lib/types/system";
import { BundleCustomizer } from "./bundle-customizer"; 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 DEFAULT_SOURCES: DiagnosticBundleSource[] = ["backend", "frontend"];
const desktopDownloadAdapter = createDesktopV1Adapter(createTauriEventTransport());
// eslint-disable-next-line max-lines-per-function -- coordinates bundle polling and shared customizer state. // eslint-disable-next-line max-lines-per-function -- coordinates bundle polling and shared customizer state.
export function LogViewer() { export function LogViewer() {
@@ -41,6 +52,48 @@ export function LogViewer() {
const [selectedSessionIDs, setSelectedSessionIDs] = useState<string[]>([]); const [selectedSessionIDs, setSelectedSessionIDs] = useState<string[]>([]);
const [customizerSubmitting, setCustomizerSubmitting] = useState(false); const [customizerSubmitting, setCustomizerSubmitting] = useState(false);
const mounted = useRef(true); const mounted = useRef(true);
const activeDesktopDownload = useRef<ActiveDownload | null>(null);
const desktopDownloadListenerReady = useRef(false);
const desktopDownloadListenerPromise = useRef<Promise<boolean> | null>(null);
const desktopDownloadListenerStop = useRef<(() => void) | null>(null);
const desktopDownloadListenerGeneration = useRef(0);
const ensureDesktopDownloadListener = useCallback((): Promise<boolean> => {
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(() => { useEffect(() => {
mounted.current = true; mounted.current = true;
@@ -49,9 +102,23 @@ export function LogViewer() {
.catch(() => undefined); .catch(() => undefined);
return () => { return () => {
mounted.current = false; 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(() => { useEffect(() => {
if (!customizerOpen || !selectedSources.includes("acp") || sessionsLoaded) return; if (!customizerOpen || !selectedSources.includes("acp") || sessionsLoaded) return;
setSessionsLoading(true); setSessionsLoading(true);
@@ -80,11 +147,17 @@ export function LogViewer() {
if (job.status !== "ready" && job.status !== "partial") { if (job.status !== "ready" && job.status !== "partial") {
throw new Error(job.warnings?.[0] ?? t("settings:diagnosticBundlePrepareError")); throw new Error(job.warnings?.[0] ?? t("settings:diagnosticBundlePrepareError"));
} }
setState(job.status === "partial" ? "partial" : "idle"); await startBundleDownload(job, buildDiagnosticBundleDownloadUrl(job.id), {
setMessage(bundleMessage(job, t)); activeDesktopDownload,
triggerDownload(buildDiagnosticBundleDownloadUrl(job.id)); setState,
setMessage,
t,
ensureDesktopDownloadListener,
isMounted: () => mounted.current,
});
} catch (error) { } catch (error) {
if (!mounted.current) return; if (!mounted.current) return;
clearActiveDesktopDownload(activeDesktopDownload);
if (error instanceof ApiError && (error.status === 429 || error.status === 503)) { if (error instanceof ApiError && (error.status === 429 || error.status === 503)) {
const retry = error.retryAfterSeconds ?? 5; const retry = error.retryAfterSeconds ?? 5;
setState("busy"); setState("busy");
@@ -113,7 +186,7 @@ export function LogViewer() {
}); });
}; };
const pending = state === "collecting" || state === "preparing"; const pending = state === "collecting" || state === "preparing" || state === "saving";
return ( return (
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
<DiagnosticDisclosure /> <DiagnosticDisclosure />
@@ -254,9 +327,119 @@ async function prepareDiagnosticBundle(
function buttonLabel(state: ViewState, t: (key: string) => string): string { function buttonLabel(state: ViewState, t: (key: string) => string): string {
if (state === "collecting") return t("settings:diagnosticCollectingFrontendLogs"); if (state === "collecting") return t("settings:diagnosticCollectingFrontendLogs");
if (state === "preparing") return t("settings:diagnosticPreparingZip"); if (state === "preparing") return t("settings:diagnosticPreparingZip");
if (state === "saving") return t("settings:saving");
return t("settings:diagnosticCustomize"); 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<boolean>;
isMounted: () => boolean;
},
): Promise<void> {
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( function bundleMessage(
job: DiagnosticBundleJob, job: DiagnosticBundleJob,
t: (key: string, options?: Record<string, unknown>) => string, t: (key: string, options?: Record<string, unknown>) => string,
@@ -267,15 +450,6 @@ function bundleMessage(
: t("settings:diagnosticPartialZipUnavailable"); : 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<void> { function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
} }
@@ -58,4 +58,20 @@ test.describe("System Logs mobile", () => {
), ),
).toBe(false); ).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();
});
}); });
@@ -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");
});
});
+28
View File
@@ -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;
}
}
@@ -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");
});
});
+9
View File
@@ -0,0 +1,9 @@
import type { DesktopV1Adapter, DesktopUnlisten } from "./adapter";
import { triggerUrlDownload } from "@/lib/utils/file-download";
export function listenForDesktopDownloadReady(adapter: DesktopV1Adapter): Promise<DesktopUnlisten> {
if (!adapter.isAvailable()) return Promise.resolve(() => undefined);
return adapter.listen("download-ready", ({ url, fileName }) => {
triggerUrlDownload(url, fileName);
});
}
@@ -23,8 +23,10 @@ describe("programmatic external links", () => {
it("uses the browser-owned download flow for selected Office exports", () => { it("uses the browser-owned download flow for selected Office exports", () => {
const contents = source("../../app/office/workspace/settings/export/export-preview.tsx"); 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('triggerBlobDownload(blob, "kandev-config-selected.zip")');
expect(contents).toContain("anchor.click()"); expect(downloadHelper).toContain("anchor.download = fileName");
expect(downloadHelper).toContain("anchor.click()");
}); });
}); });
+15
View File
@@ -9,15 +9,30 @@ export const DESKTOP_NATIVE_EVENTS = {
"open-settings": desktopEvent("open-settings"), "open-settings": desktopEvent("open-settings"),
"new-task": desktopEvent("new-task"), "new-task": desktopEvent("new-task"),
"check-for-updates": desktopEvent("check-for-updates"), "check-for-updates": desktopEvent("check-for-updates"),
download: desktopEvent("download"),
"download-ready": desktopEvent("download-ready"),
} as const; } as const;
export type DesktopEventName = keyof typeof DESKTOP_NATIVE_EVENTS; 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 = { export type DesktopEventPayloads = {
"close-context": undefined; "close-context": undefined;
"open-settings": undefined; "open-settings": undefined;
"new-task": undefined; "new-task": undefined;
"check-for-updates": undefined; "check-for-updates": undefined;
download: DesktopDownloadFeedback;
"download-ready": DesktopDownloadReady;
}; };
export const DESKTOP_NATIVE_COMMANDS = { export const DESKTOP_NATIVE_COMMANDS = {
+102
View File
@@ -1,12 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { triggerBlobDownload, triggerFileDownload } from "./file-download"; 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 createObjectURLMock = vi.fn((_blob: Blob): string => "blob:mock");
const revokeObjectURLMock = vi.fn((_url: string): void => undefined); const revokeObjectURLMock = vi.fn((_url: string): void => undefined);
const SAMPLE_BLOB_CONTENT = "bytes";
const SAMPLE_DOWNLOAD_NAME = "report.zip";
const originalCreateObjectURL = URL.createObjectURL; const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL; const originalRevokeObjectURL = URL.revokeObjectURL;
const originalTauriInternals = Object.getOwnPropertyDescriptor(window, "__TAURI_INTERNALS__");
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers();
URL.createObjectURL = createObjectURLMock as unknown as typeof URL.createObjectURL; URL.createObjectURL = createObjectURLMock as unknown as typeof URL.createObjectURL;
URL.revokeObjectURL = revokeObjectURLMock as unknown as typeof URL.revokeObjectURL; URL.revokeObjectURL = revokeObjectURLMock as unknown as typeof URL.revokeObjectURL;
createObjectURLMock.mockClear(); createObjectURLMock.mockClear();
@@ -14,8 +22,15 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
URL.createObjectURL = originalCreateObjectURL; URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL; URL.revokeObjectURL = originalRevokeObjectURL;
if (originalTauriInternals) {
Object.defineProperty(window, "__TAURI_INTERNALS__", originalTauriInternals);
} else {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
}
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -48,6 +63,8 @@ describe("triggerFileDownload", () => {
expect(blob).toBeInstanceOf(Blob); expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toContain("text/plain"); expect(blob.type).toContain("text/plain");
expect(click).toHaveBeenCalledTimes(1); expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectURLMock).not.toHaveBeenCalled();
vi.runOnlyPendingTimers();
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
}); });
@@ -82,6 +99,91 @@ describe("triggerBlobDownload", () => {
expect(createObjectURLMock).toHaveBeenCalledWith(blob); expect(createObjectURLMock).toHaveBeenCalledWith(blob);
expect(click).toHaveBeenCalledTimes(1); expect(click).toHaveBeenCalledTimes(1);
expect(download()).toBe("kandev-automations.zip"); 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"); expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock");
}); });
}); });
+19 -2
View File
@@ -1,3 +1,5 @@
import { retainNativeBlobDownload, revokeNativeBlobDownload } from "./native-blob-download";
export type TriggerFileDownloadParams = { export type TriggerFileDownloadParams = {
fileName: string; fileName: string;
content: string; content: string;
@@ -20,6 +22,7 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer {
function downloadBlobViaAnchor(blob: Blob, fileName: string): void { function downloadBlobViaAnchor(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const nativeDownload = retainNativeBlobDownload(url);
try { try {
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
@@ -27,8 +30,13 @@ function downloadBlobViaAnchor(blob: Blob, fileName: string): void {
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
document.body.removeChild(anchor); document.body.removeChild(anchor);
} finally { } catch (error) {
URL.revokeObjectURL(url); 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 { export function triggerBlobDownload(blob: Blob, fileName: string): void {
downloadBlobViaAnchor(blob, fileName); 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();
}
@@ -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<string, number>();
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);
}
+6
View File
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "Repository", "secretReferenceTypeRepository": "Repository",
"secretReferenceHidden": "A resource you cannot access", "secretReferenceHidden": "A resource you cannot access",
"deleteThisCustomLayoutAfterConfirmation": "Delete this custom layout after confirmation.", "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", "desktopNotifications": "Desktop Notifications",
"desktopNotificationsDescription": "Notify this device when a selected agent turn, question, or Office event occurs.", "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.", "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", "diagnosticOpenTask": "Open task {{title}} in a new tab",
"diagnosticPartialZipDownloading": "A partial ZIP is downloading: {{warnings}}", "diagnosticPartialZipDownloading": "A partial ZIP is downloading: {{warnings}}",
"diagnosticPartialZipUnavailable": "A partial diagnostic ZIP is downloading; some frontend logs were unavailable.", "diagnosticPartialZipUnavailable": "A partial diagnostic ZIP is downloading; some frontend logs were unavailable.",
"diagnosticPartialZipWarning": "Some frontend logs were unavailable.",
"diagnosticPreparingZip": "Preparing ZIP…", "diagnosticPreparingZip": "Preparing ZIP…",
"diagnosticReviewBeforeSharing": "Review before sharing", "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.", "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", "diagnosticSelectAllACPSessions": "Select all ACP sessions",
"diagnosticSelectSessions": "Select ACP sessions", "diagnosticSelectSessions": "Select ACP sessions",
"diagnosticSelectedSessions_one": "{{count}} session selected", "diagnosticSelectedSessions_one": "{{count}} session selected",
+6
View File
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "リポジトリ", "secretReferenceTypeRepository": "リポジトリ",
"secretReferenceHidden": "アクセスできないリソース", "secretReferenceHidden": "アクセスできないリソース",
"deleteThisCustomLayoutAfterConfirmation": "確認後、このカスタムレイアウトを削除します。", "deleteThisCustomLayoutAfterConfirmation": "確認後、このカスタムレイアウトを削除します。",
"desktopDownloadFailed": "Kandev はこのファイルを保存できませんでした。ダウンロードをもう一度お試しください。",
"desktopDownloadSaved": "Kandev がファイルを保存しました。",
"desktopNotifications": "デスクトップ通知", "desktopNotifications": "デスクトップ通知",
"desktopNotificationsDescription": "選択したエージェントのターン、質問、または Office イベントが発生したときにこのデバイスに通知します。", "desktopNotificationsDescription": "選択したエージェントのターン、質問、または Office イベントが発生したときにこのデバイスに通知します。",
"desktopOnlyWhileYouScrollPast": "デスクトップのみ。最後のプロンプトを通過してスクロールすると、短縮版をトランスクリプト上部に固定表示し、展開トグルを付けます。", "desktopOnlyWhileYouScrollPast": "デスクトップのみ。最後のプロンプトを通過してスクロールすると、短縮版をトランスクリプト上部に固定表示し、展開トグルを付けます。",
@@ -179,9 +181,13 @@
"diagnosticOpenTask": "タスク {{title}} を新しいタブで開く", "diagnosticOpenTask": "タスク {{title}} を新しいタブで開く",
"diagnosticPartialZipDownloading": "部分ZIPをダウンロード中: {{warnings}}", "diagnosticPartialZipDownloading": "部分ZIPをダウンロード中: {{warnings}}",
"diagnosticPartialZipUnavailable": "部分診断ZIPをダウンロード中。一部フロントエンドログは利用不可。", "diagnosticPartialZipUnavailable": "部分診断ZIPをダウンロード中。一部フロントエンドログは利用不可。",
"diagnosticPartialZipWarning": "一部のフロントエンドログを利用できませんでした。",
"diagnosticPreparingZip": "ZIP準備中…", "diagnosticPreparingZip": "ZIP準備中…",
"diagnosticReviewBeforeSharing": "共有前に確認", "diagnosticReviewBeforeSharing": "共有前に確認",
"diagnosticReviewDescription": "このZIPは、最大3日分バックエンドログと、接続中ブラウザからローカル保持したフロントエンドコンソールイベントをまとめる。URL、コンソール引数、スタック、パス、実行時メタデータ、ユーザーに見えるエラーを含む場合あり。", "diagnosticReviewDescription": "このZIPは、最大3日分バックエンドログと、接続中ブラウザからローカル保持したフロントエンドコンソールイベントをまとめる。URL、コンソール引数、スタック、パス、実行時メタデータ、ユーザーに見えるエラーを含む場合あり。",
"diagnosticSaveComplete": "診断 ZIP を保存しました。",
"diagnosticSaveFailed": "診断 ZIP を保存できませんでした。もう一度お試しください。",
"diagnosticSavePending": "診断 ZIP の保存先を選択してください。",
"diagnosticSelectAllACPSessions": "すべてのACPセッションを選択", "diagnosticSelectAllACPSessions": "すべてのACPセッションを選択",
"diagnosticSelectSessions": "ACPセッションを選択", "diagnosticSelectSessions": "ACPセッションを選択",
"diagnosticSelectedSessions_one": "{{count}}セッション選択済み", "diagnosticSelectedSessions_one": "{{count}}セッション選択済み",
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "Ŕēƥōśĩţōŕŷ", "secretReferenceTypeRepository": "Ŕēƥōśĩţōŕŷ",
"secretReferenceHidden": "À ŕēśōũŕćē ŷōũ ćàńńōţ àććēśś", "secretReferenceHidden": "À ŕēśōũŕćē ŷōũ ćàńńōţ àććēśś",
"deleteThisCustomLayoutAfterConfirmation": "Ďēĺēţē ţĥĩś ćũśţōḿ ĺàŷōũţ àƒţēŕ ćōńƒĩŕḿàţĩōń.", "deleteThisCustomLayoutAfterConfirmation": "Ďēĺēţē ţĥĩś ćũśţōḿ ĺàŷōũţ àƒţēŕ ćōńƒĩŕḿàţĩōń.",
"desktopDownloadFailed": "Ķàńďēv ćōũĺď ńōţ śàvē ţĥĩś ƒĩĺē. Ţŕŷ ţĥē ďōŵńĺōàď àĝàĩń.",
"desktopDownloadSaved": "Ķàńďēv śàvēď ţĥē ƒĩĺē.",
"desktopNotifications": "Ďēśķţōƥ Ńōţĩƒĩćàţĩōńś", "desktopNotifications": "Ďēśķţōƥ Ńōţĩƒĩćàţĩōńś",
"desktopNotificationsDescription": "Ńōţĩƒŷ ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.", "desktopNotificationsDescription": "Ńōţĩƒŷ ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.",
"desktopOnlyWhileYouScrollPast": "Ďēśķţōƥ ōńĺŷ. Ŵĥĩĺē ŷōũ śćŕōĺĺ ƥàśţ ŷōũŕ ĺàśţ ƥŕōḿƥţ, śţĩćķ à śĥōŕţēńēď ćōƥŷ ţō ţĥē ţōƥ ōƒ ţĥē ţŕàńśćŕĩƥţ ŵĩţĥ àń ēxƥàńď ţōĝĝĺē.", "desktopOnlyWhileYouScrollPast": "Ďēśķţōƥ ōńĺŷ. Ŵĥĩĺē ŷōũ śćŕōĺĺ ƥàśţ ŷōũŕ ĺàśţ ƥŕōḿƥţ, śţĩćķ à śĥōŕţēńēď ćōƥŷ ţō ţĥē ţōƥ ōƒ ţĥē ţŕàńśćŕĩƥţ ŵĩţĥ àń ēxƥàńď ţōĝĝĺē.",
@@ -179,9 +181,13 @@
"diagnosticOpenTask": "Ōƥēń ţàśķ {{title}} ĩń à ńēŵ ţàƀ", "diagnosticOpenTask": "Ōƥēń ţàśķ {{title}} ĩń à ńēŵ ţàƀ",
"diagnosticPartialZipDownloading": "À ƥàŕţĩàĺ ŹĨƤ ĩś ďōŵńĺōàďĩńĝ: {{warnings}}", "diagnosticPartialZipDownloading": "À ƥàŕţĩàĺ ŹĨƤ ĩś ďōŵńĺōàďĩńĝ: {{warnings}}",
"diagnosticPartialZipUnavailable": "À ƥàŕţĩàĺ ďĩàĝńōśţĩć ŹĨƤ ĩś ďōŵńĺōàďĩńĝ; śōḿē ƒŕōńţēńď ĺōĝś ŵēŕē ũńàvàĩĺàƀĺē.", "diagnosticPartialZipUnavailable": "À ƥàŕţĩàĺ ďĩàĝńōśţĩć ŹĨƤ ĩś ďōŵńĺōàďĩńĝ; śōḿē ƒŕōńţēńď ĺōĝś ŵēŕē ũńàvàĩĺàƀĺē.",
"diagnosticPartialZipWarning": "Śōḿē ƒŕōńţēńď ĺōĝś ŵēŕē ũńàvàĩĺàƀĺē.",
"diagnosticPreparingZip": "Ƥŕēƥàŕĩńĝ ŹĨƤ…", "diagnosticPreparingZip": "Ƥŕēƥàŕĩńĝ ŹĨƤ…",
"diagnosticReviewBeforeSharing": "Ŕēvĩēŵ ƀēƒōŕē śĥàŕĩńĝ", "diagnosticReviewBeforeSharing": "Ŕēvĩēŵ ƀēƒōŕē śĥàŕĩńĝ",
"diagnosticReviewDescription": "Ţĥĩś ŹĨƤ ćōḿƀĩńēś ũƥ ţō ţĥŕēē ďàŷś ōƒ ƀàćķēńď ĺōĝś ŵĩţĥ ĺōćàĺĺŷ ŕēţàĩńēď ƒŕōńţēńď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕś. Ĩţ ḿàŷ ćōńţàĩń ŨŔĹś, ćōńśōĺē àŕĝũḿēńţś, śţàćķś, ƥàţĥś, ŕũńţĩḿē ḿēţàďàţà, àńď ũśēŕ-vĩśĩƀĺē ēŕŕōŕś.", "diagnosticReviewDescription": "Ţĥĩś ŹĨƤ ćōḿƀĩńēś ũƥ ţō ţĥŕēē ďàŷś ōƒ ƀàćķēńď ĺōĝś ŵĩţĥ ĺōćàĺĺŷ ŕēţàĩńēď ƒŕōńţēńď ćōńśōĺē ēvēńţś ƒŕōḿ ćōńńēćţēď ƀŕōŵśēŕś. Ĩţ ḿàŷ ćōńţàĩń ŨŔĹś, ćōńśōĺē àŕĝũḿēńţś, śţàćķś, ƥàţĥś, ŕũńţĩḿē ḿēţàďàţà, àńď ũśēŕ-vĩśĩƀĺē ēŕŕōŕś.",
"diagnosticSaveComplete": "Ţĥē ďĩàĝńōśţĩć ŹĨƤ ŵàś śàvēď.",
"diagnosticSaveFailed": "Ţĥē ďĩàĝńōśţĩć ŹĨƤ ćōũĺď ńōţ ƀē śàvēď. Ţŕŷ àĝàĩń.",
"diagnosticSavePending": "Çhōōśē ŵhēŕē ţō śàvē ţhē ďĩàĝńōśţĩć ŹĨƤ.",
"diagnosticSelectAllACPSessions": "Śēĺēćţ àĺĺ ÀĆƤ śēśśĩōńś", "diagnosticSelectAllACPSessions": "Śēĺēćţ àĺĺ ÀĆƤ śēśśĩōńś",
"diagnosticSelectSessions": "Śēĺēćţ ÀĆƤ śēśśĩōńś", "diagnosticSelectSessions": "Śēĺēćţ ÀĆƤ śēśśĩōńś",
"diagnosticSelectedSessions_one": "{{count}} śēśśĩōń śēĺēćţēď", "diagnosticSelectedSessions_one": "{{count}} śēśśĩōń śēĺēćţēď",
+6
View File
@@ -149,6 +149,8 @@
"secretReferenceTypeRepository": "Repositório", "secretReferenceTypeRepository": "Repositório",
"secretReferenceHidden": "Um recurso ao qual não tem acesso", "secretReferenceHidden": "Um recurso ao qual não tem acesso",
"deleteThisCustomLayoutAfterConfirmation": "Eliminar este esquema personalizado após confirmação.", "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", "desktopNotifications": "Notificações no computador",
"desktopNotificationsDescription": "Notificar este dispositivo quando ocorrer um turno de agente, uma pergunta ou um evento do Office selecionados.", "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.", "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", "diagnosticOpenTask": "Abrir a tarefa {{title}} num novo separador",
"diagnosticPartialZipDownloading": "Está a ser transferido um ZIP parcial: {{warnings}}", "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.", "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…", "diagnosticPreparingZip": "A preparar o ZIP…",
"diagnosticReviewBeforeSharing": "Reveja antes de partilhar", "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.", "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", "diagnosticSelectAllACPSessions": "Selecionar todas as sessões ACP",
"diagnosticSelectSessions": "Selecionar sessões ACP", "diagnosticSelectSessions": "Selecionar sessões ACP",
"diagnosticSelectedSessions_one": "{{count}} sessão selecionada", "diagnosticSelectedSessions_one": "{{count}} sessão selecionada",
+6
View File
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "仓库", "secretReferenceTypeRepository": "仓库",
"secretReferenceHidden": "您无权访问的资源", "secretReferenceHidden": "您无权访问的资源",
"deleteThisCustomLayoutAfterConfirmation": "确认后删除此自定义布局。", "deleteThisCustomLayoutAfterConfirmation": "确认后删除此自定义布局。",
"desktopDownloadFailed": "Kandev 无法保存此文件。请重试下载。",
"desktopDownloadSaved": "Kandev 已保存此文件。",
"desktopNotifications": "桌面通知", "desktopNotifications": "桌面通知",
"desktopNotificationsDescription": "当选定的智能体回合、问题或办公室事件发生时通知此设备。", "desktopNotificationsDescription": "当选定的智能体回合、问题或办公室事件发生时通知此设备。",
"desktopOnlyWhileYouScrollPast": "仅限桌面端。滚动越过最后一条提示时,将缩短后的副本固定到记录顶部,并提供展开开关。", "desktopOnlyWhileYouScrollPast": "仅限桌面端。滚动越过最后一条提示时,将缩短后的副本固定到记录顶部,并提供展开开关。",
@@ -179,9 +181,13 @@
"diagnosticOpenTask": "在新标签页中打开任务 {{title}}", "diagnosticOpenTask": "在新标签页中打开任务 {{title}}",
"diagnosticPartialZipDownloading": "正在下载部分诊断 ZIP:{{warnings}}", "diagnosticPartialZipDownloading": "正在下载部分诊断 ZIP:{{warnings}}",
"diagnosticPartialZipUnavailable": "正在下载部分诊断 ZIP;部分前端日志不可用。", "diagnosticPartialZipUnavailable": "正在下载部分诊断 ZIP;部分前端日志不可用。",
"diagnosticPartialZipWarning": "部分前端日志不可用。",
"diagnosticPreparingZip": "正在准备 ZIP…", "diagnosticPreparingZip": "正在准备 ZIP…",
"diagnosticReviewBeforeSharing": "分享前请检查", "diagnosticReviewBeforeSharing": "分享前请检查",
"diagnosticReviewDescription": "此 ZIP 包含最多三天的后端日志,以及所连接浏览器在本地保留的前端控制台事件。其中可能包含 URL、控制台参数、堆栈、路径、运行时元数据和用户可见错误。", "diagnosticReviewDescription": "此 ZIP 包含最多三天的后端日志,以及所连接浏览器在本地保留的前端控制台事件。其中可能包含 URL、控制台参数、堆栈、路径、运行时元数据和用户可见错误。",
"diagnosticSaveComplete": "诊断 ZIP 已保存。",
"diagnosticSaveFailed": "无法保存诊断 ZIP。请重试。",
"diagnosticSavePending": "请选择诊断 ZIP 的保存位置。",
"diagnosticSelectAllACPSessions": "选择所有 ACP 会话", "diagnosticSelectAllACPSessions": "选择所有 ACP 会话",
"diagnosticSelectSessions": "选择 ACP 会话", "diagnosticSelectSessions": "选择 ACP 会话",
"diagnosticSelectedSessions_one": "已选择 {{count}} 个会话", "diagnosticSelectedSessions_one": "已选择 {{count}} 个会话",
+6
View File
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "儲存庫", "secretReferenceTypeRepository": "儲存庫",
"secretReferenceHidden": "您無權存取的資源", "secretReferenceHidden": "您無權存取的資源",
"deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。", "deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。",
"desktopDownloadFailed": "Kandev 無法儲存此檔案。請重試下載。",
"desktopDownloadSaved": "Kandev 已儲存此檔案。",
"desktopNotifications": "桌面通知", "desktopNotifications": "桌面通知",
"desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此設備。", "desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此設備。",
"desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。", "desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。",
@@ -179,9 +181,13 @@
"diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}", "diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}",
"diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}", "diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}",
"diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。", "diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。",
"diagnosticPartialZipWarning": "部分前端日誌不可用。",
"diagnosticPreparingZip": "正在準備 ZIP…", "diagnosticPreparingZip": "正在準備 ZIP…",
"diagnosticReviewBeforeSharing": "分享前請檢查", "diagnosticReviewBeforeSharing": "分享前請檢查",
"diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連接瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台參數、堆棧、路徑、執行時元數據和用戶可見錯誤。", "diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連接瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台參數、堆棧、路徑、執行時元數據和用戶可見錯誤。",
"diagnosticSaveComplete": "診斷 ZIP 已儲存。",
"diagnosticSaveFailed": "無法儲存診斷 ZIP。請重試。",
"diagnosticSavePending": "請選擇診斷 ZIP 的儲存位置。",
"diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段", "diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段",
"diagnosticSelectSessions": "選擇 ACP 工作階段", "diagnosticSelectSessions": "選擇 ACP 工作階段",
"diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段", "diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段",
+6
View File
@@ -152,6 +152,8 @@
"secretReferenceTypeRepository": "儲存庫", "secretReferenceTypeRepository": "儲存庫",
"secretReferenceHidden": "您無權存取的資源", "secretReferenceHidden": "您無權存取的資源",
"deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。", "deleteThisCustomLayoutAfterConfirmation": "確認後刪除此自訂佈局。",
"desktopDownloadFailed": "Kandev 無法儲存此檔案。請重試下載。",
"desktopDownloadSaved": "Kandev 已儲存此檔案。",
"desktopNotifications": "桌面通知", "desktopNotifications": "桌面通知",
"desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此裝置。", "desktopNotificationsDescription": "當選定的代理程式回合、問題或辦公室事件發生時通知此裝置。",
"desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。", "desktopOnlyWhileYouScrollPast": "僅限桌面端。滾動越過最後一條提示時,將縮短後的副本固定到記錄頂部,並提供展開開關。",
@@ -179,9 +181,13 @@
"diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}", "diagnosticOpenTask": "在新標籤頁中開啟任務 {{title}}",
"diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}", "diagnosticPartialZipDownloading": "正在下載部分診斷 ZIP:{{warnings}}",
"diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。", "diagnosticPartialZipUnavailable": "正在下載部分診斷 ZIP;部分前端日誌不可用。",
"diagnosticPartialZipWarning": "部分前端日誌無法使用。",
"diagnosticPreparingZip": "正在準備 ZIP…", "diagnosticPreparingZip": "正在準備 ZIP…",
"diagnosticReviewBeforeSharing": "分享前請檢查", "diagnosticReviewBeforeSharing": "分享前請檢查",
"diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連線瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台引數、堆疊、路徑、執行時後設資料和使用者可見錯誤。", "diagnosticReviewDescription": "此 ZIP 包含最多三天的後端日誌,以及所連線瀏覽器在本地保留的前端控制台事件。其中可能包含 URL、控制台引數、堆疊、路徑、執行時後設資料和使用者可見錯誤。",
"diagnosticSaveComplete": "診斷 ZIP 已儲存。",
"diagnosticSaveFailed": "無法儲存診斷 ZIP。請重試。",
"diagnosticSavePending": "請選擇診斷 ZIP 的儲存位置。",
"diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段", "diagnosticSelectAllACPSessions": "選擇所有 ACP 工作階段",
"diagnosticSelectSessions": "選擇 ACP 工作階段", "diagnosticSelectSessions": "選擇 ACP 工作階段",
"diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段", "diagnosticSelectedSessions_one": "已選擇 {{count}} 個工作階段",
@@ -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.
+137
View File
@@ -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.
@@ -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.
@@ -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.
+8 -8
View File
@@ -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-<platform>-`; use the installer format for your platform, not an updater archive. Download artifacts from [GitHub Releases](https://github.com/kdlbs/kandev/releases). Each desktop filename begins with `kandev-desktop-<platform>-`; use the installer format for your platform, not an updater archive.
| Platform label | Installer formats | In-app update support | | Platform label | Installer formats | In-app update support |
|---|---|---| | -------------- | ------------------------ | ----------------------------------- |
| `macos-arm64` | `.dmg` | Signed `.app.tar.gz` updater bundle | | `macos-arm64` | `.dmg` | Signed `.app.tar.gz` updater bundle |
| `macos-x64` | `.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-arm64` | AppImage, `.deb`, `.rpm` | AppImage installations only |
| `linux-x64` | AppImage, `.deb`, `.rpm` | AppImage installations only | | `linux-x64` | AppImage, `.deb`, `.rpm` | AppImage installations only |
| `windows-x64` | NSIS `.exe` | Signed `.nsis.zip` updater bundle | | `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. 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. 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 Native notifications are limited to selected agent-turn-finished and
agent-needs-an-answer events, plus session failures. Semantic session events agent-needs-an-answer events, plus session failures. Semantic session events
@@ -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).
@@ -8,6 +8,7 @@ updated: 2026-08-27
owners: owners:
- tbd - tbd
--- ---
# Tauri Desktop App System Design # Tauri Desktop App System Design
## Purpose and boundaries ## Purpose and boundaries
@@ -16,8 +17,8 @@ This design defines the technical contract for `REQ-DESKTOP-DESKTOP-TAURI-APP-00
## Requirement mapping ## Requirement mapping
| Requirement | Design section | | Requirement | Design section |
| --- | --- | | ----------------------------------- | --------------------------------------------- |
| `REQ-DESKTOP-DESKTOP-TAURI-APP-001` | [Desktop architecture](#desktop-architecture) | | `REQ-DESKTOP-DESKTOP-TAURI-APP-001` | [Desktop architecture](#desktop-architecture) |
## Desktop architecture ## Desktop architecture
@@ -198,8 +199,9 @@ unrestricted URL opening. Its directory command only returns a folder selected t
system panel. system panel.
External `http`, `https`, and `mailto` links open in the system browser/client. Internal loopback 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 navigation and blob URLs remain in the WebView. In-app file downloads use the
otherwise. [native download contract](native-downloads.md) for destination and completion;
the WebView still transfers their bytes.
The launch boundary remains: The launch boundary remains:
@@ -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)