From 3813c5f1e82dd4a102477a74474ff29600e96837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Mon, 4 May 2026 17:58:52 +0200 Subject: [PATCH 1/5] test --- src-tauri/Cargo.lock | 4 + src-tauri/Cargo.toml | 4 + .../icon-composer-layers/01-bubble-fill.svg | 3 +- .../icon-composer-layers/03-dots-dark.svg | 4 +- src-tauri/src/commands.rs | 121 ++++++++++-------- src-tauri/src/lib.rs | 48 ++++++- src-tauri/tauri.conf.json | 6 +- 7 files changed, 124 insertions(+), 66 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 08aeea1..d52677d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1107,6 +1107,8 @@ name = "ex-desktop" version = "0.1.0" dependencies = [ "log", + "objc2-foundation", + "objc2-user-notifications", "serde", "serde_json", "tauri", @@ -2667,6 +2669,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.11.1", + "block2", "objc2", "objc2-foundation", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dde55e3..e4e30ff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,3 +40,7 @@ log = "0.4.29" tokio = { version = "1.52.1", features = ["full", "time"] } tauri-plugin-global-shortcut = "2.3.1" url = "2.5.8" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["std", "block2", "UNNotificationContent", "UNNotificationRequest", "UNNotificationSound", "UNNotificationTrigger", "UNUserNotificationCenter"] } diff --git a/src-tauri/icons/icon-composer-layers/01-bubble-fill.svg b/src-tauri/icons/icon-composer-layers/01-bubble-fill.svg index 8ffa9e2..76af8be 100644 --- a/src-tauri/icons/icon-composer-layers/01-bubble-fill.svg +++ b/src-tauri/icons/icon-composer-layers/01-bubble-fill.svg @@ -1,5 +1,4 @@ + fill="#ffffff"/> diff --git a/src-tauri/icons/icon-composer-layers/03-dots-dark.svg b/src-tauri/icons/icon-composer-layers/03-dots-dark.svg index 7741043..465151d 100644 --- a/src-tauri/icons/icon-composer-layers/03-dots-dark.svg +++ b/src-tauri/icons/icon-composer-layers/03-dots-dark.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 47505f1..923b16f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use tauri::webview::{DownloadEvent, NewWindowResponse, WebviewWindowBuilder}; use tauri::{image::Image, webview::PageLoadEvent, AppHandle, Manager, WebviewUrl, WebviewWindow}; +#[cfg(not(target_os = "macos"))] +use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; use tauri_plugin_store::StoreExt; use url::Url; @@ -24,54 +26,13 @@ fn remote_main_init_script() -> Result { writable: false, configurable: false }}); + Object.defineProperty(globalThis, '__EX_DESKTOP__', {{ + value: true, + writable: false, + configurable: false + }}); let authRequiredShown = false; - function requestDockAttention() {{ - try {{ - globalThis.__TAURI_INTERNALS__?.invoke('request_notification_attention'); - }} catch {{ - // ignore - }} - }} - - function installNotificationAttentionBridge() {{ - try {{ - const NativeNotification = globalThis.Notification; - if ( - typeof NativeNotification !== 'function' || - NativeNotification.__exDesktopAttentionBridge - ) {{ - return false; - }} - - function DesktopNotification(title, options) {{ - const nativeOptions = Object.assign({{}}, options || {{}}); - // Browser notifications accept web asset URLs such as /logo.svg, but - // Tauri's native notification backend expects platform icon names or - // filesystem paths. Passing the web URL prevents the toast from - // rendering on macOS, so let the OS use the app icon instead. - delete nativeOptions.icon; - const notification = new NativeNotification(title, nativeOptions); - requestDockAttention(); - return notification; - }} - - Object.setPrototypeOf(DesktopNotification, NativeNotification); - DesktopNotification.prototype = NativeNotification.prototype; - Object.defineProperties( - DesktopNotification, - Object.getOwnPropertyDescriptors(NativeNotification) - ); - Object.defineProperty(DesktopNotification, '__exDesktopAttentionBridge', {{ - value: true, - configurable: false - }}); - globalThis.Notification = DesktopNotification; - return true; - }} catch {{ - return false; - }} - }} function unreadCountFromTitle() {{ const match = /^\\((\\d+)\\)\\s+/.exec(document.title || ''); @@ -123,15 +84,6 @@ fn remote_main_init_script() -> Result { }}, 250); }} - if (!installNotificationAttentionBridge()) {{ - let attempts = 0; - const timer = globalThis.setInterval(() => {{ - attempts += 1; - if (installNotificationAttentionBridge() || attempts >= 20) {{ - globalThis.clearInterval(timer); - }} - }}, 250); - }} installUnreadBadgeBridge(); function showAuthRequired() {{ @@ -845,6 +797,60 @@ pub fn request_notification_attention(app: AppHandle) -> Result<(), String> { .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn send_desktop_notification( + app: AppHandle, + title: String, + body: Option, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + send_macos_user_notification(&app, &title, body.as_deref())?; + + #[cfg(not(target_os = "macos"))] + { + let mut notification = app.notification().builder().title(title); + if let Some(body) = body.filter(|body| !body.trim().is_empty()) { + notification = notification.body(body); + } + notification.show().map_err(|e| e.to_string())?; + } + + request_notification_attention(app) +} + +#[cfg(target_os = "macos")] +fn send_macos_user_notification( + _app: &AppHandle, + title: &str, + body: Option<&str>, +) -> Result<(), String> { + use objc2_foundation::NSString; + use objc2_user_notifications::{ + UNMutableNotificationContent, UNNotificationRequest, UNNotificationSound, + UNUserNotificationCenter, + }; + use std::time::{SystemTime, UNIX_EPOCH}; + + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(title)); + if let Some(body) = body.filter(|body| !body.trim().is_empty()) { + content.setBody(&NSString::from_str(body)); + } + content.setSound(Some(&UNNotificationSound::defaultSound())); + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos(); + let identifier = NSString::from_str(&format!("ex-desktop-{timestamp}")); + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, None); + Ok(()) +} + /// Updates the tray icon and tooltip to reflect the unread message count. #[tauri::command] pub fn set_badge_count(app: AppHandle, count: u32) -> Result<(), String> { @@ -899,10 +905,13 @@ mod tests { fn remote_init_script_bridges_unread_title_to_badge_count() { let script = remote_main_init_script().unwrap(); + assert!(script.contains("'__EX_DESKTOP__'")); assert!(script.contains("unreadCountFromTitle")); assert!(script.contains("invoke('set_badge_count', { count })")); assert!(script.contains("MutationObserver(syncUnreadBadge)")); - assert!(script.contains("delete nativeOptions.icon")); + assert!(!script.contains("globalThis.Notification =")); + assert!(!script.contains("ServiceWorkerRegistration")); + assert!(!script.contains("prototype.showNotification")); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c9b6bc..b0f950b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,10 +41,30 @@ fn configure_app_menu(app: &tauri::AppHandle) -> tauri::Result<()> { None::<&str>, )?; let reload_i = MenuItem::with_id(app, "reload-chat", "Reload Chat", true, Some("CmdOrCtrl+R"))?; + let test_notification_i = MenuItem::with_id( + app, + "test-notification", + "Test Notification", + true, + None::<&str>, + )?; + let test_background_notification_i = MenuItem::with_id( + app, + "test-background-notification", + "Test Background Notification", + true, + None::<&str>, + )?; let sep = PredefinedMenuItem::separator(app)?; if let Some(MenuItemKind::Submenu(first_menu)) = app_menu.items()?.into_iter().next() { - first_menu.prepend_items(&[&switch_chat_url_i, &reload_i, &sep])?; + first_menu.prepend_items(&[ + &switch_chat_url_i, + &reload_i, + &test_notification_i, + &test_background_notification_i, + &sep, + ])?; } app.set_menu(app_menu)?; @@ -219,6 +239,31 @@ pub fn run() { let _ = window.eval("globalThis.location.reload()"); } } + "test-notification" => { + if let Err(err) = commands::send_desktop_notification( + app.clone(), + "ex test notification".to_string(), + Some("This was sent directly from the desktop app.".to_string()), + ) { + log::warn!("Could not send test notification: {err}"); + } + } + "test-background-notification" => { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + tokio::time::sleep(tokio::time::Duration::from_millis(1200)).await; + if let Err(err) = commands::send_desktop_notification( + app, + "ex background notification".to_string(), + Some("This was sent after hiding the app window.".to_string()), + ) { + log::warn!("Could not send background test notification: {err}"); + } + }); + } _ => {} }); @@ -281,6 +326,7 @@ pub fn run() { commands::show_setup_window, commands::start_relogin, commands::request_notification_attention, + commands::send_desktop_notification, commands::set_badge_count, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2a2c26a..27082ef 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,11 +33,7 @@ "active": true, "targets": "all", "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" + "icons/AppIcon.icon" ], "linux": { "deb": { From 7552ff7002de7b18751d8242abef6ed95cd641ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Mon, 4 May 2026 19:34:00 +0200 Subject: [PATCH 2/5] test --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 4 +- src-tauri/src/commands.rs | 343 +++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 24 ++- 4 files changed, 357 insertions(+), 16 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d52677d..64e74c9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1107,6 +1107,8 @@ name = "ex-desktop" version = "0.1.0" dependencies = [ "log", + "objc2", + "objc2-app-kit", "objc2-foundation", "objc2-user-notifications", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4e30ff..36ecdda 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.6.0", features = [] } [dependencies] -tauri = { version = "2.11.0", features = ["tray-icon", "image-png", "devtools"] } +tauri = { version = "2.11.0", features = ["tray-icon", "image-png"] } tauri-plugin-log = "2.8.0" # Server config persistence tauri-plugin-store = "2.4.3" @@ -42,5 +42,7 @@ tauri-plugin-global-shortcut = "2.3.1" url = "2.5.8" [target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.4" +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSMenu", "NSMenuItem"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString"] } objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["std", "block2", "UNNotificationContent", "UNNotificationRequest", "UNNotificationSound", "UNNotificationTrigger", "UNUserNotificationCenter"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 923b16f..1d25ccd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,11 +8,92 @@ use tauri_plugin_store::StoreExt; use url::Url; use crate::config::normalize_server_url; +#[cfg(target_os = "macos")] +use std::sync::Once; const STORE_FILE: &str = "config.json"; const KEY_SERVER_URL: &str = "serverUrl"; const LEGACY_KEY_REFRESH_TOKEN: &str = "refreshToken"; +fn main_webview_devtools_enabled() -> bool { + false +} + +#[cfg(target_os = "macos")] +fn disable_native_context_menu(window: &WebviewWindow) { + let _ = window; + filter_wkwebview_context_menu(); +} + +#[cfg(not(target_os = "macos"))] +fn disable_native_context_menu(_window: &WebviewWindow) {} + +#[cfg(target_os = "macos")] +fn native_context_menu_will_open_selector_name() -> &'static std::ffi::CStr { + c"willOpenMenu:withEvent:" +} + +#[cfg(target_os = "macos")] +fn filter_wkwebview_context_menu() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| unsafe { + use objc2::ffi::{class_getInstanceMethod, method_setImplementation}; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + + unsafe extern "C-unwind" fn filter_context_menu( + _this: *mut AnyObject, + _cmd: Sel, + menu: *mut AnyObject, + _event: *mut AnyObject, + ) { + if menu.is_null() { + return; + } + let menu = &*(menu as *mut objc2_app_kit::NSMenu); + let mut index = menu.numberOfItems(); + while index > 0 { + index -= 1; + let Some(item) = menu.itemAtIndex(index) else { + continue; + }; + let title = item.title().to_string().to_lowercase(); + if title == "back" || title == "reload" { + menu.removeItemAtIndex(index); + } + } + if menu.numberOfItems() == 0 { + menu.cancelTracking(); + } + } + + let filter_menu_implementation: Imp = std::mem::transmute( + filter_context_menu + as unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, *mut AnyObject), + ); + for class_name in [c"WKWebView", c"WKContentView"] { + let Some(class) = AnyClass::get(class_name) else { + log::warn!( + "Could not find {} class to disable native context menu", + class_name.to_string_lossy() + ); + continue; + }; + let method = class_getInstanceMethod( + class, + Sel::register(native_context_menu_will_open_selector_name()), + ); + if method.is_null() { + log::warn!( + "Could not find {} willOpenMenu:withEvent: to filter native context menu", + class_name.to_string_lossy() + ); + continue; + } + let _ = method_setImplementation(method, filter_menu_implementation); + } + }); +} + #[allow(clippy::useless_format)] fn remote_main_init_script() -> Result { Ok(format!( @@ -84,6 +165,180 @@ fn remote_main_init_script() -> Result { }}, 250); }} + function selectedContextMenuText() {{ + return String(globalThis.getSelection?.() || '').trim(); + }} + + function contextMenuAnchor(target) {{ + const element = target?.nodeType === Node.ELEMENT_NODE ? target : target?.parentElement; + return element?.closest?.('a[href]') || null; + }} + + function desktopExternalURL(href) {{ + try {{ + const url = new URL(href, globalThis.location.href); + if (url.protocol === 'mailto:') {{ + return url; + }} + if (url.protocol !== 'http:' && url.protocol !== 'https:') {{ + return null; + }} + return url.origin === globalThis.location.origin ? null : url; + }} catch {{ + return null; + }} + }} + + function openExternalURL(url) {{ + return globalThis.__TAURI_INTERNALS__?.invoke?.('open_external_link', {{ url: url.href }}); + }} + + function installExternalLinkBridge() {{ + document.addEventListener( + 'click', + (event) => {{ + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) {{ + return; + }} + + const anchor = contextMenuAnchor(event.target); + if (!anchor?.href) {{ + return; + }} + + const url = desktopExternalURL(anchor.href); + if (!url) {{ + return; + }} + + event.preventDefault(); + void openExternalURL(url).catch(() => {{ + globalThis.location.href = url.href; + }}); + }}, + true + ); + }} + + function copyContextMenuText(value) {{ + if (navigator.clipboard?.writeText) {{ + return navigator.clipboard.writeText(value); + }} + const input = document.createElement('textarea'); + input.value = value; + input.setAttribute('readonly', ''); + input.style.position = 'fixed'; + input.style.opacity = '0'; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + input.remove(); + return Promise.resolve(); + }} + + function removeDesktopContextMenu() {{ + document.getElementById('__ex-desktop-context-menu')?.remove(); + }} + + function showDesktopContextMenu(event, items) {{ + removeDesktopContextMenu(); + if (!items.length || !document.body) {{ + return; + }} + + const menu = document.createElement('div'); + menu.id = '__ex-desktop-context-menu'; + menu.setAttribute('role', 'menu'); + Object.assign(menu.style, {{ + position: 'fixed', + left: `${{event.clientX}}px`, + top: `${{event.clientY}}px`, + zIndex: '2147483647', + minWidth: '128px', + padding: '4px 0', + borderRadius: '6px', + border: '1px solid rgba(15, 23, 42, 0.14)', + background: 'rgba(255, 255, 255, 0.98)', + color: '#0f172a', + font: '13px/1.2 system-ui, sans-serif', + boxShadow: '0 12px 36px rgba(15, 23, 42, 0.22)' + }}); + + for (const item of items) {{ + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('role', 'menuitem'); + button.textContent = item.label; + Object.assign(button.style, {{ + display: 'block', + width: '100%', + border: '0', + padding: '7px 12px', + background: 'transparent', + color: 'inherit', + font: 'inherit', + textAlign: 'left', + cursor: 'default' + }}); + button.addEventListener('mouseenter', () => {{ + button.style.background = 'rgba(15, 23, 42, 0.08)'; + }}); + button.addEventListener('mouseleave', () => {{ + button.style.background = 'transparent'; + }}); + button.addEventListener('click', () => {{ + void copyContextMenuText(item.value); + removeDesktopContextMenu(); + }}); + menu.appendChild(button); + }} + + document.body.appendChild(menu); + }} + + function installDesktopContextMenu() {{ + const onContextMenu = (event) => {{ + event.preventDefault(); + + const anchor = contextMenuAnchor(event.target); + if (anchor?.closest?.('[data-ex-desktop-link="true"]')) {{ + return; + }} + + const selectedText = selectedContextMenuText(); + const items = []; + if (selectedText) {{ + items.push({{ label: 'Copy', value: selectedText }}); + }} + if (anchor?.href) {{ + items.push({{ label: 'Copy Link', value: anchor.href }}); + }} + showDesktopContextMenu(event, items); + }}; + + document.addEventListener('contextmenu', onContextMenu, true); + document.addEventListener('click', removeDesktopContextMenu, true); + document.addEventListener('scroll', removeDesktopContextMenu, true); + document.addEventListener( + 'keydown', + (event) => {{ + if (event.key === 'Escape') {{ + removeDesktopContextMenu(); + }} + }}, + true + ); + }} + + installExternalLinkBridge(); + installDesktopContextMenu(); installUnreadBadgeBridge(); function showAuthRequired() {{ @@ -425,6 +680,7 @@ pub(crate) fn open_or_navigate_main_window( } }) .initialization_script(init_script) + .devtools(main_webview_devtools_enabled()) .disable_drag_drop_handler() .title("ex") .data_directory(data_dir) @@ -437,6 +693,7 @@ pub(crate) fn open_or_navigate_main_window( if let Some(icon) = app.default_window_icon() { let _ = window.set_icon(icon.clone()); } + disable_native_context_menu(&window); let _ = window.show(); let _ = window.set_focus(); @@ -481,6 +738,16 @@ fn open_external_url(app: &AppHandle, url: &Url) -> Result<(), String> { .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn open_external_link(app: AppHandle, url: String) -> Result<(), String> { + let url = Url::parse(&url).map_err(|e| e.to_string())?; + if should_open_externally(&app, &url) { + open_external_url(&app, &url) + } else { + Ok(()) + } +} + fn same_origin(left: &Url, right: &Url) -> bool { left.scheme() == right.scheme() && left.host_str() == right.host_str() @@ -889,10 +1156,12 @@ pub fn set_badge_count(app: AppHandle, count: u32) -> Result<(), String> { #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] + use super::native_context_menu_will_open_selector_name; use super::{ filename_for_download, filename_from_content_disposition, is_attachment_download_url, - login_url_for_server, oidc_callback_query, percent_decode, remote_main_init_script, - same_origin, sanitize_filename, + login_url_for_server, main_webview_devtools_enabled, oidc_callback_query, percent_decode, + remote_main_init_script, same_origin, sanitize_filename, }; use url::Url; @@ -909,11 +1178,81 @@ mod tests { assert!(script.contains("unreadCountFromTitle")); assert!(script.contains("invoke('set_badge_count', { count })")); assert!(script.contains("MutationObserver(syncUnreadBadge)")); + assert!(!script.contains("installMinimalContextMenu")); assert!(!script.contains("globalThis.Notification =")); assert!(!script.contains("ServiceWorkerRegistration")); assert!(!script.contains("prototype.showNotification")); } + #[test] + fn remote_init_script_opens_external_links_with_system_browser() { + let script = remote_main_init_script().unwrap(); + + assert!(script.contains("installExternalLinkBridge")); + assert!(script.contains("document.addEventListener(\n 'click'")); + assert!(script.contains("contextMenuAnchor(event.target)")); + assert!(script.contains("url.protocol === 'mailto:'")); + assert!(script.contains("url.protocol !== 'http:' && url.protocol !== 'https:'")); + assert!(script.contains("url.origin === globalThis.location.origin ? null : url")); + assert!(script.contains("invoke?.('open_external_link', { url: url.href })")); + assert!(script.contains("event.preventDefault()")); + } + + #[test] + fn remote_init_script_suppresses_native_context_menu() { + let script = remote_main_init_script().unwrap(); + + assert!(script.contains("installDesktopContextMenu")); + assert!(script.contains("document.addEventListener('contextmenu', onContextMenu, true)")); + assert!(script.contains("event.preventDefault()")); + assert!(script.contains("__ex-desktop-context-menu")); + assert!(script.contains("Copy Link")); + assert!(script.contains("data-ex-desktop-link")); + assert!(!script.contains("event.stopPropagation()")); + assert!(!script.contains("Reload")); + assert!(!script.contains("Inspect Element")); + } + + #[test] + fn release_build_does_not_enable_tauri_devtools_context_menu() { + let manifest = include_str!("../Cargo.toml"); + let tauri_dependency_line = manifest + .lines() + .find(|line| line.trim_start().starts_with("tauri ")) + .expect("tauri dependency should be declared"); + + assert!( + !tauri_dependency_line.contains("\"devtools\""), + "the tauri devtools feature enables Inspect Element from the macOS WebKit context menu" + ); + assert!( + !main_webview_devtools_enabled(), + "main webview devtools should stay disabled so WebKit does not add Inspect Element" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_context_menu_override_targets_webkit_content_view() { + let source = include_str!("commands.rs"); + + assert!(source.contains("WKWebView")); + assert!(source.contains("WKContentView")); + assert!(source.contains("method_setImplementation")); + assert!(!source.contains(&format!("{}{}", "menuFor", "Event:"))); + assert!(!source.contains(&format!("{}{}", "no_context", "_menu"))); + assert!(!source.contains(&format!("{}{}", "set", "Menu"))); + assert!(!source.contains(&format!("{}{}", "rightMouse", "Down:"))); + assert!(!source.contains(&format!("{}{}", "rightMouse", "Up:"))); + assert_eq!( + native_context_menu_will_open_selector_name() + .to_str() + .unwrap(), + "willOpenMenu:withEvent:" + ); + assert!(source.contains("title == \"back\" || title == \"reload\"")); + } + #[test] fn relogin_url_uses_server_origin() { assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0f950b..5305367 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,7 +40,6 @@ fn configure_app_menu(app: &tauri::AppHandle) -> tauri::Result<()> { true, None::<&str>, )?; - let reload_i = MenuItem::with_id(app, "reload-chat", "Reload Chat", true, Some("CmdOrCtrl+R"))?; let test_notification_i = MenuItem::with_id( app, "test-notification", @@ -58,13 +57,16 @@ fn configure_app_menu(app: &tauri::AppHandle) -> tauri::Result<()> { let sep = PredefinedMenuItem::separator(app)?; if let Some(MenuItemKind::Submenu(first_menu)) = app_menu.items()?.into_iter().next() { - first_menu.prepend_items(&[ - &switch_chat_url_i, - &reload_i, - &test_notification_i, - &test_background_notification_i, - &sep, - ])?; + let insert_position = if first_menu.items()?.is_empty() { 0 } else { 1 }; + first_menu.insert_items( + &[ + &switch_chat_url_i, + &test_notification_i, + &test_background_notification_i, + &sep, + ], + insert_position, + )?; } app.set_menu(app_menu)?; @@ -234,11 +236,6 @@ pub fn run() { app.on_menu_event(|app, event| match event.id.as_ref() { "switch-chat-url" => show_change_server(app), - "reload-chat" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.eval("globalThis.location.reload()"); - } - } "test-notification" => { if let Err(err) = commands::send_desktop_notification( app.clone(), @@ -328,6 +325,7 @@ pub fn run() { commands::request_notification_attention, commands::send_desktop_notification, commands::set_badge_count, + commands::open_external_link, ]) .run(tauri::generate_context!()) .expect("error while running ex desktop"); From 1eb58bc2cdccd4fbd31d9d3e27e1dd0c4d323d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Mon, 4 May 2026 20:00:45 +0200 Subject: [PATCH 3/5] fix --- .gitignore | 1 + src-tauri/build.rs | 16 +- src-tauri/capabilities/default.json | 14 +- src-tauri/capabilities/remote-chat.json | 17 ++ src-tauri/src/commands.rs | 283 ++++++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- 6 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 src-tauri/capabilities/remote-chat.json diff --git a/.gitignore b/.gitignore index 5137228..f07e37a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs/ # Rust / Cargo target/ src-tauri/gen/ +src-tauri/permissions/autogenerated/ # Frontend frontend/node_modules/ diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..0facf43 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,17 @@ fn main() { - tauri_build::build() + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&[ + "get_server_url", + "set_server_url", + "save_server_url_and_load", + "clear_server_url", + "show_setup_window", + "start_relogin", + "request_notification_attention", + "send_desktop_notification", + "set_badge_count", + "open_external_link", + ]), + )) + .expect("failed to build Tauri app"); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bb6b572..9187018 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,9 +1,19 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default capabilities for the ex desktop app", - "windows": ["main", "setup"], + "description": "Local setup window capabilities for the ex desktop app", + "windows": ["setup"], "permissions": [ + "allow-get-server-url", + "allow-set-server-url", + "allow-save-server-url-and-load", + "allow-clear-server-url", + "allow-show-setup-window", + "allow-start-relogin", + "allow-request-notification-attention", + "allow-send-desktop-notification", + "allow-set-badge-count", + "allow-open-external-link", "core:default", "core:window:allow-start-dragging", "store:default", diff --git a/src-tauri/capabilities/remote-chat.json b/src-tauri/capabilities/remote-chat.json new file mode 100644 index 0000000..d44d4d7 --- /dev/null +++ b/src-tauri/capabilities/remote-chat.json @@ -0,0 +1,17 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "remote-chat", + "description": "Narrow IPC capability for the configured remote chat webview", + "windows": ["main"], + "remote": { + "urls": ["http://*", "http://*:*", "https://*", "https://*:*"] + }, + "permissions": [ + "allow-show-setup-window", + "allow-start-relogin", + "allow-set-badge-count", + "allow-open-external-link", + "opener:allow-default-urls", + "opener:allow-open-url" + ] +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1d25ccd..ab189dc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -112,9 +112,32 @@ fn remote_main_init_script() -> Result { writable: false, configurable: false }}); + Object.defineProperty(globalThis, '__EX_DESKTOP_LINK_DIAGNOSTICS__', {{ + value: [], + writable: false, + configurable: false + }}); let authRequiredShown = false; + function recordDesktopLinkDiagnostic(event, detail) {{ + const entry = {{ + event, + detail, + href: globalThis.location.href, + timestamp: new Date().toISOString() + }}; + try {{ + globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.push(entry); + if (globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.length > 50) {{ + globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.shift(); + }} + }} catch {{}} + try {{ + console.info('[ex-desktop-link]', event, detail); + }} catch {{}} + }} + function unreadCountFromTitle() {{ const match = /^\\((\\d+)\\)\\s+/.exec(document.title || ''); if (!match) {{ @@ -174,6 +197,21 @@ fn remote_main_init_script() -> Result { return element?.closest?.('a[href]') || null; }} + function eventAnchor(event) {{ + for (const item of event.composedPath?.() || []) {{ + if (item instanceof HTMLAnchorElement && item.href) {{ + return item; + }} + if (item instanceof Element) {{ + const anchor = item.closest?.('a[href]'); + if (anchor?.href) {{ + return anchor; + }} + }} + }} + return contextMenuAnchor(event.target); + }} + function desktopExternalURL(href) {{ try {{ const url = new URL(href, globalThis.location.href); @@ -190,38 +228,141 @@ fn remote_main_init_script() -> Result { }} function openExternalURL(url) {{ - return globalThis.__TAURI_INTERNALS__?.invoke?.('open_external_link', {{ url: url.href }}); + const invoke = globalThis.__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== 'function') {{ + return Promise.reject(new Error('Tauri IPC invoke is not available')); + }} + return invoke('open_external_link', {{ url: url.href }}).catch((appCommandError) => {{ + recordDesktopLinkDiagnostic('open-external-app-command-error', {{ + href: url.href, + error: String(appCommandError) + }}); + return invoke('plugin:opener|open_url', {{ url: url.href }}); + }}); }} function installExternalLinkBridge() {{ + const onExternalLinkClick = (event) => {{ + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) {{ + return; + }} + + const anchor = eventAnchor(event); + if (!anchor?.href) {{ + recordDesktopLinkDiagnostic('click-no-anchor', {{ + target: event.target?.nodeName || null + }}); + return; + }} + + const url = desktopExternalURL(anchor.href); + if (!url) {{ + recordDesktopLinkDiagnostic('click-internal-or-unsupported', {{ + href: anchor.href + }}); + return; + }} + + event.preventDefault(); + event.stopImmediatePropagation?.(); + recordDesktopLinkDiagnostic('click-open-external', {{ href: url.href }}); + void openExternalURL(url).then( + () => {{ + recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); + }}, + (error) => {{ + recordDesktopLinkDiagnostic('open-external-error', {{ + href: url.href, + error: String(error) + }}); + globalThis.location.href = url.href; + }} + ); + }}; + + globalThis.addEventListener('click', onExternalLinkClick, true); document.addEventListener( 'click', - (event) => {{ - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.altKey || - event.shiftKey - ) {{ - return; + onExternalLinkClick, + true + ); + + globalThis.__EX_DESKTOP_TEST_EXTERNAL_LINK__ = () => {{ + const anchor = document.createElement('a'); + anchor.href = 'https://example.com/ex-desktop-link-test'; + anchor.textContent = 'ex desktop link test'; + anchor.style.position = 'fixed'; + anchor.style.left = '0'; + anchor.style.top = '0'; + anchor.style.zIndex = '2147483647'; + anchor.setAttribute('data-ex-desktop-test-link', 'true'); + document.body?.appendChild(anchor); + recordDesktopLinkDiagnostic('test-link-click-dispatch', {{ href: anchor.href }}); + anchor.click(); + globalThis.setTimeout(() => anchor.remove(), 1000); + return globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__; + }}; + }} + + function installWindowOpenBridge() {{ + const originalOpen = globalThis.open?.bind(globalThis); + globalThis.open = (rawUrl, target, features) => {{ + const url = desktopExternalURL(String(rawUrl || '')); + if (!url) {{ + return originalOpen?.(rawUrl, target, features) || null; + }} + recordDesktopLinkDiagnostic('window-open-external', {{ href: url.href }}); + void openExternalURL(url).then( + () => {{ + recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); + }}, + (error) => {{ + recordDesktopLinkDiagnostic('open-external-error', {{ + href: url.href, + error: String(error) + }}); + originalOpen?.(url.href, target, features); }} + ); + return null; + }}; + }} - const anchor = contextMenuAnchor(event.target); - if (!anchor?.href) {{ + function installExternalFormBridge() {{ + document.addEventListener( + 'submit', + (event) => {{ + const form = event.target; + if (!(form instanceof HTMLFormElement) || !form.action) {{ return; }} - const url = desktopExternalURL(anchor.href); + const url = desktopExternalURL(form.action); if (!url) {{ return; }} event.preventDefault(); - void openExternalURL(url).catch(() => {{ - globalThis.location.href = url.href; - }}); + recordDesktopLinkDiagnostic('submit-open-external', {{ href: url.href }}); + void openExternalURL(url).then( + () => {{ + recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); + }}, + (error) => {{ + recordDesktopLinkDiagnostic('open-external-error', {{ + href: url.href, + error: String(error) + }}); + globalThis.location.href = url.href; + }} + ); }}, true ); @@ -338,6 +479,8 @@ fn remote_main_init_script() -> Result { }} installExternalLinkBridge(); + installWindowOpenBridge(); + installExternalFormBridge(); installDesktopContextMenu(); installUnreadBadgeBridge(); @@ -1191,11 +1334,115 @@ mod tests { assert!(script.contains("installExternalLinkBridge")); assert!(script.contains("document.addEventListener(\n 'click'")); assert!(script.contains("contextMenuAnchor(event.target)")); + assert!(script.contains("event.composedPath?.()")); assert!(script.contains("url.protocol === 'mailto:'")); assert!(script.contains("url.protocol !== 'http:' && url.protocol !== 'https:'")); assert!(script.contains("url.origin === globalThis.location.origin ? null : url")); - assert!(script.contains("invoke?.('open_external_link', { url: url.href })")); + assert!(script.contains("Tauri IPC invoke is not available")); + assert!(script.contains("invoke('open_external_link', { url: url.href })")); + assert!(script.contains("invoke('plugin:opener|open_url', { url: url.href })")); assert!(script.contains("event.preventDefault()")); + assert!(script.contains("event.stopImmediatePropagation?.()")); + assert!(script.contains("globalThis.addEventListener('click', onExternalLinkClick, true)")); + assert!(script.contains("__EX_DESKTOP_LINK_DIAGNOSTICS__")); + assert!(script.contains("__EX_DESKTOP_TEST_EXTERNAL_LINK__")); + assert!(script.contains("installWindowOpenBridge")); + assert!(script.contains("installExternalFormBridge")); + assert!(script.contains("open-external-app-command-error")); + assert!(script.contains("open-external-error")); + } + + #[test] + fn remote_chat_capability_allows_only_required_injected_commands() { + use std::str::FromStr; + + let capability: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/remote-chat.json")).unwrap(); + let urls = capability + .pointer("/remote/urls") + .and_then(serde_json::Value::as_array) + .unwrap(); + let permissions = capability + .get("permissions") + .and_then(serde_json::Value::as_array) + .unwrap(); + let permissions = permissions + .iter() + .map(|permission| permission.as_str().unwrap()) + .collect::>(); + + assert_eq!(capability["windows"], serde_json::json!(["main"])); + assert!(urls.iter().any(|url| url == "http://*")); + assert!(urls.iter().any(|url| url == "http://*:*")); + assert!(urls.iter().any(|url| url == "https://*")); + assert!(urls.iter().any(|url| url == "https://*:*")); + for (pattern, url) in [ + ( + "http://*:*", + "http://localhost:5173/channel/general?x=1#message", + ), + ( + "https://*", + "https://chat.example.com/channel/general?x=1#message", + ), + ( + "https://*:*", + "https://chat.example.com:8443/channel/general?x=1#message", + ), + ] { + let pattern = tauri::utils::acl::RemoteUrlPattern::from_str(pattern).unwrap(); + assert!( + pattern.test(&Url::parse(url).unwrap()), + "remote pattern must match full chat page URLs" + ); + } + assert_eq!( + permissions, + vec![ + "allow-show-setup-window", + "allow-start-relogin", + "allow-set-badge-count", + "allow-open-external-link", + "opener:allow-default-urls", + "opener:allow-open-url", + ] + ); + assert!(!permissions.iter().any(|permission| { + permission.starts_with("store:") + || permission.starts_with("updater:") + || permission.starts_with("global-shortcut:") + })); + } + + #[test] + fn app_manifest_generates_permissions_for_remote_injected_commands() { + let build_script = include_str!("../build.rs"); + + for command in [ + "show_setup_window", + "start_relogin", + "set_badge_count", + "open_external_link", + ] { + assert!( + build_script.contains(command), + "build.rs must generate an allow/deny permission for {command}" + ); + } + } + + #[test] + fn csp_allows_tauri_ipc_connect_sources() { + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let csp = config + .pointer("/app/security/csp") + .and_then(serde_json::Value::as_str) + .unwrap(); + + assert!(csp.contains("connect-src")); + assert!(csp.contains("ipc:")); + assert!(csp.contains("http://ipc.localhost")); } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 27082ef..9f3bc60 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ "app": { "windows": [], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob:; connect-src 'self' http://localhost:* ws://localhost:* https: wss:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:;" + "csp": "default-src 'self' 'unsafe-inline' data: blob:; connect-src 'self' ipc: http://ipc.localhost http://localhost:* ws://localhost:* https: wss:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:;" } }, "plugins": { From 7d8b7a61af0e87c48acc6725d808f1d34207318f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Mon, 4 May 2026 23:32:40 +0200 Subject: [PATCH 4/5] fix --- src-tauri/src/commands.rs | 85 ++++----------------------------------- 1 file changed, 7 insertions(+), 78 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ab189dc..86bfe00 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -112,32 +112,9 @@ fn remote_main_init_script() -> Result { writable: false, configurable: false }}); - Object.defineProperty(globalThis, '__EX_DESKTOP_LINK_DIAGNOSTICS__', {{ - value: [], - writable: false, - configurable: false - }}); let authRequiredShown = false; - function recordDesktopLinkDiagnostic(event, detail) {{ - const entry = {{ - event, - detail, - href: globalThis.location.href, - timestamp: new Date().toISOString() - }}; - try {{ - globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.push(entry); - if (globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.length > 50) {{ - globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__.shift(); - }} - }} catch {{}} - try {{ - console.info('[ex-desktop-link]', event, detail); - }} catch {{}} - }} - function unreadCountFromTitle() {{ const match = /^\\((\\d+)\\)\\s+/.exec(document.title || ''); if (!match) {{ @@ -232,11 +209,7 @@ fn remote_main_init_script() -> Result { if (typeof invoke !== 'function') {{ return Promise.reject(new Error('Tauri IPC invoke is not available')); }} - return invoke('open_external_link', {{ url: url.href }}).catch((appCommandError) => {{ - recordDesktopLinkDiagnostic('open-external-app-command-error', {{ - href: url.href, - error: String(appCommandError) - }}); + return invoke('open_external_link', {{ url: url.href }}).catch(() => {{ return invoke('plugin:opener|open_url', {{ url: url.href }}); }}); }} @@ -256,32 +229,19 @@ fn remote_main_init_script() -> Result { const anchor = eventAnchor(event); if (!anchor?.href) {{ - recordDesktopLinkDiagnostic('click-no-anchor', {{ - target: event.target?.nodeName || null - }}); return; }} const url = desktopExternalURL(anchor.href); if (!url) {{ - recordDesktopLinkDiagnostic('click-internal-or-unsupported', {{ - href: anchor.href - }}); return; }} event.preventDefault(); event.stopImmediatePropagation?.(); - recordDesktopLinkDiagnostic('click-open-external', {{ href: url.href }}); void openExternalURL(url).then( - () => {{ - recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); - }}, + () => {{}}, (error) => {{ - recordDesktopLinkDiagnostic('open-external-error', {{ - href: url.href, - error: String(error) - }}); globalThis.location.href = url.href; }} ); @@ -293,22 +253,6 @@ fn remote_main_init_script() -> Result { onExternalLinkClick, true ); - - globalThis.__EX_DESKTOP_TEST_EXTERNAL_LINK__ = () => {{ - const anchor = document.createElement('a'); - anchor.href = 'https://example.com/ex-desktop-link-test'; - anchor.textContent = 'ex desktop link test'; - anchor.style.position = 'fixed'; - anchor.style.left = '0'; - anchor.style.top = '0'; - anchor.style.zIndex = '2147483647'; - anchor.setAttribute('data-ex-desktop-test-link', 'true'); - document.body?.appendChild(anchor); - recordDesktopLinkDiagnostic('test-link-click-dispatch', {{ href: anchor.href }}); - anchor.click(); - globalThis.setTimeout(() => anchor.remove(), 1000); - return globalThis.__EX_DESKTOP_LINK_DIAGNOSTICS__; - }}; }} function installWindowOpenBridge() {{ @@ -318,16 +262,9 @@ fn remote_main_init_script() -> Result { if (!url) {{ return originalOpen?.(rawUrl, target, features) || null; }} - recordDesktopLinkDiagnostic('window-open-external', {{ href: url.href }}); void openExternalURL(url).then( - () => {{ - recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); - }}, + () => {{}}, (error) => {{ - recordDesktopLinkDiagnostic('open-external-error', {{ - href: url.href, - error: String(error) - }}); originalOpen?.(url.href, target, features); }} ); @@ -350,16 +287,9 @@ fn remote_main_init_script() -> Result { }} event.preventDefault(); - recordDesktopLinkDiagnostic('submit-open-external', {{ href: url.href }}); void openExternalURL(url).then( - () => {{ - recordDesktopLinkDiagnostic('open-external-ok', {{ href: url.href }}); - }}, + () => {{}}, (error) => {{ - recordDesktopLinkDiagnostic('open-external-error', {{ - href: url.href, - error: String(error) - }}); globalThis.location.href = url.href; }} ); @@ -1344,12 +1274,11 @@ mod tests { assert!(script.contains("event.preventDefault()")); assert!(script.contains("event.stopImmediatePropagation?.()")); assert!(script.contains("globalThis.addEventListener('click', onExternalLinkClick, true)")); - assert!(script.contains("__EX_DESKTOP_LINK_DIAGNOSTICS__")); - assert!(script.contains("__EX_DESKTOP_TEST_EXTERNAL_LINK__")); assert!(script.contains("installWindowOpenBridge")); assert!(script.contains("installExternalFormBridge")); - assert!(script.contains("open-external-app-command-error")); - assert!(script.contains("open-external-error")); + assert!(!script.contains("__EX_DESKTOP_LINK_DIAGNOSTICS__")); + assert!(!script.contains("__EX_DESKTOP_TEST_EXTERNAL_LINK__")); + assert!(!script.contains("console.info('[ex-desktop-link]'")); } #[test] From f8e95fe9d9f472e95c2e5abcad85f1729a0c7ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnter=20Grodotzki?= Date: Mon, 4 May 2026 23:36:30 +0200 Subject: [PATCH 5/5] fix build --- src-tauri/tauri.conf.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9f3bc60..18b94d9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,7 +33,8 @@ "active": true, "targets": "all", "icon": [ - "icons/AppIcon.icon" + "icons/AppIcon.icon", + "icons/icon.ico" ], "linux": { "deb": {