wayland: Implement `activate()` API and use portals to open URLs and paths (#13336)

apricotbucket28 created

This PR consists of two main changes:
1. The first commit changes the `open` crate for opening URLs/paths for
the `OpenURI` desktop portal. This fixes the activation token not being
passed to programs (at least on KDE).
2. The second commit implements the window `activate()` API on Wayland.
This allows KWin and Mutter to show a visual indicator when the window
is requesting attention. (see
https://github.com/zed-industries/zed/issues/12557)

![image](https://github.com/zed-industries/zed/assets/71973804/ce148f8e-28fd-4249-8f8d-3a5828ed6f83)


Release Notes:

- N/A

Change summary

Cargo.lock                                        | 36 --------
Cargo.toml                                        | 12 +
crates/gpui/Cargo.toml                            |  1 
crates/gpui/src/platform/linux/headless/client.rs |  2 
crates/gpui/src/platform/linux/platform.rs        | 57 +++++++++----
crates/gpui/src/platform/linux/wayland/client.rs  | 69 ++++++++++++++--
crates/gpui/src/platform/linux/wayland/window.rs  | 21 ++++
crates/gpui/src/platform/linux/x11/client.rs      | 22 +++--
8 files changed, 140 insertions(+), 80 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -341,9 +341,8 @@ dependencies = [
 
 [[package]]
 name = "ashpd"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093"
+version = "0.9.0"
+source = "git+https://github.com/bilelmoussaoui/ashpd?rev=29f2e1a#29f2e1a6f4b0911f504658f5f4630c02e01b13f2"
 dependencies = [
  "async-fs 2.1.1",
  "async-net 2.0.0",
@@ -4893,7 +4892,6 @@ dependencies = [
  "num_cpus",
  "objc",
  "oo7",
- "open",
  "parking",
  "parking_lot",
  "pathfinder_geometry",
@@ -5705,25 +5703,6 @@ version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
 
-[[package]]
-name = "is-docker"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
-dependencies = [
- "once_cell",
-]
-
-[[package]]
-name = "is-wsl"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
-dependencies = [
- "is-docker",
- "once_cell",
-]
-
 [[package]]
 name = "isahc"
 version = "1.7.2"
@@ -7225,17 +7204,6 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
-[[package]]
-name = "open"
-version = "5.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "449f0ff855d85ddbf1edd5b646d65249ead3f5e422aaa86b7d2d0b049b103e32"
-dependencies = [
- "is-wsl",
- "libc",
- "pathdiff",
-]
-
 [[package]]
 name = "open_ai"
 version = "0.1.0"

Cargo.toml 🔗

@@ -272,9 +272,9 @@ zed_actions = { path = "crates/zed_actions" }
 alacritty_terminal = "0.23"
 any_vec = "0.13"
 anyhow = "1.0.57"
-ashpd = "0.8.0"
+ashpd = { git = "https://github.com/bilelmoussaoui/ashpd", rev = "29f2e1a" }
 async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
-async-dispatcher = { version = "0.1"}
+async-dispatcher = { version = "0.1" }
 async-fs = "1.6"
 async-recursion = "1.0.0"
 async-tar = "0.4.2"
@@ -315,7 +315,9 @@ image = "0.25.1"
 indexmap = { version = "1.6.2", features = ["serde"] }
 indoc = "1"
 # We explicitly disable http2 support in isahc.
-isahc = { version = "1.7.2", default-features = false, features = [ "text-decoding" ] }
+isahc = { version = "1.7.2", default-features = false, features = [
+    "text-decoding",
+] }
 itertools = "0.11.0"
 lazy_static = "1.4.0"
 libc = "0.2"
@@ -341,7 +343,9 @@ rand = "0.8.5"
 refineable = { path = "./crates/refineable" }
 regex = "1.5"
 repair_json = "0.1.0"
-runtimelib = { version="0.12", default-features = false, features = ["async-dispatcher-runtime"] }
+runtimelib = { version = "0.12", default-features = false, features = [
+    "async-dispatcher-runtime",
+] }
 rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
 rust-embed = { version = "8.4", features = ["include-exclude"] }
 schemars = "0.8"

crates/gpui/Cargo.toml 🔗

@@ -124,7 +124,6 @@ wayland-protocols = { version = "0.31.2", features = [
 ] }
 wayland-protocols-plasma = { version = "0.2.0", features = ["client"] }
 oo7 = "0.3.0"
-open = "5.1.2"
 filedescriptor = "0.8.2"
 x11rb = { version = "0.13.0", features = [
     "allow-unsafe-code",

crates/gpui/src/platform/linux/headless/client.rs 🔗

@@ -81,6 +81,8 @@ impl LinuxClient for HeadlessClient {
 
     fn open_uri(&self, _uri: &str) {}
 
+    fn reveal_path(&self, _path: std::path::PathBuf) {}
+
     fn write_to_primary(&self, _item: crate::ClipboardItem) {}
 
     fn write_to_clipboard(&self, _item: crate::ClipboardItem) {}

crates/gpui/src/platform/linux/platform.rs 🔗

@@ -7,7 +7,7 @@ use std::ffi::OsString;
 use std::fs::File;
 use std::io::Read;
 use std::ops::{Deref, DerefMut};
-use std::os::fd::{AsRawFd, FromRawFd};
+use std::os::fd::{AsFd, AsRawFd, FromRawFd};
 use std::panic::Location;
 use std::rc::Weak;
 use std::{
@@ -20,6 +20,8 @@ use std::{
 
 use anyhow::anyhow;
 use ashpd::desktop::file_chooser::{OpenFileRequest, SaveFileRequest};
+use ashpd::desktop::open_uri::{OpenDirectoryRequest, OpenFileRequest as OpenUriRequest};
+use ashpd::{url, ActivationToken};
 use async_task::Runnable;
 use calloop::channel::Channel;
 use calloop::{EventLoop, LoopHandle, LoopSignal};
@@ -67,6 +69,7 @@ pub trait LinuxClient {
     ) -> anyhow::Result<Box<dyn PlatformWindow>>;
     fn set_cursor_style(&self, style: CursorStyle);
     fn open_uri(&self, uri: &str);
+    fn reveal_path(&self, path: PathBuf);
     fn write_to_primary(&self, item: ClipboardItem);
     fn write_to_clipboard(&self, item: ClipboardItem);
     fn read_from_primary(&self) -> Option<ClipboardItem>;
@@ -344,13 +347,7 @@ impl<P: LinuxClient + 'static> Platform for P {
     }
 
     fn reveal_path(&self, path: &Path) {
-        if path.is_dir() {
-            open::that_detached(path);
-            return;
-        }
-        // If `path` is a file, the system may try to open it in a text editor
-        let dir = path.parent().unwrap_or(Path::new(""));
-        open::that_detached(dir);
+        self.reveal_path(path.to_owned());
     }
 
     fn on_quit(&self, callback: Box<dyn FnMut()>) {
@@ -511,18 +508,40 @@ impl<P: LinuxClient + 'static> Platform for P {
     fn add_recent_document(&self, _path: &Path) {}
 }
 
-pub(super) fn open_uri_internal(uri: &str, activation_token: Option<&str>) {
-    let mut last_err = None;
-    for mut command in open::commands(uri) {
-        if let Some(token) = activation_token {
-            command.env("XDG_ACTIVATION_TOKEN", token);
-        }
-        match command.spawn() {
-            Ok(_) => return,
-            Err(err) => last_err = Some(err),
-        }
+pub(super) fn open_uri_internal(
+    executor: BackgroundExecutor,
+    uri: &str,
+    activation_token: Option<String>,
+) {
+    if let Some(uri) = url::Url::parse(uri).log_err() {
+        executor
+            .spawn(async move {
+                OpenUriRequest::default()
+                    .activation_token(activation_token.map(ActivationToken::from))
+                    .send_uri(&uri)
+                    .await
+                    .log_err();
+            })
+            .detach();
     }
-    log::error!("failed to open uri: {uri:?}, last error: {last_err:?}");
+}
+
+pub(super) fn reveal_path_internal(
+    executor: BackgroundExecutor,
+    path: PathBuf,
+    activation_token: Option<String>,
+) {
+    executor
+        .spawn(async move {
+            if let Some(dir) = File::open(path).log_err() {
+                OpenDirectoryRequest::default()
+                    .activation_token(activation_token.map(ActivationToken::from))
+                    .send(&dir.as_fd())
+                    .await
+                    .log_err();
+            }
+        })
+        .detach();
 }
 
 pub(super) fn is_within_click_distance(a: Point<Pixels>, b: Point<Pixels>) -> bool {

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -61,7 +61,6 @@ use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blu
 use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
 use xkbcommon::xkb::{self, Keycode, KEYMAP_COMPILE_NO_FLAGS};
 
-use super::super::{open_uri_internal, read_fd, DOUBLE_CLICK_INTERVAL};
 use super::display::WaylandDisplay;
 use super::window::{ImeInput, WaylandWindowStatePtr};
 use crate::platform::linux::wayland::clipboard::{
@@ -72,11 +71,14 @@ use crate::platform::linux::wayland::serial::{SerialKind, SerialTracker};
 use crate::platform::linux::wayland::window::WaylandWindow;
 use crate::platform::linux::xdg_desktop_portal::{Event as XDPEvent, XDPEventSource};
 use crate::platform::linux::LinuxClient;
-use crate::platform::linux::{get_xkb_compose_state, is_within_click_distance};
+use crate::platform::linux::{
+    get_xkb_compose_state, is_within_click_distance, open_uri_internal, read_fd,
+    reveal_path_internal,
+};
 use crate::platform::PlatformWindow;
 use crate::{
     point, px, size, Bounds, DevicePixels, FileDropEvent, ForegroundExecutor, MouseExitEvent, Size,
-    SCROLL_LINES,
+    DOUBLE_CLICK_INTERVAL, SCROLL_LINES,
 };
 use crate::{
     AnyWindowHandle, CursorStyle, DisplayId, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers,
@@ -220,7 +222,7 @@ pub(crate) struct WaylandClientState {
     data_offers: Vec<DataOffer<WlDataOffer>>,
     primary_data_offer: Option<DataOffer<ZwpPrimarySelectionOfferV1>>,
     cursor: Cursor,
-    pending_open_uri: Option<String>,
+    pending_activation: Option<PendingActivation>,
     event_loop: Option<EventLoop<'static, WaylandClientStatePtr>>,
     common: LinuxCommon,
 }
@@ -244,6 +246,15 @@ pub(crate) struct KeyRepeat {
     current_keycode: Option<xkb::Keycode>,
 }
 
+pub(crate) enum PendingActivation {
+    /// URI to open in the web browser.
+    Uri(String),
+    /// Path to open in the file explorer.
+    Path(PathBuf),
+    /// A window from ourselves to raise.
+    Window(ObjectId),
+}
+
 /// This struct is required to conform to Rust's orphan rules, so we can dispatch on the state but hand the
 /// window to GPUI.
 #[derive(Clone)]
@@ -260,6 +271,11 @@ impl WaylandClientStatePtr {
         self.0.upgrade().unwrap().borrow().serial_tracker.get(kind)
     }
 
+    pub fn set_pending_activation(&self, window: ObjectId) {
+        self.0.upgrade().unwrap().borrow_mut().pending_activation =
+            Some(PendingActivation::Window(window));
+    }
+
     pub fn enable_ime(&self) {
         let client = self.get_client();
         let mut state = client.borrow_mut();
@@ -530,7 +546,7 @@ impl WaylandClient {
             data_offers: Vec::new(),
             primary_data_offer: None,
             cursor,
-            pending_open_uri: None,
+            pending_activation: None,
             event_loop: Some(event_loop),
         }));
 
@@ -629,14 +645,33 @@ impl LinuxClient for WaylandClient {
             state.globals.activation.clone(),
             state.mouse_focused_window.clone(),
         ) {
-            state.pending_open_uri = Some(uri.to_owned());
+            state.pending_activation = Some(PendingActivation::Uri(uri.to_string()));
             let token = activation.get_activation_token(&state.globals.qh, ());
             let serial = state.serial_tracker.get(SerialKind::MousePress);
             token.set_serial(serial, &state.wl_seat);
             token.set_surface(&window.surface());
             token.commit();
         } else {
-            open_uri_internal(uri, None);
+            let executor = state.common.background_executor.clone();
+            open_uri_internal(executor, uri, None);
+        }
+    }
+
+    fn reveal_path(&self, path: PathBuf) {
+        let mut state = self.0.borrow_mut();
+        if let (Some(activation), Some(window)) = (
+            state.globals.activation.clone(),
+            state.mouse_focused_window.clone(),
+        ) {
+            state.pending_activation = Some(PendingActivation::Path(path));
+            let token = activation.get_activation_token(&state.globals.qh, ());
+            let serial = state.serial_tracker.get(SerialKind::MousePress);
+            token.set_serial(serial, &state.wl_seat);
+            token.set_surface(&window.surface());
+            token.commit();
+        } else {
+            let executor = state.common.background_executor.clone();
+            reveal_path_internal(executor, path, None);
         }
     }
 
@@ -954,13 +989,25 @@ impl Dispatch<xdg_activation_token_v1::XdgActivationTokenV1, ()> for WaylandClie
     ) {
         let client = this.get_client();
         let mut state = client.borrow_mut();
+
         if let xdg_activation_token_v1::Event::Done { token } = event {
-            if let Some(uri) = state.pending_open_uri.take() {
-                open_uri_internal(&uri, Some(&token));
-            } else {
-                log::error!("called while pending_open_uri is None");
+            let executor = state.common.background_executor.clone();
+            match state.pending_activation.take() {
+                Some(PendingActivation::Uri(uri)) => open_uri_internal(executor, &uri, Some(token)),
+                Some(PendingActivation::Path(path)) => {
+                    reveal_path_internal(executor, path, Some(token))
+                }
+                Some(PendingActivation::Window(window)) => {
+                    let Some(window) = get_window(&mut state, &window) else {
+                        return;
+                    };
+                    let activation = state.globals.activation.as_ref().unwrap();
+                    activation.activate(token, &window.surface());
+                }
+                None => log::error!("activation token received with no pending activation"),
             }
         }
+
         token.destroy();
     }
 }

crates/gpui/src/platform/linux/wayland/window.rs 🔗

@@ -76,6 +76,7 @@ pub struct WaylandWindowState {
     acknowledged_first_configure: bool,
     pub surface: wl_surface::WlSurface,
     decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>,
+    app_id: Option<String>,
     appearance: WindowAppearance,
     blur: Option<org_kde_kwin_blur::OrgKdeKwinBlur>,
     toplevel: xdg_toplevel::XdgToplevel,
@@ -158,6 +159,7 @@ impl WaylandWindowState {
             acknowledged_first_configure: false,
             surface,
             decoration,
+            app_id: None,
             blur: None,
             toplevel,
             viewport,
@@ -823,7 +825,20 @@ impl PlatformWindow for WaylandWindow {
     }
 
     fn activate(&self) {
-        log::info!("Wayland does not support this API");
+        // Try to request an activation token. Even though the activation is likely going to be rejected,
+        // KWin and Mutter can use the app_id to visually indicate we're requesting attention.
+        let state = self.borrow();
+        if let (Some(activation), Some(app_id)) = (&state.globals.activation, state.app_id.clone())
+        {
+            state.client.set_pending_activation(state.surface.id());
+            let token = activation.get_activation_token(&state.globals.qh, ());
+            // The serial isn't exactly important here, since the activation is probably going to be rejected anyway.
+            let serial = state.client.get_serial(SerialKind::MousePress);
+            token.set_app_id(app_id);
+            token.set_serial(serial, &state.globals.seat);
+            token.set_surface(&state.surface);
+            token.commit();
+        }
     }
 
     fn is_active(&self) -> bool {
@@ -835,7 +850,9 @@ impl PlatformWindow for WaylandWindow {
     }
 
     fn set_app_id(&mut self, app_id: &str) {
-        self.borrow().toplevel.set_app_id(app_id.to_owned());
+        let mut state = self.borrow_mut();
+        state.toplevel.set_app_id(app_id.to_owned());
+        state.app_id = Some(app_id.to_owned());
     }
 
     fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) {

crates/gpui/src/platform/linux/x11/client.rs 🔗

@@ -2,6 +2,7 @@ use std::cell::RefCell;
 use std::collections::HashSet;
 use std::ops::Deref;
 use std::os::fd::AsRawFd;
+use std::path::PathBuf;
 use std::rc::{Rc, Weak};
 use std::sync::Arc;
 use std::time::{Duration, Instant};
@@ -33,19 +34,18 @@ use crate::platform::linux::LinuxClient;
 use crate::platform::{LinuxCommon, PlatformWindow};
 use crate::{
     modifiers_from_xinput_info, point, px, AnyWindowHandle, Bounds, ClipboardItem, CursorStyle,
-    DisplayId, Keystroke, Modifiers, ModifiersChangedEvent, Pixels, PlatformDisplay, PlatformInput,
-    Point, QuitSignal, ScrollDelta, Size, TouchPhase, WindowParams, X11Window,
+    DisplayId, Keystroke, Modifiers, ModifiersChangedEvent, Pixels, Platform, PlatformDisplay,
+    PlatformInput, Point, QuitSignal, ScrollDelta, Size, TouchPhase, WindowParams, X11Window,
 };
 
-use super::{
-    super::{get_xkb_compose_state, open_uri_internal, SCROLL_LINES},
-    X11Display, X11WindowStatePtr, XcbAtoms,
-};
 use super::{button_of_key, modifiers_from_state, pressed_button_from_mask};
+use super::{X11Display, X11WindowStatePtr, XcbAtoms};
 use super::{XimCallbackEvent, XimHandler};
-use crate::platform::linux::is_within_click_distance;
-use crate::platform::linux::platform::DOUBLE_CLICK_INTERVAL;
+use crate::platform::linux::platform::{DOUBLE_CLICK_INTERVAL, SCROLL_LINES};
 use crate::platform::linux::xdg_desktop_portal::{Event as XDPEvent, XDPEventSource};
+use crate::platform::linux::{
+    get_xkb_compose_state, is_within_click_distance, open_uri_internal, reveal_path_internal,
+};
 
 pub(super) const XINPUT_MASTER_DEVICE: u16 = 1;
 
@@ -1100,7 +1100,11 @@ impl LinuxClient for X11Client {
     }
 
     fn open_uri(&self, uri: &str) {
-        open_uri_internal(uri, None);
+        open_uri_internal(self.background_executor(), uri, None);
+    }
+
+    fn reveal_path(&self, path: PathBuf) {
+        reveal_path_internal(self.background_executor(), path, None);
     }
 
     fn write_to_primary(&self, item: crate::ClipboardItem) {