Say when something is downloading, and where it goes

The webview saves the file perfectly well and mentions it to nobody,
which makes a download indistinguishable from a click that did nothing.
Each one is now announced in the corner as it saves, and offers to show
the finished file in the Finder. The folder is settable and defaults to
the system's Downloads.

Two gaps in the API shape this. There is no progress - DownloadEvent
reports a start and a finish and nothing between - so the destination
file is polled as it grows and the bytes written are shown; the indicator
spins rather than fills, because there is no total to divide by. And on
macOS the finish always reports no path at all, so the destination
assigned at request time is remembered against the URL and read back at
the end.

A name already taken gets "(2)" appended. Silently overwriting is the
last thing anyone wants from a download they were not told about.
This commit is contained in:
2026-09-02 10:42:45 +02:00
parent e1ac3d7509
commit 04b731cf37
20 changed files with 750 additions and 4 deletions
+142 -1
View File
@@ -2925,6 +2925,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3643,6 +3667,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.20",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.20",
"toml 1.1.4+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.4.0"
@@ -4736,6 +4802,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4769,13 +4844,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4806,6 +4898,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4818,6 +4916,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4830,12 +4934,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4848,6 +4964,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4860,6 +4982,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4872,6 +5000,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4884,6 +5018,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4939,6 +5079,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-window-state",
+1
View File
@@ -23,6 +23,7 @@ tokio = { version = "1", features = ["time"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
tauri-plugin-window-state = "2"
tauri-plugin-dialog = "2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
+2 -1
View File
@@ -8,6 +8,7 @@
"core:window:allow-start-dragging",
"opener:default",
"notification:default",
"window-state:default"
"window-state:default",
"dialog:default"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default","notification:default","window-state:default"]}}
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default","notification:default","window-state:default","dialog:default"]}}
+66
View File
@@ -2360,6 +2360,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
+66
View File
@@ -2360,6 +2360,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
+36
View File
@@ -27,6 +27,11 @@ pub struct AppState {
/// When each app last raised a notification in its own words, so a count
/// does not immediately say the same thing again in worse words.
pub last_spoke: Mutex<std::collections::HashMap<String, std::time::Instant>>,
/// Downloads that have finished, so their watcher can stop looking.
pub finished_downloads: Mutex<std::collections::HashSet<u64>>,
/// Where each download was sent, by URL — macOS never reports the path
/// back when it finishes, so it has to be remembered from the request.
pub download_paths: Mutex<std::collections::HashMap<String, (u64, PathBuf)>>,
/// A login waiting on an answer: host, account, password. Held only until
/// it is saved or declined, and never written anywhere but the Keychain.
pub pending_password: Mutex<Option<(String, String, String, String)>>,
@@ -88,6 +93,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
radius: Mutex::new(0.0),
chrome: Mutex::new(0.0),
rail: Mutex::new(72.0),
finished_downloads: Mutex::new(std::collections::HashSet::new()),
download_paths: Mutex::new(std::collections::HashMap::new()),
last_spoke: Mutex::new(std::collections::HashMap::new()),
pending_password: Mutex::new(None),
config: Mutex::new(config),
@@ -879,6 +886,35 @@ pub fn reset_config(app: AppHandle, state: State<'_, AppState>) -> Result<Config
Ok(state.cfg())
}
// ----------------------------------------------------------- downloads
/// The folder downloads go to, for showing in Settings.
#[tauri::command]
pub fn download_folder(app: AppHandle) -> String {
crate::downloads::folder(&app).to_string_lossy().into_owned()
}
#[tauri::command]
pub fn set_download_folder(
path: Option<String>,
state: State<'_, AppState>,
) -> Result<Config, String> {
state.config.lock().unwrap().settings.download_dir = path;
state.persist()?;
Ok(state.cfg())
}
/// Shows a finished download where it landed, selected, in the Finder.
#[tauri::command]
pub fn reveal(path: String) -> Result<(), String> {
std::process::Command::new("/usr/bin/open")
.arg("-R")
.arg(&path)
.spawn()
.map(|_| ())
.map_err(|e| format!("could not show {path}: {e}"))
}
// ------------------------------------------------------------ passwords
/// Writes the offered login to the macOS Keychain.
+4
View File
@@ -94,6 +94,9 @@ pub struct Settings {
/// rather hear only what an app says in its own words.
#[serde(default = "yes")]
pub count_notifications: bool,
/// Where downloads land. Unset means the system's Downloads folder.
#[serde(default)]
pub download_dir: Option<String>,
}
fn default_theme() -> String {
@@ -111,6 +114,7 @@ impl Default for Settings {
theme: default_theme(),
last_app: None,
count_notifications: true,
download_dir: None,
}
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Downloads: where they go, and telling someone they happened.
//!
//! A download that leaves no trace is indistinguishable from a click that did
//! nothing. WebKit saves the file perfectly well on its own — what it does not
//! do is say so.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadStarted {
pub id: u64,
pub name: String,
pub path: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub id: u64,
pub bytes: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadFinished {
pub id: u64,
pub success: bool,
pub path: String,
}
/// Where downloads land: the configured folder, or ~/Downloads.
pub fn folder(handle: &AppHandle) -> PathBuf {
let configured = handle
.state::<crate::commands::AppState>()
.cfg()
.settings
.download_dir;
configured
.map(PathBuf::from)
.filter(|p| p.is_dir())
.unwrap_or_else(|| {
handle
.path()
.download_dir()
.unwrap_or_else(|_| PathBuf::from("/tmp"))
})
}
/// A name not already taken, the way a browser does it: `report (2).pdf`.
///
/// Overwriting silently is the one outcome nobody wants from a download they
/// were not told about in the first place.
pub fn unique(dir: &std::path::Path, name: &str) -> PathBuf {
let candidate = dir.join(name);
if !candidate.exists() {
return candidate;
}
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.to_string(), String::new()),
};
for n in 2..1000 {
let next = dir.join(format!("{stem} ({n}){ext}"));
if !next.exists() {
return next;
}
}
candidate
}
/// The filename a URL suggests, or something honest when it suggests nothing.
pub fn name_from(url: &url::Url, fallback: &std::path::Path) -> String {
let from_path = url
.path_segments()
.and_then(|s| s.last())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| {
percent_decode(s)
});
from_path
.or_else(|| {
fallback
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.filter(|f| !f.is_empty())
})
.unwrap_or_else(|| "download".into())
}
/// Percent-decoding, without a crate for it: a filename in a URL is escaped,
/// and `My%20Report.pdf` is not what anyone wants to find on disk.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::SeqCst)
}
/// Watches the file grow, so there is something to look at while it does.
///
/// Polling the file is all there is: the webview reports that a download
/// started and that it finished, and nothing in between — no byte count, no
/// total, no percentage. The size on disk is the only honest progress
/// available, so that is what gets shown.
pub fn watch(handle: &AppHandle, id: u64, path: PathBuf) {
let handle = handle.clone();
tauri::async_runtime::spawn(async move {
let mut last = 0u64;
for _ in 0..(60 * 30) {
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let done = {
let state = handle.state::<crate::commands::AppState>();
let finished = state.finished_downloads.lock().unwrap();
finished.contains(&id)
};
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(last);
if bytes != last {
last = bytes;
let _ = handle.emit("download-progress", DownloadProgress { id, bytes });
}
if done {
return;
}
}
});
}
+5
View File
@@ -1,5 +1,6 @@
pub mod commands;
pub mod config;
pub mod downloads;
pub mod routing;
pub mod webviews;
@@ -74,6 +75,7 @@ pub fn run() {
})
// Remembers where the window was and how big, across launches.
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
@@ -124,6 +126,9 @@ pub fn run() {
commands::set_window_chrome,
commands::chrome_height,
commands::rail_width,
commands::download_folder,
commands::set_download_folder,
commands::reveal,
commands::unread_counts,
commands::focus_window,
commands::save_password,
+54
View File
@@ -686,6 +686,7 @@ pub fn create(
let load_handle = handle.clone();
let load_id = id.clone();
let dl_handle = handle.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
@@ -716,6 +717,59 @@ pub fn create(
}
NewWindowResponse::Deny
})
// WebKit saves the file happily on its own; what it never does is
// mention it. Redirected to the chosen folder, and announced.
.on_download(move |_wv, event| {
use tauri::webview::DownloadEvent;
let state = dl_handle.state::<crate::commands::AppState>();
match event {
DownloadEvent::Requested { url, destination } => {
let dir = crate::downloads::folder(&dl_handle);
let _ = std::fs::create_dir_all(&dir);
let name = crate::downloads::name_from(&url, destination);
let path = crate::downloads::unique(&dir, &name);
let id = crate::downloads::next_id();
state
.download_paths
.lock()
.unwrap()
.insert(url.to_string(), (id, path.clone()));
let _ = dl_handle.emit(
"download-started",
crate::downloads::DownloadStarted {
id,
name,
path: path.to_string_lossy().into_owned(),
},
);
crate::downloads::watch(&dl_handle, id, path.clone());
*destination = path;
}
DownloadEvent::Finished { url, success, .. } => {
// The path is never reported back on macOS, so it comes
// from what was assigned when the download was requested.
let found = state.download_paths.lock().unwrap().remove(&url.to_string());
if let Some((id, path)) = found {
state.finished_downloads.lock().unwrap().insert(id);
let _ = dl_handle.emit(
"download-finished",
crate::downloads::DownloadFinished {
id,
success,
path: path.to_string_lossy().into_owned(),
},
);
}
}
_ => {}
}
true
})
// The script carries a snapshot of the hidden list from when the view
// was built, so anything chosen since would come back on reload. This
// re-asserts the real list on every navigation.