diff --git a/flake.nix b/flake.nix index 0333e1ef6..a065a872a 100644 --- a/flake.nix +++ b/flake.nix @@ -112,6 +112,7 @@ '' install -Dm644 resources/niri.desktop -t $out/share/wayland-sessions install -Dm644 resources/niri-portals.conf -t $out/share/xdg-desktop-portal + install -Dm644 resources/niri.portal -t $out/share/xdg-desktop-portal/portals '' + lib.optionalString withSystemd '' install -Dm755 resources/niri-session $out/bin/niri-session diff --git a/niri.spec.rpkg b/niri.spec.rpkg index 71ef94947..55a486a8e 100644 --- a/niri.spec.rpkg +++ b/niri.spec.rpkg @@ -122,6 +122,7 @@ sed -i 's/.*please-remove-me$//' .cargo/config.toml install -Dm755 -t %{buildroot}%{_bindir} ./resources/niri-session install -Dm644 -t %{buildroot}%{_datadir}/wayland-sessions ./resources/niri.desktop install -Dm644 -t %{buildroot}%{_datadir}/xdg-desktop-portal ./resources/niri-portals.conf +install -Dm644 -t %{buildroot}%{_datadir}/xdg-desktop-portal/portals ./resources/niri.portal install -Dm644 -t %{buildroot}%{_userunitdir} ./resources/niri.service install -Dm644 -t %{buildroot}%{_userunitdir} ./resources/niri-shutdown.target @@ -140,6 +141,7 @@ install -Dm644 -t %{buildroot}%{_userunitdir} ./resources/niri-shutdown.target %{_datadir}/wayland-sessions/niri.desktop %dir %{_datadir}/xdg-desktop-portal %{_datadir}/xdg-desktop-portal/niri-portals.conf +%{_datadir}/xdg-desktop-portal/portals/niri.portal %{_userunitdir}/niri.service %{_userunitdir}/niri-shutdown.target diff --git a/resources/niri-portals.conf b/resources/niri-portals.conf index 7e1094cf0..2da0e9702 100644 --- a/resources/niri-portals.conf +++ b/resources/niri-portals.conf @@ -1,3 +1,4 @@ [preferred] default=gnome;gtk; +org.freedesktop.impl.portal.Access=niri; org.freedesktop.impl.portal.Secret=gnome-keyring; diff --git a/resources/niri.portal b/resources/niri.portal new file mode 100644 index 000000000..36fac1eb0 --- /dev/null +++ b/resources/niri.portal @@ -0,0 +1,4 @@ +[portal] +DBusName=org.freedesktop.impl.portal.desktop.niri +Interfaces=org.freedesktop.impl.portal.Access; +UseIn=niri \ No newline at end of file diff --git a/src/dbus/mod.rs b/src/dbus/mod.rs index eab72ce72..679a39805 100644 --- a/src/dbus/mod.rs +++ b/src/dbus/mod.rs @@ -8,6 +8,7 @@ pub mod gnome_shell_introspect; pub mod gnome_shell_screenshot; pub mod mutter_display_config; pub mod mutter_service_channel; +pub mod xdg_deskop_portal_impl_access; #[cfg(feature = "xdp-gnome-screencast")] pub mod mutter_screen_cast; @@ -32,6 +33,7 @@ pub struct DBusServers { pub conn_introspect: Option, #[cfg(feature = "xdp-gnome-screencast")] pub conn_screen_cast: Option, + pub conn_portal: Option, } impl DBusServers { @@ -98,6 +100,21 @@ impl DBusServers { } else { warn!("disabling screencast support because we couldn't start PipeWire"); } + + let (to_niri, from_access_dialog) = calloop::channel::channel(); + niri.event_loop + .insert_source(from_access_dialog, { + move |event, _, state| match event { + calloop::channel::Event::Msg(msg) => { + state.niri.access_dialog_ui.enque_request(msg); + state.niri.queue_redraw_all(); + } + calloop::channel::Event::Closed => (), + } + }) + .unwrap(); + let portal = xdg_deskop_portal_impl_access::AccessPortalImpl::new(to_niri); + dbus.conn_portal = try_start(portal); } niri.dbus = Some(dbus); diff --git a/src/dbus/xdg_deskop_portal_impl_access.rs b/src/dbus/xdg_deskop_portal_impl_access.rs new file mode 100644 index 000000000..127c3dcdc --- /dev/null +++ b/src/dbus/xdg_deskop_portal_impl_access.rs @@ -0,0 +1,150 @@ +use std::ops::Not; + +use zbus::dbus_interface; + +use super::Start; + +pub struct AccessPortalImpl { + to_niri: calloop::channel::Sender, +} + +#[dbus_interface(name = "org.freedesktop.impl.portal.Access")] +impl AccessPortalImpl { + /// AccessDialog method + #[allow(clippy::too_many_arguments)] + async fn access_dialog( + &self, + _handle: zbus::zvariant::ObjectPath<'_>, + app_id: &str, + parent_window: &str, + title: &str, + subtitle: &str, + body: &str, + options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, + ) -> zbus::fdo::Result<( + u32, + std::collections::HashMap, + )> { + let (response_channel_sender, response_channel_receiver) = async_channel::bounded(1); + + let options = AccessDialogOptions::from_options(options); + let request = AccessDialogRequest { + app_id: app_id.to_string(), + parent_window: parent_window + .is_empty() + .not() + .then(|| parent_window.to_string()), + title: title.to_string(), + subtitle: subtitle.to_string(), + body: body.is_empty().not().then(|| body.to_string()), + options, + + response_channel_sender, + }; + + if let Err(err) = self.to_niri.send(request) { + tracing::warn!(?err, "failed to send access dialog request"); + return Ok((2, Default::default())); + }; + + let result = std::collections::HashMap::::new(); + let response = match response_channel_receiver.recv().await { + Ok(AccessDialogResponse::Grant) => { + // FIXME: Add selected choices to the result + 0 + } + Ok(AccessDialogResponse::Deny) => 1, + Err(err) => { + tracing::warn!(?err, "failed to receive response for access dialog request"); + return Ok((2, Default::default())); + } + }; + + return Ok((response, result)); + } +} + +impl AccessPortalImpl { + pub fn new(to_niri: calloop::channel::Sender) -> Self { + Self { to_niri } + } +} + +impl Start for AccessPortalImpl { + fn start(self) -> anyhow::Result { + let conn = zbus::blocking::ConnectionBuilder::session()? + .name("org.freedesktop.impl.portal.desktop.niri")? + .serve_at("/org/freedesktop/portal/desktop", self)? + .build()?; + Ok(conn) + } +} + +pub struct AccessDialogRequest { + pub app_id: String, + pub parent_window: Option, + pub title: String, + pub subtitle: String, + pub body: Option, + pub options: AccessDialogOptions, + + response_channel_sender: async_channel::Sender, +} + +pub struct AccessDialogOptions { + /// Whether to make the dialog modal. Defaults to true. + pub modal: bool, + /// Label for the Deny button. + pub deny_label: Option, + /// Label for the Grant button. + pub grant_label: Option, + /// Icon name for an icon to show in the dialog. This should be a symbolic icon name. + pub icon: Option, +} + +impl AccessDialogOptions { + fn from_options(options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>) -> Self { + let modal: bool = options + .get("modal") + .and_then(|option| option.downcast_ref()) + .copied() + .unwrap_or(true); + let deny_label: Option = options + .get("deny_label") + .and_then(|option| option.clone().downcast()); + let grant_label: Option = options + .get("grant_label") + .and_then(|option| option.clone().downcast()); + let icon: Option = options + .get("icon") + .and_then(|option| option.clone().downcast()); + + // FIXME: Add support for choices + + Self { + modal, + deny_label, + grant_label, + icon, + } + } +} + +impl AccessDialogRequest { + pub fn grant(self) -> anyhow::Result<()> { + self.response_channel_sender + .send_blocking(AccessDialogResponse::Grant)?; + Ok(()) + } + + pub fn deny(self) -> anyhow::Result<()> { + self.response_channel_sender + .send_blocking(AccessDialogResponse::Deny)?; + Ok(()) + } +} + +pub enum AccessDialogResponse { + Grant, + Deny, +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 315e5dfda..c0b817e92 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -36,6 +36,8 @@ use self::move_grab::MoveGrab; use self::resize_grab::ResizeGrab; use self::spatial_movement_grab::SpatialMovementGrab; use crate::niri::State; +#[cfg(feature = "dbus")] +use crate::ui::access_dialog_ui::AccessDialogUi; use crate::ui::screenshot_ui::ScreenshotUi; use crate::utils::spawning::spawn; use crate::utils::{center, get_monotonic_time, ResizeEdge}; @@ -372,6 +374,8 @@ impl State { pressed, *mods, &this.niri.screenshot_ui, + #[cfg(feature = "dbus")] + &this.niri.access_dialog_ui, this.niri.config.borrow().input.disable_power_key_handling, ) }, @@ -2447,6 +2451,7 @@ fn should_intercept_key( pressed: bool, mods: ModifiersState, screenshot_ui: &ScreenshotUi, + #[cfg(feature = "dbus")] access_dialog_ui: &AccessDialogUi, disable_power_key_handling: bool, ) -> FilterResult> { // Actions are only triggered on presses, release of the key @@ -2493,6 +2498,13 @@ fn should_intercept_key( } } + #[cfg(feature = "dbus")] + if access_dialog_ui.is_visible() { + access_dialog_ui.handle_key(raw, mods); + suppressed_keys.insert(key_code); + return FilterResult::Intercept(None); + } + match (final_bind, pressed) { (Some(bind), true) => { suppressed_keys.insert(key_code); @@ -2991,6 +3003,7 @@ mod tests { let mut suppressed_keys = HashSet::new(); let screenshot_ui = ScreenshotUi::new(Default::default()); + let access_dialog_ui = AccessDialogUi::new(); let disable_power_key_handling = false; // The key_code we pick is arbitrary, the only thing @@ -3008,6 +3021,7 @@ mod tests { pressed, mods, &screenshot_ui, + &access_dialog_ui, disable_power_key_handling, ) }; @@ -3024,6 +3038,7 @@ mod tests { pressed, mods, &screenshot_ui, + &access_dialog_ui, disable_power_key_handling, ) }; diff --git a/src/niri.rs b/src/niri.rs index 0993661bd..4e61d2735 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -135,6 +135,8 @@ use crate::render_helpers::{ render_to_dmabuf, render_to_encompassing_texture, render_to_shm, render_to_texture, render_to_vec, shaders, RenderTarget, }; +#[cfg(feature = "dbus")] +use crate::ui::access_dialog_ui::AccessDialogUi; use crate::ui::config_error_notification::ConfigErrorNotification; use crate::ui::exit_confirm_dialog::ExitConfirmDialog; use crate::ui::hotkey_overlay::HotkeyOverlay; @@ -286,6 +288,8 @@ pub struct Niri { pub config_error_notification: ConfigErrorNotification, pub hotkey_overlay: HotkeyOverlay, pub exit_confirm_dialog: Option, + #[cfg(feature = "dbus")] + pub access_dialog_ui: AccessDialogUi, pub debug_draw_opaque_regions: bool, pub debug_draw_damage: bool, @@ -1729,6 +1733,9 @@ impl Niri { } }; + #[cfg(feature = "dbus")] + let access_dialog_ui = AccessDialogUi::new(); + event_loop .insert_source( Timer::from_duration(Duration::from_secs(1)), @@ -1892,6 +1899,8 @@ impl Niri { config_error_notification, hotkey_overlay, exit_confirm_dialog, + #[cfg(feature = "dbus")] + access_dialog_ui, debug_draw_opaque_regions: false, debug_draw_damage: false, @@ -2294,6 +2303,11 @@ impl Niri { return None; } + #[cfg(feature = "dbus")] + if self.access_dialog_ui.is_visible() { + return None; + } + let (output, pos_within_output) = self.output_under(pos)?; // Check if some layer-shell surface is on top. @@ -2364,6 +2378,11 @@ impl Niri { return rv; } + #[cfg(feature = "dbus")] + if self.access_dialog_ui.is_visible() { + return rv; + } + let layers = layer_map_for_output(output); let layer_surface_under = |layer| { layers @@ -3072,6 +3091,12 @@ impl Niri { elements.push(element.into()); } + // Draw the access dialogs. + #[cfg(feature = "dbus")] + if let Some(element) = self.access_dialog_ui.render(renderer, output) { + elements.push(element.into()); + } + // Get monitor elements. let mon = self.layout.monitor_for_output(output).unwrap(); let monitor_elements: Vec<_> = mon.render_elements(renderer, target).collect(); diff --git a/src/ui/access_dialog_ui.rs b/src/ui/access_dialog_ui.rs new file mode 100644 index 000000000..8aca8d8c3 --- /dev/null +++ b/src/ui/access_dialog_ui.rs @@ -0,0 +1,181 @@ +use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; + +use pango::{Alignment, FontDescription}; +use pangocairo::cairo::{self, ImageSurface}; +use smithay::backend::renderer::element::Kind; +use smithay::input::keyboard::{Keysym, ModifiersState}; +use smithay::output::{Output, WeakOutput}; +use smithay::reexports::gbm::Format as Fourcc; +use smithay::utils::{Scale, Transform}; + +use crate::dbus::xdg_deskop_portal_impl_access::AccessDialogRequest; +use crate::render_helpers::memory::MemoryBuffer; +use crate::render_helpers::primary_gpu_texture::PrimaryGpuTextureRenderElement; +use crate::render_helpers::renderer::NiriRenderer; +use crate::render_helpers::texture::{TextureBuffer, TextureRenderElement}; +use crate::utils::{output_size, to_physical_precise_round}; + +const PADDING: i32 = 16; +const FONT: &str = "sans 14px"; +const BORDER: i32 = 8; + +pub struct AccessDialogUi { + requests: RefCell>, + buffers: RefCell>>, +} + +impl AccessDialogUi { + pub fn new() -> Self { + Self { + requests: Default::default(), + buffers: Default::default(), + } + } + + pub fn enque_request(&self, request: AccessDialogRequest) { + self.requests.borrow_mut().push_back(request); + } + + pub fn is_visible(&self) -> bool { + !self.requests.borrow().is_empty() + } + + pub fn render( + &self, + renderer: &mut R, + output: &Output, + ) -> Option { + let requests = self.requests.borrow(); + let Some(current_request) = requests.front() else { + return None; + }; + + let scale = output.current_scale().fractional_scale(); + let output_size = output_size(output); + let weak_output = output.downgrade(); + + let mut buffers = self.buffers.borrow_mut(); + buffers.retain(|output, buffer| { + output.is_alive() + && (*output != weak_output + || buffer + .as_ref() + .map(|buffer| Scale::from(scale) == buffer.scale()) + .unwrap_or(false)) + }); + + let Some(buffer) = buffers + .entry(weak_output) + .or_insert_with(|| render(scale, current_request).ok()) + else { + buffers.clear(); + return None; + }; + + let size = buffer.logical_size(); + let buffer = TextureBuffer::from_memory_buffer(renderer.as_gles_renderer(), buffer).ok()?; + + let location = (output_size.to_f64().to_point() - size.to_point()).downscale(2.); + let mut location = location.to_physical_precise_round(scale).to_logical(scale); + location.x = f64::max(0., location.x); + location.y = f64::max(0., location.y); + + let elem = TextureRenderElement::from_texture_buffer( + buffer, + location, + 1., + None, + None, + Kind::Unspecified, + ); + Some(PrimaryGpuTextureRenderElement(elem)) + } + + pub fn handle_key(&self, raw: Option, _mods: ModifiersState) { + if let Some(Keysym::Escape) = raw { + if let Some(request) = self.requests.borrow_mut().pop_front() { + let _ = request.deny(); + } + self.buffers.borrow_mut().clear(); + } + + if let Some(Keysym::Return) = raw { + if let Some(request) = self.requests.borrow_mut().pop_front() { + // FIXME: Allow to select choices + let _ = request.grant(); + } + self.buffers.borrow_mut().clear(); + } + } +} + +fn render(scale: f64, request: &AccessDialogRequest) -> anyhow::Result { + let _span = tracy_client::span!("access_dialog_ui::render"); + + let padding: i32 = to_physical_precise_round(scale, PADDING); + + let mut font = FontDescription::from_string(FONT); + font.set_absolute_size(to_physical_precise_round(scale, font.size())); + + let text = format!( + "{}\n\ + {}\n\n\ + {}\ + Press Enter to {} or Escape to {}.", + request.title, + request.subtitle, + request.body.as_deref().map(|body| format!("{}\n\n", body)).unwrap_or("".to_string()), + request.options.grant_label.as_deref().unwrap_or("grant"), + request.options.deny_label.as_deref().unwrap_or("deny"), + ); + + let surface = ImageSurface::create(cairo::Format::ARgb32, 0, 0)?; + let cr = cairo::Context::new(&surface)?; + let layout = pangocairo::functions::create_layout(&cr); + layout.context().set_round_glyph_positions(false); + layout.set_font_description(Some(&font)); + layout.set_alignment(Alignment::Center); + layout.set_markup(&text); + + let (mut width, mut height) = layout.pixel_size(); + width += padding * 2; + height += padding * 2; + + let surface = ImageSurface::create(cairo::Format::ARgb32, width, height)?; + let cr = cairo::Context::new(&surface)?; + cr.set_source_rgb(0.1, 0.1, 0.1); + cr.paint()?; + + cr.move_to(padding.into(), padding.into()); + let layout = pangocairo::functions::create_layout(&cr); + layout.context().set_round_glyph_positions(false); + layout.set_font_description(Some(&font)); + layout.set_alignment(Alignment::Center); + layout.set_markup(&text); + + cr.set_source_rgb(1., 1., 1.); + pangocairo::functions::show_layout(&cr, &layout); + + cr.move_to(0., 0.); + cr.line_to(width.into(), 0.); + cr.line_to(width.into(), height.into()); + cr.line_to(0., height.into()); + cr.line_to(0., 0.); + cr.set_source_rgb(1., 0.3, 0.3); + // Keep the border width even to avoid blurry edges. + cr.set_line_width((f64::from(BORDER) / 2. * scale).round() * 2.); + cr.stroke()?; + drop(cr); + + let data = surface.take_data().unwrap(); + let buffer = MemoryBuffer::new( + data.to_vec(), + Fourcc::Argb8888, + (width, height), + scale, + Transform::Normal, + ); + + Ok(buffer) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b546bda5a..f785a9960 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "dbus")] +pub mod access_dialog_ui; pub mod config_error_notification; pub mod exit_confirm_dialog; pub mod hotkey_overlay;