diff --git a/Cargo.lock b/Cargo.lock index 8996f5f2e..5b835b5ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ name = "OpenCADStudio" version = "0.9.0" dependencies = [ "acadrust", + "ashpd", "bincode", "bytemuck", "cavalier_contours", @@ -423,6 +424,23 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "getrandom 0.4.3", + "serde", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" diff --git a/Cargo.toml b/Cargo.toml index 85548199e..e0bf293ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,11 @@ naga = { version = "27", features = ["wgsl-in"] } # "Open with" for the portable .exe. windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } +[target.'cfg(target_os = "linux")'.dependencies] +# Mint an xdg-activation token for external URL handlers on Wayland. The token +# is passed to xdg-open so an existing browser window may take focus. +ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } + [patch.crates-io] # Track standard DWG object relationships used by the scene integration. acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "a7b1e07" } diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index d96f4125e..0cd109998 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -4,19 +4,28 @@ impl OpenCADStudio { pub(super) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "DONATE" => { - crate::sys::open_url("https://patreon.com/HakanSeven12"); self.command_line.push_info("Opening Patreon page..."); + return Some(crate::sys::open_url( + "https://patreon.com/HakanSeven12", + self.main_window, + )); } "WEBVERSION" => { - crate::sys::open_url("https://hakanseven12.github.io/OpenCADStudio/"); self.command_line.push_info("Opening OCS Web..."); + return Some(crate::sys::open_url( + "https://hakanseven12.github.io/OpenCADStudio/", + self.main_window, + )); } "HELP" => { - crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/discussions"); self.command_line .push_info("Opening OCS Discussions for help and questions..."); + return Some(crate::sys::open_url( + "https://github.com/HakanSeven12/OpenCADStudio/discussions", + self.main_window, + )); } // ── DWGPROPS — print round-trip-only HeaderVariables ───────── @@ -204,8 +213,8 @@ impl OpenCADStudio { "https://github.com/HakanSeven12/OpenCADStudio/issues/new?body={}", crate::sys::percent_encode(&body) ); - crate::sys::open_url(&url); self.command_line.push_info("Opening feedback page..."); + return Some(crate::sys::open_url(&url, self.main_window)); } "ABOUT" => { @@ -217,8 +226,11 @@ impl OpenCADStudio { } "CHANGELOG" => { - crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/releases"); self.command_line.push_info("Opening release notes..."); + return Some(crate::sys::open_url( + "https://github.com/HakanSeven12/OpenCADStudio/releases", + self.main_window, + )); } // ── ALIASEDIT — command-alias editor ─────────────────────────── diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7c7ab09ef..d26a516c9 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -290,10 +290,7 @@ impl OpenCADStudio { Message::OpenPathPicked(None) => Task::none(), - Message::OpenUrl(url) => { - crate::sys::open_url(&url); - Task::none() - } + Message::OpenUrl(url) => crate::sys::open_url(&url, self.main_window), Message::StartSectionSelect(section) => { self.start_section = section; @@ -5040,9 +5037,12 @@ impl OpenCADStudio { Task::none() } Message::UpdateNoticeOpenRelease => { - crate::sys::open_url(crate::io::update_check::RELEASES_PAGE); + let open = crate::sys::open_url( + crate::io::update_check::RELEASES_PAGE, + self.main_window, + ); self.close_active_modal(); - Task::none() + open } Message::AssocPromptYes => { self.file_assoc_enabled = true; diff --git a/src/sys.rs b/src/sys.rs index 08bf15b17..c17e21b90 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1,20 +1,101 @@ // Small platform shims for things the desktop build does natively but the web // (wasm) build must handle differently or skip. -/// Open a URL in the user's browser. The desktop launches the default handler; -/// the web opens a new tab (the button click is a user gesture, so it isn't -/// caught by the pop-up blocker). Focus of the opened page is left to the -/// OS / browser. -#[cfg(not(target_arch = "wasm32"))] -pub fn open_url(url: &str) { - let _ = open::that(url); +/// Open a URL in the user's browser. +/// +/// Wayland requires an xdg-activation token from the source window before it +/// will let an existing browser window take focus. Linux obtains that token +/// from the OCS surface and passes it to xdg-open. +#[cfg(target_os = "linux")] +pub fn open_url( + url: &str, + parent: Option, +) -> iced::Task { + let url = url.to_string(); + match parent { + Some(parent) => iced::window::run(parent, move |window| { + open_url_linux(&url, linux_activation_token(window)); + }) + .discard(), + None => { + let _ = open::that_detached(url); + iced::Task::none() + } + } } +#[cfg(target_os = "linux")] +fn linux_activation_token(window: &dyn iced::window::Window) -> Option { + use iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + + let RawWindowHandle::Wayland(window_handle) = + window.window_handle().ok()?.as_raw() + else { + return None; + }; + let RawDisplayHandle::Wayland(display_handle) = + window.display_handle().ok()?.as_raw() + else { + return None; + }; + + // Keep the iced window borrowed while ashpd uses its raw Wayland surface. + // The returned token is an owned string and is immediately handed to the + // child process. + unsafe { + iced::futures::executor::block_on( + ashpd::ActivationToken::from_wayland_raw( + None, + window_handle.surface.as_ptr(), + display_handle.display.as_ptr(), + ), + ) + } + .map(String::from) +} + +#[cfg(target_os = "linux")] +fn open_url_linux(url: &str, activation_token: Option) { + let Some(token) = activation_token else { + let _ = open::that_detached(url); + return; + }; + + let mut command = std::process::Command::new("xdg-open"); + command + .arg(url) + .env("XDG_ACTIVATION_TOKEN", &token) + .env("DESKTOP_STARTUP_ID", token); + + if let Ok(mut child) = command.spawn() { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } else { + let _ = open::that_detached(url); + } +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "linux")))] +pub fn open_url( + url: &str, + _parent: Option, +) -> iced::Task { + let _ = open::that_detached(url); + iced::Task::none() +} + +/// Web opens the tab synchronously so the browser still sees the click as a +/// user gesture and does not block it as a pop-up. #[cfg(target_arch = "wasm32")] -pub fn open_url(url: &str) { +pub fn open_url( + url: &str, + _parent: Option, +) -> iced::Task { if let Some(window) = web_sys::window() { let _ = window.open_with_url_and_target(url, "_blank"); } + iced::Task::none() } #[cfg(target_arch = "wasm32")]