From 4ee06f04ffe0dfb418cd21cf17b6eca40405e075 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 15 Dec 2024 16:43:10 +0100 Subject: [PATCH 1/8] Add a list of discovered devices to app model. --- src/app.rs | 49 +++++++------- src/app/commandline.rs | 9 +-- src/app/debuginfo.rs | 11 ++- src/app/model.rs | 3 + src/app/model/devices.rs | 96 +++++++++++++++++++++++++++ src/app/searchprovider.rs | 6 +- src/app/widgets/application_window.rs | 48 ++++++++------ 7 files changed, 165 insertions(+), 57 deletions(-) create mode 100644 src/app/model/devices.rs diff --git a/src/app.rs b/src/app.rs index 1adc2d7..cbc895f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use adw::prelude::*; use adw::subclass::prelude::*; use glib::{dgettext, dpgettext2, Object}; -use gtk::gio::{ActionEntry, ApplicationFlags, ListStore}; +use gtk::gio::{ActionEntry, ApplicationFlags}; use crate::config::{APP_ID, G_LOG_DOMAIN}; @@ -28,16 +28,12 @@ glib::wrapper! { } impl TurnOnApplication { - pub fn model(&self) -> &ListStore { - self.imp().model() - } - fn setup_actions(&self) { let actions = [ ActionEntry::builder("add-device") .activate(|app: &TurnOnApplication, _, _| { let dialog = EditDeviceDialog::new(); - let devices = app.imp().model().clone(); + let devices = app.devices().registered_devices(); dialog.connect_saved(move |_, device| { glib::debug!("Adding new device: {:?}", device.imp()); devices.append(device); @@ -56,12 +52,12 @@ impl TurnOnApplication { ); glib::spawn_future_local(glib::clone!( - #[strong(rename_to = model)] - app.model(), + #[strong(rename_to = devices)] + app.devices(), #[weak] dialog, async move { - let info = DebugInfo::assemble(model).await; + let info = DebugInfo::assemble(devices).await; dialog.set_debug_info(&info.to_string()); dialog.set_debug_info_filename(&info.suggested_file_name()); } @@ -138,13 +134,16 @@ mod imp { use crate::config::G_LOG_DOMAIN; use super::commandline; - use super::model::Device; + use super::model::{Device, Devices}; use super::searchprovider::register_app_search_provider; use super::storage::{StorageService, StorageServiceClient}; use super::widgets::TurnOnApplicationWindow; + #[derive(glib::Properties)] + #[properties(wrapper_type = super::TurnOnApplication)] pub struct TurnOnApplication { - model: ListStore, + #[property(get)] + devices: Devices, registered_search_provider: RefCell>, /// Use a different file to store devices at. devices_file: RefCell>, @@ -159,26 +158,22 @@ mod imp { } impl TurnOnApplication { - pub fn model(&self) -> &ListStore { - &self.model - } - /// Start saving changes to the model automatically. /// /// Monitor the device model for changes, and automatically persist /// devices to `storage` whenever the model changed. fn save_automatically(&self, storage: StorageServiceClient) { // Monitor existing devices for changes - for device in &self.model { + for device in &self.devices.registered_devices() { save_device_automatically( storage.clone(), - self.model.clone(), + self.devices.registered_devices(), device.unwrap().downcast().unwrap(), ); } // Monitor any newly added device for changes - self.model - .connect_items_changed(move |model, pos, _, n_added| { + self.devices.registered_devices().connect_items_changed( + move |model, pos, _, n_added| { glib::debug!("Device list changed, saving devices"); storage.request_save_device_store(model); for n in pos..n_added { @@ -188,7 +183,8 @@ mod imp { model.item(n).unwrap().downcast().unwrap(), ); } - }); + }, + ); } } @@ -202,15 +198,14 @@ mod imp { fn new() -> Self { Self { - model: ListStore::builder() - .item_type(Device::static_type()) - .build(), + devices: Devices::default(), registered_search_provider: Default::default(), devices_file: Default::default(), } } } + #[glib::derived_properties] impl ObjectImpl for TurnOnApplication { fn constructed(&self) { self.parent_constructed(); @@ -305,8 +300,10 @@ mod imp { } Ok(devices) => devices.into_iter().map(Device::from).collect(), }; - self.model.remove_all(); - self.model.extend_from_slice(devices.as_slice()); + self.devices.registered_devices().remove_all(); + self.devices + .registered_devices() + .extend_from_slice(devices.as_slice()); self.save_automatically(storage.client()); glib::spawn_future_local(storage.spawn()); @@ -332,7 +329,7 @@ mod imp { None => { glib::debug!("Creating new application window"); let window = TurnOnApplicationWindow::new(app); - window.bind_model(self.model()); + window.bind_model(&self.devices); window.present(); } } diff --git a/src/app/commandline.rs b/src/app/commandline.rs index a2e5d2e..aa3e238 100644 --- a/src/app/commandline.rs +++ b/src/app/commandline.rs @@ -56,14 +56,14 @@ pub fn turn_on_device_by_label( ) -> glib::ExitCode { let guard = app.hold(); glib::debug!("Turning on device in response to command line argument"); - match app - .model() + let registered_devices = app.devices().registered_devices(); + match registered_devices .find_with_equal_func(|o| { o.downcast_ref::() .filter(|d| d.label() == label) .is_some() }) - .and_then(|position| app.model().item(position)) + .and_then(|position| registered_devices.item(position)) .and_then(|o| o.downcast::().ok()) { Some(device) => { @@ -123,7 +123,8 @@ pub fn list_devices( command_line, async move { let pinged_devices = ping_all_devices( - app.model() + app.devices() + .registered_devices() .into_iter() .map(|o| o.unwrap().downcast().unwrap()), ) diff --git a/src/app/debuginfo.rs b/src/app/debuginfo.rs index 65a0969..b93c49f 100644 --- a/src/app/debuginfo.rs +++ b/src/app/debuginfo.rs @@ -20,7 +20,7 @@ use macaddr::MacAddr6; use crate::config; use crate::net::{ping_address_with_timeout, PingDestination}; -use super::model::Device; +use super::model::{Device, Devices}; #[derive(Debug)] pub enum DevicePingResult { @@ -80,7 +80,7 @@ impl DebugInfo { /// /// This method returns a human-readable plain text debug report which can help /// to identify issues. - pub async fn assemble(model: gio::ListStore) -> DebugInfo { + pub async fn assemble(devices: Devices) -> DebugInfo { let monitor = gio::NetworkMonitor::default(); let (connectivity, ping_results) = futures_util::future::join( // Give network monitor time to actually figure out what the state of the network is, @@ -91,7 +91,12 @@ impl DebugInfo { MacAddr6::nil().into(), "localhost", )) - .chain(model.into_iter().map(|d| d.unwrap().downcast().unwrap())) + .chain( + devices + .registered_devices() + .into_iter() + .map(|d| d.unwrap().downcast().unwrap()), + ) .map(ping_device) .collect::>() .collect::>(), diff --git a/src/app/model.rs b/src/app/model.rs index a83f4c1..b5d51ea 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -5,4 +5,7 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. mod device; +mod devices; + pub use device::Device; +pub use devices::Devices; diff --git a/src/app/model/devices.rs b/src/app/model/devices.rs new file mode 100644 index 0000000..192bc88 --- /dev/null +++ b/src/app/model/devices.rs @@ -0,0 +1,96 @@ +// Copyright Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +use gtk::gio; + +glib::wrapper! { + pub struct Devices(ObjectSubclass) @implements gio::ListModel; +} + +impl Default for Devices { + fn default() -> Self { + glib::Object::builder().build() + } +} + +mod imp { + use glib::types::StaticType; + use gtk::gio; + use gtk::gio::prelude::*; + use gtk::gio::subclass::prelude::*; + + use super::super::Device; + + #[derive(Debug, glib::Properties)] + #[properties(wrapper_type = super::Devices)] + pub struct Devices { + #[property(get)] + pub registered_devices: gio::ListStore, + #[property(get)] + pub discovered_devices: gio::ListStore, + } + + #[glib::object_subclass] + impl ObjectSubclass for Devices { + const NAME: &'static str = "Devices"; + + type Type = super::Devices; + + type Interfaces = (gio::ListModel,); + + fn new() -> Self { + Self { + registered_devices: gio::ListStore::with_type(Device::static_type()), + discovered_devices: gio::ListStore::with_type(Device::static_type()), + } + } + } + + #[glib::derived_properties] + impl ObjectImpl for Devices { + fn constructed(&self) { + self.parent_constructed(); + + self.registered_devices.connect_items_changed(glib::clone!( + #[strong(rename_to=devices)] + self.obj(), + move |_, position, removed, added| { + devices.items_changed(position, removed, added); + } + )); + self.discovered_devices.connect_items_changed(glib::clone!( + #[strong(rename_to=devices)] + self.obj(), + move |_, position, removed, added| { + devices.items_changed( + position + devices.registered_devices().n_items(), + removed, + added, + ); + } + )); + } + } + + impl ListModelImpl for Devices { + fn item_type(&self) -> glib::Type { + Device::static_type() + } + + fn n_items(&self) -> u32 { + self.registered_devices.n_items() + self.discovered_devices.n_items() + } + + fn item(&self, position: u32) -> Option { + if position < self.registered_devices.n_items() { + self.registered_devices.item(position) + } else { + self.discovered_devices + .item(position - self.registered_devices.n_items()) + } + } + } +} diff --git a/src/app/searchprovider.rs b/src/app/searchprovider.rs index ea9fbd5..bd55856 100644 --- a/src/app/searchprovider.rs +++ b/src/app/searchprovider.rs @@ -43,7 +43,7 @@ fn get_ids_for_terms>(devices: &ListStore, terms: &[S]) -> Vec>(app: &TurnOnApplication, terms: &[S]) -> Variant { - let results = get_ids_for_terms(app.model(), terms); + let results = get_ids_for_terms(&app.devices().registered_devices(), terms); (results,).into() } @@ -55,7 +55,7 @@ async fn activate_result( .identifier .parse::() .ok() - .and_then(|n| app.model().item(n)) + .and_then(|n| app.devices().registered_devices().item(n)) .map(|o| o.downcast::().unwrap()); glib::trace!( "Activating device at index {}, device found? {}", @@ -130,7 +130,7 @@ fn get_result_metas(app: &TurnOnApplication, call: GetResultMetas) -> Option() .ok() - .and_then(|n| app.model().item(n)) + .and_then(|n| app.devices().registered_devices().item(n)) .map(|obj| { let device = obj.downcast::().unwrap(); let metas = VariantDict::new(None); diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index 08ec5f7..70a6496 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -7,10 +7,11 @@ use adw::prelude::*; use adw::subclass::prelude::*; use glib::object::IsA; +use gtk::gio; use gtk::gio::ActionEntry; -use gtk::gio::{self, ListStore}; use gtk::glib; +use crate::app::model::Devices; use crate::config::G_LOG_DOMAIN; use super::EditDeviceDialog; @@ -31,25 +32,29 @@ impl TurnOnApplicationWindow { .build() } - pub fn bind_model(&self, devices: &ListStore) { - self.setup_actions(devices.clone()); + pub fn bind_model(&self, devices: &Devices) { + self.setup_actions(devices); self.imp().bind_model(devices); } - fn setup_actions(&self, devices: ListStore) { + fn setup_actions(&self, devices: &Devices) { let add_device = ActionEntry::builder("add-device") - .activate(move |window: &TurnOnApplicationWindow, _, _| { - let dialog = EditDeviceDialog::new(); - dialog.connect_saved(glib::clone!( - #[weak] - devices, - move |_, device| { - glib::debug!("Adding new device: {:?}", device.imp()); - devices.append(device); - } - )); - dialog.present(Some(window)); - }) + .activate(glib::clone!( + #[weak] + devices, + move |window: &TurnOnApplicationWindow, _, _| { + let dialog = EditDeviceDialog::new(); + dialog.connect_saved(glib::clone!( + #[weak] + devices, + move |_, device| { + glib::debug!("Adding new device: {:?}", device.imp()); + devices.registered_devices().append(device); + } + )); + dialog.present(Some(window)); + } + )) .build(); self.add_action_entries([add_device]); @@ -65,11 +70,10 @@ mod imp { use adw::{prelude::*, ToastOverlay}; use futures_util::{stream, StreamExt, TryStreamExt}; use glib::dpgettext2; - use gtk::gio::ListStore; use gtk::glib::subclass::InitializingObject; use gtk::{glib, CompositeTemplate}; - use crate::app::model::Device; + use crate::app::model::{Device, Devices}; use crate::config::G_LOG_DOMAIN; use crate::net; @@ -102,7 +106,7 @@ mod imp { } impl TurnOnApplicationWindow { - pub fn bind_model(&self, model: &ListStore) { + pub fn bind_model(&self, model: &Devices) { self.devices_list.get().bind_model( Some(model), glib::clone!( @@ -195,7 +199,7 @@ mod imp { abort_monitoring } - fn create_device_row(&self, devices: &ListStore, object: &glib::Object) -> gtk::Widget { + fn create_device_row(&self, devices: &Devices, object: &glib::Object) -> gtk::Widget { let device = &object.clone().downcast::().unwrap(); let row = DeviceRow::new(device); let ongoing_monitor = Rc::new(RefCell::new(Self::monitor_device(&row))); @@ -218,7 +222,9 @@ mod imp { devices, move |_, device| { glib::info!("Deleting device {}", device.label()); - devices.remove(devices.find(device).unwrap()); + if let Some(index) = devices.registered_devices().find(device) { + devices.registered_devices().remove(index) + } } )); row.upcast() From 5bf25918ea28356bb59c85cffd3541578d5a707d Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 15 Dec 2024 15:50:56 +0100 Subject: [PATCH 2/8] Add a button to enable network scanning This button does not yet do anything. --- resources/de.swsnr.turnon.metainfo.xml.in | 9 ++++++++- .../actions/waves-and-screen-symbolic.svg | 2 ++ resources/resources.gresource.xml | 1 + resources/ui/turnon-application-window.blp | 7 +++++++ resources/ui/turnon-application-window.ui | 7 +++++++ src/app/widgets/application_window.rs | 15 ++++++++++++++- 6 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 resources/icons/scalable/actions/waves-and-screen-symbolic.svg diff --git a/resources/de.swsnr.turnon.metainfo.xml.in b/resources/de.swsnr.turnon.metainfo.xml.in index c6ea789..51de4cd 100644 --- a/resources/de.swsnr.turnon.metainfo.xml.in +++ b/resources/de.swsnr.turnon.metainfo.xml.in @@ -10,8 +10,9 @@

Features:

  • Add devices to turn on.
  • -
  • Turn on devices with magic Wake On LAN (WoL) packets.
  • +
  • Discover devices in the local network.
  • Monitor device status.
  • +
  • Turn on devices with magic Wake On LAN (WoL) packets.
  • Turn on devices from GNOME Shell search.
  • Turn on devices from the command line.
@@ -30,6 +31,12 @@ https://github.com/swsnr/turnon de.swsnr.turnon.desktop + + +

Add a toolbar button to enable or disable network scanning.

+
+ https://github.com/swsnr/turnon/releases/tag/next +

The dialog to add a new device now uses success and error styles for the validity indicators.

diff --git a/resources/icons/scalable/actions/waves-and-screen-symbolic.svg b/resources/icons/scalable/actions/waves-and-screen-symbolic.svg new file mode 100644 index 0000000..1a472a5 --- /dev/null +++ b/resources/icons/scalable/actions/waves-and-screen-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml index cc983d0..fb3027c 100644 --- a/resources/resources.gresource.xml +++ b/resources/resources.gresource.xml @@ -11,6 +11,7 @@ gtk/help-overlay.ui icons/scalable/actions/sonar-symbolic.svg + icons/scalable/actions/waves-and-screen-symbolic.svg icons/scalable/apps/de.swsnr.turnon.svg icons/scalable/apps/de.swsnr.turnon-symbolic.svg icons/scalable/apps/de.swsnr.turnon.Devel.svg diff --git a/resources/ui/turnon-application-window.blp b/resources/ui/turnon-application-window.blp index 60623e7..7e954c7 100644 --- a/resources/ui/turnon-application-window.blp +++ b/resources/ui/turnon-application-window.blp @@ -22,6 +22,13 @@ template $TurnOnApplicationWindow: Adw.ApplicationWindow { tooltip-text: C_("application-window.action.win.add-device.tooltip", "Add a new device"); } + [start] + Gtk.ToggleButton toggle_scan_network { + action-name: "win.toggle-scan-network"; + icon-name: "waves-and-screen-symbolic"; + tooltip-text: C_("application-window.action.win.scan-network.tooltip", "Scan the network for devices"); + } + [end] MenuButton button_menu { menu-model: main_menu; diff --git a/resources/ui/turnon-application-window.ui b/resources/ui/turnon-application-window.ui index 5aa398e..1269546 100644 --- a/resources/ui/turnon-application-window.ui +++ b/resources/ui/turnon-application-window.ui @@ -22,6 +22,13 @@ corresponding .blp file and regenerate this file with blueprint-compiler. Add a new device + + + win.toggle-scan-network + waves-and-screen-symbolic + Scan the network for devices + + main_menu diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index 70a6496..1ab049f 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -57,7 +57,20 @@ impl TurnOnApplicationWindow { )) .build(); - self.add_action_entries([add_device]); + let scan_network = ActionEntry::builder("toggle-scan-network") + .state(false.into()) + .change_state(|_, act, state| { + act.set_state(state.unwrap()); + let is_scanning = state.unwrap().try_get::().unwrap(); + if is_scanning { + // TODO: + } else { + // TODO + } + }) + .build(); + + self.add_action_entries([add_device, scan_network]); } } From 82e240c2afc1fc23e38002208686a2129da7e3ee Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 15 Dec 2024 18:57:12 +0100 Subject: [PATCH 3/8] Style device rows of discovered devices --- resources/resources.gresource.xml | 1 + resources/style.css | 5 +++ resources/ui/device-row.blp | 8 ++-- resources/ui/device-row.ui | 8 ++-- src/app/widgets/application_window.rs | 58 +++++++++++++++++---------- src/app/widgets/device_row.rs | 10 ++++- 6 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 resources/style.css diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml index fb3027c..7afe63f 100644 --- a/resources/resources.gresource.xml +++ b/resources/resources.gresource.xml @@ -16,6 +16,7 @@ icons/scalable/apps/de.swsnr.turnon-symbolic.svg icons/scalable/apps/de.swsnr.turnon.Devel.svg + style.css de.swsnr.turnon.metainfo.xml diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..b817fc0 --- /dev/null +++ b/resources/style.css @@ -0,0 +1,5 @@ +/* Dim labels of discovered devices */ +row.discovered .title > .title, +row.discovered .suffixes .title { + opacity: var(--dim-opacity); +} diff --git a/resources/ui/device-row.blp b/resources/ui/device-row.blp index 318fdfc..7baad22 100644 --- a/resources/ui/device-row.blp +++ b/resources/ui/device-row.blp @@ -12,7 +12,7 @@ template $DeviceRow: Adw.ActionRow { [prefix] Gtk.Stack { - visible-child-name: bind $device_state_name(template.is_device_online) as ; + visible-child-name: bind $device_state_name(template.is-device-online) as ; Gtk.StackPage { name: "offline"; @@ -48,12 +48,12 @@ template $DeviceRow: Adw.ActionRow { label: bind (template.device as <$Device>).host; styles [ - "dim-label" + "title" ] } Gtk.Stack { - visible-child-name: bind template.suffix_mode; + visible-child-name: bind template.suffix-mode; margin-start: 12; hhomogeneous: false; transition-type: slide_left_right; @@ -69,6 +69,7 @@ template $DeviceRow: Adw.ActionRow { tooltip-text: C_("device-row.action.row.edit.tooltip", "Edit this device"); action-name: "row.edit"; valign: center; + visible: bind template.can-edit; styles [ "flat" @@ -81,6 +82,7 @@ template $DeviceRow: Adw.ActionRow { action-name: "row.ask_delete"; margin-start: 6; valign: center; + visible: bind template.can-delete; styles [ "flat" diff --git a/resources/ui/device-row.ui b/resources/ui/device-row.ui index d4fd889..06f8728 100644 --- a/resources/ui/device-row.ui +++ b/resources/ui/device-row.ui @@ -26,7 +26,7 @@ corresponding .blp file and regenerate this file with blueprint-compiler. - + DeviceRow @@ -73,13 +73,13 @@ corresponding .blp file and regenerate this file with blueprint-compiler. - + 12 false 6 @@ -95,6 +95,7 @@ corresponding .blp file and regenerate this file with blueprint-compiler. Edit this device row.edit 3 + @@ -107,6 +108,7 @@ corresponding .blp file and regenerate this file with blueprint-compiler. row.ask_delete 6 3 + diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index 1ab049f..49cfdfb 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -12,6 +12,7 @@ use gtk::gio::ActionEntry; use gtk::glib; use crate::app::model::Devices; +use crate::app::TurnOnApplication; use crate::config::G_LOG_DOMAIN; use super::EditDeviceDialog; @@ -32,40 +33,47 @@ impl TurnOnApplicationWindow { .build() } + pub fn application(&self) -> TurnOnApplication { + GtkWindowExt::application(self) + .unwrap() + .downcast::() + .unwrap() + } + pub fn bind_model(&self, devices: &Devices) { - self.setup_actions(devices); + self.setup_actions(); self.imp().bind_model(devices); } - fn setup_actions(&self, devices: &Devices) { + fn setup_actions(&self) { let add_device = ActionEntry::builder("add-device") - .activate(glib::clone!( - #[weak] - devices, - move |window: &TurnOnApplicationWindow, _, _| { - let dialog = EditDeviceDialog::new(); - dialog.connect_saved(glib::clone!( - #[weak] - devices, - move |_, device| { - glib::debug!("Adding new device: {:?}", device.imp()); - devices.registered_devices().append(device); - } - )); - dialog.present(Some(window)); - } - )) + .activate(move |window: &TurnOnApplicationWindow, _, _| { + let dialog = EditDeviceDialog::new(); + dialog.connect_saved(glib::clone!( + #[weak(rename_to = devices)] + window.application().devices(), + move |_, device| { + glib::debug!("Adding new device: {:?}", device.imp()); + devices.registered_devices().append(device); + } + )); + dialog.present(Some(window)); + }) .build(); let scan_network = ActionEntry::builder("toggle-scan-network") .state(false.into()) - .change_state(|_, act, state| { + .change_state(|window: &TurnOnApplicationWindow, act, state| { act.set_state(state.unwrap()); let is_scanning = state.unwrap().try_get::().unwrap(); if is_scanning { - // TODO: + // TODO: Add discovered devices to model } else { - // TODO + window + .application() + .devices() + .discovered_devices() + .remove_all(); } }) .build(); @@ -240,6 +248,14 @@ mod imp { } } )); + + if devices.registered_devices().find(device).is_some() { + row.set_can_delete(true); + row.set_can_edit(true); + } else { + row.add_css_class("discovered"); + } + row.upcast() } } diff --git a/src/app/widgets/device_row.rs b/src/app/widgets/device_row.rs index 26d6541..6fad910 100644 --- a/src/app/widgets/device_row.rs +++ b/src/app/widgets/device_row.rs @@ -19,7 +19,9 @@ impl DeviceRow { pub fn new(device: &Device) -> Self { glib::Object::builder() .property("device", device) - .property("is_device_online", false) + .property("is-device-online", false) + .property("can-delete", false) + .property("can-edit", false) .build() } @@ -74,6 +76,10 @@ mod imp { is_device_online: Cell, #[property(get)] suffix_mode: RefCell, + #[property(get, set, default = false)] + can_delete: Cell, + #[property(get, set, default = false)] + can_edit: Cell, } #[template_callbacks] @@ -134,6 +140,8 @@ mod imp { device: Default::default(), is_device_online: Default::default(), suffix_mode: RefCell::new("buttons".into()), + can_edit: Default::default(), + can_delete: Default::default(), } } } From b0adf77e0b475720ac0646dae618df54503c627f Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Tue, 17 Dec 2024 12:24:29 +0100 Subject: [PATCH 4/8] Discover devices from ARP cache Parse ARP cache and add all devices discovered from it to the list of devices. --- Cargo.lock | 1 + Cargo.toml | 1 + resources/de.swsnr.turnon.metainfo.xml.in | 1 + src/app/model.rs | 1 + src/app/model/discovery.rs | 54 +++++ src/app/widgets/application_window.rs | 26 ++- src/net.rs | 5 +- src/net/arpcache.rs | 243 ++++++++++++++++++++++ 8 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 src/app/model/discovery.rs create mode 100644 src/net/arpcache.rs diff --git a/Cargo.lock b/Cargo.lock index ce7a86c..4b9611a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -752,6 +752,7 @@ name = "turnon" version = "1.6.2" dependencies = [ "async-channel", + "bitflags", "futures-util", "glib", "glob", diff --git a/Cargo.toml b/Cargo.toml index ade9f17..1817fda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ macaddr = { version = "1.0.1", default-features = false } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" socket2 = "0.5.7" +bitflags = "2.6.0" [build-dependencies] glob = "0.3.1" diff --git a/resources/de.swsnr.turnon.metainfo.xml.in b/resources/de.swsnr.turnon.metainfo.xml.in index 51de4cd..c32c878 100644 --- a/resources/de.swsnr.turnon.metainfo.xml.in +++ b/resources/de.swsnr.turnon.metainfo.xml.in @@ -34,6 +34,7 @@

Add a toolbar button to enable or disable network scanning.

+

When network scanning is enabled show devices from the system's ARP cache.

https://github.com/swsnr/turnon/releases/tag/next
diff --git a/src/app/model.rs b/src/app/model.rs index b5d51ea..f3b6a8c 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -6,6 +6,7 @@ mod device; mod devices; +pub mod discovery; pub use device::Device; pub use devices::Devices; diff --git a/src/app/model/discovery.rs b/src/app/model/discovery.rs new file mode 100644 index 0000000..5636a4c --- /dev/null +++ b/src/app/model/discovery.rs @@ -0,0 +1,54 @@ +// Copyright Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +use glib::dpgettext2; +use gtk::gio; + +use crate::net::arpcache::{ + self, ArpCacheEntry, ArpCacheEntryFlags, ArpHardwareType, ArpKnownHardwareType, +}; + +use super::Device; +use crate::config::G_LOG_DOMAIN; + +/// Whether `entry` denotes a complete ethernet entry. +/// +/// Return `true` if `entry` has the `ATF_COM` flag which signifies that the +/// entry is complete, and the `Ether` hardware type. +fn is_complete_ethernet_entry(entry: &ArpCacheEntry) -> bool { + entry.hardware_type == ArpHardwareType::Known(ArpKnownHardwareType::Ether) + && entry.flags.contains(ArpCacheEntryFlags::ATF_COM) +} + +/// Read devices from the ARP cache. +/// +/// Return an error if opening the ARP cache file failed; otherwise return a +/// (potentially empty) iterator of all devices found in the ARP cache, skipping +/// over invalid or malformed entries. +/// +/// All discovered devices have their IP address has `host` and a constant +/// human readable and translated `label`. +pub async fn devices_from_arp_cache() -> std::io::Result> { + let arp_cache = gio::spawn_blocking(arpcache::read_linux_arp_cache) + .await + .unwrap()?; + + Ok(arp_cache + .filter_map(|item| { + item.inspect_err(|error| { + glib::warn!("Failed to parse ARP cache entry: {error}"); + }) + .ok() + }) + .filter(is_complete_ethernet_entry) + .map(|entry| { + Device::new( + &dpgettext2(None, "discovered-device.label", "Discovered device"), + entry.hardware_address.into(), + &entry.ip_address.to_string(), + ) + })) +} diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index 49cfdfb..00b7354 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -11,6 +11,7 @@ use gtk::gio; use gtk::gio::ActionEntry; use gtk::glib; +use crate::app::model::discovery::devices_from_arp_cache; use crate::app::model::Devices; use crate::app::TurnOnApplication; use crate::config::G_LOG_DOMAIN; @@ -66,14 +67,27 @@ impl TurnOnApplicationWindow { .change_state(|window: &TurnOnApplicationWindow, act, state| { act.set_state(state.unwrap()); let is_scanning = state.unwrap().try_get::().unwrap(); + let mut discovered_devices = window.application().devices().discovered_devices(); if is_scanning { - // TODO: Add discovered devices to model + glib::spawn_future_local(glib::clone!( + #[strong] + act, + async move { + match devices_from_arp_cache().await { + Ok(devices) => { + // Only add devices if scanning is still enabled + if act.state().unwrap().try_get::().unwrap() { + discovered_devices.extend(devices); + } + } + Err(error) => { + glib::warn!("Failed to read ARP cache: {error}"); + } + } + } + )); } else { - window - .application() - .devices() - .discovered_devices() - .remove_all(); + discovered_devices.remove_all(); } }) .build(); diff --git a/src/net.rs b/src/net.rs index d01c6ce..194a137 100644 --- a/src/net.rs +++ b/src/net.rs @@ -6,8 +6,11 @@ //! Networking for TurnOn. //! -//! Contains a dead simple and somewhat inefficient ping implementation. +//! This module provides various utilities around networking required by TurnOn. +//! Specifically, it has a user-space ping implementation, a WakeOnLan +//! implementation, some helper types, and various tools for network scanning. +pub mod arpcache; mod macaddr; mod monitor; mod ping; diff --git a/src/net/arpcache.rs b/src/net/arpcache.rs new file mode 100644 index 0000000..0e52f43 --- /dev/null +++ b/src/net/arpcache.rs @@ -0,0 +1,243 @@ +// Copyright Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//! Access the Linux ARP cache. + +use std::fmt::Display; +use std::io::prelude::*; +use std::io::BufReader; +use std::io::ErrorKind; +use std::net::{AddrParseError, Ipv4Addr}; +use std::num::ParseIntError; +use std::path::Path; +use std::str::FromStr; + +use bitflags::bitflags; +use macaddr::MacAddr6; + +/// A ARP hardware type. +/// +/// See +/// for known hardware types as of Linux 6.12. +/// +/// We do not represent all hardware types, but only those we're interested in +/// with regards to TurnOn. +#[derive(Debug, PartialEq, Eq)] +#[repr(u16)] +pub enum ArpKnownHardwareType { + // Ethernet (including WiFi) + Ether = 1, +} + +/// A known or unknown hardware type. +#[derive(Debug, PartialEq, Eq)] +pub enum ArpHardwareType { + /// A hardware type we know. + Known(ArpKnownHardwareType), + /// A hardware type we do not understand. + Unknown(u16), +} + +impl FromStr for ArpHardwareType { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + use ArpHardwareType::*; + let hw_type = match u16::from_str_radix(s, 16)? { + 1 => Known(ArpKnownHardwareType::Ether), + other => Unknown(other), + }; + Ok(hw_type) + } +} + +bitflags! { + /// Flags for ARP cache entries. + /// + /// See + /// for known flags as of Linux 6.12. + #[derive(Debug, Eq, PartialEq)] + pub struct ArpCacheEntryFlags: u8 { + /// completed entry (ha valid) + const ATF_COM = 0x02; + /// permanent entry + const ATF_PERM = 0x04; + /// publish entry + const ATF_PUBL = 0x08; + /// has requested trailers + const ATF_USETRAILERS = 0x10; + /// want to use a netmask (only for proxy entries) + const ATF_NETMASK = 0x20; + /// don't answer this addresses + const ATF_DONTPUB = 0x40; + } +} + +impl FromStr for ArpCacheEntryFlags { + type Err = ParseIntError; + + /// Parse flags, discarding unknown flags. + fn from_str(s: &str) -> Result { + Ok(ArpCacheEntryFlags::from_bits_truncate(u8::from_str(s)?)) + } +} + +/// An ARP cache entry. +#[derive(Debug)] +pub struct ArpCacheEntry { + /// The IP address. + pub ip_address: Ipv4Addr, + /// The hardware type. + pub hardware_type: ArpHardwareType, + /// Internal flags for this cache entry. + pub flags: ArpCacheEntryFlags, + /// The hardware address for this entry. + pub hardware_address: MacAddr6, +} + +#[derive(Debug)] +pub enum ArpCacheParseError { + MissingCell(&'static str, u8), + InvalidIpAddress(AddrParseError), + InvalidHardwareType(ParseIntError), + InvalidFlags(ParseIntError), + InvalidHardwareAddess(macaddr::ParseError), +} + +impl Display for ArpCacheParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArpCacheParseError::MissingCell(cell, index) => { + write!(f, "Missing cell {cell} at index {index}") + } + ArpCacheParseError::InvalidIpAddress(addr_parse_error) => { + write!(f, "Failed to parse IP address: {addr_parse_error}") + } + ArpCacheParseError::InvalidHardwareType(parse_int_error) => { + write!(f, "Invalid hardware type: {parse_int_error}") + } + ArpCacheParseError::InvalidFlags(parse_int_error) => { + write!(f, "Invalid flags: {parse_int_error}") + } + ArpCacheParseError::InvalidHardwareAddess(parse_error) => { + write!(f, "Failed to parse hardware address: {parse_error}") + } + } + } +} + +impl std::error::Error for ArpCacheParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ArpCacheParseError::InvalidIpAddress(addr_parse_error) => Some(addr_parse_error), + ArpCacheParseError::InvalidHardwareType(parse_int_error) => Some(parse_int_error), + ArpCacheParseError::InvalidFlags(parse_int_error) => Some(parse_int_error), + _ => None, + } + } +} + +impl From for ArpCacheParseError { + fn from(value: AddrParseError) -> Self { + ArpCacheParseError::InvalidIpAddress(value) + } +} + +impl From for ArpCacheParseError { + fn from(value: macaddr::ParseError) -> Self { + ArpCacheParseError::InvalidHardwareAddess(value) + } +} + +impl FromStr for ArpCacheEntry { + type Err = ArpCacheParseError; + + /// Parse an ARP cache entry from one line of `/proc/net/arp`. + /// + /// See `proc_net(5)` for some details. + fn from_str(s: &str) -> Result { + use ArpCacheParseError::*; + let mut parts = s.trim_ascii().split_ascii_whitespace(); + let ip_address = Ipv4Addr::from_str(parts.next().ok_or(MissingCell("IP address", 0))?)?; + let hardware_type = ArpHardwareType::from_str( + parts + .next() + .ok_or(MissingCell("HW type", 1))? + .trim_start_matches("0x"), + ) + .map_err(InvalidHardwareType)?; + let flags = ArpCacheEntryFlags::from_str( + parts + .next() + .ok_or(MissingCell("Flags", 2))? + .trim_start_matches("0x"), + ) + .map_err(InvalidFlags)?; + let hardware_address = + MacAddr6::from_str(parts.next().ok_or(MissingCell("HW address", 3))?)?; + // The cache table also has mask and device columns, but we don't care for these + Ok(ArpCacheEntry { + ip_address, + hardware_type, + flags, + hardware_address, + }) + } +} + +pub fn read_arp_cache( + reader: R, +) -> impl Iterator> { + reader + .lines() + .skip(1) // skip over the headling line + .map(|l| { + l.and_then(|l| { + ArpCacheEntry::from_str(&l) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e)) + }) + }) +} + +pub fn read_arp_cache_from_path>( + path: P, +) -> std::io::Result>> { + let source = BufReader::new(std::fs::File::open(path)?); + Ok(read_arp_cache(source)) +} + +pub fn read_linux_arp_cache( +) -> std::io::Result>> { + read_arp_cache_from_path("/proc/net/arp") +} + +#[cfg(test)] +mod tests { + use std::{net::Ipv4Addr, str::FromStr}; + + use macaddr::MacAddr6; + + use super::*; + + #[test] + pub fn test_arp_cache_entry_from_str() { + let entry = ArpCacheEntry::from_str( + "192.168.178.130 0x1 0x2 b6:a3:b0:48:80:f1 * wlp4s0 +", + ) + .unwrap(); + assert_eq!(entry.ip_address, Ipv4Addr::new(192, 168, 178, 130)); + assert_eq!( + entry.hardware_type, + ArpHardwareType::Known(ArpKnownHardwareType::Ether) + ); + assert_eq!(entry.flags, ArpCacheEntryFlags::ATF_COM); + assert_eq!( + entry.hardware_address, + MacAddr6::new(0xb6, 0xa3, 0xb0, 0x48, 0x80, 0xf1) + ); + } +} From 22e0ad3f509c866d149b2b24f6695a4000d4d062 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Tue, 17 Dec 2024 22:03:38 +0100 Subject: [PATCH 5/8] Add a button to add a discovered device Fixes #4 --- resources/de.swsnr.turnon.metainfo.xml.in | 4 ++ resources/ui/device-row.blp | 12 +++++ resources/ui/device-row.ui | 12 +++++ src/app/widgets/application_window.rs | 9 ++++ src/app/widgets/device_row.rs | 65 +++++++++++++++++++---- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/resources/de.swsnr.turnon.metainfo.xml.in b/resources/de.swsnr.turnon.metainfo.xml.in index c32c878..2549bea 100644 --- a/resources/de.swsnr.turnon.metainfo.xml.in +++ b/resources/de.swsnr.turnon.metainfo.xml.in @@ -35,7 +35,11 @@

Add a toolbar button to enable or disable network scanning.

When network scanning is enabled show devices from the system's ARP cache.

+

Add a button to directly add devices discovered from network scanning.

+ + GH-4 + https://github.com/swsnr/turnon/releases/tag/next diff --git a/resources/ui/device-row.blp b/resources/ui/device-row.blp index 7baad22..d342c5d 100644 --- a/resources/ui/device-row.blp +++ b/resources/ui/device-row.blp @@ -64,6 +64,18 @@ template $DeviceRow: Adw.ActionRow { child: Gtk.Box { orientation: horizontal; + Gtk.Button { + icon-name: "list-add-symbolic"; + tooltip-text: C_("device-row.action.row.add.tooltip", "Add this device"); + action-name: "row.add"; + valign: center; + visible: bind template.can-add; + + styles [ + "flat" + ] + } + Gtk.Button { icon-name: "document-edit-symbolic"; tooltip-text: C_("device-row.action.row.edit.tooltip", "Edit this device"); diff --git a/resources/ui/device-row.ui b/resources/ui/device-row.ui index 06f8728..fb64cb6 100644 --- a/resources/ui/device-row.ui +++ b/resources/ui/device-row.ui @@ -89,6 +89,18 @@ corresponding .blp file and regenerate this file with blueprint-compiler. 0 + + + list-add-symbolic + Add this device + row.add + 3 + + + + document-edit-symbolic diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index 00b7354..e78d047 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -262,11 +262,20 @@ mod imp { } } )); + row.connect_added(glib::clone!( + #[strong] + devices, + move |_, device| { + glib::info!("Adding device {}", device.label()); + devices.registered_devices().append(device); + } + )); if devices.registered_devices().find(device).is_some() { row.set_can_delete(true); row.set_can_edit(true); } else { + row.set_can_add(true); row.add_css_class("discovered"); } diff --git a/src/app/widgets/device_row.rs b/src/app/widgets/device_row.rs index 6fad910..deb9ff4 100644 --- a/src/app/widgets/device_row.rs +++ b/src/app/widgets/device_row.rs @@ -44,6 +44,26 @@ impl DeviceRow { ), ) } + + pub fn connect_added(&self, callback: F) -> glib::SignalHandlerId + where + F: Fn(&Self, &Device) + 'static, + { + self.connect_local( + "added", + false, + glib::clone!( + #[weak(rename_to=row)] + &self, + #[upgrade_or_default] + move |args| { + let device = &args[1].get().expect("No device passed as signal argument?"); + callback(&row, device); + None + } + ), + ) + } } impl Default for DeviceRow { @@ -80,6 +100,8 @@ mod imp { can_delete: Cell, #[property(get, set, default = false)] can_edit: Cell, + #[property(get, set, default = false)] + can_add: Cell, } #[template_callbacks] @@ -116,19 +138,35 @@ mod imp { klass.bind_template(); klass.bind_template_callbacks(); - klass.install_action("row.ask_delete", None, |obj, _, _| { - obj.imp().set_suffix_mode("confirm-delete"); + klass.install_action("row.ask_delete", None, |row, _, _| { + row.imp().set_suffix_mode("confirm-delete"); }); - klass.install_action("row.cancel-delete", None, |obj, _, _| { - obj.imp().set_suffix_mode("buttons"); + klass.install_action("row.cancel-delete", None, |row, _, _| { + row.imp().set_suffix_mode("buttons"); }); - klass.install_action("row.delete", None, |obj, _, _| { - obj.emit_by_name::<()>("deleted", &[&obj.device()]) + klass.install_action("row.delete", None, |row, _, _| { + row.emit_by_name::<()>("deleted", &[&row.device()]) }); klass.install_action("row.edit", None, |obj, _, _| { let dialog = EditDeviceDialog::edit(obj.device()); dialog.present(Some(obj)); }); + klass.install_action("row.add", None, |row, _, _| { + // Create a fresh device, edit it, and then emit an added signal + // if the user saves the device. + let current_device = row.device(); + let dialog = EditDeviceDialog::edit(Device::new( + ¤t_device.label(), + current_device.mac_address(), + ¤t_device.host(), + )); + dialog.connect_saved(glib::clone!( + #[weak] + row, + move |_, device| row.emit_by_name::<()>("added", &[device]) + )); + dialog.present(Some(row)); + }) } fn instance_init(obj: &InitializingObject) { @@ -142,6 +180,7 @@ mod imp { suffix_mode: RefCell::new("buttons".into()), can_edit: Default::default(), can_delete: Default::default(), + can_add: Default::default(), } } } @@ -150,10 +189,16 @@ mod imp { impl ObjectImpl for DeviceRow { fn signals() -> &'static [Signal] { static SIGNALS: LazyLock> = LazyLock::new(|| { - vec![Signal::builder("deleted") - .action() - .param_types([Device::static_type()]) - .build()] + vec![ + Signal::builder("deleted") + .action() + .param_types([Device::static_type()]) + .build(), + Signal::builder("added") + .action() + .param_types([Device::static_type()]) + .build(), + ] }); SIGNALS.as_ref() } From f90e90532af000fab3bdeb5a72ffe90f18284b47 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 19 Dec 2024 14:13:56 +0100 Subject: [PATCH 6/8] Wrap ARP cache discovery into dedicated object Removes complexity from the main application window, and enables us to change discovery without adapting other code. --- src/app/model.rs | 3 +- src/app/model/device_discovery.rs | 158 ++++++++++++++++++++++++++ src/app/model/devices.rs | 6 +- src/app/model/discovery.rs | 54 --------- src/app/widgets/application_window.rs | 40 ++----- 5 files changed, 172 insertions(+), 89 deletions(-) create mode 100644 src/app/model/device_discovery.rs delete mode 100644 src/app/model/discovery.rs diff --git a/src/app/model.rs b/src/app/model.rs index f3b6a8c..bc785a7 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -5,8 +5,9 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. mod device; +mod device_discovery; mod devices; -pub mod discovery; pub use device::Device; +pub use device_discovery::DeviceDiscovery; pub use devices::Devices; diff --git a/src/app/model/device_discovery.rs b/src/app/model/device_discovery.rs new file mode 100644 index 0000000..91c845b --- /dev/null +++ b/src/app/model/device_discovery.rs @@ -0,0 +1,158 @@ +// Copyright Sebastian Wiesner + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +use glib::{dpgettext2, Object}; +use gtk::gio; + +use crate::net::arpcache::{ + self, ArpCacheEntry, ArpCacheEntryFlags, ArpHardwareType, ArpKnownHardwareType, +}; + +use super::Device; +use crate::config::G_LOG_DOMAIN; + +glib::wrapper! { + /// Device discovery. + pub struct DeviceDiscovery(ObjectSubclass) @implements gio::ListModel; +} + +impl Default for DeviceDiscovery { + fn default() -> Self { + Object::builder().build() + } +} + +mod imp { + use gtk::gio; + use gtk::gio::prelude::*; + use gtk::gio::subclass::prelude::*; + + use std::cell::{Cell, RefCell}; + + use super::{super::Device, devices_from_arp_cache}; + use crate::config::G_LOG_DOMAIN; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::DeviceDiscovery)] + pub struct DeviceDiscovery { + #[property(get, set = Self::set_discovery_enabled)] + discovery_enabled: Cell, + discovered_devices: RefCell>, + } + + impl DeviceDiscovery { + fn set_discovery_enabled(&self, enabled: bool) { + self.discovery_enabled.replace(enabled); + self.obj().notify_discovery_enabled(); + if enabled { + self.scan_devices(); + } else { + let mut discovered_devices = self.discovered_devices.borrow_mut(); + let n_items_removed = discovered_devices.len(); + discovered_devices.clear(); + // Drop mutable borrow of devices before emtting the signal, because signal handlers + // can already try to access the mdoel + drop(discovered_devices); + self.obj() + .items_changed(0, n_items_removed.try_into().unwrap(), 0); + } + } + + fn scan_devices(&self) { + let discovery = self.obj().clone(); + glib::spawn_future_local(async move { + match devices_from_arp_cache().await { + Ok(devices_from_arp_cache) => { + if discovery.discovery_enabled() { + // If discovery is still enabled remember all discovered devices + let mut devices = discovery.imp().discovered_devices.borrow_mut(); + let len_before = devices.len(); + devices.extend(devices_from_arp_cache); + let n_changed = devices.len() - len_before; + drop(devices); + discovery.items_changed( + len_before.try_into().unwrap(), + 0, + n_changed.try_into().unwrap(), + ); + } + } + Err(error) => { + glib::warn!("Failed to read ARP cache: {error}"); + } + } + }); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for DeviceDiscovery { + const NAME: &'static str = "DeviceDiscovery"; + + type Type = super::DeviceDiscovery; + + type Interfaces = (gio::ListModel,); + } + + #[glib::derived_properties] + impl ObjectImpl for DeviceDiscovery {} + + impl ListModelImpl for DeviceDiscovery { + fn item_type(&self) -> glib::Type { + Device::static_type() + } + + fn n_items(&self) -> u32 { + self.discovered_devices.borrow().len().try_into().unwrap() + } + + fn item(&self, position: u32) -> Option { + self.discovered_devices + .borrow() + .get(usize::try_from(position).unwrap()) + .map(|d| d.clone().upcast()) + } + } +} + +/// Whether `entry` denotes a complete ethernet entry. +/// +/// Return `true` if `entry` has the `ATF_COM` flag which signifies that the +/// entry is complete, and the `Ether` hardware type. +fn is_complete_ethernet_entry(entry: &ArpCacheEntry) -> bool { + entry.hardware_type == ArpHardwareType::Known(ArpKnownHardwareType::Ether) + && entry.flags.contains(ArpCacheEntryFlags::ATF_COM) +} + +/// Read devices from the ARP cache. +/// +/// Return an error if opening the ARP cache file failed; otherwise return a +/// (potentially empty) iterator of all devices found in the ARP cache, skipping +/// over invalid or malformed entries. +/// +/// All discovered devices have their IP address has `host` and a constant +/// human readable and translated `label`. +async fn devices_from_arp_cache() -> std::io::Result> { + let arp_cache = gio::spawn_blocking(arpcache::read_linux_arp_cache) + .await + .unwrap()?; + + Ok(arp_cache + .filter_map(|item| { + item.inspect_err(|error| { + glib::warn!("Failed to parse ARP cache entry: {error}"); + }) + .ok() + }) + .filter(is_complete_ethernet_entry) + .map(|entry| { + Device::new( + &dpgettext2(None, "discovered-device.label", "Discovered device"), + entry.hardware_address.into(), + &entry.ip_address.to_string(), + ) + })) +} diff --git a/src/app/model/devices.rs b/src/app/model/devices.rs index 192bc88..3e33378 100644 --- a/src/app/model/devices.rs +++ b/src/app/model/devices.rs @@ -22,6 +22,8 @@ mod imp { use gtk::gio::prelude::*; use gtk::gio::subclass::prelude::*; + use crate::app::model::DeviceDiscovery; + use super::super::Device; #[derive(Debug, glib::Properties)] @@ -30,7 +32,7 @@ mod imp { #[property(get)] pub registered_devices: gio::ListStore, #[property(get)] - pub discovered_devices: gio::ListStore, + pub discovered_devices: DeviceDiscovery, } #[glib::object_subclass] @@ -44,7 +46,7 @@ mod imp { fn new() -> Self { Self { registered_devices: gio::ListStore::with_type(Device::static_type()), - discovered_devices: gio::ListStore::with_type(Device::static_type()), + discovered_devices: Default::default(), } } } diff --git a/src/app/model/discovery.rs b/src/app/model/discovery.rs deleted file mode 100644 index 5636a4c..0000000 --- a/src/app/model/discovery.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright Sebastian Wiesner - -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -use glib::dpgettext2; -use gtk::gio; - -use crate::net::arpcache::{ - self, ArpCacheEntry, ArpCacheEntryFlags, ArpHardwareType, ArpKnownHardwareType, -}; - -use super::Device; -use crate::config::G_LOG_DOMAIN; - -/// Whether `entry` denotes a complete ethernet entry. -/// -/// Return `true` if `entry` has the `ATF_COM` flag which signifies that the -/// entry is complete, and the `Ether` hardware type. -fn is_complete_ethernet_entry(entry: &ArpCacheEntry) -> bool { - entry.hardware_type == ArpHardwareType::Known(ArpKnownHardwareType::Ether) - && entry.flags.contains(ArpCacheEntryFlags::ATF_COM) -} - -/// Read devices from the ARP cache. -/// -/// Return an error if opening the ARP cache file failed; otherwise return a -/// (potentially empty) iterator of all devices found in the ARP cache, skipping -/// over invalid or malformed entries. -/// -/// All discovered devices have their IP address has `host` and a constant -/// human readable and translated `label`. -pub async fn devices_from_arp_cache() -> std::io::Result> { - let arp_cache = gio::spawn_blocking(arpcache::read_linux_arp_cache) - .await - .unwrap()?; - - Ok(arp_cache - .filter_map(|item| { - item.inspect_err(|error| { - glib::warn!("Failed to parse ARP cache entry: {error}"); - }) - .ok() - }) - .filter(is_complete_ethernet_entry) - .map(|entry| { - Device::new( - &dpgettext2(None, "discovered-device.label", "Discovered device"), - entry.hardware_address.into(), - &entry.ip_address.to_string(), - ) - })) -} diff --git a/src/app/widgets/application_window.rs b/src/app/widgets/application_window.rs index e78d047..10ceb13 100644 --- a/src/app/widgets/application_window.rs +++ b/src/app/widgets/application_window.rs @@ -7,11 +7,10 @@ use adw::prelude::*; use adw::subclass::prelude::*; use glib::object::IsA; -use gtk::gio; use gtk::gio::ActionEntry; +use gtk::gio::{self, PropertyAction}; use gtk::glib; -use crate::app::model::discovery::devices_from_arp_cache; use crate::app::model::Devices; use crate::app::TurnOnApplication; use crate::config::G_LOG_DOMAIN; @@ -62,37 +61,14 @@ impl TurnOnApplicationWindow { }) .build(); - let scan_network = ActionEntry::builder("toggle-scan-network") - .state(false.into()) - .change_state(|window: &TurnOnApplicationWindow, act, state| { - act.set_state(state.unwrap()); - let is_scanning = state.unwrap().try_get::().unwrap(); - let mut discovered_devices = window.application().devices().discovered_devices(); - if is_scanning { - glib::spawn_future_local(glib::clone!( - #[strong] - act, - async move { - match devices_from_arp_cache().await { - Ok(devices) => { - // Only add devices if scanning is still enabled - if act.state().unwrap().try_get::().unwrap() { - discovered_devices.extend(devices); - } - } - Err(error) => { - glib::warn!("Failed to read ARP cache: {error}"); - } - } - } - )); - } else { - discovered_devices.remove_all(); - } - }) - .build(); + self.add_action_entries([add_device]); - self.add_action_entries([add_device, scan_network]); + let scan_network = PropertyAction::new( + "toggle-scan-network", + &self.application().devices().discovered_devices(), + "discovery-enabled", + ); + self.add_action(&scan_network); } } From a228933a4f98a0553917005bfde1787cf1e46154 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 19 Dec 2024 15:27:19 +0100 Subject: [PATCH 7/8] Add command line flag to override arp cache file --- src/app.rs | 21 +++++++++++++- src/app/model/device_discovery.rs | 47 ++++++++++++++++++------------- src/net/arpcache.rs | 16 +++++++++-- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/src/app.rs b/src/app.rs index cbc895f..c4537ce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -258,7 +258,19 @@ mod imp { "Use the given file as storage for devices (for development only)", ), None, - ) + ); + app.add_main_option( + "arp-cache-file", + 0.into(), + OptionFlags::NONE, + OptionArg::Filename, + &dpgettext2( + None, + "option.arp-cache-file.description", + "Use the given file as ARP cache source (for development only)", + ), + None, + ); } } @@ -345,6 +357,13 @@ mod imp { ); self.devices_file.replace(Some(path)); } + if let Ok(Some(path)) = options.lookup::("arp-cache-file") { + glib::warn!( + "Reading ARP cache from {}; only use for development purposes!", + path.display() + ); + self.devices.discovered_devices().set_arp_cache_file(path); + } // -1 means continue normal command line processing glib::ExitCode::from(-1) } diff --git a/src/app/model/device_discovery.rs b/src/app/model/device_discovery.rs index 91c845b..bf585f6 100644 --- a/src/app/model/device_discovery.rs +++ b/src/app/model/device_discovery.rs @@ -4,12 +4,12 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +use std::path::Path; + use glib::{dpgettext2, Object}; use gtk::gio; -use crate::net::arpcache::{ - self, ArpCacheEntry, ArpCacheEntryFlags, ArpHardwareType, ArpKnownHardwareType, -}; +use crate::net::arpcache::*; use super::Device; use crate::config::G_LOG_DOMAIN; @@ -30,16 +30,21 @@ mod imp { use gtk::gio::prelude::*; use gtk::gio::subclass::prelude::*; - use std::cell::{Cell, RefCell}; + use std::{ + cell::{Cell, RefCell}, + path::PathBuf, + }; use super::{super::Device, devices_from_arp_cache}; - use crate::config::G_LOG_DOMAIN; + use crate::{config::G_LOG_DOMAIN, net::arpcache::default_arp_cache_path}; - #[derive(Debug, Default, glib::Properties)] + #[derive(Debug, glib::Properties)] #[properties(wrapper_type = super::DeviceDiscovery)] pub struct DeviceDiscovery { #[property(get, set = Self::set_discovery_enabled)] discovery_enabled: Cell, + #[property(get, set)] + arp_cache_file: RefCell, discovered_devices: RefCell>, } @@ -64,7 +69,7 @@ mod imp { fn scan_devices(&self) { let discovery = self.obj().clone(); glib::spawn_future_local(async move { - match devices_from_arp_cache().await { + match devices_from_arp_cache(discovery.arp_cache_file()).await { Ok(devices_from_arp_cache) => { if discovery.discovery_enabled() { // If discovery is still enabled remember all discovered devices @@ -95,6 +100,14 @@ mod imp { type Type = super::DeviceDiscovery; type Interfaces = (gio::ListModel,); + + fn new() -> Self { + Self { + discovery_enabled: Default::default(), + arp_cache_file: RefCell::new(default_arp_cache_path().into()), + discovered_devices: Default::default(), + } + } } #[glib::derived_properties] @@ -118,15 +131,6 @@ mod imp { } } -/// Whether `entry` denotes a complete ethernet entry. -/// -/// Return `true` if `entry` has the `ATF_COM` flag which signifies that the -/// entry is complete, and the `Ether` hardware type. -fn is_complete_ethernet_entry(entry: &ArpCacheEntry) -> bool { - entry.hardware_type == ArpHardwareType::Known(ArpKnownHardwareType::Ether) - && entry.flags.contains(ArpCacheEntryFlags::ATF_COM) -} - /// Read devices from the ARP cache. /// /// Return an error if opening the ARP cache file failed; otherwise return a @@ -135,8 +139,10 @@ fn is_complete_ethernet_entry(entry: &ArpCacheEntry) -> bool { /// /// All discovered devices have their IP address has `host` and a constant /// human readable and translated `label`. -async fn devices_from_arp_cache() -> std::io::Result> { - let arp_cache = gio::spawn_blocking(arpcache::read_linux_arp_cache) +async fn devices_from_arp_cache + Send + 'static>( + arp_cache_file: P, +) -> std::io::Result> { + let arp_cache = gio::spawn_blocking(move || read_arp_cache_from_path(arp_cache_file)) .await .unwrap()?; @@ -147,7 +153,10 @@ async fn devices_from_arp_cache() -> std::io::Result( reader: R, ) -> impl Iterator> { @@ -202,6 +207,11 @@ pub fn read_arp_cache( }) } +/// Read the ARP cache table from the given path. +/// +/// The file is expected to contain a textual ARP cache table, as in `/proc/net/arp`. +/// +/// Return an iterator over all valid cache entries. pub fn read_arp_cache_from_path>( path: P, ) -> std::io::Result>> { @@ -209,9 +219,9 @@ pub fn read_arp_cache_from_path>( Ok(read_arp_cache(source)) } -pub fn read_linux_arp_cache( -) -> std::io::Result>> { - read_arp_cache_from_path("/proc/net/arp") +/// Get the default path to the ARP cache table. +pub fn default_arp_cache_path() -> &'static Path { + Path::new("/proc/net/arp") } #[cfg(test)] From 73f3da429b23cb9339c011390b1b5584a268c6a7 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Thu, 19 Dec 2024 15:44:41 +0100 Subject: [PATCH 8/8] Add dummy ARP cache for screenshots --- screenshots/arp | 3 +++ screenshots/run-for-screenshot.bash | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 screenshots/arp diff --git a/screenshots/arp b/screenshots/arp new file mode 100644 index 0000000..c47a60a --- /dev/null +++ b/screenshots/arp @@ -0,0 +1,3 @@ +IP address HW type Flags HW address Mask Device +192.168.2.100 0x1 0x2 62:6f:a3:f8:3e:ef * eth0 +192.168.2.101 0x1 0x2 a3:6f:7f:3a:9d:ff * eth0 diff --git a/screenshots/run-for-screenshot.bash b/screenshots/run-for-screenshot.bash index 2b8bf3d..6be8983 100755 --- a/screenshots/run-for-screenshot.bash +++ b/screenshots/run-for-screenshot.bash @@ -2,6 +2,8 @@ set -euo pipefail +DEVICES_FILE="${1:-"$(git rev-parse --show-toplevel)/screenshots/devices.json"}" + variables=( # Run app with default settings: Force the in-memory gsettings backend to # block access to standard Gtk settings, and tell Adwaita not to access @@ -14,4 +16,5 @@ variables=( ) exec env "${variables[@]}" cargo run -- \ - --devices-file "$(git rev-parse --show-toplevel)/screenshots/devices.json" + --devices-file "${DEVICES_FILE}" \ + --arp-cache-file "$(git rev-parse --show-toplevel)/screenshots/arp"