From 84b7308c60be70cdd802dd33b52c2398b849f254 Mon Sep 17 00:00:00 2001 From: Jurek Date: Sun, 17 Mar 2024 19:59:32 +0100 Subject: [PATCH 01/11] pass focus state to web application --- src-tauri/src/main.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b7be1269..fb07f108 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,14 +16,13 @@ fn main() { tauri::Builder::default() .setup(|app| { let handle = app.handle(); - let _ = tauri_plugin_deep_link::register("xmpp", move |request| { + tauri_plugin_deep_link::register("xmpp", move |request| { if let Some(window) = handle.get_window("main") { // rather fail silently than crash the app let _ = window.set_focus(); let _ = window.emit("scheme-request-received", request); } - }) - .unwrap(); + }).unwrap(); #[cfg(not(target_os = "macos"))] if let Some(url) = std::env::args().nth(1) { app.emit_all("scheme-request-received", url).unwrap(); @@ -31,8 +30,8 @@ fn main() { Ok(()) }) - .on_window_event(|event| { - if let WindowEvent::CloseRequested { api, .. } = event.event() { + .on_window_event(|event| match event.event() { + WindowEvent::CloseRequested { api, .. } => { #[cfg(not(target_os = "macos"))] { event.window().hide().unwrap(); @@ -45,6 +44,10 @@ fn main() { api.prevent_close(); } + WindowEvent::Focused(focus) => { + event.window().emit("window-focused", focus).unwrap() + } + _ => {} }) .plugin(download::init()) .plugin(notifications::init()) From ce961b1b0e397e94888bb12146e578c7c176082c Mon Sep 17 00:00:00 2001 From: Jurek Date: Sun, 17 Mar 2024 22:24:10 +0100 Subject: [PATCH 02/11] add focus --- src-tauri/src/main.rs | 4 +- src/assemblies/inbox/InboxMessaging.vue | 4 +- src/utilities/runtime.ts | 76 ++++++++++++++++++------- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fb07f108..eafd976c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -44,8 +44,8 @@ fn main() { api.prevent_close(); } - WindowEvent::Focused(focus) => { - event.window().emit("window-focused", focus).unwrap() + WindowEvent::Focused(focused) => { + event.window().emit("window-focused", focused).unwrap() } _ => {} }) diff --git a/src/assemblies/inbox/InboxMessaging.vue b/src/assemblies/inbox/InboxMessaging.vue index e1f1b6b0..bde8d4d7 100644 --- a/src/assemblies/inbox/InboxMessaging.vue +++ b/src/assemblies/inbox/InboxMessaging.vue @@ -1589,8 +1589,8 @@ export default { event.file.url, event.file.name, (actual, total) => { - let percent = Math.round(actual / total * 100) - console.log(`${percent}% done`) + let percent = Math.round((actual / total) * 100); + console.log(`${percent}% done`); } ); diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index fff0aac2..a88144a6 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -22,10 +22,8 @@ import logger from "@/utilities/logger"; import BaseAlert from "@/components/base/BaseAlert.vue"; -import UtilitiesFile from "@/utilities/file"; import UtilitiesTitle from "@/utilities/title"; - /************************************************************************** * CONSTANTS * ************************************************************************* */ @@ -48,28 +46,55 @@ interface ProgressPayload { } type ProgressHandler = (progress: number, total: number) => void; +type FocusHandler = (focus: boolean) => void; class UtilitiesRuntime { private readonly __isBrowser: boolean; private readonly __isApp: boolean; private __download_progress_handlers: Map; - + private __isWindowFocused: boolean; + private __WindowFocusedCallbacks: Array; constructor() { // Initialize markers this.__isBrowser = platform === "web"; this.__isApp = window.__TAURI__ !== undefined; this.__download_progress_handlers = new Map(); + this.__isWindowFocused = true; + this.__WindowFocusedCallbacks = []; - tauriAppWindow.listen("download://progress", ({ payload }) => { - const handler = this.__download_progress_handlers.get(payload.id) - if (handler != null) { - handler(payload.progress, payload.total) - } - }); - - tauriAppWindow.listen("scheme-request-received", ({ payload }) => { - BaseAlert.info("opened", payload) - }); + if (this.__isApp) { + tauriAppWindow.listen( + "download://progress", + ({ payload }) => { + const handler = this.__download_progress_handlers.get(payload.id); + if (handler != null) { + handler(payload.progress, payload.total); + } + } + ); + + tauriAppWindow.listen( + "scheme-request-received", + ({ payload }) => { + BaseAlert.info("opened", payload); + } + ); + // unfortunately "visibilitychange" is less precise than Tauri + // especially if Tauri window is behind another window(s) + tauriAppWindow.listen("window-focused", ({ payload }) => { + this.__isWindowFocused = payload; + this.__WindowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } else { + document.addEventListener("visibilitychange", () => { + this.__isWindowFocused = document.visibilityState === "visible"; + this.__WindowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } } async requestOpenUrl(url: string, target = "_blank"): Promise { @@ -85,6 +110,13 @@ class UtilitiesRuntime { } } + isWindowFocused(): boolean { + return this.__isWindowFocused; + } + + registerWindowFocusCallback(callback: FocusHandler): void { + this.__WindowFocusedCallbacks.push(callback); + } async requestFileDownload( url: string, @@ -104,9 +136,9 @@ class UtilitiesRuntime { await tauriInvoke("plugin:downloader|download_file", { id, url, - filename, - }) - this.__download_progress_handlers.delete(id) + filename + }); + this.__download_progress_handlers.delete(id); } else { // Request to download file via browser APIs (Web build) await new FileDownloader({ @@ -116,12 +148,16 @@ class UtilitiesRuntime { } async requestNotificationSend(title: string, body: string): Promise { + // do not send notification if window is focused + if (this.__isWindowFocused) { + return; + } if (this.__isApp) { // Request to show notification via Tauri API (application build) await tauriInvoke("plugin:notifications|send_notification", { title, body - }) + }); } else { const hasPermission = await this.requestNotificationPermission(); if (hasPermission) { @@ -140,13 +176,13 @@ class UtilitiesRuntime { if (this.__isApp) { await tauriInvoke("plugin:notifications|set_badge_count", { count - }) + }); } } async requestNotificationPermission(): Promise { // Request to show notification via browser APIs (Web build) - let hasPermission = + let hasPermission = Notification.permission === NOTIFICATION_PERMISSIONS.granted; if ( !hasPermission && @@ -161,7 +197,7 @@ class UtilitiesRuntime { } async requestUnreadCountUpdate(count: number): Promise { - if (this.__isApp === true) { + if (this.__isApp) { // Request to update unread count via Tauri API (application build) await tauriInvoke("set_badge_count", { count }); } else { From d9423403b66c950267d3ae11eace3fdf9e11abca Mon Sep 17 00:00:00 2001 From: Jurek Date: Tue, 19 Mar 2024 11:48:57 +0100 Subject: [PATCH 03/11] fix revert of pull 66 that broke everything --- src-tauri/Cargo.lock | 88 ++++++++-- src-tauri/Cargo.toml | 6 +- src-tauri/src/download.rs | 221 ++++++++++++++++++++++++ src-tauri/src/main.rs | 135 +++------------ src-tauri/src/notifications.rs | 57 ++++++ src-tauri/tauri.conf.json | 1 + src/assemblies/inbox/InboxMessaging.vue | 10 +- src/utilities/runtime.ts | 180 ++++++++++++------- 8 files changed, 508 insertions(+), 190 deletions(-) create mode 100644 src-tauri/src/download.rs create mode 100644 src-tauri/src/notifications.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 096d7bf4..c77b5b5a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -806,6 +806,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -1786,6 +1795,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interprocess" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f2533f3be42fffe3b5e63b71aeca416c1c3bc33e4e27be018521e76b1f38fb" +dependencies = [ + "cfg-if", + "libc", + "rustc_version", + "to_method", + "winapi", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -2235,6 +2257,28 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c71324e4180d0899963fc83d9d241ac39e699609fc1025a850aadac8257459" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + [[package]] name = "objc_exception" version = "0.1.2" @@ -2398,15 +2442,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "path_trav" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d097b5a2259048b00d3f1ed5ceb4017efe8be4dd336d10b8f09fadf007155d" -dependencies = [ - "substring", -] - [[package]] name = "pathdiff" version = "0.2.1" @@ -2712,13 +2747,15 @@ version = "0.0.0" dependencies = [ "cocoa 0.25.0", "directories", + "mac-notification-sys", "objc", - "path_trav", + "percent-encoding", "reqwest", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-deep-link", "thiserror", "tokio", ] @@ -3410,15 +3447,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" -[[package]] -name = "substring" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" -dependencies = [ - "autocfg", -] - [[package]] name = "syn" version = "1.0.109" @@ -3679,6 +3707,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4536f5f6602e8fdfaa7b3b185076c2a0704f8eb7015f4e58461eb483ec3ed1f8" +dependencies = [ + "dirs", + "interprocess", + "log", + "objc2", + "once_cell", + "tauri-utils", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "tauri-runtime" version = "0.14.2" @@ -3875,6 +3919,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "to_method" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" + [[package]] name = "tokio" version = "1.36.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc7b5cee..b396dccf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,12 +13,14 @@ tauri = { version = "1", features = [ "window-start-dragging", "shell-open", "no serde = { version = "1", features = ["derive"] } serde_json = "1" directories = "5.0.1" -path_trav = "2.0.0" -reqwest = { version = "0.11.25", features = ["blocking"] } +reqwest = "0.11.25" tokio = { version = "1.36.0", features = ["full"] } thiserror = "1.0.58" cocoa = "0.25.0" objc = "0.2.7" +percent-encoding = "2.3.1" +mac-notification-sys = "0.6.1" +tauri-plugin-deep-link = "0.1.2" [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs new file mode 100644 index 00000000..1c2e70b5 --- /dev/null +++ b/src-tauri/src/download.rs @@ -0,0 +1,221 @@ +use directories::UserDirs; +use percent_encoding::percent_decode; +use serde::{Deserialize, Serialize}; +use std::cmp::min; +use std::time::Instant; +use tauri::plugin::{Builder, TauriPlugin}; +use tauri::{Runtime, Window}; +use thiserror::Error; +use tokio::fs::File; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Clone, serde::Serialize)] +struct DownloadProgress { + id: u64, + progress: usize, + total: usize, +} + +#[derive(Serialize, Deserialize, Debug, Error, PartialEq, Eq)] +pub enum DownloadError { + #[error("Could not obtain download directory")] + CouldNotObtainDirectory, + #[error("Packet is too small, missing bytes")] + CouldNotCreateFile, + #[error("Could not download file")] + DownloadError, +} + +#[tauri::command] +pub async fn download_file( + window: Window, + id: u64, + url: &str, + filename: &str, +) -> Result { + let mut filename = filename.to_string(); + // if no filename provided, use the last part of the url + if filename.is_empty() || filename == "undefined" { + let url_fragment = url.split('/').last().unwrap_or(""); + filename = percent_decode(url_fragment.as_ref()) + .decode_utf8_lossy() + .to_string(); + } + + filename = remove_path_traversal(&filename); + + if filename.is_empty() { + filename = "file".to_string(); + } + + // fetch the download directory + let user_dirs = UserDirs::new().ok_or_else(|| DownloadError::CouldNotObtainDirectory)?; + let download_dir = user_dirs + .download_dir() + .ok_or_else(|| DownloadError::CouldNotObtainDirectory)?; + let mut download_path = download_dir.join(&filename); + + // if the file already exists, add a number to the filename + let (pure_filename, filename_extension) = split_filename(&filename); + let mut i = 1; + while download_path.exists() { + download_path = download_dir.join(format!("{pure_filename} ({i}){filename_extension}")); + i += 1; + } + + let mut response = reqwest::get(url) + .await + .map_err(|_| DownloadError::DownloadError)?; + + let mut file = File::create(&download_path) + .await + .map_err(|_| DownloadError::CouldNotCreateFile)?; + + let total_size = response.content_length().unwrap_or(0) as usize; + let mut downloaded = 0; + let mut last_report = Instant::now(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| DownloadError::DownloadError)? + { + file.write_all(&chunk) + .await + .map_err(|_| DownloadError::DownloadError)?; + + downloaded = min(downloaded + chunk.len(), total_size); + + if last_report.elapsed().as_millis() > 100 || downloaded == total_size { + last_report = Instant::now(); + window + .emit( + "download://progress", + DownloadProgress { + id, + progress: downloaded, + total: total_size, + }, + ) + .unwrap(); + } + } + + file.flush() + .await + .map_err(|_| DownloadError::DownloadError)?; + println!("Downloaded {}", download_path.to_string_lossy()); + Ok(download_path.to_string_lossy().to_string()) +} + +/// Splits file into pure filename and extension while conserving double file extensions (.tar.gz, .tar.bz2, .tar.xz) +fn split_filename(filename: &str) -> (String, String) { + const DOUBLE_FILE_EXTENSION: [&str; 3] = [".tar.gz", ".tar.bz2", ".tar.xz"]; + for extension in DOUBLE_FILE_EXTENSION.iter() { + if filename.ends_with(extension) { + let pure_filename = filename.strip_suffix(extension).unwrap_or(filename); + return (pure_filename.to_string(), extension.to_string()); + } + } + let extension = if filename.contains('.') { + filename + .split('.') + .last() + .map(|ext| format!(".{}", ext)) + .unwrap_or("".to_string()) + } else { + "".to_string() + }; + let pure_filename = filename.strip_suffix(&extension).unwrap_or(filename); + (pure_filename.to_string(), extension) +} + +pub fn init() -> TauriPlugin { + Builder::new("downloader") + .invoke_handler(tauri::generate_handler![download_file]) + .setup(|_app_handle| { + // setup plugin specific state here + //app_handle.manage(MyState::default()); + Ok(()) + }) + .build() +} + +fn remove_path_traversal(filename: &str) -> String { + // todo path traversal not secure yet + // can't use fs::canonicalize because it doesn't work with non-existing files + // many path traversal crates are based on fs::canonicalize, therefore they also can't be used + filename + .replace(|c| c < ' ', "") // remove control characters + .replace(['/', '\\', ':', '~', '@', '?', '[', ']'], "") // remove all path separators + .replace("..", "") // remove path traversal +} + +// Test +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_filename() { + assert_eq!( + split_filename("file.tar.gz"), + ("file".to_string(), ".tar.gz".to_string()) + ); + assert_eq!( + split_filename("file.tar.bz2"), + ("file".to_string(), ".tar.bz2".to_string()) + ); + assert_eq!( + split_filename("file.tar.xz"), + ("file".to_string(), ".tar.xz".to_string()) + ); + assert_eq!( + split_filename("file.txt"), + ("file".to_string(), ".txt".to_string()) + ); + assert_eq!(split_filename("file"), ("file".to_string(), "".to_string())); + + assert_eq!( + split_filename("file..."), + ("file..".to_string(), ".".to_string()) + ); + assert_eq!( + split_filename("file.tar.gz.tar.gz"), + ("file.tar.gz".to_string(), ".tar.gz".to_string()) + ); + } + + #[test] + fn test_path_traversal() { + assert_eq!(remove_path_traversal("file"), "file"); + assert_eq!(remove_path_traversal("file.txt"), "file.txt"); + assert_eq!(remove_path_traversal("file.tar.gz"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz/"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz\\"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz:"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz~"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz@"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz//"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz\\\\"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz::"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz~~"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz@@"), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz.."), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz...."), "file.tar.gz"); + assert_eq!(remove_path_traversal("file.tar.gz..\\.."), "file.tar.gz"); + assert_eq!( + remove_path_traversal("C:\\file.tar.gz..//.."), + "Cfile.tar.gz" + ); + assert_eq!(remove_path_traversal("~/file.tar.gz..:.."), "file.tar.gz"); + assert_eq!( + remove_path_traversal("../../../../file.tar.gz..~~.."), + "file.tar.gz" + ); + assert_eq!(remove_path_traversal("/file.tar.gz..@@.."), "file.tar.gz"); + assert_eq!(remove_path_traversal("/./."), ""); + assert_eq!(remove_path_traversal("/.../..."), ""); + assert_eq!(remove_path_traversal("\x00hi"), "hi"); + assert_eq!(remove_path_traversal("🤰🏽¨¬ø¡你好"), "🤰🏽¨¬ø¡你好"); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 73b1c78f..eafd976c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,117 +5,33 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -/************************************************************************** - * IMPORTS - * ************************************************************************* */ +mod download; +mod notifications; -use directories::UserDirs; -use path_trav::*; -use serde::{Deserialize, Serialize}; -use std::path::Path; use tauri::{Manager, WindowEvent}; -use thiserror::Error; -use tokio::fs::File; -use tokio::io::AsyncWriteExt; - -/************************************************************************** - * ENUMERATIONS - * ************************************************************************* */ - -#[derive(Serialize, Deserialize, Debug, Error, PartialEq, Eq)] -pub enum DownloadError { - #[error("Could not obtain download directory")] - CouldNotObtainDirectory, - #[error("Packet is too small, missing bytes")] - CouldNotCreateFile, - #[error("File name is dangerous, attempting path traversal")] - DangerousFileName, - #[error("Could not download file")] - DownloadError, -} - -/************************************************************************** - * COMMANDS - * ************************************************************************* */ - -#[tauri::command] -async fn download_file(url: &str, filename: &str) -> Result<(), DownloadError> { - // Acquire filename as path - let filename_path = Path::new(filename); - - // Acquire directories - let user_dirs = UserDirs::new().ok_or(DownloadError::CouldNotObtainDirectory)?; - let download_dir = user_dirs - .download_dir() - .ok_or(DownloadError::CouldNotObtainDirectory)?; - - // Security: assert that provided filename is not attempting to perform a \ - // path traversal. For instance, passing a filename '../dangerous.txt' \ - // to store files outside of the Downloads folder. - if download_dir.is_path_trav(filename_path) == Ok(true) { - return Err(DownloadError::DangerousFileName); - } - - // Generate download path - let download_path = download_dir.join(filename); - - // Download file - let mut response = reqwest::get(url) - .await - .map_err(|_| DownloadError::DownloadError)?; - - // Create file on filesystem - let mut file = File::create(download_path) - .await - .map_err(|_| DownloadError::CouldNotCreateFile)?; - - // Drain bytes from HTTP response to file - while let Some(chunk) = response - .chunk() - .await - .map_err(|_| DownloadError::DownloadError)? - { - file.write_all(&chunk) - .await - .map_err(|_| DownloadError::DownloadError)?; - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -#[tauri::command] -fn set_badge_count(count: u32) { - // Reference: https://github.com/tauri-apps/tauri/issues/4489 - use cocoa::{appkit::NSApp, foundation::NSString}; - use objc::{msg_send, sel, sel_impl}; - - unsafe { - let label = if count == 0 { - cocoa::base::nil - } else { - NSString::alloc(cocoa::base::nil).init_str(&format!("{}", count)) - }; - - let dock_tile: cocoa::base::id = msg_send![NSApp(), dockTile]; - let _: cocoa::base::id = msg_send![dock_tile, setBadgeLabel: label]; - } -} - -#[tauri::command] -#[cfg(not(target_os = "macos"))] -fn set_badge_count(count: u32) { - println!("set_badge_count is not implemented for this platform"); -} - -/************************************************************************** - * MAIN - * ************************************************************************* */ fn main() { + // if there are problems with deep linking, check this + tauri_plugin_deep_link::prepare("prose"); tauri::Builder::default() - .on_window_event(|event| { - if let WindowEvent::CloseRequested { api, .. } = event.event() { + .setup(|app| { + let handle = app.handle(); + tauri_plugin_deep_link::register("xmpp", move |request| { + if let Some(window) = handle.get_window("main") { + // rather fail silently than crash the app + let _ = window.set_focus(); + let _ = window.emit("scheme-request-received", request); + } + }).unwrap(); + #[cfg(not(target_os = "macos"))] + if let Some(url) = std::env::args().nth(1) { + app.emit_all("scheme-request-received", url).unwrap(); + } + + Ok(()) + }) + .on_window_event(|event| match event.event() { + WindowEvent::CloseRequested { api, .. } => { #[cfg(not(target_os = "macos"))] { event.window().hide().unwrap(); @@ -128,8 +44,13 @@ fn main() { api.prevent_close(); } + WindowEvent::Focused(focused) => { + event.window().emit("window-focused", focused).unwrap() + } + _ => {} }) - .invoke_handler(tauri::generate_handler![download_file, set_badge_count]) + .plugin(download::init()) + .plugin(notifications::init()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 00000000..e310a9b7 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,57 @@ +use mac_notification_sys::{Notification, Sound}; +use tauri::plugin::{Builder, TauriPlugin}; +use tauri::Runtime; + +#[cfg(target_os = "macos")] +#[tauri::command] +fn set_badge_count(count: u32) { + // Reference: https://github.com/tauri-apps/tauri/issues/4489 + use cocoa::{appkit::NSApp, foundation::NSString}; + use objc::{msg_send, sel, sel_impl}; + + unsafe { + let label = if count == 0 { + cocoa::base::nil + } else { + NSString::alloc(cocoa::base::nil).init_str(&format!("{}", count)) + }; + + let dock_tile: cocoa::base::id = msg_send![NSApp(), dockTile]; + let _: cocoa::base::id = msg_send![dock_tile, setBadgeLabel: label]; + } +} + +#[cfg(target_os = "macos")] +#[tauri::command] +fn send_notification(title: String, body: String) { + let _ = Notification::default() + .title(&title) + .message(&body) + .sound(Sound::Default) + .send(); +} + +#[cfg(not(target_os = "macos"))] +#[tauri::command] +fn send_notification(title: String, body: String) {} + +pub fn init() -> TauriPlugin { + init_notifications(); + Builder::new("notifications") + .invoke_handler(tauri::generate_handler![send_notification, set_badge_count]) + .build() +} + +#[cfg(target_os = "macos")] +pub fn init_notifications() { + let bundle = mac_notification_sys::get_bundle_identifier_or_default("prose"); + mac_notification_sys::set_application(&bundle).unwrap(); +} +#[cfg(not(target_os = "macos"))] +fn init_notifications() {} + +#[tauri::command] +#[cfg(not(target_os = "macos"))] +fn set_badge_count(count: u32) { + println!("set_badge_count is not implemented for this platform"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c9714a8c..9f388e43 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,6 +16,7 @@ "windows": [ { "title": "Prose", + "userAgent": "prose-app-web", "width": 1360, "height": 940, "minWidth": 980, diff --git a/src/assemblies/inbox/InboxMessaging.vue b/src/assemblies/inbox/InboxMessaging.vue index 2577ce86..32384a52 100644 --- a/src/assemblies/inbox/InboxMessaging.vue +++ b/src/assemblies/inbox/InboxMessaging.vue @@ -1603,10 +1603,16 @@ export default { try { await UtilitiesRuntime.requestFileDownload( event.file.url, - event.file.name + event.file.name, + (actual, total) => { + let percent = Math.round((actual / total) * 100); + console.log(`${percent}% done`); + if (actual === total) { + BaseAlert.info("File saved", "The file has been downloaded"); + } + } ); - BaseAlert.info("File saved", "The file has been downloaded"); } catch (error) { this.$log.error( `Could not download file from message file view event at URL: ` + diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index 6322549c..a88144a6 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -11,11 +11,7 @@ // NPM import { invoke as tauriInvoke } from "@tauri-apps/api"; import { open as tauriOpen } from "@tauri-apps/api/shell"; -import { - isPermissionGranted as tauriIsPermissionGranted, - requestPermission as tauriRequestPermission, - sendNotification as tauriSendNotification -} from "@tauri-apps/api/notification"; +import { appWindow as tauriAppWindow } from "@tauri-apps/api/window"; import FileDownloader from "js-file-downloader"; // PROJECT: COMMONS @@ -23,7 +19,9 @@ import CONFIG from "@/commons/config"; // PROJECT: UTILITIES import logger from "@/utilities/logger"; -import UtilitiesFile from "@/utilities/file"; + +import BaseAlert from "@/components/base/BaseAlert.vue"; + import UtilitiesTitle from "@/utilities/title"; /************************************************************************** @@ -41,18 +39,66 @@ const NOTIFICATION_PERMISSIONS = { * RUNTIME * ************************************************************************* */ +interface ProgressPayload { + id: number; + progress: number; + total: number; +} + +type ProgressHandler = (progress: number, total: number) => void; +type FocusHandler = (focus: boolean) => void; + class UtilitiesRuntime { private readonly __isBrowser: boolean; private readonly __isApp: boolean; - + private __download_progress_handlers: Map; + private __isWindowFocused: boolean; + private __WindowFocusedCallbacks: Array; constructor() { // Initialize markers - this.__isBrowser = platform === "web" ? true : false; - this.__isApp = !this.__isBrowser; + this.__isBrowser = platform === "web"; + this.__isApp = window.__TAURI__ !== undefined; + this.__download_progress_handlers = new Map(); + this.__isWindowFocused = true; + this.__WindowFocusedCallbacks = []; + + if (this.__isApp) { + tauriAppWindow.listen( + "download://progress", + ({ payload }) => { + const handler = this.__download_progress_handlers.get(payload.id); + if (handler != null) { + handler(payload.progress, payload.total); + } + } + ); + + tauriAppWindow.listen( + "scheme-request-received", + ({ payload }) => { + BaseAlert.info("opened", payload); + } + ); + // unfortunately "visibilitychange" is less precise than Tauri + // especially if Tauri window is behind another window(s) + tauriAppWindow.listen("window-focused", ({ payload }) => { + this.__isWindowFocused = payload; + this.__WindowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } else { + document.addEventListener("visibilitychange", () => { + this.__isWindowFocused = document.visibilityState === "visible"; + this.__WindowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } } async requestOpenUrl(url: string, target = "_blank"): Promise { - if (this.__isApp === true) { + if (this.__isApp) { // Request to open via Tauri API (application build) await tauriOpen(url); } else { @@ -64,80 +110,94 @@ class UtilitiesRuntime { } } + isWindowFocused(): boolean { + return this.__isWindowFocused; + } + + registerWindowFocusCallback(callback: FocusHandler): void { + this.__WindowFocusedCallbacks.push(callback); + } + async requestFileDownload( url: string, - name: string | null = null + filename: string | null = null, + progressHandler?: ProgressHandler ): Promise { - // Generate download options - // Notice: attempt to extract file name from URL (if none given) - const downloadOptions = { - url, - filename: - name || UtilitiesFile.detectAttributesFromUrl(url).name || undefined - }; - - if (this.__isApp === true) { + // Tauri build + if (this.__isApp) { // Request to download file via Tauri API (application build) - await tauriInvoke("download_file", downloadOptions); + const ids = new Uint32Array(1); + window.crypto.getRandomValues(ids); + const id = ids[0]; + + if (progressHandler != undefined) { + this.__download_progress_handlers.set(id, progressHandler); + } + await tauriInvoke("plugin:downloader|download_file", { + id, + url, + filename + }); + this.__download_progress_handlers.delete(id); } else { // Request to download file via browser APIs (Web build) - await new FileDownloader(downloadOptions); + await new FileDownloader({ + url + }); } } async requestNotificationSend(title: string, body: string): Promise { - const hasPermission = await this.requestNotificationPermission(); - - if (hasPermission === true) { - if (this.__isApp === true) { - // Request to show notification via Tauri API (application build) - tauriSendNotification({ - title, - body - }); - } else { + // do not send notification if window is focused + if (this.__isWindowFocused) { + return; + } + if (this.__isApp) { + // Request to show notification via Tauri API (application build) + await tauriInvoke("plugin:notifications|send_notification", { + title, + body + }); + } else { + const hasPermission = await this.requestNotificationPermission(); + if (hasPermission) { // Request to show notification via browser APIs (Web build) new Notification(title, { body }); + } else { + logger.warn( + "Not sending notification since permission is denied:", + title + ); } - } else { - logger.warn( - "Not sending notification since permission is denied:", - title - ); } } - async requestNotificationPermission(): Promise { - let hasPermission = false; - - if (this.__isApp === true) { - // Request to show notification via Tauri API (application build) - hasPermission = await tauriIsPermissionGranted(); + async setBadgeCount(count: number) { + if (this.__isApp) { + await tauriInvoke("plugin:notifications|set_badge_count", { + count + }); + } + } - if (hasPermission === false) { - hasPermission = - (await tauriRequestPermission()) === NOTIFICATION_PERMISSIONS.granted; - } - } else { - // Request to show notification via browser APIs (Web build) + async requestNotificationPermission(): Promise { + // Request to show notification via browser APIs (Web build) + let hasPermission = + Notification.permission === NOTIFICATION_PERMISSIONS.granted; + if ( + !hasPermission && + Notification.permission !== NOTIFICATION_PERMISSIONS.denied + ) { hasPermission = - Notification.permission === NOTIFICATION_PERMISSIONS.granted; - - if ( - hasPermission === false && - Notification.permission !== NOTIFICATION_PERMISSIONS.denied - ) { - hasPermission = - (await Notification.requestPermission()) === - NOTIFICATION_PERMISSIONS.granted; - } + (await Notification.requestPermission()) === + NOTIFICATION_PERMISSIONS.granted; } return hasPermission; } async requestUnreadCountUpdate(count: number): Promise { - if (this.__isApp === true) { + if (this.__isApp) { // Request to update unread count via Tauri API (application build) await tauriInvoke("set_badge_count", { count }); } else { From 55891414b4625eb24fa737449d38adb94e3beb35 Mon Sep 17 00:00:00 2001 From: Jurek Date: Tue, 19 Mar 2024 11:53:15 +0100 Subject: [PATCH 04/11] lint --- src/assemblies/inbox/InboxMessaging.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assemblies/inbox/InboxMessaging.vue b/src/assemblies/inbox/InboxMessaging.vue index 32384a52..f5648232 100644 --- a/src/assemblies/inbox/InboxMessaging.vue +++ b/src/assemblies/inbox/InboxMessaging.vue @@ -1612,7 +1612,6 @@ export default { } } ); - } catch (error) { this.$log.error( `Could not download file from message file view event at URL: ` + From 1f2f533695cde71b064244d0cad071fb02948f61 Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 19:48:07 +0100 Subject: [PATCH 05/11] fix: remove download progress tester --- src/assemblies/inbox/InboxMessaging.vue | 11 +++-------- src/utilities/runtime.ts | 2 ++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/assemblies/inbox/InboxMessaging.vue b/src/assemblies/inbox/InboxMessaging.vue index f5648232..2577ce86 100644 --- a/src/assemblies/inbox/InboxMessaging.vue +++ b/src/assemblies/inbox/InboxMessaging.vue @@ -1603,15 +1603,10 @@ export default { try { await UtilitiesRuntime.requestFileDownload( event.file.url, - event.file.name, - (actual, total) => { - let percent = Math.round((actual / total) * 100); - console.log(`${percent}% done`); - if (actual === total) { - BaseAlert.info("File saved", "The file has been downloaded"); - } - } + event.file.name ); + + BaseAlert.info("File saved", "The file has been downloaded"); } catch (error) { this.$log.error( `Could not download file from message file view event at URL: ` + diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index a88144a6..e9eec31b 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -140,6 +140,8 @@ class UtilitiesRuntime { }); this.__download_progress_handlers.delete(id); } else { + // TODO: implement download progress callback here too + // Request to download file via browser APIs (Web build) await new FileDownloader({ url From 462141ad4df8d3ea862742d7d21d8aa808fb4ca7 Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 19:49:41 +0100 Subject: [PATCH 06/11] fix: re-remove custom useragent in tauri due to merge --- src-tauri/tauri.conf.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9f388e43..c9714a8c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,6 @@ "windows": [ { "title": "Prose", - "userAgent": "prose-app-web", "width": 1360, "height": 940, "minWidth": 980, From 7bd3db5c20d0af51e05f06caadd72bcdf144f7c2 Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 19:50:51 +0100 Subject: [PATCH 07/11] style: auto-format rust code --- src-tauri/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index eafd976c..89c22314 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -22,7 +22,8 @@ fn main() { let _ = window.set_focus(); let _ = window.emit("scheme-request-received", request); } - }).unwrap(); + }) + .unwrap(); #[cfg(not(target_os = "macos"))] if let Some(url) = std::env::args().nth(1) { app.emit_all("scheme-request-received", url).unwrap(); From c17b14b66af3ea24fd4ac3740728b875565a270e Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 20:37:44 +0100 Subject: [PATCH 08/11] chore: improve tauri code quality --- src-tauri/src/download.rs | 128 ++++++++++++++------ src-tauri/src/main.rs | 31 +++-- src-tauri/src/notifications.rs | 65 ++++++---- src/utilities/runtime.ts | 212 ++++++++++++++++++--------------- 4 files changed, 271 insertions(+), 165 deletions(-) diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 1c2e70b5..ce86e374 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -1,3 +1,11 @@ +// This file is part of prose-app-web +// +// Copyright 2024, Prose Foundation + +/************************************************************************** + * IMPORTS + * ************************************************************************* */ + use directories::UserDirs; use percent_encoding::percent_decode; use serde::{Deserialize, Serialize}; @@ -9,12 +17,9 @@ use thiserror::Error; use tokio::fs::File; use tokio::io::AsyncWriteExt; -#[derive(Debug, Clone, serde::Serialize)] -struct DownloadProgress { - id: u64, - progress: usize, - total: usize, -} +/************************************************************************** + * ENUMERATIONS + * ************************************************************************* */ #[derive(Serialize, Deserialize, Debug, Error, PartialEq, Eq)] pub enum DownloadError { @@ -26,6 +31,21 @@ pub enum DownloadError { DownloadError, } +/************************************************************************** + * STRUCTURES + * ************************************************************************* */ + +#[derive(Debug, Clone, serde::Serialize)] +struct DownloadProgress { + id: u64, + progress: usize, + total: usize, +} + +/************************************************************************** + * COMMANDS + * ************************************************************************* */ + #[tauri::command] pub async fn download_file( window: Window, @@ -34,88 +54,111 @@ pub async fn download_file( filename: &str, ) -> Result { let mut filename = filename.to_string(); - // if no filename provided, use the last part of the url + + // No filename provided? Then use the last part of the URL if filename.is_empty() || filename == "undefined" { let url_fragment = url.split('/').last().unwrap_or(""); + filename = percent_decode(url_fragment.as_ref()) .decode_utf8_lossy() .to_string(); } + // Security: remove path traversal characters from filename filename = remove_path_traversal(&filename); + // No filename? Assign fallback filename if filename.is_empty() { - filename = "file".to_string(); + filename = "File".to_string(); } - // fetch the download directory + // Acquire the download directory let user_dirs = UserDirs::new().ok_or_else(|| DownloadError::CouldNotObtainDirectory)?; let download_dir = user_dirs .download_dir() .ok_or_else(|| DownloadError::CouldNotObtainDirectory)?; - let mut download_path = download_dir.join(&filename); - // if the file already exists, add a number to the filename + // Generate unique filename (if it already exists, otherwise do not change) + let mut download_path = download_dir.join(&filename); let (pure_filename, filename_extension) = split_filename(&filename); + let mut i = 1; + while download_path.exists() { download_path = download_dir.join(format!("{pure_filename} ({i}){filename_extension}")); + i += 1; } + // Download file let mut response = reqwest::get(url) .await .map_err(|_| DownloadError::DownloadError)?; + // Create file on filesystem let mut file = File::create(&download_path) .await .map_err(|_| DownloadError::CouldNotCreateFile)?; - let total_size = response.content_length().unwrap_or(0) as usize; - let mut downloaded = 0; - let mut last_report = Instant::now(); + // Compute total download size + let total_bytes = response.content_length().unwrap_or(0) as usize; + let mut downloaded_bytes = 0; + let mut last_size_report = Instant::now(); + + // Drain bytes from HTTP response to file while let Some(chunk) = response .chunk() .await .map_err(|_| DownloadError::DownloadError)? { + // Write received bytes file.write_all(&chunk) .await .map_err(|_| DownloadError::DownloadError)?; - downloaded = min(downloaded + chunk.len(), total_size); + // Compute download progress (de-bounced) + downloaded_bytes = min(downloaded_bytes + chunk.len(), total_bytes); - if last_report.elapsed().as_millis() > 100 || downloaded == total_size { - last_report = Instant::now(); + if last_size_report.elapsed().as_millis() > 100 || downloaded_bytes == total_bytes { + last_size_report = Instant::now(); window .emit( - "download://progress", + "download:progress", DownloadProgress { id, - progress: downloaded, - total: total_size, + progress: downloaded_bytes, + total: total_bytes, }, ) .unwrap(); } } + // Flush downloaded file on disk file.flush() .await .map_err(|_| DownloadError::DownloadError)?; - println!("Downloaded {}", download_path.to_string_lossy()); + Ok(download_path.to_string_lossy().to_string()) } -/// Splits file into pure filename and extension while conserving double file extensions (.tar.gz, .tar.bz2, .tar.xz) +/************************************************************************** + * HELPERS + * ************************************************************************* */ + fn split_filename(filename: &str) -> (String, String) { + // Splits file into pure filename and extension while conserving double \ + // file extensions (.tar.gz, .tar.bz2, .tar.xz) const DOUBLE_FILE_EXTENSION: [&str; 3] = [".tar.gz", ".tar.bz2", ".tar.xz"]; + for extension in DOUBLE_FILE_EXTENSION.iter() { if filename.ends_with(extension) { let pure_filename = filename.strip_suffix(extension).unwrap_or(filename); + return (pure_filename.to_string(), extension.to_string()); } } + let extension = if filename.contains('.') { filename .split('.') @@ -125,32 +168,41 @@ fn split_filename(filename: &str) -> (String, String) { } else { "".to_string() }; + let pure_filename = filename.strip_suffix(&extension).unwrap_or(filename); + (pure_filename.to_string(), extension) } -pub fn init() -> TauriPlugin { +fn remove_path_traversal(filename: &str) -> String { + // TODO: path traversal not secure yet + // TODO: can't use fs::canonicalize because it doesn't work with \ + // non-existing files; many path traversal crates are based on \ + // fs::canonicalize, therefore they also can't be used. + // 1. Remove control characters + // 2. Remove all path separators + // 3. Remove path traversal + filename + .replace(|c| c < ' ', "") + .replace(['/', '\\', ':', '~', '@', '?', '[', ']'], "") + .replace("..", "") +} + +/************************************************************************** + * PROVIDERS + * ************************************************************************* */ + +pub fn provide() -> TauriPlugin { Builder::new("downloader") .invoke_handler(tauri::generate_handler![download_file]) - .setup(|_app_handle| { - // setup plugin specific state here - //app_handle.manage(MyState::default()); - Ok(()) - }) + .setup(|_| Ok(())) .build() } -fn remove_path_traversal(filename: &str) -> String { - // todo path traversal not secure yet - // can't use fs::canonicalize because it doesn't work with non-existing files - // many path traversal crates are based on fs::canonicalize, therefore they also can't be used - filename - .replace(|c| c < ' ', "") // remove control characters - .replace(['/', '\\', ':', '~', '@', '?', '[', ']'], "") // remove all path separators - .replace("..", "") // remove path traversal -} +/************************************************************************** + * TESTS + * ************************************************************************* */ -// Test #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 89c22314..8e3f98a3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,28 +5,43 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +/************************************************************************** + * MODULES + * ************************************************************************* */ + mod download; mod notifications; +/************************************************************************** + * IMPORTS + * ************************************************************************* */ + use tauri::{Manager, WindowEvent}; +/************************************************************************** + * MAIN + * ************************************************************************* */ + fn main() { - // if there are problems with deep linking, check this + // Prepare Prose for deep-linking tauri_plugin_deep_link::prepare("prose"); + tauri::Builder::default() .setup(|app| { let handle = app.handle(); + + // Register URL opener on XMPP URIs tauri_plugin_deep_link::register("xmpp", move |request| { if let Some(window) = handle.get_window("main") { - // rather fail silently than crash the app let _ = window.set_focus(); - let _ = window.emit("scheme-request-received", request); + let _ = window.emit("url:open", request); } }) .unwrap(); + #[cfg(not(target_os = "macos"))] if let Some(url) = std::env::args().nth(1) { - app.emit_all("scheme-request-received", url).unwrap(); + app.emit_all("url:open", url).unwrap(); } Ok(()) @@ -45,13 +60,11 @@ fn main() { api.prevent_close(); } - WindowEvent::Focused(focused) => { - event.window().emit("window-focused", focused).unwrap() - } + WindowEvent::Focused(focused) => event.window().emit("window:focus", focused).unwrap(), _ => {} }) - .plugin(download::init()) - .plugin(notifications::init()) + .plugin(download::provide()) + .plugin(notifications::provide()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index e310a9b7..c6de3ff1 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -1,7 +1,39 @@ +// This file is part of prose-app-web +// +// Copyright 2024, Prose Foundation + +/************************************************************************** + * IMPORTS + * ************************************************************************* */ + use mac_notification_sys::{Notification, Sound}; use tauri::plugin::{Builder, TauriPlugin}; use tauri::Runtime; +/************************************************************************** + * COMMANDS + * ************************************************************************* */ + +#[cfg(not(target_os = "macos"))] +#[tauri::command] +fn send_notification(title: String, body: String) {} + +#[cfg(target_os = "macos")] +#[tauri::command] +fn send_notification(title: String, body: String) { + let _ = Notification::default() + .title(&title) + .message(&body) + .sound(Sound::Default) + .send(); +} + +#[tauri::command] +#[cfg(not(target_os = "macos"))] +fn set_badge_count(count: u32) { + println!("set_badge_count is not implemented for this platform"); +} + #[cfg(target_os = "macos")] #[tauri::command] fn set_badge_count(count: u32) { @@ -21,37 +53,24 @@ fn set_badge_count(count: u32) { } } -#[cfg(target_os = "macos")] -#[tauri::command] -fn send_notification(title: String, body: String) { - let _ = Notification::default() - .title(&title) - .message(&body) - .sound(Sound::Default) - .send(); -} +/************************************************************************** + * PROVIDERS + * ************************************************************************* */ -#[cfg(not(target_os = "macos"))] -#[tauri::command] -fn send_notification(title: String, body: String) {} +pub fn provide() -> TauriPlugin { + provide_notifications(); -pub fn init() -> TauriPlugin { - init_notifications(); Builder::new("notifications") .invoke_handler(tauri::generate_handler![send_notification, set_badge_count]) .build() } +#[cfg(not(target_os = "macos"))] +fn provide_notifications() {} + #[cfg(target_os = "macos")] -pub fn init_notifications() { +pub fn provide_notifications() { let bundle = mac_notification_sys::get_bundle_identifier_or_default("prose"); - mac_notification_sys::set_application(&bundle).unwrap(); -} -#[cfg(not(target_os = "macos"))] -fn init_notifications() {} -#[tauri::command] -#[cfg(not(target_os = "macos"))] -fn set_badge_count(count: u32) { - println!("set_badge_count is not implemented for this platform"); + mac_notification_sys::set_application(&bundle).unwrap(); } diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index e9eec31b..73cce2c2 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -19,9 +19,6 @@ import CONFIG from "@/commons/config"; // PROJECT: UTILITIES import logger from "@/utilities/logger"; - -import BaseAlert from "@/components/base/BaseAlert.vue"; - import UtilitiesTitle from "@/utilities/title"; /************************************************************************** @@ -36,69 +33,52 @@ const NOTIFICATION_PERMISSIONS = { }; /************************************************************************** - * RUNTIME + * TYPES * ************************************************************************* */ -interface ProgressPayload { +export type RuntimeProgressHandler = (progress: number, total: number) => void; +export type RuntimeFocusHandler = (focused: boolean) => void; + +/************************************************************************** + * INTERFACES + * ************************************************************************* */ + +interface RuntimeProgressPayload { id: number; progress: number; total: number; } -type ProgressHandler = (progress: number, total: number) => void; -type FocusHandler = (focus: boolean) => void; +/************************************************************************** + * RUNTIME + * ************************************************************************* */ class UtilitiesRuntime { private readonly __isBrowser: boolean; private readonly __isApp: boolean; - private __download_progress_handlers: Map; - private __isWindowFocused: boolean; - private __WindowFocusedCallbacks: Array; + + private __isWindowFocused = true; + private __windowFocusedCallbacks: Array = []; + + private __downloadProgressHandlers: Map = + new Map(); + constructor() { // Initialize markers this.__isBrowser = platform === "web"; - this.__isApp = window.__TAURI__ !== undefined; - this.__download_progress_handlers = new Map(); - this.__isWindowFocused = true; - this.__WindowFocusedCallbacks = []; - - if (this.__isApp) { - tauriAppWindow.listen( - "download://progress", - ({ payload }) => { - const handler = this.__download_progress_handlers.get(payload.id); - if (handler != null) { - handler(payload.progress, payload.total); - } - } - ); + this.__isApp = !this.__isBrowser; - tauriAppWindow.listen( - "scheme-request-received", - ({ payload }) => { - BaseAlert.info("opened", payload); - } - ); - // unfortunately "visibilitychange" is less precise than Tauri - // especially if Tauri window is behind another window(s) - tauriAppWindow.listen("window-focused", ({ payload }) => { - this.__isWindowFocused = payload; - this.__WindowFocusedCallbacks.forEach(callback => - callback(this.__isWindowFocused) - ); - }); - } else { - document.addEventListener("visibilitychange", () => { - this.__isWindowFocused = document.visibilityState === "visible"; - this.__WindowFocusedCallbacks.forEach(callback => - callback(this.__isWindowFocused) - ); - }); - } + // Register listeners + this.__registerListeners(); + } + + // TODO: call it from somewhere + registerWindowFocusCallback(callback: RuntimeFocusHandler): void { + this.__windowFocusedCallbacks.push(callback); } async requestOpenUrl(url: string, target = "_blank"): Promise { - if (this.__isApp) { + if (this.__isApp === true) { // Request to open via Tauri API (application build) await tauriOpen(url); } else { @@ -110,35 +90,29 @@ class UtilitiesRuntime { } } - isWindowFocused(): boolean { - return this.__isWindowFocused; - } - - registerWindowFocusCallback(callback: FocusHandler): void { - this.__WindowFocusedCallbacks.push(callback); - } - async requestFileDownload( url: string, filename: string | null = null, - progressHandler?: ProgressHandler + progressHandler?: RuntimeProgressHandler ): Promise { - // Tauri build - if (this.__isApp) { + if (this.__isApp === true) { // Request to download file via Tauri API (application build) + // TODO: simplify this random number generation const ids = new Uint32Array(1); window.crypto.getRandomValues(ids); const id = ids[0]; - if (progressHandler != undefined) { - this.__download_progress_handlers.set(id, progressHandler); + if (progressHandler !== undefined) { + this.__downloadProgressHandlers.set(id, progressHandler); } + await tauriInvoke("plugin:downloader|download_file", { id, url, filename }); - this.__download_progress_handlers.delete(id); + + this.__downloadProgressHandlers.delete(id); } else { // TODO: implement download progress callback here too @@ -150,21 +124,21 @@ class UtilitiesRuntime { } async requestNotificationSend(title: string, body: string): Promise { - // do not send notification if window is focused - if (this.__isWindowFocused) { - return; - } - if (this.__isApp) { - // Request to show notification via Tauri API (application build) - await tauriInvoke("plugin:notifications|send_notification", { - title, - body - }); - } else { + // Skip notification banners if window has focus + if (this.__isWindowFocused !== true) { const hasPermission = await this.requestNotificationPermission(); - if (hasPermission) { - // Request to show notification via browser APIs (Web build) - new Notification(title, { body }); + + if (hasPermission === true) { + if (this.__isApp === true) { + // Request to show notification via Tauri API (application build) + await tauriInvoke("plugin:notifications|send_notification", { + title, + body + }); + } else { + // Request to show notification via browser APIs (Web build) + new Notification(title, { body }); + } } else { logger.warn( "Not sending notification since permission is denied:", @@ -174,39 +148,87 @@ class UtilitiesRuntime { } } - async setBadgeCount(count: number) { - if (this.__isApp) { - await tauriInvoke("plugin:notifications|set_badge_count", { - count - }); - } - } - async requestNotificationPermission(): Promise { - // Request to show notification via browser APIs (Web build) - let hasPermission = - Notification.permission === NOTIFICATION_PERMISSIONS.granted; - if ( - !hasPermission && - Notification.permission !== NOTIFICATION_PERMISSIONS.denied - ) { + let hasPermission = false; + + if (this.__isApp === true) { + // Request to show notification via Tauri API (application build) + // Notice: permission request is managed at a lower level, therefore \ + // always consider we have permission here. + hasPermission = true; + } else { + // Request to show notification via browser APIs (Web build) hasPermission = - (await Notification.requestPermission()) === - NOTIFICATION_PERMISSIONS.granted; + Notification.permission === NOTIFICATION_PERMISSIONS.granted; + + if ( + hasPermission === false && + Notification.permission !== NOTIFICATION_PERMISSIONS.denied + ) { + hasPermission = + (await Notification.requestPermission()) === + NOTIFICATION_PERMISSIONS.granted; + } } return hasPermission; } async requestUnreadCountUpdate(count: number): Promise { - if (this.__isApp) { + if (this.__isApp === true) { // Request to update unread count via Tauri API (application build) - await tauriInvoke("set_badge_count", { count }); + await tauriInvoke("plugin:notifications|set_badge_count", { + count + }); } else { // Request to update unread count via browser APIs (Web build) UtilitiesTitle.setUnreadCount(count); } } + + private __registerListeners(): void { + if (this.__isApp === true) { + // Register listeners via Tauri API (application build) + tauriAppWindow.listen( + "download:progress", + + ({ payload }) => { + const progressHandler = this.__downloadProgressHandlers.get( + payload.id + ); + + if (progressHandler !== undefined) { + progressHandler(payload.progress, payload.total); + } + } + ); + + tauriAppWindow.listen( + "url:open", + + ({ payload }) => { + // TODO: open conversation in Prose + } + ); + + tauriAppWindow.listen("window:focus", ({ payload }) => { + this.__isWindowFocused = payload; + + this.__windowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } else { + // Register listeners via browser Document API (Web build) + document.addEventListener("visibilitychange", () => { + this.__isWindowFocused = document.visibilityState === "visible"; + + this.__windowFocusedCallbacks.forEach(callback => + callback(this.__isWindowFocused) + ); + }); + } + } } /************************************************************************** From 4e69d4c338ff5b16e875f055543309e35624fda0 Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 20:39:38 +0100 Subject: [PATCH 09/11] fix: restore tauri check --- src/utilities/runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index 73cce2c2..3d4a5998 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -66,13 +66,13 @@ class UtilitiesRuntime { constructor() { // Initialize markers this.__isBrowser = platform === "web"; - this.__isApp = !this.__isBrowser; + this.__isApp = !this.__isBrowser && window.__TAURI__ !== undefined; // Register listeners this.__registerListeners(); } - // TODO: call it from somewhere + // TODO: call it from somewhere? registerWindowFocusCallback(callback: RuntimeFocusHandler): void { this.__windowFocusedCallbacks.push(callback); } From 8e4460873ae5f50dddf05ed8c929313bc4e3ca7e Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 22:15:27 +0100 Subject: [PATCH 10/11] style: add comment --- src-tauri/src/download.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index ce86e374..b9707fde 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -64,7 +64,10 @@ pub async fn download_file( .to_string(); } - // Security: remove path traversal characters from filename + // Security: ensure that provided filename is not attempting to perform a \ + // path traversal. For instance, passing a filename '../dangerous.txt' \ + // to store files outside of the Downloads folder. Sanitize the file \ + // name if it is deemed dangerous. filename = remove_path_traversal(&filename); // No filename? Assign fallback filename From 15674342fcab2e654426ddf943886e79076a2577 Mon Sep 17 00:00:00 2001 From: Valerian Saliou Date: Tue, 19 Mar 2024 22:53:17 +0100 Subject: [PATCH 11/11] chore: improve code quality for runtime focus management et al --- src/App.vue | 38 +++++++----------- src/utilities/runtime.ts | 87 +++++++++++++++++++++++++--------------- 2 files changed, 69 insertions(+), 56 deletions(-) diff --git a/src/App.vue b/src/App.vue index df9e6f2f..a71a32db 100644 --- a/src/App.vue +++ b/src/App.vue @@ -33,8 +33,8 @@ import Store from "@/store"; import { SessionAppearance } from "@/store/tables/session"; -// CONSTANTS -const FOCUS_CHANGE_EVENTS = ["focus", "blur"]; +// PROJECT: UTILITIES +import UtilitiesRuntime from "@/utilities/runtime"; export default { name: "App", @@ -59,9 +59,6 @@ export default { }, beforeMount() { - // Initialize focus-based visibility - this.initializeFocusVisibility(); - // Detect if system requests dark mode this.initializeSystemDarkMode(); }, @@ -112,13 +109,6 @@ export default { } }, - initializeFocusVisibility(): void { - // Initialize value (trigger an explicit focus change) - // Notice: this is required, since focus status might have changed from \ - // last stored one, or default, before those event listeners got bound. - this.onFocusChange(); - }, - initializeSystemDarkMode(): void { this.matchMediaDarkMode = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") @@ -131,17 +121,19 @@ export default { }, setupListenerFocusChange(): void { - // Bind all focus event listeners - FOCUS_CHANGE_EVENTS.forEach(eventName => { - document.addEventListener(eventName, this.onFocusChange); - }); + // Register platform-dependant focus change handler + const hasFocus = UtilitiesRuntime.registerFocusHandler( + this.onFocusChange + ); + + // Initialize value (trigger an explicit focus change) + // Notice: this is required, since focus status might have changed from \ + // last stored one, or default, before those event listeners got bound. + this.onFocusChange(hasFocus); }, unsetupListenerFocusChange(): void { - // Unbind all focus event listeners - FOCUS_CHANGE_EVENTS.forEach(eventName => { - document.removeEventListener(eventName, this.onFocusChange); - }); + UtilitiesRuntime.unregisterFocusHandler(); }, setupListenerSystemDarkMode(): void { @@ -164,10 +156,8 @@ export default { // --> EVENT LISTENERS <-- - onFocusChange(): void { - const isDocumentVisible = document.hasFocus() === true ? true : false; - - this.session.setVisible(isDocumentVisible); + onFocusChange(focused: boolean): void { + this.session.setVisible(focused); }, onSystemDarkModeChange({ matches = false }): void { diff --git a/src/utilities/runtime.ts b/src/utilities/runtime.ts index 3d4a5998..65ef3005 100644 --- a/src/utilities/runtime.ts +++ b/src/utilities/runtime.ts @@ -57,24 +57,35 @@ class UtilitiesRuntime { private readonly __isBrowser: boolean; private readonly __isApp: boolean; - private __isWindowFocused = true; - private __windowFocusedCallbacks: Array = []; + private __states = { + focused: false + }; - private __downloadProgressHandlers: Map = - new Map(); + private __handlers = { + focus: null as RuntimeFocusHandler | null, + download: new Map() as Map + }; constructor() { // Initialize markers this.__isBrowser = platform === "web"; this.__isApp = !this.__isBrowser && window.__TAURI__ !== undefined; - // Register listeners - this.__registerListeners(); + // Bind listeners + this.__bindListeners(); } - // TODO: call it from somewhere? - registerWindowFocusCallback(callback: RuntimeFocusHandler): void { - this.__windowFocusedCallbacks.push(callback); + registerFocusHandler(handler: RuntimeFocusHandler): boolean { + // Register platform-agnostic focus handler + this.__handlers.focus = handler; + + // Return current value (can be used to synchronize external states) + return this.__states.focused; + } + + unregisterFocusHandler(): void { + // Unregister platform-agnostic focus handler + this.__handlers.focus = null; } async requestOpenUrl(url: string, target = "_blank"): Promise { @@ -97,13 +108,10 @@ class UtilitiesRuntime { ): Promise { if (this.__isApp === true) { // Request to download file via Tauri API (application build) - // TODO: simplify this random number generation - const ids = new Uint32Array(1); - window.crypto.getRandomValues(ids); - const id = ids[0]; + const id = Date.now(); if (progressHandler !== undefined) { - this.__downloadProgressHandlers.set(id, progressHandler); + this.__handlers.download.set(id, progressHandler); } await tauriInvoke("plugin:downloader|download_file", { @@ -112,20 +120,29 @@ class UtilitiesRuntime { filename }); - this.__downloadProgressHandlers.delete(id); + this.__handlers.download.delete(id); } else { - // TODO: implement download progress callback here too - // Request to download file via browser APIs (Web build) await new FileDownloader({ - url + url, + + process: (event: ProgressEvent): undefined => { + if ( + event.lengthComputable === true && + progressHandler !== undefined + ) { + progressHandler(event.loaded, event.total); + } + + return undefined; + } }); } } async requestNotificationSend(title: string, body: string): Promise { // Skip notification banners if window has focus - if (this.__isWindowFocused !== true) { + if (this.__states.focused !== true) { const hasPermission = await this.requestNotificationPermission(); if (hasPermission === true) { @@ -186,16 +203,16 @@ class UtilitiesRuntime { } } - private __registerListeners(): void { + private __bindListeners(): void { if (this.__isApp === true) { // Register listeners via Tauri API (application build) + this.__states.focused = true; + tauriAppWindow.listen( "download:progress", ({ payload }) => { - const progressHandler = this.__downloadProgressHandlers.get( - payload.id - ); + const progressHandler = this.__handlers.download.get(payload.id); if (progressHandler !== undefined) { progressHandler(payload.progress, payload.total); @@ -212,23 +229,29 @@ class UtilitiesRuntime { ); tauriAppWindow.listen("window:focus", ({ payload }) => { - this.__isWindowFocused = payload; - - this.__windowFocusedCallbacks.forEach(callback => - callback(this.__isWindowFocused) - ); + this.__changeFocusState(payload); }); } else { // Register listeners via browser Document API (Web build) - document.addEventListener("visibilitychange", () => { - this.__isWindowFocused = document.visibilityState === "visible"; + this.__states.focused = + document.visibilityState === "visible" ? true : false; - this.__windowFocusedCallbacks.forEach(callback => - callback(this.__isWindowFocused) + document.addEventListener("visibilitychange", () => { + this.__changeFocusState( + document.visibilityState === "visible" ? true : false ); }); } } + + private __changeFocusState(focused: boolean): void { + this.__states.focused = focused; + + // Trigger focus handler? (if any) + if (this.__handlers.focus !== null) { + this.__handlers.focus(focused); + } + } } /**************************************************************************