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..b9707fde --- /dev/null +++ b/src-tauri/src/download.rs @@ -0,0 +1,276 @@ +// 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}; +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; + +/************************************************************************** + * 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("Could not download file")] + 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, + id: u64, + url: &str, + filename: &str, +) -> Result { + let mut filename = filename.to_string(); + + // 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: 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 + if filename.is_empty() { + filename = "File".to_string(); + } + + // 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)?; + + // 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)?; + + // 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)?; + + // Compute download progress (de-bounced) + downloaded_bytes = min(downloaded_bytes + chunk.len(), total_bytes); + + if last_size_report.elapsed().as_millis() > 100 || downloaded_bytes == total_bytes { + last_size_report = Instant::now(); + window + .emit( + "download:progress", + DownloadProgress { + id, + progress: downloaded_bytes, + total: total_bytes, + }, + ) + .unwrap(); + } + } + + // Flush downloaded file on disk + file.flush() + .await + .map_err(|_| DownloadError::DownloadError)?; + + Ok(download_path.to_string_lossy().to_string()) +} + +/************************************************************************** + * 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('.') + .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) +} + +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(|_| Ok(())) + .build() +} + +/************************************************************************** + * TESTS + * ************************************************************************* */ + +#[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..8e3f98a3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,116 +6,48 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] /************************************************************************** - * IMPORTS - * ************************************************************************* */ - -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 + * MODULES * ************************************************************************* */ -#[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, -} +mod download; +mod notifications; /************************************************************************** - * COMMANDS + * IMPORTS * ************************************************************************* */ -#[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"); -} +use tauri::{Manager, WindowEvent}; /************************************************************************** * MAIN * ************************************************************************* */ fn main() { + // Prepare Prose for deep-linking + 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(); + + // Register URL opener on XMPP URIs + tauri_plugin_deep_link::register("xmpp", move |request| { + if let Some(window) = handle.get_window("main") { + let _ = window.set_focus(); + let _ = window.emit("url:open", request); + } + }) + .unwrap(); + + #[cfg(not(target_os = "macos"))] + if let Some(url) = std::env::args().nth(1) { + app.emit_all("url:open", url).unwrap(); + } + + Ok(()) + }) + .on_window_event(|event| match event.event() { + WindowEvent::CloseRequested { api, .. } => { #[cfg(not(target_os = "macos"))] { event.window().hide().unwrap(); @@ -128,8 +60,11 @@ fn main() { api.prevent_close(); } + WindowEvent::Focused(focused) => event.window().emit("window:focus", focused).unwrap(), + _ => {} }) - .invoke_handler(tauri::generate_handler![download_file, set_badge_count]) + .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 new file mode 100644 index 00000000..c6de3ff1 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,76 @@ +// 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) { + // 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]; + } +} + +/************************************************************************** + * PROVIDERS + * ************************************************************************* */ + +pub fn provide() -> TauriPlugin { + provide_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 provide_notifications() { + let bundle = mac_notification_sys::get_bundle_identifier_or_default("prose"); + + mac_notification_sys::set_application(&bundle).unwrap(); +} 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 6322549c..65ef3005 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,6 @@ import CONFIG from "@/commons/config"; // PROJECT: UTILITIES import logger from "@/utilities/logger"; -import UtilitiesFile from "@/utilities/file"; import UtilitiesTitle from "@/utilities/title"; /************************************************************************** @@ -37,6 +32,23 @@ const NOTIFICATION_PERMISSIONS = { denied: "denied" }; +/************************************************************************** + * TYPES + * ************************************************************************* */ + +export type RuntimeProgressHandler = (progress: number, total: number) => void; +export type RuntimeFocusHandler = (focused: boolean) => void; + +/************************************************************************** + * INTERFACES + * ************************************************************************* */ + +interface RuntimeProgressPayload { + id: number; + progress: number; + total: number; +} + /************************************************************************** * RUNTIME * ************************************************************************* */ @@ -45,10 +57,35 @@ class UtilitiesRuntime { private readonly __isBrowser: boolean; private readonly __isApp: boolean; + private __states = { + focused: false + }; + + private __handlers = { + focus: null as RuntimeFocusHandler | null, + download: new Map() as Map + }; + constructor() { // Initialize markers - this.__isBrowser = platform === "web" ? true : false; - this.__isApp = !this.__isBrowser; + this.__isBrowser = platform === "web"; + this.__isApp = !this.__isBrowser && window.__TAURI__ !== undefined; + + // Bind listeners + this.__bindListeners(); + } + + 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 { @@ -66,44 +103,65 @@ class UtilitiesRuntime { async requestFileDownload( url: string, - name: string | null = null + filename: string | null = null, + progressHandler?: RuntimeProgressHandler ): 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) { // Request to download file via Tauri API (application build) - await tauriInvoke("download_file", downloadOptions); + const id = Date.now(); + + if (progressHandler !== undefined) { + this.__handlers.download.set(id, progressHandler); + } + + await tauriInvoke("plugin:downloader|download_file", { + id, + url, + filename + }); + + this.__handlers.download.delete(id); } else { // Request to download file via browser APIs (Web build) - await new FileDownloader(downloadOptions); + await new FileDownloader({ + 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 { - const hasPermission = await this.requestNotificationPermission(); - - if (hasPermission === true) { - if (this.__isApp === true) { - // Request to show notification via Tauri API (application build) - tauriSendNotification({ - title, - body - }); + // Skip notification banners if window has focus + if (this.__states.focused !== true) { + const hasPermission = await this.requestNotificationPermission(); + + 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 { - // Request to show notification via browser APIs (Web build) - new Notification(title, { body }); + logger.warn( + "Not sending notification since permission is denied:", + title + ); } - } else { - logger.warn( - "Not sending notification since permission is denied:", - title - ); } } @@ -112,12 +170,9 @@ class UtilitiesRuntime { if (this.__isApp === true) { // Request to show notification via Tauri API (application build) - hasPermission = await tauriIsPermissionGranted(); - - if (hasPermission === false) { - hasPermission = - (await tauriRequestPermission()) === NOTIFICATION_PERMISSIONS.granted; - } + // 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 = @@ -139,12 +194,64 @@ class UtilitiesRuntime { async requestUnreadCountUpdate(count: number): Promise { 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 __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.__handlers.download.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.__changeFocusState(payload); + }); + } else { + // Register listeners via browser Document API (Web build) + this.__states.focused = + document.visibilityState === "visible" ? true : false; + + 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); + } + } } /**************************************************************************