diff --git a/cinny b/cinny index 3282832..053b364 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 3282832a4a784fa9eb3e8f54dbcde5b450e5d8ab +Subproject commit 053b364a4414e6077cdd9b92e10b0a01a555636b diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f0cb128..50febe3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-process = "2" tauri-plugin-os = "2" tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-deep-link = "2" [features] # by default Tauri runs in production mode @@ -41,9 +42,11 @@ custom-protocol = [ "tauri/custom-protocol" ] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" +tauri-plugin-single-instance = "2" [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" +window-vibrancy = "0.6" windows = { version = "0.61", features = [ "Win32_Graphics_Gdi", "Win32_System_Com", diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index d6c5559..7564d9f 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -10,6 +10,7 @@ ], "permissions": [ "updater:default", - "global-shortcut:default" + "global-shortcut:default", + "deep-link:default" ] } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e44f91..20b593e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,9 +3,44 @@ windows_subsystem = "windows" )] -use tauri::{webview::{NewWindowResponse, WebviewWindowBuilder}, WebviewUrl}; +use tauri::{ + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder}, + Manager, WebviewUrl, +}; use tauri_plugin_opener::OpenerExt; +/// Bring the main window to the foreground from the tray / a hidden / +/// minimized state. Shared by the tray, single-instance, and deep-link paths. +fn show_main(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +/// Hand a `matrix:` / `matrix.to` URL to the web app by dispatching a DOM +/// CustomEvent the client listens for (see useDeepLinkNavigate.ts). Uses +/// `eval` so we don't need the @tauri-apps/api event package on the web side. +fn forward_deeplink(app: &tauri::AppHandle, url: &str) { + show_main(app); + if let Some(window) = app.get_webview_window("main") { + if let Ok(json) = serde_json::to_string(url) { + let _ = window.eval(&format!( + "window.dispatchEvent(new CustomEvent('lotus-deeplink',{{detail:{json}}}))" + )); + } + } +} + +/// Pull the first `matrix:` link out of a process's CLI args (Windows/Linux +/// pass deep-link URLs as argv to a freshly launched instance). +fn matrix_url_from_args(args: &[String]) -> Option { + args.iter().find(|a| a.starts_with("matrix:")).cloned() +} + // Injected into every page before app scripts load. // Patches window.Notification to route through tauri-plugin-notification so // WebView2's default "denied" state never reaches cinny's permission check. @@ -242,7 +277,22 @@ pub fn run() { let context = tauri::generate_context!(); #[allow(unused_mut)] - let mut builder = tauri::Builder::default() + let mut builder = tauri::Builder::default(); + + // Single-instance MUST be registered first: a second launch focuses the + // existing window (and forwards any matrix: link) instead of colliding on + // the localhost port. Desktop-only plugin. + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + show_main(app); + if let Some(url) = matrix_url_from_args(&argv) { + forward_deeplink(app, &url); + } + })); + } + + builder = builder .invoke_handler(tauri::generate_handler![ set_badge_count, send_notification, @@ -252,7 +302,8 @@ pub fn run() { .plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_notification::init()); + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_deep_link::init()); #[cfg(not(any(target_os = "android", target_os = "ios")))] { @@ -261,6 +312,41 @@ pub fn run() { builder .setup(move |app| { + // --- System tray: keeps Lotus Chat running in the background so + // notifications keep arriving after the window is closed-to-tray. --- + let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; + let _tray = TrayIconBuilder::with_id("main-tray") + .icon(app.default_window_icon().cloned().unwrap()) + .tooltip("Lotus Chat") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "open" => show_main(app), + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + show_main(app); + } + } + } + }) + .build(app)?; + #[cfg(debug_assertions)] let window_url = WebviewUrl::App(Default::default()); @@ -273,14 +359,72 @@ pub fn run() { let app_handle = app.handle().clone(); let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url) .title("Lotus Chat") + // First-run defaults; tauri-plugin-window-state restores geometry + // on later launches. + .inner_size(1100.0, 720.0) + .min_inner_size(480.0, 600.0) + .center() + // Start hidden and reveal once the page has painted, to avoid the + // white launch flash. + .visible(false) .initialization_script(NOTIFICATION_BRIDGE) .disable_drag_drop_handler() + .on_page_load(|window, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + let _ = window.show(); + } + }) .on_new_window(move |url, _features| { let _ = app_handle.opener().open_url(url.as_str(), None::<&str>); NewWindowResponse::Deny }) .build()?; + // Close-to-tray: hide instead of exiting; the app is quit explicitly + // from the tray menu. + let window_for_close = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let _ = window_for_close.hide(); + } + }); + + // Failsafe: never leave the window stuck hidden if the page-load + // event doesn't fire for some reason. + let window_for_show = window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(8)); + let _ = window_for_show.show(); + }); + + // Deep links (matrix:): route both the cold-start case and the + // already-running case (forwarded via single-instance argv) into the + // web client. + { + use tauri_plugin_deep_link::DeepLinkExt; + // Runtime scheme registration is a Linux/Windows-only API; macOS + // registers the scheme from the bundle config at build time. + #[cfg(any(target_os = "linux", target_os = "windows"))] + let _ = app.deep_link().register_all(); + let deep_link_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + forward_deeplink(&deep_link_handle, url.as_str()); + } + }); + if let Some(url) = matrix_url_from_args(&std::env::args().collect::>()) { + forward_deeplink(&app.handle().clone(), &url); + } + } + + // Windows 11 Mica backdrop. The app paints an opaque TDS background, + // so this is subtle (mainly window chrome); harmless if unsupported. + #[cfg(target_os = "windows")] + { + let _ = window_vibrancy::apply_mica(&window, Some(true)); + } + // Auto-grant camera, microphone, and notification permissions in WebView2. #[cfg(target_os = "windows")] window.with_webview(|webview| { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs deleted file mode 100644 index 010a2cf..0000000 --- a/src-tauri/src/menu.rs +++ /dev/null @@ -1,44 +0,0 @@ -use tauri::menu::{MenuBuilder, SubmenuBuilder}; -use tauri::AppHandle; - -pub fn menu() -> tauri::menu::Menu { - let app_menu = SubmenuBuilder::new(app, "Cinny") - .about(Some(Default::default())) - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .quit() - .build() - .unwrap(); - - let edit_menu = SubmenuBuilder::new(app, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build() - .unwrap(); - - let view_menu = SubmenuBuilder::new(app, "View") - .fullscreen() // `.fullscreen()` works instead of `.enter_fullscreen()` - .build() - .unwrap(); - - let window_menu = SubmenuBuilder::new(app, "Window") - .minimize() - .build() // no `.zoom()` method directly available - .unwrap(); - - MenuBuilder::new(app) - .item(&app_menu) - .item(&edit_menu) - .item(&view_menu) - .item(&window_menu) - .build() - .unwrap() -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6b2e865..a14ca30 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,6 +6,12 @@ "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "", + "webviewInstallMode": { + "type": "downloadBootstrapper" + }, + "nsis": { + "installMode": "currentUser" + }, "wix": { "bannerPath": "wix/banner.bmp", "dialogImagePath": "wix/dialogImage.bmp" @@ -55,6 +61,11 @@ "endpoints": [ "https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/release.json" ] + }, + "deep-link": { + "desktop": { + "schemes": ["matrix"] + } } }, "app": {