diff --git a/ashpd-demo/Cargo.lock b/ashpd-demo/Cargo.lock index bae07d545..df35148dd 100644 --- a/ashpd-demo/Cargo.lock +++ b/ashpd-demo/Cargo.lock @@ -78,6 +78,7 @@ dependencies = [ "gtk4", "libadwaita", "libshumate", + "rusb", "serde", "tracing", "tracing-subscriber", @@ -1590,6 +1591,18 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1982,6 +1995,16 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2349,6 +2372,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" diff --git a/ashpd-demo/Cargo.toml b/ashpd-demo/Cargo.toml index 4e2baa9ae..1cb3073ba 100644 --- a/ashpd-demo/Cargo.toml +++ b/ashpd-demo/Cargo.toml @@ -14,6 +14,7 @@ gettext-rs = {version = "0.7", features = ["gettext-system"]} gst = {package = "gstreamer", version = "0.23"} gst4gtk = {package = "gst-plugin-gtk4", version = "0.13", features = ["wayland", "x11egl", "x11glx", "gtk_v4_14"]} gtk = {package = "gtk4", version = "0.9", features = ["v4_14"]} +rusb = "0.9.4" serde = {version = "1.0", features = ["derive"]} shumate = {version = "0.6", package = "libshumate"} tracing = "0.1" diff --git a/ashpd-demo/build-aux/com.belmoussaoui.ashpd.demo.Devel.json b/ashpd-demo/build-aux/com.belmoussaoui.ashpd.demo.Devel.json index d7d081810..bb86c0c14 100644 --- a/ashpd-demo/build-aux/com.belmoussaoui.ashpd.demo.Devel.json +++ b/ashpd-demo/build-aux/com.belmoussaoui.ashpd.demo.Devel.json @@ -14,6 +14,7 @@ "--socket=wayland", "--device=dri", "--own-name=com.belmoussaoui.ashpd.demo", + "--usb=all", "--env=RUST_LOG=ashpd_demo=debug,ashpd=debug", "--env=G_MESSAGES_DEBUG=none", "--env=RUST_BACKTRACE=1" @@ -31,6 +32,16 @@ ] }, "modules": [ + { + "name": "libusb", + "sources": [ + { + "type": "archive", + "url": "https://github.com/libusb/libusb/releases/download/v1.0.27/libusb-1.0.27.tar.bz2", + "sha256": "ffaa41d741a8a3bee244ac8e54a72ea05bf2879663c098c82fc5757853441575" + } + ] + }, { "name": "libshumate", "buildsystem": "meson", diff --git a/ashpd-demo/data/resources.gresource.xml b/ashpd-demo/data/resources.gresource.xml index 1202c6af2..28fc4afbe 100644 --- a/ashpd-demo/data/resources.gresource.xml +++ b/ashpd-demo/data/resources.gresource.xml @@ -26,6 +26,8 @@ resources/ui/screenshot.ui resources/ui/screencast.ui resources/ui/secret.ui + resources/ui/usb.ui + resources/ui/usb_device_row.ui resources/ui/wallpaper.ui resources/style.css diff --git a/ashpd-demo/data/resources/ui/usb.ui b/ashpd-demo/data/resources/ui/usb.ui new file mode 100644 index 000000000..a168dcefa --- /dev/null +++ b/ashpd-demo/data/resources/ui/usb.ui @@ -0,0 +1,63 @@ + + + + diff --git a/ashpd-demo/data/resources/ui/usb_device_row.ui b/ashpd-demo/data/resources/ui/usb_device_row.ui new file mode 100644 index 000000000..420159e45 --- /dev/null +++ b/ashpd-demo/data/resources/ui/usb_device_row.ui @@ -0,0 +1,31 @@ + + + + diff --git a/ashpd-demo/data/resources/ui/window.ui b/ashpd-demo/data/resources/ui/window.ui index a95869152..c6fbaeed6 100644 --- a/ashpd-demo/data/resources/ui/window.ui +++ b/ashpd-demo/data/resources/ui/window.ui @@ -155,6 +155,12 @@ secret + + + USB + usb + + Wallpaper @@ -325,6 +331,14 @@ + + + usb + + + + + wallpaper diff --git a/ashpd-demo/src/portals/desktop/mod.rs b/ashpd-demo/src/portals/desktop/mod.rs index 79a12f47d..97bedde8c 100644 --- a/ashpd-demo/src/portals/desktop/mod.rs +++ b/ashpd-demo/src/portals/desktop/mod.rs @@ -16,6 +16,7 @@ mod remote_desktop; mod screencast; mod screenshot; mod secret; +mod usb; mod wallpaper; pub use account::AccountPage; @@ -36,4 +37,5 @@ pub use remote_desktop::RemoteDesktopPage; pub use screencast::ScreenCastPage; pub use screenshot::ScreenshotPage; pub use secret::SecretPage; +pub use usb::UsbPage; pub use wallpaper::WallpaperPage; diff --git a/ashpd-demo/src/portals/desktop/usb.rs b/ashpd-demo/src/portals/desktop/usb.rs new file mode 100644 index 000000000..9b02fe5d7 --- /dev/null +++ b/ashpd-demo/src/portals/desktop/usb.rs @@ -0,0 +1,373 @@ +// Copyright (C) 2024 GNOME Foundation +// +// Authors: +// Hubert Figuière +// + +use std::{collections::HashMap, os::fd::AsRawFd, sync::Arc}; + +use adw::{prelude::*, subclass::prelude::*}; +use ashpd::{ + desktop::{ + usb::{Device, UsbDevice, UsbError, UsbProxy}, + Session, + }, + zbus::zvariant::{Fd, OwnedFd}, + WindowIdentifier, +}; +use futures_util::{lock::Mutex, StreamExt}; +use glib::clone; +use gtk::glib; +use rusb::UsbContext; + +use crate::widgets::{PortalPage, PortalPageExt, PortalPageImpl}; + +glib::wrapper! { + pub struct UsbDeviceRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, adw::PreferencesRow, adw::ActionRow; +} + +impl UsbDeviceRow { + fn with_device(page: UsbPage, device_id: String, writable: bool) -> Self { + let obj: Self = glib::Object::new(); + + let imp = obj.imp(); + imp.page.replace(Some(page)); + imp.device_id.replace(device_id); + imp.writable.set(writable); + obj + } + + fn acquire(&self) { + self.imp().checkbox.set_active(true); + } + + fn release(&self) { + self.imp().checkbox.set_active(false); + } +} + +mod imp { + use std::cell::{Cell, RefCell}; + + use super::*; + + #[derive(Debug, gtk::CompositeTemplate, Default)] + #[template(resource = "/com/belmoussaoui/ashpd/demo/usb_device_row.ui")] + pub struct UsbDeviceRow { + #[template_child] + pub(super) checkbox: TemplateChild, + #[template_child] + pub(super) acquire: TemplateChild, + #[template_child] + pub(super) release: TemplateChild, + pub(super) page: RefCell>, + pub(super) device_id: RefCell, + pub(super) writable: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for UsbDeviceRow { + const NAME: &'static str = "UsbDeviceRow"; + type Type = super::UsbDeviceRow; + type ParentType = adw::ActionRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[gtk::template_callbacks] + impl UsbDeviceRow { + #[template_callback] + async fn handle_acquire_clicked(&self, _: >k::Button) { + let page = { + self.page.borrow().clone() + }; + if let Some(page) = page { + let device_id = self.device_id.borrow().clone(); + let writable = self.writable.get(); + page.share(&device_id, writable).await; + } + } + + #[template_callback] + async fn handle_release_clicked(&self, _: >k::Button) { + let page = { + self.page.borrow().clone() + }; + if let Some(page) = page { + let device_id = self.device_id.borrow().clone(); + page.unshare(&device_id).await; + } + } + } + + impl ObjectImpl for UsbDeviceRow {} + impl WidgetImpl for UsbDeviceRow {} + impl ListBoxRowImpl for UsbDeviceRow {} + impl PreferencesRowImpl for UsbDeviceRow {} + impl ActionRowImpl for UsbDeviceRow {} + + #[derive(Debug, gtk::CompositeTemplate, Default)] + #[template(resource = "/com/belmoussaoui/ashpd/demo/usb.ui")] + pub struct UsbPage { + #[template_child] + pub usb_devices: TemplateChild, + rows: RefCell>, + pub session: Arc>>>>, + pub event_source: Arc>>, + } + + impl UsbPage { + fn add(&self, uuid: String, row: super::UsbDeviceRow) { + self.usb_devices.get().add(&row); + self.rows.borrow_mut().insert(uuid, row); + } + + fn clear_devices(&self) { + for row in self.rows.borrow().values() { + self.usb_devices.get().remove(row); + } + self.rows.borrow_mut().clear(); + } + + fn acquired_device(&self, uuid: &str) { + if let Some(row) = self.rows.borrow().get(uuid) { + row.acquire(); + } + } + + pub(super) fn released_device(&self, uuid: &str) { + if let Some(row) = self.rows.borrow().get(uuid) { + row.release(); + } + } + + fn usb_describe_device(fd: &dyn AsRawFd) -> ashpd::Result { + let context = rusb::Context::new() + .map_err(|_| ashpd::PortalError::Failed("rusb Context".to_string()))?; + let handle = unsafe { context.open_device_with_fd(fd.as_raw_fd()) } + .map_err(|_| ashpd::PortalError::Failed("open USB device".to_string()))?; + let device = handle.device(); + let device_desc = device.device_descriptor().unwrap(); + Ok(format!( + "Bus {:03} Device {:03} ID {:04x}:{:04x}", + device.bus_number(), + device.address(), + device_desc.vendor_id(), + device_desc.product_id() + )) + } + + fn add_device_row(&self, page: &super::UsbPage, device: &(String, UsbDevice)) { + let row = super::UsbDeviceRow::with_device( + page.clone(), + device.0.clone(), + device.1.writable.unwrap_or_default(), + ); + let vendor = device.1.vendor().unwrap_or_default(); + let dev = device.1.model().unwrap_or_default(); + row.set_title(&format!("{} {}", &vendor, &dev)); + if let Some(devnode) = &device.1.device_file { + row.set_subtitle(devnode); + } + page.imp().add(device.0.clone(), row); + } + + pub(super) async fn refresh_devices(&self) -> ashpd::Result<()> { + let page = self.obj(); + + self.clear_devices(); + + let usb = UsbProxy::new().await?; + let devices = usb.enumerate_devices().await?; + for device in devices { + self.add_device_row(&page, &device); + } + Ok(()) + } + + pub(super) fn finish_acquire_devices( + &self, + devices: &[(String, Result)], + ) { + devices.iter().for_each(|device| { + if let Ok(fd) = &device.1 { + match Self::usb_describe_device(&Fd::from(fd)) { + Ok(describe) => self.obj().info(&describe), + Err(err) => self.obj().info(&err.to_string()), + } + } + self.acquired_device(&device.0); + }); + } + + pub(super) async fn start_session(&self) -> ashpd::Result<()> { + let usb = UsbProxy::new().await?; + let session = usb.create_session().await?; + self.session.lock().await.replace(session); + + let session = self.session.clone(); + loop { + if session.lock().await.is_none() { + tracing::debug!("session is gone"); + break; + } + if let Some(response) = usb.receive_device_events().await?.next().await { + let events = response.events(); + for ev in events { + println!( + "Received event: {} for device {}", + ev.event_action(), + ev.event_device_id() + ); + } + } + } + Ok(()) + } + + pub(super) async fn stop_session(&self) -> anyhow::Result<()> { + if let Some(session) = self.session.lock().await.take() { + session.close().await?; + } + Ok(()) + } + } + + #[glib::object_subclass] + impl ObjectSubclass for UsbPage { + const NAME: &'static str = "UsbPage"; + type Type = super::UsbPage; + type ParentType = PortalPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + + klass.install_action_async("usb.refresh", None, |page, _, _| async move { + page.refresh_devices().await + }); + klass.install_action_async("usb.start_session", None, |page, _, _| async move { + page.start_session().await + }); + klass.install_action_async("usb.stop_session", None, |page, _, _| async move { + page.stop_session().await; + }); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for UsbPage { + fn constructed(&self) { + self.parent_constructed(); + self.obj().action_set_enabled("usb.stop_session", false); + } + } + + impl WidgetImpl for UsbPage { + fn map(&self) { + glib::spawn_future_local(clone!( + #[weak(rename_to = widget)] + self, + async move { + widget.obj().refresh_devices().await; + } + )); + + self.parent_map(); + } + } + + impl BinImpl for UsbPage {} + impl PortalPageImpl for UsbPage {} +} + +glib::wrapper! { + pub struct UsbPage(ObjectSubclass) + @extends gtk::Widget, adw::Bin, PortalPage; +} + +impl UsbPage { + async fn refresh_devices(&self) { + match self.imp().refresh_devices().await { + Ok(_) => {} + Err(err) => { + tracing::error!("Failed to refresh USB devices: {err}"); + self.error(&format!("Failed to refresh USB devices: {err}.")); + } + } + } + + async fn start_session(&self) { + self.action_set_enabled("usb.start_session", false); + self.action_set_enabled("usb.stop_session", true); + + match self.imp().start_session().await { + Ok(_) => self.info("USB session started"), + Err(err) => { + tracing::error!("Failed to start USB session: {err}"); + self.error(&format!("Failed to start USB session: {err}.")); + self.action_set_enabled("usb.start_session", true); + self.action_set_enabled("usb.stop_session", false); + } + } + } + + async fn stop_session(&self) { + self.action_set_enabled("usb.start_session", true); + self.action_set_enabled("usb.stop_session", false); + + match self.imp().stop_session().await { + Ok(_) => self.info("USB session stopped"), + Err(err) => { + tracing::error!("Failed to stop USB session: {err}"); + self.error(&format!("Failed to stop USB session: {err}.")); + } + } + } + + async fn do_share(&self, device_id: &String, device_writable: bool) -> ashpd::Result<()> { + let root = self.native().unwrap(); + let identifier = WindowIdentifier::from_native(&root).await; + let usb = UsbProxy::new().await?; + let devices = usb + .acquire_devices( + identifier.as_ref(), + &[Device(device_id.to_string(), device_writable)], + ) + .await?; + + self.imp().finish_acquire_devices(&devices); + Ok(()) + } + + async fn share(&self, device_id: &String, device_writable: bool) { + let result = self.do_share(device_id, device_writable).await; + if let Err(err) = result { + tracing::error!("Acquire device error: {err}"); + self.error(&format!("Acquire device error: {err}")); + } + } + + async fn unshare(&self, device_id: &str) { + let result = async { + let usb = UsbProxy::new().await?; + usb.release_devices(&[device_id]).await + } + .await; + if let Err(err) = result { + tracing::error!("Acquire device error: {err}"); + self.error(&format!("Acquire device error: {err}")); + } + self.imp().released_device(device_id); + } +} diff --git a/ashpd-demo/src/window.rs b/ashpd-demo/src/window.rs index b80e68847..4a216c1af 100644 --- a/ashpd-demo/src/window.rs +++ b/ashpd-demo/src/window.rs @@ -14,7 +14,7 @@ use crate::{ AccountPage, BackgroundPage, CameraPage, DevicePage, DynamicLauncherPage, EmailPage, FileChooserPage, InhibitPage, LocationPage, NetworkMonitorPage, NotificationPage, OpenUriPage, PrintPage, ProxyResolverPage, RemoteDesktopPage, ScreenCastPage, - ScreenshotPage, SecretPage, WallpaperPage, + ScreenshotPage, SecretPage, UsbPage, WallpaperPage, }, DocumentsPage, }, @@ -45,6 +45,8 @@ mod imp { #[template_child] pub camera: TemplateChild, #[template_child] + pub usb: TemplateChild, + #[template_child] pub wallpaper: TemplateChild, #[template_child] pub location: TemplateChild, @@ -88,6 +90,7 @@ mod imp { split_view: TemplateChild::default(), camera: TemplateChild::default(), dynamic_launcher: TemplateChild::default(), + usb: TemplateChild::default(), wallpaper: TemplateChild::default(), location: TemplateChild::default(), notification: TemplateChild::default(),