From 600720b7d1dd3d57a03ca92ae0a94d7548facf10 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Wed, 8 Oct 2025 08:19:35 +0200 Subject: [PATCH] feat: merge subscriptions crate into cosmic-settings repo --- Cargo.lock | 128 ++- Cargo.toml | 5 +- README.md | 12 +- cosmic-settings/Cargo.toml | 19 +- .../src/pages/accessibility/mod.rs | 8 +- cosmic-settings/src/pages/bluetooth/mod.rs | 2 +- cosmic-settings/src/pages/networking/mod.rs | 2 +- .../src/pages/networking/vpn/mod.rs | 8 +- cosmic-settings/src/pages/networking/wifi.rs | 8 +- cosmic-settings/src/pages/networking/wired.rs | 8 +- cosmic-settings/src/pages/sound.rs | 2 +- subscriptions/a11y-manager/Cargo.toml | 16 + subscriptions/a11y-manager/LICENSE.md | 359 +++++++ subscriptions/a11y-manager/src/lib.rs | 310 ++++++ subscriptions/accessibility/Cargo.toml | 15 + subscriptions/accessibility/LICENSE.md | 358 +++++++ subscriptions/accessibility/src/lib.rs | 133 +++ subscriptions/airplane-mode/Cargo.toml | 14 + subscriptions/airplane-mode/LICENSE.md | 359 +++++++ subscriptions/airplane-mode/src/lib.rs | 146 +++ subscriptions/bluetooth/Cargo.toml | 15 + subscriptions/bluetooth/LICENSE.md | 359 +++++++ subscriptions/bluetooth/src/adapter.rs | 320 +++++++ subscriptions/bluetooth/src/agent.rs | 67 ++ subscriptions/bluetooth/src/device.rs | 374 ++++++++ subscriptions/bluetooth/src/lib.rs | 41 + subscriptions/bluetooth/src/subscription.rs | 227 +++++ subscriptions/network-manager/Cargo.toml | 18 + subscriptions/network-manager/LICENSE.md | 359 +++++++ .../network-manager/src/active_conns.rs | 67 ++ .../network-manager/src/available_wifi.rs | 120 +++ .../network-manager/src/current_networks.rs | 112 +++ subscriptions/network-manager/src/devices.rs | 230 +++++ .../network-manager/src/hw_address.rs | 34 + subscriptions/network-manager/src/lib.rs | 889 ++++++++++++++++++ .../network-manager/src/wireless_enabled.rs | 63 ++ subscriptions/settings-daemon/Cargo.toml | 14 + subscriptions/settings-daemon/LICENSE.md | 359 +++++++ subscriptions/settings-daemon/src/lib.rs | 82 ++ subscriptions/sound/Cargo.toml | 19 + subscriptions/sound/LICENSE.md | 359 +++++++ subscriptions/sound/src/lib.rs | 827 ++++++++++++++++ subscriptions/sound/src/pipewire.rs | 279 ++++++ subscriptions/sound/src/pulse.rs | 752 +++++++++++++++ subscriptions/upower/Cargo.toml | 16 + subscriptions/upower/LICENSE.md | 359 +++++++ subscriptions/upower/src/lib.rs | 189 ++++ 47 files changed, 8399 insertions(+), 63 deletions(-) create mode 100644 subscriptions/a11y-manager/Cargo.toml create mode 100644 subscriptions/a11y-manager/LICENSE.md create mode 100644 subscriptions/a11y-manager/src/lib.rs create mode 100644 subscriptions/accessibility/Cargo.toml create mode 100644 subscriptions/accessibility/LICENSE.md create mode 100644 subscriptions/accessibility/src/lib.rs create mode 100644 subscriptions/airplane-mode/Cargo.toml create mode 100644 subscriptions/airplane-mode/LICENSE.md create mode 100644 subscriptions/airplane-mode/src/lib.rs create mode 100644 subscriptions/bluetooth/Cargo.toml create mode 100644 subscriptions/bluetooth/LICENSE.md create mode 100644 subscriptions/bluetooth/src/adapter.rs create mode 100644 subscriptions/bluetooth/src/agent.rs create mode 100644 subscriptions/bluetooth/src/device.rs create mode 100644 subscriptions/bluetooth/src/lib.rs create mode 100644 subscriptions/bluetooth/src/subscription.rs create mode 100644 subscriptions/network-manager/Cargo.toml create mode 100644 subscriptions/network-manager/LICENSE.md create mode 100644 subscriptions/network-manager/src/active_conns.rs create mode 100644 subscriptions/network-manager/src/available_wifi.rs create mode 100644 subscriptions/network-manager/src/current_networks.rs create mode 100644 subscriptions/network-manager/src/devices.rs create mode 100644 subscriptions/network-manager/src/hw_address.rs create mode 100644 subscriptions/network-manager/src/lib.rs create mode 100644 subscriptions/network-manager/src/wireless_enabled.rs create mode 100644 subscriptions/settings-daemon/Cargo.toml create mode 100644 subscriptions/settings-daemon/LICENSE.md create mode 100644 subscriptions/settings-daemon/src/lib.rs create mode 100644 subscriptions/sound/Cargo.toml create mode 100644 subscriptions/sound/LICENSE.md create mode 100644 subscriptions/sound/src/lib.rs create mode 100644 subscriptions/sound/src/pipewire.rs create mode 100644 subscriptions/sound/src/pulse.rs create mode 100644 subscriptions/upower/Cargo.toml create mode 100644 subscriptions/upower/LICENSE.md create mode 100644 subscriptions/upower/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c8a5293..7e46ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -459,16 +459,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fn-stream" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e71711442f1016c768c259bec59300a10efe753bc3e686ec19e2c6a54a97c29b" -dependencies = [ - "futures-util", - "pin-project-lite", -] - [[package]] name = "async-fn-stream" version = "0.3.1" @@ -1684,7 +1674,7 @@ dependencies = [ "accounts-zbus", "anyhow", "ashpd 0.12.0", - "async-fn-stream 0.3.1", + "async-fn-stream", "bluez-zbus", "chrono", "clap", @@ -1699,11 +1689,17 @@ dependencies = [ "cosmic-protocols", "cosmic-randr", "cosmic-randr-shell", + "cosmic-settings-a11y-manager-subscription", + "cosmic-settings-accessibility-subscription", + "cosmic-settings-airplane-mode-subscription", + "cosmic-settings-bluetooth-subscription", "cosmic-settings-config", "cosmic-settings-daemon-config", + "cosmic-settings-network-manager-subscription", "cosmic-settings-page", - "cosmic-settings-subscriptions", + "cosmic-settings-sound-subscription", "cosmic-settings-system", + "cosmic-settings-upower-subscription", "cosmic-settings-wallpaper", "derive_setters", "dirs 6.0.0", @@ -1753,6 +1749,54 @@ dependencies = [ "zbus_polkit", ] +[[package]] +name = "cosmic-settings-a11y-manager-subscription" +version = "0.1.0" +dependencies = [ + "cosmic-protocols", + "iced_futures", + "num-derive", + "num-traits", + "smithay-client-toolkit 0.20.0", + "tokio", + "tracing", +] + +[[package]] +name = "cosmic-settings-accessibility-subscription" +version = "0.1.0" +dependencies = [ + "cosmic-dbus-a11y", + "futures", + "iced_futures", + "tokio", + "tracing", + "zbus 5.11.0", +] + +[[package]] +name = "cosmic-settings-airplane-mode-subscription" +version = "0.1.0" +dependencies = [ + "futures", + "iced_futures", + "log", + "rustix 1.1.2", + "tokio", +] + +[[package]] +name = "cosmic-settings-bluetooth-subscription" +version = "0.1.0" +dependencies = [ + "bluez-zbus", + "futures", + "iced_futures", + "tokio", + "tracing", + "zbus 5.11.0", +] + [[package]] name = "cosmic-settings-config" version = "0.1.0" @@ -1783,6 +1827,33 @@ dependencies = [ "serde", ] +[[package]] +name = "cosmic-settings-daemon-subscription" +version = "0.1.0" +dependencies = [ + "futures", + "iced_futures", + "log", + "tokio", + "tokio-stream", + "zbus 5.11.0", +] + +[[package]] +name = "cosmic-settings-network-manager-subscription" +version = "0.1.0" +dependencies = [ + "cosmic-dbus-networkmanager", + "futures", + "iced_futures", + "itertools 0.14.0", + "secure-string", + "thiserror 2.0.17", + "tokio", + "tracing", + "zbus 5.11.0", +] + [[package]] name = "cosmic-settings-page" version = "0.1.0" @@ -1797,33 +1868,19 @@ dependencies = [ ] [[package]] -name = "cosmic-settings-subscriptions" -version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-settings-subscriptions#f858ca0b6416a2b75d5f7fa513bc6fc43647d3f8" +name = "cosmic-settings-sound-subscription" +version = "1.0.0-beta1" dependencies = [ - "async-fn-stream 0.2.2", - "bluez-zbus", - "cosmic-dbus-a11y", - "cosmic-dbus-networkmanager", - "cosmic-protocols", + "async-fn-stream", "futures", - "iced_futures", "indexmap 2.11.4", - "itertools 0.14.0", "libcosmic", "libpulse-binding", "log", - "num-derive", - "num-traits", "pipewire", "rustix 1.1.2", - "secure-string", - "smithay-client-toolkit 0.20.0", - "thiserror 2.0.17", "tokio", - "tokio-stream", "tracing", - "zbus 5.11.0", ] [[package]] @@ -1838,6 +1895,19 @@ dependencies = [ "sysinfo", ] +[[package]] +name = "cosmic-settings-upower-subscription" +version = "0.1.0" +dependencies = [ + "futures", + "iced_futures", + "log", + "tokio", + "tokio-stream", + "upower_dbus", + "zbus 5.11.0", +] + [[package]] name = "cosmic-settings-wallpaper" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a245e89..9eaa818 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cosmic-settings", "page", "pages/*"] +members = ["cosmic-settings", "page", "pages/*", "subscriptions/*"] default-members = ["cosmic-settings"] resolver = "3" @@ -33,9 +33,6 @@ git = "https://github.com/pop-os/cosmic-panel" [workspace.dependencies.cosmic-randr-shell] git = "https://github.com/pop-os/cosmic-randr" -[workspace.dependencies.cosmic-settings-subscriptions] -git = "https://github.com/pop-os/cosmic-settings-subscriptions" - [workspace.dependencies.sctk] git = "https://github.com/smithay/client-toolkit/" package = "smithay-client-toolkit" diff --git a/README.md b/README.md index 4be1a56..47eed51 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ The settings application for the [COSMIC desktop environment][cosmic-epoch]. ## Translators -Translation files may be found in the [i18n directory](./i18n). New translations may copy the [English (en) localization](./i18n/en) of the project and rename `en` to the desired [ISO 639-1 language code][iso-codes]. Translations may be submitted through GitHub as an issue or pull request. Submissions by email or other means are also acceptable; with the preferred name and email to associate with the changes. +Translations must go through Weblate at https://hosted.weblate.org/projects/pop-os/cosmic-settings. ## Distributors +We will accept pull requests for distro-specific features and pages. +Make them compile conditionally with a [cargo feature][cargo-feature]. + The accent palettes on the Appearance settings page are configurable through the cosmic-config directory at `/usr/share/cosmic/com.system76.CosmicSettings/v1/`. One at `accent_palette_dark`, and another at `accent_palette_light`. Examples can be found at [resources/accent_palette_dark.ron](./resources/accent_palette_dark.ron) and [resources/accent_palette_light.ron](./resources/accent_palette_light.ron). This can be copied locally to `~/.config/cosmic/com.system76.CosmicSettings/v1/` for testing, and then move to `/usr/share/cosmic` for packaging. ## Build @@ -41,10 +44,10 @@ It is recommended to build a source tarball with the vendored dependencies, whic Developers should install [rustup][rustup] and configure their editor to use [rust-analyzer][rust-analyzer]. Run `just check` to ensure that the changes you make are free of linter warnings. You may configure your editor to run `just check-json` as the rust-analyzer check command. -To improve compilation times, disable LTO in the release profile, install the [mold][mold] linker, and configure [sccache][sccache] for use with Rust. The [mold][mold] linker will only improve link times if LTO is disabled. - Run the cosmic-settings binary with `just run` so that logs will be emitted to stderr, and crashes will generate detailed backtraces. Applications shouldn't crash, so when writing code, avoid use of `unwrap()` and `expect()`. Instead, log errors with `tracing::error!()` or `tracing::warn!()`. +To improve compilation times, use Rust >= 1.90.0 and configure [sccache][sccache] for use with Rust. + ## License Licensed under the [GNU Public License 3.0](https://choosealicense.com/licenses/gpl-3.0). @@ -57,10 +60,9 @@ Any contribution intentionally submitted for inclusion in the work by you shall // SPDX-License-Identifier: GPL-3.0-only ``` +[cargo-feature]: https://doc.rust-lang.org/cargo/reference/features.html [cosmic-epoch]: https://github.com/pop-os/cosmic-epoch -[iso-codes]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes [just]: https://github.com/casey/just [rustup]: https://rustup.rs/ [rust-analyzer]: https://rust-analyzer.github.io/ -[mold]: https://github.com/rui314/mold [sccache]: https://github.com/mozilla/sccache diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 119b5aa..d30f7ff 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -3,6 +3,7 @@ name = "cosmic-settings" version = "0.1.0" edition = "2024" license = "GPL-3.0-only" +publish = false [dependencies] accounts-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } @@ -24,7 +25,13 @@ cosmic-randr-shell.workspace = true cosmic-randr = { workspace = true, optional = true } cosmic-settings-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } cosmic-settings-page = { path = "../page" } -cosmic-settings-subscriptions = { workspace = true, optional = true } +cosmic-settings-accessibility-subscription = { path = "../subscriptions/accessibility", optional = true } +cosmic-settings-a11y-manager-subscription = { path = "../subscriptions/a11y-manager", optional = true } +cosmic-settings-airplane-mode-subscription = { path = "../subscriptions/airplane-mode", optional = true } +cosmic-settings-bluetooth-subscription = { path = "../subscriptions/bluetooth", optional = true } +cosmic-settings-network-manager-subscription = { path = "../subscriptions/network-manager", optional = true } +cosmic-settings-upower-subscription = { path = "../subscriptions/upower", optional = true } +cosmic-settings-sound-subscription = { path = "../subscriptions/sound", optional = true } cosmic-settings-system = { path = "../pages/system", optional = true } cosmic-settings-wallpaper = { path = "../pages/wallpapers" } cosmic-settings-daemon-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true } @@ -131,12 +138,12 @@ page-accessibility = [ "dep:cosmic-comp-config", "dep:cosmic-settings-config", "dep:cosmic-settings-daemon-config", - "cosmic-settings-subscriptions/accessibility", - "cosmic-settings-subscriptions/cosmic_a11y_manager", + "dep:cosmic-settings-accessibility-subscription", + "dep:cosmic-settings-a11y-manager-subscription", ] page-about = ["dep:cosmic-settings-system", "dep:hostname1-zbus", "dep:zbus"] page-bluetooth = [ - "cosmic-settings-subscriptions/bluetooth", + "dep:cosmic-settings-bluetooth-subscription", "dep:zbus", "dep:bluez-zbus", ] @@ -153,14 +160,14 @@ page-input = [ ] page-legacy-applications = ["dep:cosmic-comp-config"] page-networking = [ - "cosmic-settings-subscriptions/network_manager", + "dep:cosmic-settings-network-manager-subscription", "xdg-portal", "dep:cosmic-dbus-networkmanager", "dep:zbus", ] page-power = ["dep:upower_dbus", "dep:zbus"] page-region = ["gettext", "dep:locales-rs", "dep:locale1", "dep:zbus"] -page-sound = ["cosmic-settings-subscriptions/sound"] +page-sound = ["dep:cosmic-settings-sound-subscription"] page-users = ["xdg-portal", "dep:accounts-zbus", "dep:zbus", "dep:zbus_polkit"] page-window-management = ["dep:cosmic-settings-config"] page-workspaces = ["dep:cosmic-comp-config"] diff --git a/cosmic-settings/src/pages/accessibility/mod.rs b/cosmic-settings/src/pages/accessibility/mod.rs index 213544c..4213a7a 100644 --- a/cosmic-settings/src/pages/accessibility/mod.rs +++ b/cosmic-settings/src/pages/accessibility/mod.rs @@ -8,15 +8,15 @@ use cosmic::{ }; pub use cosmic_comp_config::ZoomMovement; use cosmic_config::CosmicConfigEntry; +use cosmic_settings_a11y_manager_subscription as cosmic_a11y_manager; +use cosmic_settings_accessibility_subscription::{ + DBusRequest, DBusUpdate, subscription as a11y_subscription, +}; use cosmic_settings_daemon_config::CosmicSettingsDaemonConfig; use cosmic_settings_page::{ self as page, Insert, section::{self, Section}, }; -use cosmic_settings_subscriptions::accessibility::{ - DBusRequest, DBusUpdate, subscription as a11y_subscription, -}; -use cosmic_settings_subscriptions::cosmic_a11y_manager; use num_traits::FromPrimitive; use slotmap::SlotMap; diff --git a/cosmic-settings/src/pages/bluetooth/mod.rs b/cosmic-settings/src/pages/bluetooth/mod.rs index 71a50af..6d85d5e 100644 --- a/cosmic-settings/src/pages/bluetooth/mod.rs +++ b/cosmic-settings/src/pages/bluetooth/mod.rs @@ -5,8 +5,8 @@ use cosmic::iced::{Alignment, Length, color}; use cosmic::iced_core::text::Wrapping; use cosmic::widget::{self, settings, text}; use cosmic::{Apply, Element, Task, theme}; +use cosmic_settings_bluetooth_subscription::*; use cosmic_settings_page::{self as page, Section, section}; -use cosmic_settings_subscriptions::bluetooth::*; use futures::StreamExt; use futures::channel::oneshot; use slab::Slab; diff --git a/cosmic-settings/src/pages/networking/mod.rs b/cosmic-settings/src/pages/networking/mod.rs index a1172c5..5f261fa 100644 --- a/cosmic-settings/src/pages/networking/mod.rs +++ b/cosmic-settings/src/pages/networking/mod.rs @@ -13,8 +13,8 @@ use cosmic_dbus_networkmanager::{ interface::enums::{DeviceState, DeviceType}, nm::NetworkManager, }; +use cosmic_settings_network_manager_subscription as network_manager; use cosmic_settings_page::{self as page, Section, section}; -use cosmic_settings_subscriptions::network_manager; use futures::StreamExt; use slotmap::SlotMap; diff --git a/cosmic-settings/src/pages/networking/vpn/mod.rs b/cosmic-settings/src/pages/networking/vpn/mod.rs index 3e1a014..98e6d54 100644 --- a/cosmic-settings/src/pages/networking/vpn/mod.rs +++ b/cosmic-settings/src/pages/networking/vpn/mod.rs @@ -13,10 +13,10 @@ use cosmic::{ iced_core::text::Wrapping, widget::{self, icon}, }; -use cosmic_settings_page::{self as page, Section, section}; -use cosmic_settings_subscriptions::network_manager::{ - self, NetworkManagerState, UUID, current_networks::ActiveConnectionInfo, +use cosmic_settings_network_manager_subscription::{ + self as network_manager, NetworkManagerState, UUID, current_networks::ActiveConnectionInfo, }; +use cosmic_settings_page::{self as page, Section, section}; use futures::{FutureExt, StreamExt}; use indexmap::IndexMap; use secure_string::SecureString; @@ -219,7 +219,7 @@ impl page::Page for Page { Some(vec![sections.insert(devices_view())]) } - fn dialog(&self) -> Option> { + fn dialog(&'_ self) -> Option> { self.dialog.as_ref().map(|dialog| match dialog { VpnDialog::Error(error_kind, message) => { let reason = widget::text::body(message.as_str()).wrapping(Wrapping::Word); diff --git a/cosmic-settings/src/pages/networking/wifi.rs b/cosmic-settings/src/pages/networking/wifi.rs index 892916a..d4060ec 100644 --- a/cosmic-settings/src/pages/networking/wifi.rs +++ b/cosmic-settings/src/pages/networking/wifi.rs @@ -14,13 +14,13 @@ use cosmic::{ iced_widget::focus_next, widget::{self, column, icon}, }; -use cosmic_settings_page::{self as page, Section, section}; -use cosmic_settings_subscriptions::network_manager::{ - self, NetworkManagerState, +use cosmic_settings_network_manager_subscription::{ + self as network_manager, NetworkManagerState, available_wifi::{AccessPoint, NetworkType}, current_networks::ActiveConnectionInfo, hw_address::HwAddress, }; +use cosmic_settings_page::{self as page, Section, section}; use futures::StreamExt; use secure_string::SecureString; @@ -141,7 +141,7 @@ impl page::Page for Page { Some(vec![sections.insert(devices_view())]) } - fn dialog(&self) -> Option> { + fn dialog(&'_ self) -> Option> { self.dialog.as_ref().map(|dialog| match dialog { WiFiDialog::Password { password, diff --git a/cosmic-settings/src/pages/networking/wired.rs b/cosmic-settings/src/pages/networking/wired.rs index 6bb42ce..0a95aa7 100644 --- a/cosmic-settings/src/pages/networking/wired.rs +++ b/cosmic-settings/src/pages/networking/wired.rs @@ -11,10 +11,10 @@ use cosmic::{ widget::{self, icon}, }; use cosmic_dbus_networkmanager::interface::enums::DeviceState; -use cosmic_settings_page::{self as page, Section, section}; -use cosmic_settings_subscriptions::network_manager::{ - self, NetworkManagerState, current_networks::ActiveConnectionInfo, +use cosmic_settings_network_manager_subscription::{ + self as network_manager, NetworkManagerState, current_networks::ActiveConnectionInfo, }; +use cosmic_settings_page::{self as page, Section, section}; use futures::StreamExt; pub type ConnectionId = Arc; @@ -118,7 +118,7 @@ impl page::Page for Page { Some(vec![sections.insert(devices_view())]) } - fn dialog(&self) -> Option> { + fn dialog(&'_ self) -> Option> { self.dialog.as_ref().map(|dialog| match dialog { WiredDialog::RemoveProfile(uuid) => { let primary_action = widget::button::destructive(fl!("remove")) diff --git a/cosmic-settings/src/pages/sound.rs b/cosmic-settings/src/pages/sound.rs index 14cfd8b..3d66222 100644 --- a/cosmic-settings/src/pages/sound.rs +++ b/cosmic-settings/src/pages/sound.rs @@ -12,7 +12,7 @@ use cosmic_settings_page::{self as page, Section, section}; use slab::Slab; use slotmap::SlotMap; -use cosmic_settings_subscriptions::sound as subscription; +use cosmic_settings_sound_subscription as subscription; const AUDIO_CONFIG: &str = "com.system76.CosmicAudio"; const AMPLIFICATION_SINK: &str = "amplification_sink"; diff --git a/subscriptions/a11y-manager/Cargo.toml b/subscriptions/a11y-manager/Cargo.toml new file mode 100644 index 0000000..6002b16 --- /dev/null +++ b/subscriptions/a11y-manager/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cosmic-settings-a11y-manager-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols" } +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +num-derive = "0.4.2" +num-traits = "0.2.19" +sctk = { git = "https://github.com/smithay/client-toolkit/", package = "smithay-client-toolkit" } +tokio = "1.47.1" +tracing = "0.1.41" diff --git a/subscriptions/a11y-manager/LICENSE.md b/subscriptions/a11y-manager/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/a11y-manager/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/a11y-manager/src/lib.rs b/subscriptions/a11y-manager/src/lib.rs new file mode 100644 index 0000000..ffdb971 --- /dev/null +++ b/subscriptions/a11y-manager/src/lib.rs @@ -0,0 +1,310 @@ +use cosmic_protocols::a11y::v1::client::cosmic_a11y_manager_v1::{self, ActiveState}; +use num_derive::{FromPrimitive, ToPrimitive}; +use sctk::{ + reexports::{ + calloop::{self, LoopSignal, channel}, + calloop_wayland_source::WaylandSource, + client::{ + ConnectError, Connection, Dispatch, Proxy, WEnum, + globals::{GlobalListContents, registry_queue_init}, + protocol::wl_registry, + }, + }, + registry::RegistryState, +}; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, Copy)] +pub enum AccessibilityEvent { + Bound(u32), + Magnifier(bool), + ScreenFilter { + inverted: bool, + filter: Option, + }, + Closed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)] +pub enum ColorFilter { + Greyscale, + Deuteranopia, + Protanopia, + Tritanopia, + Unknown, +} + +impl Default for ColorFilter { + fn default() -> Self { + ColorFilter::Unknown + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AccessibilityRequest { + Magnifier(bool), + ScreenFilter { + inverted: bool, + filter: Option, + }, +} + +pub type Sender = calloop::channel::Sender; + +pub fn spawn_wayland_connection( + a11y_manager_min: u32, +) -> Result< + ( + channel::Sender, + mpsc::Receiver, + ), + ConnectError, +> { + let (event_tx, event_rx) = mpsc::channel(10); + let (request_tx, request_rx) = channel::channel(); + let conn = Connection::connect_to_env()?; + + std::thread::spawn(move || { + if let Err(err) = wayland_thread(conn, event_tx.clone(), request_rx, a11y_manager_min) { + tracing::warn!("Accessibility protocol wayland thread crashed: {}", err); + let _ = event_tx.blocking_send(AccessibilityEvent::Closed); + } + }); + + Ok((request_tx, event_rx)) +} + +fn wayland_thread( + conn: Connection, + tx: mpsc::Sender, + rx: channel::Channel, + a11y_manager_min: u32, +) -> Result<(), Box> { + struct State { + loop_signal: LoopSignal, + tx: mpsc::Sender, + global: cosmic_a11y_manager_v1::CosmicA11yManagerV1, + + magnifier: bool, + screen_inverted: bool, + screen_filter: Option, + } + + impl Dispatch for State { + fn event( + state: &mut Self, + _proxy: &cosmic_a11y_manager_v1::CosmicA11yManagerV1, + event: ::Event, + _data: &(), + _conn: &Connection, + _qhandle: &sctk::reexports::client::QueueHandle, + ) { + match event { + cosmic_a11y_manager_v1::Event::Magnifier { active } => { + let magnifier = active + .into_result() + .unwrap_or(cosmic_a11y_manager_v1::ActiveState::Disabled) + == cosmic_a11y_manager_v1::ActiveState::Enabled; + if magnifier != state.magnifier { + if state + .tx + .blocking_send(AccessibilityEvent::Magnifier(magnifier)) + .is_err() + { + state.loop_signal.stop(); + state.loop_signal.wakeup(); + }; + state.magnifier = magnifier; + } + } + cosmic_a11y_manager_v1::Event::ScreenFilter { inverted, filter } => { + let inverted = inverted + .into_result() + .unwrap_or(cosmic_a11y_manager_v1::ActiveState::Disabled) + == cosmic_a11y_manager_v1::ActiveState::Enabled; + let filter = match filter { + WEnum::Value(cosmic_a11y_manager_v1::Filter::Disabled) => None, + WEnum::Value(cosmic_a11y_manager_v1::Filter::Greyscale) => { + Some(ColorFilter::Greyscale) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeProtanopia) => { + Some(ColorFilter::Protanopia) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeDeuteranopia) => { + Some(ColorFilter::Deuteranopia) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeTritanopia) => { + Some(ColorFilter::Tritanopia) + } + WEnum::Value(_) | WEnum::Unknown(_) => Some(ColorFilter::Unknown), + }; + + if inverted != state.screen_inverted || filter != state.screen_filter { + if state + .tx + .blocking_send(AccessibilityEvent::ScreenFilter { inverted, filter }) + .is_err() + { + state.loop_signal.stop(); + state.loop_signal.wakeup(); + }; + state.screen_inverted = inverted; + state.screen_filter = filter; + } + } + cosmic_a11y_manager_v1::Event::ScreenFilter2 { + inverted, + filter, + filter_state, + } => { + let inverted = inverted + .into_result() + .unwrap_or(cosmic_a11y_manager_v1::ActiveState::Disabled) + == cosmic_a11y_manager_v1::ActiveState::Enabled; + let filter = if matches!(filter_state, WEnum::Value(ActiveState::Enabled)) { + match filter { + WEnum::Value(cosmic_a11y_manager_v1::Filter::Disabled) => { + unreachable!() + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::Greyscale) => { + Some(ColorFilter::Greyscale) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeProtanopia) => { + Some(ColorFilter::Protanopia) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeDeuteranopia) => { + Some(ColorFilter::Deuteranopia) + } + WEnum::Value(cosmic_a11y_manager_v1::Filter::DaltonizeTritanopia) => { + Some(ColorFilter::Tritanopia) + } + WEnum::Value(_) | WEnum::Unknown(_) => Some(ColorFilter::Unknown), + } + } else { + None + }; + + if inverted != state.screen_inverted || filter != state.screen_filter { + if state + .tx + .blocking_send(AccessibilityEvent::ScreenFilter { inverted, filter }) + .is_err() + { + state.loop_signal.stop(); + state.loop_signal.wakeup(); + }; + state.screen_inverted = inverted; + state.screen_filter = filter; + } + } + _ => unreachable!(), + } + } + } + impl Dispatch for State { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: ::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qhandle: &sctk::reexports::client::QueueHandle, + ) { + // We don't care about any dynamic globals + } + } + + let mut event_loop = calloop::EventLoop::::try_new().unwrap(); + + let loop_handle = event_loop.handle(); + let (globals, event_queue) = registry_queue_init(&conn).unwrap(); + let qhandle = event_queue.handle(); + + WaylandSource::new(conn, event_queue) + .insert(loop_handle.clone()) + .map_err(|err| err.error)?; + + let registry_state = RegistryState::new(&globals); + let Ok(global) = registry_state.bind_one::( + &qhandle, + a11y_manager_min..=3, + (), + ) else { + return Ok(()); + }; + + let _ = tx.blocking_send(AccessibilityEvent::Bound(global.version())); + + loop_handle + .insert_source(rx, |request, _, state| match request { + channel::Event::Msg(AccessibilityRequest::Magnifier(val)) => { + state.global.set_magnifier(if val { + cosmic_a11y_manager_v1::ActiveState::Enabled + } else { + cosmic_a11y_manager_v1::ActiveState::Disabled + }); + } + channel::Event::Msg(AccessibilityRequest::ScreenFilter { inverted, filter }) => { + let mut filter_state = ActiveState::Enabled; + let filter = match filter { + None => { + if state.global.version() < 3 { + cosmic_a11y_manager_v1::Filter::Disabled + } else { + filter_state = ActiveState::Disabled; + cosmic_a11y_manager_v1::Filter::Unknown + } + } + Some(ColorFilter::Greyscale) => cosmic_a11y_manager_v1::Filter::Greyscale, + Some(ColorFilter::Protanopia) => { + cosmic_a11y_manager_v1::Filter::DaltonizeProtanopia + } + Some(ColorFilter::Deuteranopia) => { + cosmic_a11y_manager_v1::Filter::DaltonizeDeuteranopia + } + Some(ColorFilter::Tritanopia) => { + cosmic_a11y_manager_v1::Filter::DaltonizeTritanopia + } + Some(ColorFilter::Unknown) => cosmic_a11y_manager_v1::Filter::Unknown, + }; + if state.global.version() < 3 { + state.global.set_screen_filter( + if inverted { + cosmic_a11y_manager_v1::ActiveState::Enabled + } else { + cosmic_a11y_manager_v1::ActiveState::Disabled + }, + filter, + ); + } else { + state.global.set_screen_filter2( + if inverted { + cosmic_a11y_manager_v1::ActiveState::Enabled + } else { + cosmic_a11y_manager_v1::ActiveState::Disabled + }, + filter, + filter_state, + ); + } + } + channel::Event::Closed => { + state.loop_signal.stop(); + state.loop_signal.wakeup(); + } + }) + .map_err(|err| err.error)?; + + let mut state = State { + loop_signal: event_loop.get_signal(), + tx, + global, + + magnifier: false, + screen_inverted: false, + screen_filter: None, + }; + + event_loop.run(None, &mut state, |_| {})?; + Ok(()) +} diff --git a/subscriptions/accessibility/Cargo.toml b/subscriptions/accessibility/Cargo.toml new file mode 100644 index 0000000..d20dcd9 --- /dev/null +++ b/subscriptions/accessibility/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cosmic-settings-accessibility-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +cosmic-dbus-a11y = { git = "https://github.com/pop-os/dbus-settings-bindings" } +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +tokio = "1.47.1" +tracing = "0.1.41" +zbus = "5.11.0" diff --git a/subscriptions/accessibility/LICENSE.md b/subscriptions/accessibility/LICENSE.md new file mode 100644 index 0000000..61cfa30 --- /dev/null +++ b/subscriptions/accessibility/LICENSE.md @@ -0,0 +1,358 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/subscriptions/accessibility/src/lib.rs b/subscriptions/accessibility/src/lib.rs new file mode 100644 index 0000000..b7908bc --- /dev/null +++ b/subscriptions/accessibility/src/lib.rs @@ -0,0 +1,133 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic_dbus_a11y::*; +use futures::FutureExt; +use futures::{self, SinkExt, StreamExt, select}; +use iced_futures::{Subscription, stream}; +use std::fmt::Debug; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use zbus::Connection; + +#[derive(Debug, Clone)] +pub enum DBusUpdate { + Error(String), + Status(bool), + Init(bool, UnboundedSender), +} + +pub enum DBusRequest { + Status(bool), +} + +#[derive(Debug)] +pub enum State { + Ready, + Waiting(Connection, u8, bool, UnboundedReceiver), + Finished, +} + +pub fn subscription() -> Subscription { + struct MyId; + + Subscription::run_with_id( + std::any::TypeId::of::(), + stream::channel(50, move |mut output| async move { + let mut state = State::Ready; + + loop { + state = start_listening(state, &mut output).await; + } + }), + ) +} + +async fn start_listening( + state: State, + output: &mut futures::channel::mpsc::Sender, +) -> State { + match state { + State::Ready => { + let conn = match Connection::session().await.map_err(|e| e.to_string()) { + Ok(conn) => conn, + Err(e) => { + _ = output.send(DBusUpdate::Error(e)).await; + return State::Finished; + } + }; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let mut enabled = false; + if let Ok(proxy) = StatusProxy::new(&conn).await { + if let Ok(status) = proxy.screen_reader_enabled().await { + enabled = status; + } + } + _ = output.send(DBusUpdate::Init(enabled, tx)).await; + State::Waiting(conn, 20, enabled, rx) + } + State::Waiting(conn, mut retry, mut enabled, mut rx) => { + let Ok(proxy) = StatusProxy::new(&conn).await else { + if retry == 0 { + tracing::error!("Accessibility Status is unavailable."); + return State::Finished; + } else { + _ = tokio::time::sleep(tokio::time::Duration::from_secs( + 2_u64.pow(retry as u32), + )) + .await; + retry -= 1; + return State::Waiting(conn, retry, enabled, rx); + } + }; + retry = 20; + + let mut watch_changes = proxy.receive_screen_reader_enabled_changed().await; + + if let Ok(status) = proxy.screen_reader_enabled().await { + if enabled != status { + _ = output.send(DBusUpdate::Status(enabled)); + } + enabled = status; + } + + loop { + if let Ok(status) = proxy.screen_reader_enabled().await { + if enabled != status { + _ = output.send(DBusUpdate::Status(enabled)); + } + enabled = status; + } + + let mut next_change = Box::pin(watch_changes.next()).fuse(); + let mut next_request = Box::pin(rx.recv()).fuse(); + + select! { + v = next_request => { + match v { + Some(DBusRequest::Status(is_enabled)) => { + // Set status + enabled = is_enabled; + _ = proxy.set_is_enabled(is_enabled).await; + _ = proxy.set_screen_reader_enabled(is_enabled).await; + } + None => return State::Finished, + } + } + v = next_change => { + match v { + Some(f) => { + if let Ok(enabled) = f.get().await { + _ = output.send(DBusUpdate::Status(enabled)); + } + } + None => break, + }; + } + } + } + + State::Waiting(conn, retry, enabled, rx) + } + State::Finished => futures::future::pending().await, + } +} diff --git a/subscriptions/airplane-mode/Cargo.toml b/subscriptions/airplane-mode/Cargo.toml new file mode 100644 index 0000000..acfd285 --- /dev/null +++ b/subscriptions/airplane-mode/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cosmic-settings-airplane-mode-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +log = "0.4.28" +rustix = "1.1.2" +tokio = "1.47.1" diff --git a/subscriptions/airplane-mode/LICENSE.md b/subscriptions/airplane-mode/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/airplane-mode/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/airplane-mode/src/lib.rs b/subscriptions/airplane-mode/src/lib.rs new file mode 100644 index 0000000..8a18ee1 --- /dev/null +++ b/subscriptions/airplane-mode/src/lib.rs @@ -0,0 +1,146 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use futures::{FutureExt, StreamExt}; +use iced_futures::Subscription; +use std::collections::HashMap; + +pub fn subscription() -> iced_futures::Subscription { + Subscription::run_with_id( + "airplane-mode", + async { + match rfkill::rfkill_updates() { + Ok(updates) => updates.filter_map(|state| async { + match state { + Ok(state) => Some(is_airplane_mode(&state)), + Err(err) => { + log::error!("Failed to read rfkill: {}", err); + None + } + } + }), + Err(err) => { + log::error!("Failed to monitor rfkill: {}", err); + futures::future::pending().await + } + } + } + .flatten_stream(), + ) +} + +// Test that: +// - There is at least one device +// - All devices have either a hard or soft block active +fn is_airplane_mode(rfkill_state: &HashMap) -> bool { + !rfkill_state.is_empty() + && rfkill_state + .values() + .all(|device_state| device_state.hard || device_state.soft) +} + +mod rfkill { + use futures::stream::Stream; + use std::os::unix::fs::OpenOptionsExt; + use std::{collections::HashMap, fs, io, mem, slice}; + use tokio::io::unix::AsyncFd; + + // /usr/include/linux/rfkill.h + // https://www.kernel.org/doc/html/latest/driver-api/rfkill.html#id5 + // + // The preferred way to get rfkill events is by reading /dev/rfkill. We can + // simply poll the file descriptor (using tokio's async reactor) and reading + // one event per `read` system call. + + const RFKILL_OP_ADD: u8 = 0; + const RFKILL_OP_DEL: u8 = 1; + const RFKILL_OP_CHANGE: u8 = 2; + + #[repr(C)] + #[derive(Debug, Copy, Clone, Default)] + #[allow(non_camel_case_types)] + pub struct rfkill_event { + pub idx: u32, + pub type_: u8, + pub op: u8, + pub soft: u8, + pub hard: u8, + } + + #[derive(Debug, Copy, Clone)] + #[allow(dead_code)] + pub struct DeviceState { + pub type_: u8, + pub soft: bool, + pub hard: bool, + } + + pub fn rfkill_updates() + -> io::Result>> + Unpin> { + struct State { + file: AsyncFd, + devices: HashMap, + } + + let file = fs::File::options() + .read(true) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as _) + .open("/dev/rfkill")?; + + let state = State { + file: AsyncFd::new(file)?, + devices: HashMap::new(), + }; + + Ok(futures::stream::unfold(state, |mut state| { + Box::pin(async { + let mut guard = match state.file.readable().await { + Ok(guard) => guard, + Err(err) => { + return Some((Err(err), state)); + } + }; + let mut event = rfkill_event::default(); + // Read as many events as we can until it returns `EWOULDBLOCK`, + // then yield new state after these updates. + loop { + match read_event(guard.get_inner(), &mut event) { + Ok(()) => (), + Err(rustix::io::Errno::WOULDBLOCK) => { + break; + } + Err(err) => { + return Some((Err(err.into()), state)); + } + }; + match event.op { + RFKILL_OP_ADD | RFKILL_OP_CHANGE => { + state.devices.insert( + event.idx, + DeviceState { + type_: event.type_, + soft: event.soft != 0, + hard: event.hard != 0, + }, + ); + } + RFKILL_OP_DEL => { + state.devices.remove(&event.idx); + } + _ => {} + } + } + guard.clear_ready(); + Some((Ok(state.devices.clone()), state)) + }) + })) + } + + fn read_event(dev: &fs::File, event: &mut rfkill_event) -> rustix::io::Result<()> { + let bytes = unsafe { + slice::from_raw_parts_mut(event as *mut _ as *mut u8, mem::size_of::()) + }; + rustix::io::read(dev, bytes)?; + Ok(()) + } +} diff --git a/subscriptions/bluetooth/Cargo.toml b/subscriptions/bluetooth/Cargo.toml new file mode 100644 index 0000000..3b42aaa --- /dev/null +++ b/subscriptions/bluetooth/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cosmic-settings-bluetooth-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +bluez-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings" } +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +tokio = "1.47.1" +tracing = "0.1.41" +zbus = "5.11.0" diff --git a/subscriptions/bluetooth/LICENSE.md b/subscriptions/bluetooth/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/bluetooth/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/bluetooth/src/adapter.rs b/subscriptions/bluetooth/src/adapter.rs new file mode 100644 index 0000000..768d3f8 --- /dev/null +++ b/subscriptions/bluetooth/src/adapter.rs @@ -0,0 +1,320 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{Active, Event}; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + time::Duration, +}; +use zbus::zvariant::OwnedObjectPath; + +#[derive(Default, Debug, Clone)] +pub struct Adapter { + pub alias: String, + pub address: String, + pub scanning: Active, + pub enabled: Active, +} + +impl Hash for Adapter { + fn hash(&self, state: &mut H) { + self.address.hash(state); + } +} + +impl PartialEq for Adapter { + fn eq(&self, other: &Self) -> bool { + self.address == other.address + } +} + +impl Eq for Adapter {} + +impl Adapter { + pub async fn from_device( + proxy: &bluez_zbus::adapter1::Adapter1Proxy<'_>, + ) -> zbus::Result { + let (address, alias, scanning, enabled) = futures::try_join!( + proxy.address(), + proxy.alias(), + async { + Ok( + if proxy.discoverable().await? && proxy.discovering().await? { + Active::Enabled + } else { + Active::Disabled + }, + ) + }, + async { + Ok(if proxy.powered().await? { + Active::Enabled + } else { + Active::Disabled + }) + } + )?; + + Ok(Self { + alias, + address, + scanning, + enabled, + }) + } + + pub fn update(&mut self, updates: Vec) { + for update in updates { + match update { + AdapterUpdate::Alias(alias) => self.alias = alias, + AdapterUpdate::Address(address) => self.address = address, + AdapterUpdate::Enabled(enabled) => { + self.enabled = match (self.enabled, enabled) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + AdapterUpdate::Scanning(scanning) => { + self.scanning = match (self.scanning, scanning) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + } + } + } +} + +#[derive(Debug, Clone)] +pub enum AdapterUpdate { + Alias(String), + Address(String), + Scanning(Active), + Enabled(Active), +} + +impl AdapterUpdate { + #[must_use] + pub fn from_update(update: HashMap<&'_ str, zbus::zvariant::Value<'_>>) -> Vec { + update + .into_iter() + .filter_map(|(key, value)| { + match (key, value) { + ("Alias", zbus::zvariant::Value::Str(value)) => Some(Self::Alias(value.into())), + ("Discovering" | "Discoverable", zbus::zvariant::Value::Bool(value)) => { + Some(Self::Scanning(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Powered", zbus::zvariant::Value::Bool(value)) => { + Some(Self::Enabled(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Address", zbus::zvariant::Value::Str(value)) => { + Some(Self::Address(value.into())) + } + // Battery + (message, value) => { + tracing::error!(message, ?value, "adapter update"); + None + } + } + }) + .collect() + } +} + +pub async fn start_discovery(connection: zbus::Connection, adapter_path: OwnedObjectPath) -> Event { + let result: zbus::Result<()> = Ok(()); + + let adapter = match bluez_zbus::get_adapter(&connection, adapter_path).await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Event::DBusError(why); + } + Ok(adapter) => adapter, + }; + + for attempt in 1..5 { + let result = async { + tracing::debug!("Starting discovery"); + // We don't seem to be able to use join here as it seem to lead to some kind of race condition and not start scanning occasionally + adapter.set_pairable(true).await?; + adapter.set_discoverable(true).await?; + if adapter.discovering().await? { + return Ok(()); + } + adapter.start_discovery().await + } + .await; + + if let Err(why) = result { + tracing::warn!("Unable to start bluetooth scanning: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + tracing::debug!("Discovery started"); + return Event::Ok; + } + } + + if let Err(why) = result { + Event::DBusError(why) + } else { + Event::Ok + } +} + +pub async fn stop_discovery(connection: zbus::Connection, adapter_path: OwnedObjectPath) -> Event { + let result: zbus::Result<()> = Ok(()); + + let adapter = match bluez_zbus::get_adapter(&connection, adapter_path).await { + Err(why) => return Event::DBusError(why), + Ok(adapter) => adapter, + }; + + for attempt in 1..5 { + let result = async { + tracing::debug!("Stopping discovery"); + + // We don't seem to be able to use join here as it seem to lead to some kind of race condition and not stop scanning occasionally + adapter.set_pairable(false).await?; + adapter.set_discoverable(false).await?; + if adapter.discovering().await? { + adapter.stop_discovery().await + } else { + Ok(()) + } + } + .await; + + if let Err(why) = result { + tracing::warn!("Unable to stop bluetooth scanning: {why}"); + if why.to_string().contains("No discovery started") { + return Event::DBusError(why); + } + + tracing::warn!("Unable to stop bluetooth scanning: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + tracing::debug!("Discovery stopped"); + return Event::Ok; + } + } + + if let Err(why) = result { + return Event::DBusError(why); + } + Event::Ok +} + +pub async fn change_adapter_status( + connection: zbus::Connection, + adapter_path: OwnedObjectPath, + active: bool, +) -> Event { + let mut result: zbus::Result<()> = Ok(()); + for attempt in 1..5 { + result = async { + let adapter = bluez_zbus::get_adapter(&connection, adapter_path.clone()).await?; + if active { + adapter.set_powered(true).await?; + adapter.set_discoverable(true).await + } else { + if let Err(why) = adapter.set_discoverable(false).await { + tracing::warn!("Unable to change discoverability: {why}"); + } + adapter.set_powered(false).await + } + } + .await; + if let Err(why) = &result { + tracing::warn!("Unable to change the adapter state: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Event::Ok; + } + } + + if let Err(why) = result { + tracing::error!("Failed to change the adapter state!"); + return Event::DBusError(why); + } + + Event::Ok +} + +pub async fn get_adapters(connection: zbus::Connection) -> Event { + let result: zbus::Result> = async { + futures::future::join_all( + bluez_zbus::get_adapters(&connection) + .await? + .into_iter() + .map(|(path, proxy)| async move { + Ok((path.to_owned(), Adapter::from_device(&proxy).await?)) + }), + ) + .await + .into_iter() + .collect::>>() + } + .await; + match result { + Ok(adapters) => Event::SetAdapters(adapters), + Err(why) => { + tracing::error!("dbus connection failed. {why}"); + Event::DBusError(why) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adapter_device_with_intermediary_state() { + let mut adapter = Adapter { + alias: "foo".to_owned(), + address: "AA:BB:CC:DD:EE:FF".to_owned(), + scanning: Active::Disabled, + enabled: Active::Disabled, + }; + adapter.update(vec![ + AdapterUpdate::Enabled(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.enabled, Active::Enabled); + assert_eq!(&adapter.alias, "xxx"); + + adapter.enabled = Active::Disabling; + adapter.update(vec![ + AdapterUpdate::Enabled(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.enabled, Active::Disabling); + + adapter.scanning = Active::Enabling; + adapter.update(vec![ + AdapterUpdate::Scanning(Active::Disabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.scanning, Active::Enabling); + + adapter.update(vec![ + AdapterUpdate::Scanning(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.scanning, Active::Enabled); + assert_eq!(&adapter.alias, "xxx"); + } +} diff --git a/subscriptions/bluetooth/src/agent.rs b/subscriptions/bluetooth/src/agent.rs new file mode 100644 index 0000000..cb7e7db --- /dev/null +++ b/subscriptions/bluetooth/src/agent.rs @@ -0,0 +1,67 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use crate::Event; + +use std::sync::Arc; + +use futures::{SinkExt, StreamExt}; +use zbus::zvariant::ObjectPath; + +const AGENT_PATH: &str = "/org/bluez/agent/cosmic_settings"; + +pub async fn unregister(connection: zbus::Connection) -> zbus::Result<()> { + let agent_path = ObjectPath::from_static_str_unchecked(AGENT_PATH); + let bluez = bluez_zbus::agent_manager1::AgentManager1Proxy::new(&connection).await?; + bluez.unregister_agent(&agent_path).await +} + +pub async fn watch( + connection: zbus::Connection, + mut tx: futures::channel::mpsc::Sender, +) -> zbus::Result<()> { + let span = tracing::span!(tracing::Level::INFO, "bluetooth::agent::watch"); + let _span = span.enter(); + + let (agent, mut receiver) = bluez_zbus::agent1::create(); + + let agent_path = ObjectPath::from_static_str_unchecked(AGENT_PATH); + + tracing::debug!("connecting agent"); + + connection.object_server().at(&agent_path, agent).await?; + + tracing::debug!("connecting to bluez agent manager"); + + let bluez = bluez_zbus::agent_manager1::AgentManager1Proxy::new(&connection).await?; + + tracing::debug!("registering agent"); + + bluez + .register_agent( + &agent_path, + <&'static str>::from(bluez_zbus::agent1::Capability::DisplayYesNo), + ) + .await?; + + if let Err(why) = bluez.request_default_agent(&agent_path).await { + _ = bluez.unregister_agent(&agent_path).await; + Err(why)?; + } + + tracing::debug!("registered"); + + while let Some(msg) = receiver.next().await { + tracing::debug!(?msg, "agent message received"); + + if tx.send(Event::Agent(Arc::new(msg))).await.is_err() { + break; + } + } + + _ = bluez.unregister_agent(&agent_path).await; + + tracing::debug!("exiting"); + + Ok(()) +} diff --git a/subscriptions/bluetooth/src/device.rs b/subscriptions/bluetooth/src/device.rs new file mode 100644 index 0000000..bea2e03 --- /dev/null +++ b/subscriptions/bluetooth/src/device.rs @@ -0,0 +1,374 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{Active, Event}; +use futures::join; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + time::Duration, +}; +use zbus::zvariant::OwnedObjectPath; + +const DEFAILT_DEVICE_ICON: &str = "bluetooth-symbolic"; + +// Copied from https://github.com/bluez/bluez/blob/39467578207889fd015775cbe81a3db9dd26abea/src/dbus-common.c#L53 +fn device_type_to_icon(device_type: &str) -> &'static str { + match device_type { + "computer" => "laptop-symbolic", + "phone" => "smartphone-symbolic", + "network-wireless" => "network-wireless-symbolic", + "audio-headset" => "audio-headset-symbolic", + "audio-headphones" => "audio-headphones-symbolic", + "camera-video" => "camera-video-symbolic", + "audio-card" => "audio-card-symbolic", + "input-gaming" => "input-gaming-symbolic", + "input-keyboard" => "input-keyboard-symbolic", + "input-tablet" => "input-tablet-symbolic", + "input-mouse" => "input-mouse-symbolic", + "printer" => "printer-network-symbolic", + "camera-photo" => "camera-photo-symbolic", + _ => DEFAILT_DEVICE_ICON, + } +} + +#[derive(Default, Debug, Clone)] +pub struct Device { + alias: Option, + pub address: String, + pub adapter: OwnedObjectPath, + pub enabled: Active, + pub paired: bool, + pub icon: &'static str, + pub battery: Option, +} + +impl Device { + pub async fn from_device(proxy: &bluez_zbus::BluetoothDevice<'_>) -> zbus::Result { + let (address, adapter, alias) = join!( + proxy.device.address(), + proxy.device.adapter(), + proxy.device.name() + ); + let address = address?; + if address.is_empty() { + return Err(zbus::Error::Failure("Device has no MAC address".to_owned())); + } + let adapter = adapter?; + if adapter.is_empty() { + return Err(zbus::Error::Failure("Device has no adapter".to_owned())); + } + let alias = alias.ok(); + let device_type: String = proxy.icon().await; + let paired = proxy.device.paired().await.unwrap_or(false); + let enabled = if proxy.device.connected().await.unwrap_or(false) && paired { + Active::Enabled + } else { + Active::Disabled + }; + let battery = match &proxy.battery { + Some(battery) => match battery.percentage().await { + Ok(percentage) => Some(percentage.to_string()), + Err(why) => { + eprintln!("couldn't fetch battery percentage: {why}"); + None + } + }, + None => None, + }; + + let icon = device_type_to_icon(device_type.as_str()); + + Ok(Self { + alias, + address, + adapter, + enabled, + paired, + icon, + battery, + }) + } + #[must_use] + pub fn is_connected(&self) -> bool { + self.enabled == Active::Enabled + } + /// Update the state of the device without overriding intermediary states. + /// + /// # Panics + /// + /// Panics if the device used for update doesn't have the same MAC address + pub fn update(&mut self, updates: Vec) { + for udpate in updates { + match udpate { + DeviceUpdate::Alias(alias) => self.alias = alias, + DeviceUpdate::Enabled(enabled) => { + self.enabled = match (self.enabled, enabled) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + DeviceUpdate::Paired(paired) => { + self.enabled = Active::Disabling; + self.paired = paired; + } + DeviceUpdate::Icon(icon) => self.icon = icon, + DeviceUpdate::Battery(battery) => self.battery = battery, + } + } + if self.enabled == Active::Disabled { + self.battery = None; + } + } + #[must_use] + pub fn has_alias(&self) -> bool { + self.alias.is_some() + } + #[must_use] + pub fn is_known_device_type(&self) -> bool { + self.icon != DEFAILT_DEVICE_ICON + } + #[must_use] + pub fn alias_or_addr(&self) -> &str { + self.alias.as_ref().unwrap_or(&self.address) + } +} + +impl Hash for Device { + fn hash(&self, state: &mut H) { + self.address.hash(state); + } +} + +impl PartialEq for Device { + fn eq(&self, other: &Self) -> bool { + self.address == other.address + } +} + +impl Eq for Device {} + +#[derive(Debug, Clone)] +pub enum DeviceUpdate { + Alias(Option), + Enabled(Active), + Paired(bool), + Icon(&'static str), + Battery(Option), +} + +impl DeviceUpdate { + pub fn from_update(update: HashMap<&'_ str, zbus::zvariant::Value<'_>>) -> Vec { + update + .into_iter() + .filter_map(|(key, value)| { + match (key, value) { + ("Alias", zbus::zvariant::Value::Str(value)) => { + Some(DeviceUpdate::Alias(Some(value.into()))) + } + ("Connected", zbus::zvariant::Value::Bool(value)) => { + Some(DeviceUpdate::Enabled(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Paired", zbus::zvariant::Value::Bool(value)) => { + Some(DeviceUpdate::Paired(value)) + } + ("Icon", zbus::zvariant::Value::Str(value)) => { + Some(DeviceUpdate::Icon(device_type_to_icon(&value))) + } + ("Percentage", zbus::zvariant::Value::U8(percentage)) => { + Some(DeviceUpdate::Battery(Some(percentage.to_string()))) + } + // Battery + (message, value) => { + tracing::debug!(message, ?value, "device update"); + None + } + } + }) + .collect() + } +} + +pub async fn disconnect_device( + connection: zbus::Connection, + device_path: OwnedObjectPath, +) -> Event { + let proxy = match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Event::DeviceFailed(device_path); + } + Ok(proxy) => proxy, + }; + + for attempt in 1..5 { + let result = async { + if !proxy.device.connected().await? { + return Ok(()); + } + + proxy.device.disconnect().await + } + .await; + + if let Err(why) = result { + tracing::warn!("Unable to disconnect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Event::Ok; + } + } + + Event::DeviceFailed(device_path) +} + +pub async fn connect_device(connection: zbus::Connection, device_path: OwnedObjectPath) -> Event { + let proxy = match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Event::DeviceFailed(device_path); + } + Ok(proxy) => proxy, + }; + + for attempt in 1..5 { + let result = async { + if proxy.device.connected().await? { + Ok(()) + } else { + proxy.device.connect().await + } + } + .await; + + if let Err(why) = result { + tracing::warn!("Unable to connect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Event::Ok; + } + } + + Event::DeviceFailed(device_path) +} + +pub async fn forget_device(connection: zbus::Connection, device_path: OwnedObjectPath) -> Event { + let mut result: zbus::Result<()> = Ok(()); + + let proxy = match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Event::DeviceFailed(device_path); + } + Ok(proxy) => proxy, + }; + + let adapter_path = match proxy.device.adapter().await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Event::DeviceFailed(device_path); + } + Ok(adapter_path) => adapter_path, + }; + + let adapter = match bluez_zbus::get_adapter(&connection, adapter_path).await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Event::DeviceFailed(device_path); + } + Ok(adapter) => adapter, + }; + + for attempt in 1..5 { + result = async { + if proxy.device.connected().await? { + proxy.device.disconnect().await?; + } + + adapter.remove_device(&proxy.path()).await + } + .await; + + if let Err(why) = &result { + tracing::warn!("Unable to connect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Event::Ok; + } + } + + if result.is_err() { + Event::DeviceFailed(device_path) + } else { + Event::Ok + } +} + +pub async fn get_devices(connection: zbus::Connection, adapter_path: OwnedObjectPath) -> Event { + // TODO error handling + let result: zbus::Result> = async { + futures::future::join_all( + bluez_zbus::get_devices(&connection, Some(&adapter_path)) + .await? + .into_iter() + .map( + |(path, device)| async move { Ok((path, Device::from_device(&device).await?)) }, + ), + ) + .await + .into_iter() + .collect::, _>>() + } + .await; + match result { + Ok(devices) => Event::SetDevices(devices), + Err(why) => { + tracing::error!("zbus connection failed. {why}"); + Event::DBusError(why) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_device_with_intermediary_state() { + let mut device = Device { + alias: None, + adapter: OwnedObjectPath::try_from("/dev/bluez/hci0").unwrap(), + address: "AA:BB:CC:DD:EE:FF".to_owned(), + enabled: Active::Disabled, + paired: false, + icon: "bluetooth-symbolic", + battery: None, + }; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Enabled); + assert_eq!(device.alias, Some("Foo".to_owned())); + + device.enabled = Active::Disabling; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Disabling); + + device.enabled = Active::Enabling; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Enabled); + } +} diff --git a/subscriptions/bluetooth/src/lib.rs b/subscriptions/bluetooth/src/lib.rs new file mode 100644 index 0000000..f4263ac --- /dev/null +++ b/subscriptions/bluetooth/src/lib.rs @@ -0,0 +1,41 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; +use std::sync::Arc; +use zbus::zvariant::OwnedObjectPath; + +mod adapter; +pub mod agent; +mod device; +pub mod subscription; + +pub use adapter::*; +pub use device::*; + +#[derive(Clone, Debug)] +pub enum Event { + AddedAdapter(OwnedObjectPath, Adapter), + AddedDevice(OwnedObjectPath, Device), + Agent(Arc), + DBusError(zbus::Error), + DBusServiceUnknown, + DeviceFailed(OwnedObjectPath), + Ok, + NameHasNoOwner, + RemovedAdapter(OwnedObjectPath), + RemovedDevice(OwnedObjectPath), + SetAdapters(HashMap), + SetDevices(HashMap), + UpdatedAdapter(OwnedObjectPath, Vec), + UpdatedDevice(OwnedObjectPath, Vec), +} + +#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)] +pub enum Active { + #[default] + Disabled, + Disabling, + Enabling, + Enabled, +} diff --git a/subscriptions/bluetooth/src/subscription.rs b/subscriptions/bluetooth/src/subscription.rs new file mode 100644 index 0000000..c88b98f --- /dev/null +++ b/subscriptions/bluetooth/src/subscription.rs @@ -0,0 +1,227 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{AdapterUpdate, Device, DeviceUpdate, Event}; +use std::pin::Pin; + +use bluez_zbus::BluetoothDevice; +use futures::{channel::mpsc, stream::FusedStream}; +use iced_futures::futures::{SinkExt, StreamExt}; +use zbus::{fdo, zvariant::OwnedObjectPath}; + +enum DevicePropertyWatcherTask { + Add(OwnedObjectPath), + Removed(OwnedObjectPath), +} + +struct DevicePropertyWatcher { + stream: futures::stream::SelectAll, + rx: mpsc::Receiver, +} + +struct SignalWatcher { + stream: zbus::fdo::PropertiesChangedStream, + path: OwnedObjectPath, +} + +impl futures::Stream for SignalWatcher { + type Item = zbus::fdo::PropertiesChanged; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + futures::Stream::poll_next(Pin::new(&mut self.stream), cx) + } + fn size_hint(&self) -> (usize, Option) { + self.stream.size_hint() + } +} + +impl DevicePropertyWatcher { + fn new() -> (Self, mpsc::Sender) { + let stream = futures::stream::select_all(vec![]); + let (tx, rx) = mpsc::channel(10); + + (Self { stream, rx }, tx) + } + async fn insert( + &mut self, + connection: &zbus::Connection, + path: OwnedObjectPath, + ) -> zbus::Result<()> { + if let Some(signal) = self.stream.iter_mut().find(|s| s.path.eq(&path)) { + if signal.stream.is_terminated() { + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + signal.stream = property_proxy.receive_properties_changed().await?; + } + return Ok(()); + } + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + let stream = property_proxy.receive_properties_changed().await?; + self.stream.push(SignalWatcher { stream, path }); + Ok(()) + } + fn remove(mut self, path: &OwnedObjectPath) -> Self { + self.stream = + futures::stream::select_all(self.stream.into_iter().filter(|p| !p.path.eq(path))); + self + } +} + +/// Watching new/removed devices, connected state changed +pub async fn watch(connection: zbus::Connection, mut tx: futures::channel::mpsc::Sender) { + let span = tracing::span!(tracing::Level::INFO, "bluetooth::subscription::watch"); + let _span = span.enter(); + + loop { + let result = async { + let managed_object_proxy = + zbus::fdo::ObjectManagerProxy::new(&connection, "org.bluez", "/") + .await?; + + let mut receive_interfaces_added = managed_object_proxy + .receive_interfaces_added() + .await?; + let mut receive_interfaces_removed = managed_object_proxy + .receive_interfaces_removed() + .await?; + + let (mut property_watcher, mut property_watcher_task) = DevicePropertyWatcher::new(); + + for (path, interfaces) in managed_object_proxy.get_managed_objects().await? { + if interfaces.contains_key("org.bluez.Device1") + || interfaces.contains_key("org.bluez.Adapter1") + || interfaces.contains_key("org.bluez.Battery1") + { + property_watcher.insert(&connection, path).await?; + } + } + + while !property_watcher.rx.is_terminated() { + futures::select! { + task = property_watcher.rx.next() => match task { + Some(DevicePropertyWatcherTask::Add(path)) => { + property_watcher.insert(&connection, path).await?; + } + Some(DevicePropertyWatcherTask::Removed(path)) => { + property_watcher = property_watcher.remove(&path); + } + None => { + tracing::error!("Bluetooth property watcher has shutdown unexpectedly"); + } + }, + signal = property_watcher.stream.next() => match signal { + Some(signal) => { + let args = signal.args()?; + let header = signal.message().header(); + match header.path() { + Some(path) if path.contains("/dev_") => + tx + .send(Event::UpdatedDevice(path.to_owned().into(), DeviceUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + Some(path) => tx + .send(Event::UpdatedAdapter(path.to_owned().into(), AdapterUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + None => continue + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_added.next() => match signal { + Some(signal) => { + let args = signal.args()?; + match BluetoothDevice::new(&connection, args.object_path.clone()).await { + Ok(device) => { + match Device::from_device(&device).await { + Ok(device) => { + property_watcher_task + .send(DevicePropertyWatcherTask::Add(args.object_path.to_owned().into())).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + + tx + .send(Event::AddedDevice(args.object_path.to_owned().into(), device)) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } + Err(why) => { + tracing::warn!("Cannot deserialise device: {why}"); + } + } + } + Err(zbus::Error::InterfaceNotFound) => continue, + Err(e) => return Err(e), + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_removed.next() => match signal { + Some(signal) => { + let args = signal.args()?; + if args.interfaces.iter().any(|i| i == "org.bluez.Device1") { + property_watcher_task.send(DevicePropertyWatcherTask::Removed( + args.object_path.to_owned().into(), + )).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + tx + .send(Event::RemovedDevice(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } else if args.interfaces.iter().any(|i| i == "org.bluez.Battery1") { + tx + .send(Event::UpdatedDevice(args.object_path.to_owned().into(), vec![DeviceUpdate::Battery(None)])) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } else if args.interfaces.iter().any(|i| i == "org.bluez.Adapter1") { + tx + .send(Event::RemovedAdapter(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } + }, + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + } + } + tracing::warn!("bluetooth event loop gracefully terminated"); + Ok(()) + }.await; + + if let Err(why) = result { + _ = tx.send(Event::DBusError(why.clone())).await; + + tracing::error!("failed to watch bluetooth event: {why:?}."); + + // Exit if the dbus service is not found. + if let zbus::Error::FDO(fdo_error) = why { + match *fdo_error { + fdo::Error::ServiceUnknown(_) => { + tracing::error!( + "The org.bluez dbus service is unknown. Is the bluez service installed and activatable?" + ); + _ = tx.send(Event::DBusServiceUnknown).await; + return; + } + + fdo::Error::NameHasNoOwner(_) => { + tracing::error!("The org.bluez dbus service is not enabled or active"); + _ = tx.send(Event::NameHasNoOwner).await; + return; + } + + _ => (), + } + } + } + } +} diff --git a/subscriptions/network-manager/Cargo.toml b/subscriptions/network-manager/Cargo.toml new file mode 100644 index 0000000..59cda8f --- /dev/null +++ b/subscriptions/network-manager/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cosmic-settings-network-manager-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +cosmic-dbus-networkmanager = { git = "https://github.com/pop-os/dbus-settings-bindings" } +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +itertools = "0.14.0" +secure-string = "0.3.0" +thiserror = "2.0.17" +tokio = "1.47.1" +tracing = "0.1.41" +zbus = "5.11.0" diff --git a/subscriptions/network-manager/LICENSE.md b/subscriptions/network-manager/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/network-manager/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/network-manager/src/active_conns.rs b/subscriptions/network-manager/src/active_conns.rs new file mode 100644 index 0000000..291dca5 --- /dev/null +++ b/subscriptions/network-manager/src/active_conns.rs @@ -0,0 +1,67 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::Event; +use cosmic_dbus_networkmanager::nm::NetworkManager; +use futures::{SinkExt, StreamExt}; +use iced_futures::{Subscription, stream}; +use std::{fmt::Debug, hash::Hash}; +use zbus::Connection; + +#[derive(Debug, Clone)] +pub enum State { + Continue(Connection), + Error, +} + +pub fn active_conns_subscription( + id: I, + conn: Connection, +) -> iced_futures::Subscription { + Subscription::run_with_id( + id, + stream::channel(50, move |output| async move { + watch(conn, output).await; + futures::future::pending().await + }), + ) +} + +pub async fn watch(conn: zbus::Connection, mut output: futures::channel::mpsc::Sender) { + let mut state = State::Continue(conn); + + loop { + state = start_listening(state, &mut output).await; + } +} + +async fn start_listening( + state: State, + output: &mut futures::channel::mpsc::Sender, +) -> State { + let conn = match state { + State::Continue(conn) => conn, + State::Error => futures::future::pending().await, + }; + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(why) => { + tracing::error!(why = why.to_string(), "Failed to connect to NetworkManager"); + return State::Error; + } + }; + + let mut active_conns_changed = network_manager.receive_active_connections_changed().await; + active_conns_changed.next().await; + + while let (Some(_change), _) = futures::future::join( + active_conns_changed.next(), + tokio::time::sleep(tokio::time::Duration::from_secs(1)), + ) + .await + { + _ = output.send(Event::ActiveConns).await; + } + + State::Continue(conn) +} diff --git a/subscriptions/network-manager/src/available_wifi.rs b/subscriptions/network-manager/src/available_wifi.rs new file mode 100644 index 0000000..5b3058a --- /dev/null +++ b/subscriptions/network-manager/src/available_wifi.rs @@ -0,0 +1,120 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use cosmic_dbus_networkmanager::{ + device::wireless::WirelessDevice, + interface::enums::{ApFlags, ApSecurityFlags, DeviceState}, +}; + +use futures::StreamExt; +use itertools::Itertools; +use std::{collections::HashMap, sync::Arc}; +use zbus::zvariant::ObjectPath; + +use super::hw_address::HwAddress; + +pub async fn handle_wireless_device( + device: WirelessDevice<'_>, + hw_address: Option, +) -> zbus::Result> { + device.request_scan(HashMap::new()).await?; + + let mut scan_changed = device.receive_last_scan_changed().await; + + if let Some(t) = scan_changed.next().await { + match t.get().await { + Ok(-1) => { + tracing::error!("wireless device scan errored"); + return Ok(Default::default()); + } + + _ => (), + } + } + + let access_points = device.get_access_points().await?; + + let state: DeviceState = device + .upcast() + .await + .and_then(|dev| dev.cached_state()) + .unwrap_or_default() + .map(|s| s.into()) + .unwrap_or_else(|| DeviceState::Unknown); + + // Sort by strength and remove duplicates + let mut aps = HashMap::::new(); + for ap in access_points { + let (ssid_res, strength_res) = futures::join!(ap.ssid(), ap.strength()); + + if let Some((ssid, strength)) = ssid_res.ok().zip(strength_res.ok()) { + let ssid = String::from_utf8_lossy(&ssid.clone()).into_owned(); + if let Some(access_point) = aps.get(&ssid) { + if access_point.strength > strength { + continue; + } + } + + let Ok(flags) = ap.rsn_flags().await else { + continue; + }; + let network_type = if flags.intersects(ApSecurityFlags::KEY_MGMT_802_1X) { + NetworkType::EAP + } else if flags.intersects(ApSecurityFlags::KEY_MGMTPSK) { + NetworkType::PSK + } else if flags.is_empty() { + NetworkType::Open + } else { + continue; + }; + + aps.insert( + ssid.clone(), + AccessPoint { + ssid: Arc::from(ssid), + strength, + state, + working: false, + path: ap.inner().path().to_owned(), + secured: !ap.wpa_flags().await?.is_empty(), + wps_push: ap.flags().await?.contains(ApFlags::WPS_PBC), + network_type, + hw_address: hw_address + .as_ref() + .and_then(|str_addr| HwAddress::from_str(str_addr)) + .unwrap_or_default(), + }, + ); + } + } + + let aps = aps + .into_values() + .sorted_by(|a, b| b.strength.cmp(&a.strength)) + .collect(); + + Ok(aps) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccessPoint { + pub ssid: Arc, + pub strength: u8, + pub state: DeviceState, + pub working: bool, + pub path: ObjectPath<'static>, + pub hw_address: HwAddress, + pub secured: bool, + pub wps_push: bool, + pub network_type: NetworkType, +} + +// TODO do we want to support eap methods other than peap in the applet? +// Then we'd need a dropdown for the eap method, +// and tls requires a cert instead of a password +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkType { + Open, + PSK, + EAP, +} diff --git a/subscriptions/network-manager/src/current_networks.rs b/subscriptions/network-manager/src/current_networks.rs new file mode 100644 index 0000000..2fc6b9b --- /dev/null +++ b/subscriptions/network-manager/src/current_networks.rs @@ -0,0 +1,112 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use cosmic_dbus_networkmanager::{ + active_connection::ActiveConnection, device::SpecificDevice, + interface::enums::ActiveConnectionState, +}; +use std::net::Ipv4Addr; + +pub async fn active_connections( + active_connections: Vec>, +) -> zbus::Result> { + let mut info = Vec::::with_capacity(active_connections.len()); + for connection in active_connections { + let ipv4 = connection + .ip4_config() + .await? + .address_data() + .await + .unwrap_or_default(); + let addresses: Vec<_> = ipv4.iter().map(|d| d.address).collect(); + let state = connection + .state() + .await + .unwrap_or(ActiveConnectionState::Unknown); + + if connection.vpn().await.unwrap_or_default() { + info.push(ActiveConnectionInfo::Vpn { + name: connection.id().await?, + ip_addresses: addresses.clone(), + }); + continue; + } + for device in connection.devices().await.unwrap_or_default() { + match device + .downcast_to_device() + .await + .ok() + .and_then(|inner| inner) + { + Some(SpecificDevice::Wired(wired_device)) => { + info.push(ActiveConnectionInfo::Wired { + name: connection.id().await?, + hw_address: wired_device.hw_address().await?, + speed: wired_device.speed().await?, + ip_addresses: addresses.clone(), + }); + } + Some(SpecificDevice::Wireless(wireless_device)) => { + if let Ok(access_point) = wireless_device.active_access_point().await { + info.push(ActiveConnectionInfo::WiFi { + name: String::from_utf8_lossy(&access_point.ssid().await?).into_owned(), + ip_addresses: addresses.clone(), + hw_address: wireless_device.hw_address().await?, + state, + strength: access_point.strength().await.unwrap_or_default(), + }); + } + } + Some(SpecificDevice::WireGuard(_)) => { + info.push(ActiveConnectionInfo::Vpn { + name: connection.id().await?, + ip_addresses: addresses.clone(), + }); + } + _ => {} + } + } + } + + info.sort_by(|a, b| { + let helper = |conn: &ActiveConnectionInfo| match conn { + ActiveConnectionInfo::Vpn { name, .. } => format!("0{name}"), + ActiveConnectionInfo::Wired { name, .. } => format!("1{name}"), + ActiveConnectionInfo::WiFi { name, .. } => format!("2{name}"), + }; + helper(a).cmp(&helper(b)) + }); + + Ok(info) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActiveConnectionInfo { + Wired { + name: String, + hw_address: String, + speed: u32, + ip_addresses: Vec, + }, + WiFi { + name: String, + ip_addresses: Vec, + hw_address: String, + state: ActiveConnectionState, + strength: u8, + }, + Vpn { + name: String, + ip_addresses: Vec, + }, +} + +impl ActiveConnectionInfo { + pub fn name(&self) -> String { + match &self { + Self::Wired { name, .. } => name.clone(), + Self::WiFi { name, .. } => name.clone(), + Self::Vpn { name, .. } => name.clone(), + } + } +} diff --git a/subscriptions/network-manager/src/devices.rs b/subscriptions/network-manager/src/devices.rs new file mode 100644 index 0000000..6fa4372 --- /dev/null +++ b/subscriptions/network-manager/src/devices.rs @@ -0,0 +1,230 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::Event; +pub use cosmic_dbus_networkmanager::interface::enums::{ + ActiveConnectionState, DeviceState, DeviceType, +}; + +use cosmic_dbus_networkmanager::nm::NetworkManager; +use futures::{SinkExt, StreamExt}; +use iced_futures::{self, Subscription, stream}; +use std::{fmt::Debug, hash::Hash, sync::Arc}; +use zbus::{Connection, zvariant::ObjectPath}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct DeviceInfo { + pub path: ObjectPath<'static>, + pub device_type: DeviceType, + pub interface: String, + pub state: DeviceState, + pub active_connection: Option<(DeviceConnection, ActiveConnectionState)>, + pub available_connections: Vec, + pub known_connections: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct DeviceConnection { + pub path: ObjectPath<'static>, + pub id: String, + pub uuid: Arc, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct KnownDeviceConnection { + pub id: String, + pub uuid: Arc, +} + +pub async fn list<'a>( + conn: &'a zbus::Connection, + device_type_filter: fn(DeviceType) -> bool, +) -> zbus::Result> { + let nm = NetworkManager::new(conn).await?; + + let (devices, nm_settings) = futures::try_join!(nm.devices(), nm.settings())?; + + let connection_settings: &Vec<_> = &futures::stream::FuturesOrdered::from_iter( + nm_settings + .list_connections() + .await? + .into_iter() + .map(|connection| async move { connection.get_settings().await }), + ) + .filter_map(|res| async move { res.ok() }) + .collect() + .await; + + let device_iter = devices.into_iter().map(|device| async move { + let (interface, hw_address, device_type, state, available_connections) = + futures::try_join!( + device.interface(), + device.hw_address(), + device.device_type(), + device.state(), + device.available_connections() + ) + .ok()?; + + if !device_type_filter(device_type) { + return None; + } + + if hw_address.is_empty() { + return None; + } + + let (active_connection, available_connections) = futures::join!( + async { + let connection = device.active_connection().await?; + + let (id, uuid, state) = + futures::try_join!(connection.id(), connection.uuid(), connection.state())?; + + Ok::<_, zbus::Error>(( + DeviceConnection { + id, + uuid: Arc::from(uuid), + path: connection.inner().path().to_owned(), + }, + state, + )) + }, + futures::stream::FuturesOrdered::from_iter(available_connections.into_iter().map( + |conn| async move { + let path = conn.inner().path().to_owned(); + + let settings = conn.get_settings().await.ok()?; + + let id = settings + .get("connection")? + .get("id")? + .downcast_ref::() + .ok()?; + + let uuid = settings["connection"] + .get("uuid")? + .downcast_ref::() + .ok()?; + + Some(DeviceConnection { + id, + uuid: Arc::from(uuid), + path, + }) + } + ),) + .filter_map(|res| async move { res }) + .collect::>() + ); + + let known_connections = connection_settings + .iter() + .flat_map(|conn_settings| { + let connection = conn_settings.get("connection")?; + + let interface_name = connection + .get("interface-name")? + .downcast_ref::() + .ok()?; + + if interface_name != interface { + return None; + } + + let id = connection.get("id")?.downcast_ref::().ok()?; + let uuid = connection.get("uuid")?.downcast_ref::().ok()?; + + Some(KnownDeviceConnection { + uuid: Arc::from(uuid), + id, + }) + }) + .collect(); + + Some(DeviceInfo { + path: device.inner().path().to_owned(), + device_type, + interface, + state, + active_connection: active_connection.ok(), + known_connections, + available_connections, + }) + }); + + let devices_info = futures::stream::FuturesOrdered::from_iter(device_iter) + .filter_map(|res| async move { res }) + .collect::>() + .await; + + Ok(devices_info) +} + +pub fn subscription( + id: I, + has_popup: bool, + conn: Connection, +) -> iced_futures::Subscription { + Subscription::run_with_id( + (id, has_popup), + stream::channel(50, move |output| async move { + watch(conn, has_popup, output).await; + futures::future::pending().await + }), + ) +} + +pub async fn watch( + conn: zbus::Connection, + has_popup: bool, + mut output: futures::channel::mpsc::Sender, +) { + let mut state = State::Continue(conn); + + loop { + state = start_listening(state, has_popup, &mut output).await; + } +} + +#[derive(Debug, Clone)] +pub enum State { + Continue(Connection), + Error, +} + +async fn start_listening( + state: State, + has_popup: bool, + output: &mut futures::channel::mpsc::Sender, +) -> State { + let conn = match state { + State::Continue(conn) => conn, + State::Error => futures::future::pending().await, + }; + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(why) => { + tracing::error!( + why = why.to_string(), + "failed to connect to network_manager" + ); + return State::Error; + } + }; + + let mut devices_changed = network_manager.receive_devices_changed().await; + + let secs = if has_popup { 4 } else { 60 }; + + while let (Some(_change), _) = futures::future::join( + devices_changed.next(), + tokio::time::sleep(tokio::time::Duration::from_secs(secs)), + ) + .await + { + _ = output.send(Event::Devices).await; + } + + State::Continue(conn) +} diff --git a/subscriptions/network-manager/src/hw_address.rs b/subscriptions/network-manager/src/hw_address.rs new file mode 100644 index 0000000..2e3636d --- /dev/null +++ b/subscriptions/network-manager/src/hw_address.rs @@ -0,0 +1,34 @@ +#[derive(Copy, Clone, PartialEq, Eq, Default, Debug, PartialOrd, Ord)] +pub struct HwAddress { + address: u64, +} + +impl HwAddress { + pub fn from_str(arg: &str) -> Option { + let columnless_vec = arg.split(":").collect::>(); + if columnless_vec.len() * 3 - 1 != arg.len() { + return None; + } + for byte in &columnless_vec { + if byte.len() != 2 { + return None; + } + } + u64::from_str_radix(columnless_vec.join("").as_str(), 16) + .ok() + .and_then(|address| Some(HwAddress { address })) + } + pub fn from_string(arg: &String) -> Option { + HwAddress::from_str(arg.as_str()) + } + pub fn to_string(&self) -> String { + format!("{:#x}", self.address) + .trim_start_matches("0x") + .chars() + .collect::>() + .chunks(2) + .map(|chunk| chunk.iter().cloned().collect::()) + .collect::>() + .join(":") + } +} diff --git a/subscriptions/network-manager/src/lib.rs b/subscriptions/network-manager/src/lib.rs new file mode 100644 index 0000000..880b09e --- /dev/null +++ b/subscriptions/network-manager/src/lib.rs @@ -0,0 +1,889 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +pub mod active_conns; +pub mod available_wifi; +pub mod current_networks; +pub mod devices; +pub mod hw_address; +pub mod wireless_enabled; + +use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration}; + +use available_wifi::NetworkType; +pub use cosmic_dbus_networkmanager as dbus; +pub use dbus::settings::connection::Settings; + +use cosmic_dbus_networkmanager::{ + active_connection::ActiveConnection, + device::SpecificDevice, + interface::{ + active_connection::ActiveConnectionProxy, + enums::{self, ActiveConnectionState, DeviceType, NmConnectivityState}, + }, + nm::NetworkManager, + settings::NetworkManagerSettings, +}; +use futures::{ + FutureExt, SinkExt, StreamExt, + channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded}, +}; +use hw_address::HwAddress; +use iced_futures::{Subscription, stream}; +use secure_string::SecureString; +use tokio::process::Command; +use zbus::zvariant::{self, ObjectPath, Value}; + +use self::{ + available_wifi::{AccessPoint, handle_wireless_device}, + current_networks::{ActiveConnectionInfo, active_connections}, +}; + +pub type SSID = Arc; +pub type UUID = Arc; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("access point not found")] + AccessPointNotFound, + #[error("failed to list bluetooth devices with rfkill")] + BluetoothRfkillList(std::io::Error), + #[error("failed to activate connection")] + ConnectionActivate, + #[error("no wifi devices found")] + NoWiFiDevices, + #[error("zbus error")] + Zbus(#[from] zbus::Error), +} + +#[derive(Debug)] +pub enum State { + Ready(zbus::Connection), + Waiting(zbus::Connection, UnboundedReceiver), + Finished, +} + +/// Reloads state on available connection changes. +pub async fn watch_connections_changed( + conn: zbus::Connection, + mut output: futures::channel::mpsc::Sender, +) { + let Ok(nm) = NetworkManager::new(&conn).await else { + return; + }; + + let mut device_stream = nm.receive_devices_changed().await; + + loop { + // Emits updates when available connections changes. + let connections_changed = std::pin::pin!(async { + let devices = nm.devices().await.unwrap_or_default(); + + let mut connection_streams = + futures::stream::FuturesUnordered::from_iter(devices.into_iter().map( + |device| async move { device.receive_available_connections_changed().await }, + )) + .collect::>() + .await; + + let mut available_connections = futures::stream::FuturesUnordered::from_iter( + connection_streams + .iter_mut() + .map(|stream| async { stream.next().await }), + ); + + loop { + _ = futures::join!( + available_connections.next(), + tokio::time::sleep(Duration::from_secs(3)) + ); + + // TODO: although it should consume the stream, the stream is never empty. + // while available_connections.next().now_or_never().is_some() {} + + let state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + + _ = output + .send(Event::RequestResponse { + req: Request::Reload, + state, + success: true, + }) + .await; + } + }); + + // Reload the connection streams whenever devices change. + futures::future::select(connections_changed, device_stream.next()).await; + } +} + +pub fn subscription( + id: I, + conn: zbus::Connection, +) -> iced_futures::Subscription { + Subscription::run_with_id( + id, + stream::channel(50, |output| async move { + watch(conn, output).await; + futures::future::pending().await + }), + ) +} + +pub async fn watch(conn: zbus::Connection, mut output: futures::channel::mpsc::Sender) { + let mut state = State::Ready(conn); + + loop { + state = start_listening(state, &mut output).await; + } +} + +async fn start_listening( + state: State, + output: &mut futures::channel::mpsc::Sender, +) -> State { + match state { + State::Ready(conn) => { + let (tx, rx) = unbounded(); + + if output + .send(Event::Init { + conn: conn.clone(), + sender: tx, + state: NetworkManagerState::new(&conn).await.unwrap_or_default(), + }) + .await + .is_ok() + { + State::Waiting(conn, rx) + } else { + State::Finished + } + } + State::Waiting(conn, mut rx) => { + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(_) => return State::Finished, + }; + + match rx.next().await { + Some(Request::Deactivate(uuid)) => { + let mut success = false; + for c in network_manager + .active_connections() + .await + .unwrap_or_default() + { + if c.uuid().await.unwrap_or_default().as_str() == uuid.as_ref() + && network_manager.deactivate_connection(&c).await.is_ok() + { + success = true; + if let Ok(ActiveConnectionState::Deactivated) = c.state().await { + break; + } else { + let mut changed = c.receive_state_changed().await; + _ = tokio::time::timeout(Duration::from_secs(5), async move { + loop { + if let Some(next) = changed.next().await { + if let Ok(ActiveConnectionState::Deactivated) = + next.get().await.map(ActiveConnectionState::from) + { + break; + } + } + } + }) + .await; + } + break; + } + } + + _ = request_response(&conn, Request::Deactivate(uuid.clone()), success) + .then(|event| output.send(event)) + .await; + } + + Some(Request::Disconnect(ssid)) => { + let mut success = false; + for c in network_manager + .active_connections() + .await + .unwrap_or_default() + { + if c.id().await.unwrap_or_default().as_str() == ssid.as_ref() + && network_manager.deactivate_connection(&c).await.is_ok() + { + success = true; + if let Ok(ActiveConnectionState::Deactivated) = c.state().await { + break; + } else { + let mut changed = c.receive_state_changed().await; + _ = tokio::time::timeout(Duration::from_secs(5), async move { + loop { + if let Some(next) = changed.next().await { + if let Ok(ActiveConnectionState::Deactivated) = + next.get().await.map(ActiveConnectionState::from) + { + break; + } + } + } + }) + .await; + } + break; + } + } + + _ = request_response(&conn, Request::Disconnect(ssid.clone()), success) + .then(|event| output.send(event)) + .await; + } + + Some(Request::SetAirplaneMode(airplane_mode)) => { + // wifi + let mut success = network_manager + .set_wireless_enabled(!airplane_mode) + .await + .is_ok(); + // bluetooth + success = success + && Command::new("rfkill") + .arg(if airplane_mode { "block" } else { "unblock" }) + .arg("bluetooth") + .output() + .await + .is_ok(); + + let mut state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + state.airplane_mode = if success { + airplane_mode + } else { + !airplane_mode + }; + if state.airplane_mode { + state.wifi_enabled = false; + } + + _ = output + .send(Event::RequestResponse { + req: Request::SetAirplaneMode(airplane_mode), + success, + state, + }) + .await; + } + + Some(Request::SetWiFi(enabled)) => { + let success = network_manager.set_wireless_enabled(enabled).await.is_ok(); + + let mut state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + + state.wifi_enabled = if success { enabled } else { !enabled }; + + if state.wifi_enabled { + tokio::time::sleep(Duration::from_secs(3)).await; + } + + _ = request_response(&conn, Request::SetWiFi(enabled), success) + .then(|event| output.send(event)) + .await; + } + + Some(Request::Authenticate { + ssid, + identity, + password, + hw_address, + }) => { + let nm_state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + let success = nm_state + .connect_wifi( + &conn, + &ssid, + identity.as_deref(), + Some(password.unsecure()), + hw_address, + ) + .await + .is_ok(); + + _ = output + .send(Event::RequestResponse { + req: Request::Authenticate { + ssid: ssid.clone(), + identity: identity.clone(), + password: password.clone(), + hw_address, + }, + success, + state: NetworkManagerState::new(&conn).await.unwrap_or_default(), + }) + .await; + } + + Some(Request::SelectAccessPoint(ssid, hw_address, network_type)) => { + if matches!(network_type, NetworkType::Open) { + attempt_wifi_connection(&conn, ssid, hw_address, network_type, output) + .await; + } else { + // For secured networks, check if we have saved credentials + if !has_saved_wifi_credentials(&conn, &ssid).await { + return State::Waiting(conn, rx); + } + + // We have saved credentials, attempt connection + attempt_wifi_connection(&conn, ssid, hw_address, network_type, output) + .await; + } + } + + Some(Request::Activate(device_path, connection_path)) => { + let mut success = true; + + if let Err(why) = network_manager + .activate_connection_by_paths(&connection_path, &device_path) + .await + { + tracing::error!( + ?why, + "failed to activate connection on {device_path:?} to {connection_path}" + ); + success = false; + }; + + _ = request_response( + &conn, + Request::Activate(device_path, connection_path), + success, + ) + .then(|event| output.send(event)) + .await; + } + + Some(Request::Reload) => { + _ = output + .send(request_response(&conn, Request::Reload, true).await) + .await; + } + + Some(Request::Remove(uuid)) => { + let s = match NetworkManagerSettings::new(&conn).await { + Ok(s) => s, + Err(why) => { + tracing::error!(?why, "error getting network manager settings"); + _ = output + .send(Event::RequestResponse { + req: Request::Forget(uuid.clone()), + success: false, + state: NetworkManagerState::new(&conn) + .await + .unwrap_or_default(), + }) + .await; + + return State::Waiting(conn, rx); + } + }; + + let known_conns = s.list_connections().await.unwrap_or_default(); + let mut success = false; + for c in known_conns { + let settings = c.get_settings().await.ok().unwrap_or_default(); + + let c_uuid = settings + .get("connection") + .and_then(|conn| conn.get("uuid")) + .and_then(|uuid| uuid.downcast_ref::().ok()) + .unwrap_or_default(); + + if uuid.as_ref() == c_uuid.as_str() { + _ = c.delete().await; + success = true; + } + } + + _ = request_response(&conn, Request::Remove(uuid.clone()), success) + .then(|event| output.send(event)) + .await; + } + + Some(Request::Forget(ssid)) => { + let s = match NetworkManagerSettings::new(&conn).await { + Ok(s) => s, + Err(why) => { + tracing::error!(?why, "error getting network manager settings"); + _ = output + .send(Event::RequestResponse { + req: Request::Forget(ssid.clone()), + success: false, + state: NetworkManagerState::new(&conn) + .await + .unwrap_or_default(), + }) + .await; + + return State::Waiting(conn, rx); + } + }; + + let known_conns = s.list_connections().await.unwrap_or_default(); + let mut success = false; + for c in known_conns { + let settings = c.get_settings().await.ok().unwrap_or_default(); + let s = Settings::new(settings); + if s.wifi + .clone() + .and_then(|w| w.ssid) + .and_then(|ssid| String::from_utf8(ssid).ok()) + .is_some_and(|s| s == ssid.as_ref()) + { + _ = c.delete().await; + success = true; + break; + } + } + + _ = request_response(&conn, Request::Forget(ssid.clone()), success) + .then(|event| output.send(event)) + .await; + } + + None => { + return State::Finished; + } + }; + + State::Waiting(conn, rx) + } + State::Finished => futures::future::pending().await, + } +} + +async fn request_response(conn: &zbus::Connection, req: Request, success: bool) -> Event { + Event::RequestResponse { + req, + success, + state: NetworkManagerState::new(conn).await.unwrap_or_default(), + } +} + +async fn has_saved_wifi_credentials(conn: &zbus::Connection, ssid: &str) -> bool { + let Ok(nm_settings) = NetworkManagerSettings::new(conn).await else { + return false; + }; + + let known_conns = nm_settings.list_connections().await.unwrap_or_default(); + + for connection in known_conns { + if let Ok(settings) = connection.get_settings().await { + let settings = Settings::new(settings); + if let Some(saved_ssid) = settings + .wifi + .and_then(|w| w.ssid) + .and_then(|ssid| String::from_utf8(ssid).ok()) + { + if saved_ssid == ssid { + return true; + } + } + } + } + + false +} + +async fn attempt_wifi_connection( + conn: &zbus::Connection, + ssid: SSID, + hw_address: HwAddress, + network_type: NetworkType, + output: &mut futures::channel::mpsc::Sender, +) { + let state = NetworkManagerState::new(conn).await.unwrap_or_default(); + + let success = if let Err(err) = state + .connect_wifi(conn, ssid.as_ref(), None, None, hw_address) + .await + { + tracing::error!("Failed to connect to access point: {:?}", err); + false + } else { + true + }; + + _ = request_response( + conn, + Request::SelectAccessPoint(ssid, hw_address, network_type), + success, + ) + .then(|event| output.send(event)) + .await; +} + +#[derive(Debug, Clone)] +pub enum Request { + /// Activate a device's connection profile + Activate(ObjectPath<'static>, ObjectPath<'static>), + /// Deactivate a connection + Deactivate(UUID), + /// Disconnect from an access point. + Disconnect(SSID), + /// Forget a known access point. + Forget(SSID), + /// Create a connection to a new access point. + Authenticate { + ssid: String, + identity: Option, + password: SecureString, + hw_address: HwAddress, + }, + /// Signal to reload the service. + Reload, + /// Remove a connection profile. + Remove(UUID), + /// Connect to a known access point. + SelectAccessPoint(SSID, HwAddress, NetworkType), + /// Toggle airplaine mode. + SetAirplaneMode(bool), + /// Toggle WiFi enablement. + SetWiFi(bool), +} + +#[derive(Debug, Clone)] +pub enum Event { + RequestResponse { + req: Request, + state: NetworkManagerState, + success: bool, + }, + Init { + conn: zbus::Connection, + sender: UnboundedSender, + state: NetworkManagerState, + }, + Devices, + WiFiEnabled(bool), + WirelessAccessPoints, + ActiveConns, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkManagerState { + pub wireless_access_points: Vec, + pub active_conns: Vec, + pub known_access_points: Vec, + pub wifi_enabled: bool, + pub airplane_mode: bool, + pub connectivity: NmConnectivityState, +} + +impl Default for NetworkManagerState { + fn default() -> Self { + Self { + wireless_access_points: Vec::new(), + active_conns: Vec::new(), + known_access_points: Vec::new(), + wifi_enabled: false, + airplane_mode: false, + connectivity: NmConnectivityState::Unknown, + } + } +} + +impl NetworkManagerState { + pub async fn new(conn: &zbus::Connection) -> Result { + let network_manager = NetworkManager::new(conn).await?; + let mut this = Self::default(); + + this.refresh_wifi_state(conn, &network_manager).await?; + + Ok(this) + } + + pub async fn refresh_wifi_state( + &mut self, + conn: &zbus::Connection, + network_manager: &NetworkManager<'_>, + ) -> Result<(), Error> { + let (airplane_mode, wireless_enabled, settings_res) = futures::join!( + Command::new("rfkill") + .arg("list") + .arg("bluetooth") + .output() + .then(|res| async move { + let Ok(output) = res else { + return false; + }; + + std::str::from_utf8(&output.stdout) + .ok() + .map_or(false, |stdout| stdout.contains("Soft blocked: yes")) + }), + network_manager + .wireless_enabled() + .then(|res| async move { res.unwrap_or_default() }), + NetworkManagerSettings::new(conn) + ); + + self.wifi_enabled = wireless_enabled; + self.airplane_mode = airplane_mode && !self.wifi_enabled; + + let settings = settings_res?; + + _ = settings.load_connections(&[]).await; + + let (known_conns, active_conns, devices, connectivity) = futures::join!( + settings.list_connections(), + network_manager.active_connections(), + network_manager.devices(), + network_manager.connectivity(), + ); + + let devices = devices.unwrap_or_default(); + let known_conns = known_conns.unwrap_or_default(); + + let (active_conns, wireless_access_points) = futures::join!( + // Retrieve active connections. + async move { + let mut active_conns = active_connections(active_conns.unwrap_or_default()) + .await + .unwrap_or_default(); + + active_conns.sort_by(|a, b| { + let helper = |conn: &ActiveConnectionInfo| match conn { + ActiveConnectionInfo::Vpn { name, .. } => format!("0{name}"), + ActiveConnectionInfo::Wired { name, .. } => format!("1{name}"), + ActiveConnectionInfo::WiFi { name, .. } => format!("2{name}"), + }; + helper(a).cmp(&helper(b)) + }); + + active_conns + }, + // Retrieve all access points, and sort by strength. + async move { + let mut wireless_access_points = futures::stream::FuturesUnordered::from_iter( + devices.iter().map(|device| async move { + if let Ok(Some(SpecificDevice::Wireless(wireless_device))) = + device.downcast_to_device().await + { + handle_wireless_device(wireless_device, device.hw_address().await.ok()) + .await + .unwrap_or_default() + } else { + Vec::new() + } + }), + ) + .fold( + Vec::with_capacity(devices.len()), + |mut access_points, mut f| async move { + access_points.append(&mut f); + access_points + }, + ) + .await; + + wireless_access_points.sort_by(|a, b| b.strength.cmp(&a.strength)); + wireless_access_points + } + ); + + // Concurrently get + let known_ssid: Vec> = futures::stream::FuturesOrdered::from_iter( + known_conns.into_iter().map(|c| async move { + let s = c.get_settings().await.ok()?; + let s = Settings::new(s); + let curr_ssid = s + .wifi + .clone() + .and_then(|w| w.ssid) + .and_then(|ssid| String::from_utf8(ssid).ok())?; + + Some(Arc::from(curr_ssid)) + }), + ) + .filter_map(|c| async move { c }) + .collect() + .await; + + self.known_access_points = wireless_access_points + .iter() + .filter(|a| { + known_ssid.contains(&a.ssid) + && !active_conns.iter().any(|ac| &ac.name() == a.ssid.as_ref()) + }) + .cloned() + .collect(); + + self.wireless_access_points = wireless_access_points; + self.active_conns = active_conns; + self.connectivity = connectivity?; + + Ok(()) + } + + #[allow(dead_code)] + pub fn clear(&mut self) { + self.active_conns = Vec::new(); + self.known_access_points = Vec::new(); + self.wireless_access_points = Vec::new(); + } + + async fn connect_wifi<'a>( + &self, + conn: &zbus::Connection, + ssid: &str, + identity: Option<&str>, + password: Option<&str>, + hw_address: HwAddress, + ) -> Result<(), Error> { + let nm = NetworkManager::new(conn).await?; + + for c in nm.active_connections().await.unwrap_or_default() { + if self + .wireless_access_points + .iter() + .any(|w| Ok(Some(w.ssid.as_ref())) == c.cached_id().as_ref().map(|v| v.as_deref())) + { + _ = nm.deactivate_connection(&c).await; + break; + } + } + + let Some(ap) = self + .wireless_access_points + .iter() + .find(|ap| ap.ssid.as_ref() == ssid && ap.hw_address == hw_address) + else { + return Err(Error::AccessPointNotFound); + }; + + let mut conn_settings: HashMap<&str, HashMap<&str, zvariant::Value>> = HashMap::from([ + ( + "802-11-wireless", + HashMap::from([("ssid", Value::Array(ssid.as_bytes().into()))]), + ), + ( + "connection", + HashMap::from([ + ("id", Value::Str(ssid.into())), + ("type", Value::Str("802-11-wireless".into())), + ]), + ), + ]); + if let Some(identity) = identity { + conn_settings.insert( + "802-1x", + HashMap::from([ + ("identity", Value::Str(identity.into())), + // most common default + ("eap", Value::Array(vec!["peap"].into())), + // most common default + ("phase2-auth", Value::Str("mschapv2".into())), + ("password", Value::Str(password.unwrap_or("").into())), + ]), + ); + let wireless = conn_settings.get_mut("802-11-wireless").unwrap(); + wireless.insert("security", Value::Str("802-11-wireless-security".into())); + wireless.insert("mode", Value::Str("infrastructure".into())); + conn_settings.insert( + "802-11-wireless-security", + HashMap::from([("key-mgmt", Value::Str("wpa-eap".into()))]), + ); + } else if let Some(pass) = password { + conn_settings.insert( + "802-11-wireless-security", + HashMap::from([ + ("psk", Value::Str(pass.into())), + ("key-mgmt", Value::Str("wpa-psk".into())), + ]), + ); + } + + let devices = nm.devices().await?; + for device in devices { + if !matches!( + device.device_type().await.unwrap_or(DeviceType::Other), + DeviceType::Wifi + ) { + continue; + } + + let s = NetworkManagerSettings::new(conn).await?; + let known_conns = s.list_connections().await.unwrap_or_default(); + let mut known_conn = None; + for c in known_conns { + let settings = c.get_settings().await.ok().unwrap_or_default(); + + let s = Settings::new(settings); + if let Some(cur_ssid) = s + .wifi + .clone() + .and_then(|w| w.ssid) + .and_then(|ssid| String::from_utf8(ssid).ok()) + { + if cur_ssid == ssid { + known_conn = Some(c); + break; + } + } + } + + let active_conn = if let Some(known_conn) = known_conn.as_ref() { + // update settings if needed + if password.is_some() { + known_conn.update(conn_settings).await?; + } + + nm.activate_connection(known_conn, &device).await? + } else { + let (_, active_conn) = nm + .add_and_activate_connection(conn_settings, device.inner().path(), &ap.path) + .await?; + let dummy = ActiveConnectionProxy::new(&conn, active_conn).await?; + let active = ActiveConnectionProxy::builder(&conn) + .destination(dummy.inner().destination().to_owned()) + .unwrap() + .interface(dummy.inner().interface().to_owned()) + .unwrap() + .path(dummy.inner().path().to_owned()) + .unwrap() + .build() + .await + .unwrap(); + ActiveConnection::from(active) + }; + let mut changes = active_conn.receive_state_changed().await; + _ = tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + let mut count = 5; + loop { + let state = active_conn.state().await; + if let Ok(enums::ActiveConnectionState::Activated) = state { + return Ok(()); + } else if let Ok(enums::ActiveConnectionState::Deactivated) = state { + return Err(Error::ConnectionActivate); + } + match tokio::time::timeout(Duration::from_secs(20), changes.next()).await { + Ok(Some(s)) => { + let state = s.get().await.unwrap_or_default().into(); + if matches!(state, enums::ActiveConnectionState::Activated) { + return Ok(()); + } + } + _ => {} + }; + + count -= 1; + if count <= 0 { + return Err(Error::ConnectionActivate); + } + } + } + + Err(Error::NoWiFiDevices) + } +} diff --git a/subscriptions/network-manager/src/wireless_enabled.rs b/subscriptions/network-manager/src/wireless_enabled.rs new file mode 100644 index 0000000..d028093 --- /dev/null +++ b/subscriptions/network-manager/src/wireless_enabled.rs @@ -0,0 +1,63 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use super::Event; +use cosmic_dbus_networkmanager::nm::NetworkManager; +use futures::{SinkExt, StreamExt}; +use iced_futures::{Subscription, stream}; +use std::{fmt::Debug, hash::Hash}; +use zbus::Connection; + +#[derive(Debug, Clone)] +pub enum State { + Continue(Connection), + Error, +} + +pub fn wireless_enabled_subscription( + id: I, + conn: Connection, +) -> iced_futures::Subscription { + Subscription::run_with_id( + id, + stream::channel(50, move |output| async move { + watch(conn, output).await; + futures::future::pending().await + }), + ) +} + +pub async fn watch(conn: zbus::Connection, mut output: futures::channel::mpsc::Sender) { + let mut state = State::Continue(conn); + + loop { + state = start_listening(state, &mut output).await; + } +} + +async fn start_listening( + state: State, + output: &mut futures::channel::mpsc::Sender, +) -> State { + let conn = match state { + State::Continue(conn) => conn, + State::Error => futures::future::pending().await, + }; + + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(why) => { + tracing::error!(why = why.to_string(), "Failed to connect to NetworkManager"); + return State::Error; + } + }; + + let mut wireless_enabled_changed = network_manager.receive_wireless_enabled_changed().await; + + while let Some(change) = wireless_enabled_changed.next().await { + if let Ok(enable) = change.get().await { + _ = output.send(Event::WiFiEnabled(enable)).await; + } + } + State::Continue(conn) +} diff --git a/subscriptions/settings-daemon/Cargo.toml b/subscriptions/settings-daemon/Cargo.toml new file mode 100644 index 0000000..7e85e75 --- /dev/null +++ b/subscriptions/settings-daemon/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cosmic-settings-daemon-subscription" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +publish = true + +[dependencies] +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +log = "0.4.28" +tokio = "1.47.1" +tokio-stream = "0.1.17" +zbus = "5.11.0" diff --git a/subscriptions/settings-daemon/LICENSE.md b/subscriptions/settings-daemon/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/settings-daemon/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/settings-daemon/src/lib.rs b/subscriptions/settings-daemon/src/lib.rs new file mode 100644 index 0000000..5438593 --- /dev/null +++ b/subscriptions/settings-daemon/src/lib.rs @@ -0,0 +1,82 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +// XXX error handling? + +use futures::{FutureExt, StreamExt}; +use iced_futures::Subscription; +use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; +use tokio_stream::wrappers::UnboundedReceiverStream; + +pub fn subscription(connection: zbus::Connection) -> iced_futures::Subscription { + Subscription::run_with_id( + "settings-daemon", + async move { + let settings_daemon = match CosmicSettingsDaemonProxy::new(&connection).await { + Ok(value) => value, + Err(err) => { + log::error!("Error connecting to settings daemon: {}", err); + futures::future::pending().await + } + }; + + let (tx, rx) = unbounded_channel(); + + let max_brightness_stream = settings_daemon + .receive_max_display_brightness_changed() + .await; + let brightness_stream = settings_daemon.receive_display_brightness_changed().await; + + let initial = futures::stream::iter([Event::Sender(tx)]); + + initial.chain(futures::stream_select!( + Box::pin(UnboundedReceiverStream::new(rx).filter_map(move |request| { + let settings_daemon = settings_daemon.clone(); + async move { + match request { + Request::SetDisplayBrightness(brightness) => { + let _ = settings_daemon.set_display_brightness(brightness).await; + } + } + None:: + } + })), + Box::pin(max_brightness_stream.filter_map(|evt| async move { + Some(Event::MaxDisplayBrightness(evt.get().await.ok()?)) + })), + Box::pin(brightness_stream.filter_map(|evt| async move { + Some(Event::DisplayBrightness(evt.get().await.ok()?)) + })) + )) + } + .flatten_stream(), + ) +} + +#[derive(Clone, Debug)] +pub enum Event { + Sender(UnboundedSender), + MaxDisplayBrightness(i32), + DisplayBrightness(i32), +} + +#[zbus::proxy( + default_service = "com.system76.CosmicSettingsDaemon", + interface = "com.system76.CosmicSettingsDaemon", + default_path = "/com/system76/CosmicSettingsDaemon" +)] +trait CosmicSettingsDaemon { + #[zbus(property)] + fn display_brightness(&self) -> zbus::Result; + #[zbus(property)] + fn set_display_brightness(&self, value: i32) -> zbus::Result<()>; + #[zbus(property)] + fn max_display_brightness(&self) -> zbus::Result; + #[zbus(property)] + fn keyboard_brightness(&self) -> zbus::Result; +} + +#[derive(Debug, Clone)] +pub enum Request { + SetDisplayBrightness(i32), +} diff --git a/subscriptions/sound/Cargo.toml b/subscriptions/sound/Cargo.toml new file mode 100644 index 0000000..b538269 --- /dev/null +++ b/subscriptions/sound/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cosmic-settings-sound-subscription" +version = "1.0.0-beta1" +edition = "2024" +rust-version.workspace = true +license = "MPL-2.0" +publish = true + +[dependencies] +async-fn-stream = "0.3.1" +futures = "0.3.31" +indexmap = "2.11.4" +libcosmic = { git = "https://github.com/pop-os/libcosmic" } +libpulse-binding = "2.30.1" +log = "0.4.28" +pipewire = "0.8" +rustix = "1.0.8" +tokio = "1.47.1" +tracing = "0.1.41" diff --git a/subscriptions/sound/LICENSE.md b/subscriptions/sound/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/sound/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/sound/src/lib.rs b/subscriptions/sound/src/lib.rs new file mode 100644 index 0000000..54b0a4b --- /dev/null +++ b/subscriptions/sound/src/lib.rs @@ -0,0 +1,827 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +pub mod pipewire; +pub mod pulse; + +use cosmic::Task; +use cosmic::iced_futures::MaybeSend; +use futures::{Stream, StreamExt}; +use indexmap::IndexMap; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +pub type NodeId = u32; +pub type ProfileId = u32; + +pub fn watch() -> impl Stream + MaybeSend + 'static { + async_fn_stream::fn_stream(|emitter| async move { + let (cancel_tx, mut cancel_rx) = futures::channel::oneshot::channel::<()>(); + + let (tx, mut pulse_rx) = futures::channel::mpsc::channel(1); + let _pulse_handle = std::thread::spawn(move || { + pulse::thread(tx); + }); + + let (tx, mut pw_rx) = futures::channel::mpsc::channel(1); + let (_pipewire_handle, pipewire_terminate) = pipewire::thread(tx); + + emitter + .emit( + Message::SubHandle(Arc::new(SubscriptionHandle { + cancel_tx, + pipewire: pipewire_terminate, + })) + .into(), + ) + .await; + + let mut pulse_channels = None; + let mut balance = None; + let mut source_volume = None; + let mut sink_volume = None; + let mut events = Vec::new(); + let mut timer = tokio::time::interval(Duration::from_millis(64)); + + loop { + tokio::select! { + event = pulse_rx.next() => { + let Some(event) = event else { + break; + }; + + match event { + pulse::Event::Channels(channels) => pulse_channels = Some(channels), + pulse::Event::SinkVolume(volume) => sink_volume = Some(volume), + pulse::Event::SourceVolume(volume) => source_volume = Some(volume), + pulse::Event::Balance(value) => balance = Some(value), + _ => { + events.push(Server::Pulse(event)); + timer.reset(); + } + } + } + + event = pw_rx.next() => { + let Some(event) = event else { + break; + }; + + timer.reset(); + events.push(Server::Pipewire(event)); + } + + _ = timer.tick() => { + if let Some(channels) = pulse_channels.take() { + events.push(Server::Pulse(pulse::Event::Channels(channels))); + } + + if let Some(volume) = sink_volume.take() { + events.push(Server::Pulse(pulse::Event::SinkVolume(volume))); + } + + if let Some(volume) = source_volume.take() { + events.push(Server::Pulse(pulse::Event::SourceVolume(volume))); + } + + if let Some(balance) = balance.take() { + events.push(Server::Pulse(pulse::Event::Balance(balance))); + } + + if !events.is_empty() { + emitter + .emit(Message::Server(Arc::from(std::mem::take(&mut events)))) + .await; + } + } + + _ = &mut cancel_rx => break, + } + } + + drop(pulse_rx); + drop(pw_rx); + + futures::future::pending::().await; + }) +} + +#[derive(Default)] +pub struct Model { + subscription_handle: Option, + sink_channels: Option, + + devices: BTreeMap, + card_names: IndexMap, + card_profiles: IndexMap>, + active_profiles: IndexMap>, + + /** Sink devices */ + + /// Product names for source sink devices. + sinks: Vec, + /// Pipewire object IDs for sink devices. + sink_pw_ids: Vec, + /// Profile IDs for the actively-selected sink device. + sink_profiles: Vec, + /// Names of profiles for the actively-selected sink device. + sink_profile_names: Vec, + /// Device ID of active sink device. + active_sink_device: Option, + /// Index of active sink device. + active_sink: Option, + /// Card profile index of active sink device. + active_sink_profile: Option, + + /** Source devices */ + + /// Product names for source devices. + sources: Vec, + /// Pipewire object IDs for source devices. + source_pw_ids: Vec, + /// Profile IDs for the actively-selected source device. + source_profiles: Vec, + /// Names of profiles for the actively-selected source device. + source_profile_names: Vec, + /// Device ID of active source device. + active_source_device: Option, + /// Index of active source device. + active_source: Option, + /// Card profile index of active source device. + active_source_profile: Option, + + /// Device identifier of the default sink. + default_sink: String, + /// Device identifier of the default source. + default_source: String, + + pub sink_volume_text: String, + pub source_volume_text: String, + + pub sink_balance_text: Option, + pub sink_balance: Option, + + pub sink_volume: u32, + pub source_volume: u32, + + pub sink_mute: bool, + sink_volume_debounce: bool, + sink_balance_debounce: bool, + pub source_mute: bool, + source_volume_debounce: bool, + + changing_sink_profile: Option, + changing_source_profile: Option, +} + +impl Model { + pub fn active_sink(&self) -> Option { + self.active_sink + } + + pub fn active_sink_profile(&self) -> Option { + self.active_sink_profile + } + + pub fn active_source(&self) -> Option { + self.active_source + } + + pub fn active_source_profile(&self) -> Option { + self.active_source_profile + } + + pub fn sinks(&self) -> &[String] { + &self.sinks + } + + pub fn sink_profiles(&self) -> &[String] { + &self.sink_profiles + } + + pub fn sources(&self) -> &[String] { + &self.sources + } + + pub fn source_profiles(&self) -> &[String] { + &self.source_profiles + } + + pub fn clear(&mut self) { + if let Some(handle) = self.subscription_handle.take() { + _ = handle.cancel_tx.send(()); + _ = handle.pipewire.send(()); + } + + if let Some(channel) = self.sink_channels.take() { + channel.quit(); + } + } + + pub fn sink_balance_changed(&mut self, balance: u32) -> Task { + self.sink_balance = Some((balance as f32 - 100.) / 100.); + self.sink_balance_text = Some(format!("{balance:.2}")); + if self.sink_balance_debounce { + return Task::none(); + } + + if !self + .sink_pw_ids + .get(self.active_sink.unwrap_or(0)) + .is_none() + { + self.sink_balance_debounce = true; + return cosmic::Task::future(async move { + tokio::time::sleep(Duration::from_millis(64)).await; + Message::SinkBalanceApply.into() + }); + } + + Task::none() + } + + pub fn sink_changed(&mut self, pos: usize) -> Task { + if let Some(&node_id) = self.sink_pw_ids.get(pos) { + for card in self.devices.values() { + for (&nid, port) in &card.ports { + if node_id == nid { + self.active_sink = Some(pos); + let identifier = port.identifier.clone(); + return cosmic::Task::future(async move { + wpctl_set_default(nid).await; + Message::SetDefaultSink(identifier).into() + }); + } + } + } + } + + Task::none() + } + + pub fn sink_mute_toggle(&mut self) { + self.sink_mute = !self.sink_mute; + if let Some(&node_id) = self.sink_pw_ids.get(self.active_sink.unwrap_or(0)) { + wpctl_set_mute(node_id, self.sink_mute); + } + } + + pub fn sink_profile_changed(&mut self, profile: usize) -> Task { + self.active_sink_profile = Some(profile); + + if let Some(profile) = self.sink_profile_names.get(profile).cloned() { + if let Some(device_id) = self.active_sink_device.clone() { + if let Some(name) = self.card_names.get(&device_id).cloned() { + self.active_profiles + .insert(device_id.clone(), Some(profile.clone())); + + self.changing_sink_profile = Some(device_id); + return cosmic::Task::future(async move { + pactl_set_card_profile(name, profile).await; + }) + .discard(); + } + } + } + + Task::none() + } + + pub fn sink_volume_changed(&mut self, volume: u32) -> Task { + self.sink_volume = volume; + self.sink_volume_text = volume.to_string(); + if self.sink_volume_debounce { + return Task::none(); + } + + if let Some(&node_id) = self.sink_pw_ids.get(self.active_sink.unwrap_or(0)) { + self.sink_volume_debounce = true; + return cosmic::Task::future(async move { + tokio::time::sleep(Duration::from_millis(64)).await; + Message::SinkVolumeApply(node_id).into() + }); + } + + Task::none() + } + + pub fn source_changed(&mut self, pos: usize) -> Task { + if let Some(&node_id) = self.source_pw_ids.get(pos) { + for card in self.devices.values() { + for (&nid, port) in &card.ports { + if node_id == nid { + self.active_source = Some(pos); + let identifier = port.identifier.clone(); + return cosmic::Task::future(async move { + wpctl_set_default(nid).await; + Message::SetDefaultSource(identifier).into() + }); + } + } + } + } + + Task::none() + } + + pub fn source_mute_toggle(&mut self) { + self.source_mute = !self.source_mute; + if let Some(&node_id) = self.source_pw_ids.get(self.active_source.unwrap_or(0)) { + wpctl_set_mute(node_id, self.source_mute); + } + } + + pub fn source_profile_changed(&mut self, profile: usize) -> Task { + self.active_source_profile = Some(profile); + if let Some(profile) = self.source_profile_names.get(profile).cloned() { + if let Some(device_id) = self.active_source_device.clone() { + if let Some(name) = self.card_names.get(&device_id).cloned() { + self.active_profiles + .insert(device_id.clone(), Some(profile.clone())); + + self.changing_source_profile = Some(device_id.clone()); + return cosmic::Task::future(async move { + pactl_set_card_profile(name, profile).await; + }) + .discard(); + } + } + } + + Task::none() + } + + pub fn source_volume_changed(&mut self, volume: u32) -> Task { + self.source_volume = volume; + self.source_volume_text = volume.to_string(); + if self.source_volume_debounce { + return Task::none(); + } + + if let Some(&node_id) = self.source_pw_ids.get(self.active_source.unwrap_or(0)) { + self.source_volume_debounce = true; + return cosmic::Task::future(async move { + tokio::time::sleep(Duration::from_millis(64)).await; + Message::SourceVolumeApply(node_id).into() + }); + } + + Task::none() + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Server(events) => { + for event in Arc::into_inner(events).into_iter().flatten() { + match event { + Server::Pulse(event) => match event { + pulse::Event::SourceVolume(volume) => { + if self.sink_volume_debounce { + return Task::none(); + } + + self.source_volume = volume; + self.source_volume_text = volume.to_string(); + } + + pulse::Event::SinkVolume(volume) => { + if self.sink_volume_debounce { + return Task::none(); + } + + self.sink_volume = volume; + self.sink_volume_text = volume.to_string(); + } + + pulse::Event::CardInfo(card) => { + let device_id = match card.variant { + pulse::DeviceVariant::Alsa { alsa_card, .. } => { + DeviceId::Alsa(alsa_card) + } + pulse::DeviceVariant::Bluez5 { address, .. } => { + DeviceId::Bluez5(address) + } + }; + + eprintln!( + "inserting card {:?}: name={}, active_profile={:?}, profiles={:?}", + device_id, + card.name, + card.active_profile.as_ref().map(|p| p.name.as_str()), + card.profiles + ); + + self.card_names.insert(device_id.clone(), card.name); + self.card_profiles.insert(device_id.clone(), card.profiles); + self.active_profiles + .insert(device_id, card.active_profile.map(|p| p.name)); + } + + pulse::Event::DefaultSink(sink) => { + if !self.changing_sink_profile.is_some() { + self.set_default_sink(sink); + } + } + pulse::Event::DefaultSource(source) => { + if !self.changing_source_profile.is_some() { + self.set_default_source(source); + } + } + pulse::Event::SinkMute(mute) => { + self.sink_mute = mute; + } + pulse::Event::SourceMute(mute) => { + self.source_mute = mute; + } + pulse::Event::Balance(balance) => { + self.sink_balance = balance; + self.sink_balance_text = balance.map(|b| format!("{b:.2}")); + } + pulse::Event::Channels(channels) => { + self.sink_channels = Some(channels); + } + }, + + Server::Pipewire(event) => match event { + pipewire::DeviceEvent::Add(device) => { + let device_id = match device.variant { + pipewire::DeviceVariant::Alsa { alsa_card, .. } => { + DeviceId::Alsa(alsa_card) + } + pipewire::DeviceVariant::Bluez5 { address, .. } => { + DeviceId::Bluez5(address) + } + pipewire::DeviceVariant::Unknown {} => DeviceId::Unknown {}, + }; + + match device.media_class { + pipewire::MediaClass::Sink => { + self.sinks.push(device.product_name.clone()); + self.sink_pw_ids.push(device.object_id); + + sort_pulse_devices(&mut self.sinks, &mut self.sink_pw_ids); + + if self.default_sink == device.node_name { + self.active_sink_device = Some(device_id.clone()); + self.active_sink = self + .sinks + .iter() + .position(|s| *s == device.product_name); + self.set_sink_profiles(&device_id); + } + } + + pipewire::MediaClass::Source => { + self.sources.push(device.product_name.clone()); + self.source_pw_ids.push(device.object_id); + + sort_pulse_devices( + &mut self.sources, + &mut self.source_pw_ids, + ); + + if self.default_source == device.node_name { + self.active_source = self + .sources + .iter() + .position(|s| *s == device.product_name); + self.active_source_device = Some(device_id.clone()); + self.set_source_profiles(&device_id); + } + } + } + + let card = self.devices.entry(device_id).or_insert_with(|| Card { + ports: IndexMap::new(), + }); + + card.ports.insert( + device.object_id, + CardPort { + class: device.media_class, + identifier: device.node_name, + description: device.product_name, + }, + ); + + card.ports.sort_unstable_by(|_, av, _, bv| { + av.description.cmp(&bv.description) + }); + } + + pipewire::DeviceEvent::Remove(node_id) => { + let mut remove = None; + for (card_id, card) in &mut self.devices { + if card.ports.shift_remove(&node_id).is_some() { + if card.ports.is_empty() { + remove = Some(card_id.clone()); + } + break; + } + } + + if let Some(card_id) = remove { + _ = self.devices.remove(&card_id); + } + + if let Some(pos) = + self.sink_pw_ids.iter().position(|&id| id == node_id) + { + _ = self.sink_pw_ids.remove(pos); + _ = self.sinks.remove(pos); + if self.active_sink == Some(pos) { + self.active_sink = None; + self.active_sink_device = None; + self.active_sink_profile = None; + } else { + self.active_sink = self.active_sink.map(|active_pos| { + if active_pos > pos { + active_pos - 1 + } else { + active_pos + } + }); + } + } else if let Some(pos) = + self.source_pw_ids.iter().position(|&id| id == node_id) + { + _ = self.source_pw_ids.remove(pos); + _ = self.sources.remove(pos); + if self.active_source == Some(pos) { + self.active_source = None; + self.active_source_device = None; + self.active_source_profile = None; + } + } + } + }, + } + } + + let mut tasks = Task::none(); + + if let Some(device_id) = self.changing_sink_profile.take() { + tasks = tasks.chain(self.sink_profile_select(device_id)); + } + + if let Some(device_id) = self.changing_source_profile.take() { + tasks = tasks.chain(self.source_profile_select(device_id)); + } + + return tasks; + } + + Message::SinkBalanceApply => { + self.sink_balance_debounce = false; + if let Some((balance, channels)) = + self.sink_balance.zip(self.sink_channels.as_mut()) + { + channels.set_balance(balance); + } + } + + Message::SinkVolumeApply(_) => { + self.sink_volume_debounce = false; + if let Some(channels) = self.sink_channels.as_mut() { + channels.set_volume(self.sink_volume as f32 / 100.); + } + } + + Message::SourceVolumeApply(node_id) => { + self.source_volume_debounce = false; + wpctl_set_volume(node_id, self.source_volume); + } + + Message::SetDefaultSink(identifier) => self.set_default_sink(identifier), + + Message::SetDefaultSource(identifier) => self.set_default_source(identifier), + + Message::SubHandle(handle) => { + if let Some(handle) = Arc::into_inner(handle) { + self.subscription_handle = Some(handle); + } + } + } + + Task::none() + } + + fn device_profiles(&self, device_id: &DeviceId) -> (Vec, Vec, Option) { + let (profiles, profile_descriptions): (Vec, Vec) = self + .card_profiles + .get(device_id) + .map_or((Vec::new(), Vec::new()), |profiles| { + profiles + .iter() + .filter(|p| p.available && p.name != "off") + .map(|p| (p.name.clone(), p.description.clone())) + .collect() + }); + + let active_profile = self.active_profiles.get(device_id).and_then(|profile| { + profile + .as_ref() + .and_then(|profile| profiles.iter().position(|p| p == profile)) + }); + + (profiles, profile_descriptions, active_profile) + } + + /// Update the state of the default sink and its profiles. + fn set_default_sink(&mut self, sink: String) { + if self.default_sink == sink { + return; + } + + self.default_sink = sink; + + for (device_id, card) in &self.devices { + for (&node_id, card_port) in &card.ports { + if let pipewire::MediaClass::Sink = card_port.class { + if &card_port.identifier == &self.default_sink { + let device_id = device_id.clone(); + self.set_sink_profiles(&device_id); + self.active_sink = self.sink_pw_ids.iter().position(|&id| id == node_id); + self.active_sink_device = Some(device_id); + return; + } + } + } + } + } + + fn set_default_source(&mut self, source: String) { + if self.default_source == source { + return; + } + + self.default_source = source; + + for (device_id, card) in &self.devices { + for (&node_id, card_ports) in &card.ports { + if let pipewire::MediaClass::Source = card_ports.class { + if card_ports.identifier == self.default_source { + self.active_source = + self.source_pw_ids.iter().position(|&id| id == node_id); + let device_id = device_id.clone(); + self.set_source_profiles(&device_id); + self.active_source_device = Some(device_id); + return; + } + } + } + } + } + + fn set_sink_profiles(&mut self, device_id: &DeviceId) { + ( + self.sink_profile_names, + self.sink_profiles, + self.active_sink_profile, + ) = self.device_profiles(device_id); + } + + fn set_source_profiles(&mut self, device_id: &DeviceId) { + ( + self.source_profile_names, + self.source_profiles, + self.active_source_profile, + ) = self.device_profiles(device_id); + } + + fn sink_profile_select(&mut self, device_id: DeviceId) -> Task { + let sink_pos = self.active_sink.unwrap_or(0); + if let Some(card) = self.devices.get(&device_id) { + if let Some((&nid, port)) = card.ports.get_index(sink_pos) { + let identifier = port.identifier.clone(); + return cosmic::Task::future(async move { + wpctl_set_default(nid).await; + Message::SetDefaultSink(identifier) + }); + } + } + + Task::none() + } + + fn source_profile_select(&mut self, device_id: DeviceId) -> Task { + self.changing_source_profile = None; + let source_pos = self.active_source.unwrap_or(0); + + if let Some(card) = self.devices.get(&device_id) { + if let Some((&nid, port)) = card.ports.get_index(source_pos) { + let identifier = port.identifier.clone(); + return cosmic::Task::future(async move { + wpctl_set_default(nid).await; + Message::SetDefaultSource(identifier) + }); + } + } + + Task::none() + } +} + +#[derive(Debug)] +struct Card { + ports: IndexMap, +} + +#[derive(Debug)] +struct CardPort { + class: pipewire::MediaClass, + identifier: String, + description: String, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub enum DeviceId { + Alsa(u32), + Bluez5(String), + Unknown(), +} + +#[derive(Clone, Debug)] +pub enum Message { + /// Handle messages from the sound server. + Server(Arc>), + /// Set the default sink. + SetDefaultSink(String), + /// Set the default source. + SetDefaultSource(String), + /// Change the output volume. + SinkVolumeApply(NodeId), + /// Change the output balance. + SinkBalanceApply, + /// Change the input volume. + SourceVolumeApply(NodeId), + /// On init of the subscription, channels for closing background threads are given to the app. + SubHandle(Arc), +} + +#[derive(Clone, Debug)] +pub enum Server { + /// Get default sinks/sources and their volumes/mute status. + Pulse(pulse::Event), + /// Get ALSA cards and their profiles. + Pipewire(pipewire::DeviceEvent), +} + +pub struct SubscriptionHandle { + cancel_tx: futures::channel::oneshot::Sender<()>, + pipewire: pipewire::Sender<()>, +} + +impl std::fmt::Debug for SubscriptionHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SubscriptionHandle") + } +} + +fn sort_pulse_devices(descriptions: &mut Vec, node_ids: &mut Vec) { + let mut tmp: Vec<(String, NodeId)> = std::mem::take(descriptions) + .into_iter() + .zip(std::mem::take(node_ids)) + .collect(); + + tmp.sort_unstable_by(|(ak, _), (bk, _)| ak.cmp(bk)); + + (*descriptions, *node_ids) = tmp.into_iter().collect(); +} + +async fn pactl_set_card_profile(id: String, profile: String) { + tracing::debug!("pactl set-card-profile {id} {profile}"); + _ = tokio::process::Command::new("pactl") + .args(["set-card-profile", id.as_str(), profile.as_str()]) + .status() + .await +} + +async fn wpctl_set_default(id: u32) { + tracing::debug!("wpctl set-default {id}"); + let id = id.to_string(); + _ = tokio::process::Command::new("wpctl") + .args(["set-default", id.as_str()]) + .status() + .await; +} + +fn wpctl_set_mute(id: u32, mute: bool) { + tokio::task::spawn(async move { + let default = id.to_string(); + _ = tokio::process::Command::new("wpctl") + .args(["set-mute", default.as_str(), if mute { "1" } else { "0" }]) + .status() + .await; + }); +} + +fn wpctl_set_volume(id: u32, volume: u32) { + tokio::task::spawn(async move { + let id = id.to_string(); + let volume = format!("{}.{:02}", volume / 100, volume % 100); + _ = tokio::process::Command::new("wpctl") + .args(["set-volume", id.as_str(), volume.as_str()]) + .status() + .await; + }); +} diff --git a/subscriptions/sound/src/pipewire.rs b/subscriptions/sound/src/pipewire.rs new file mode 100644 index 0000000..5daf2c3 --- /dev/null +++ b/subscriptions/sound/src/pipewire.rs @@ -0,0 +1,279 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +// #![deny(missing_docs)] + +pub use pipewire::channel::Sender; + +use cosmic::iced_futures::{self, Subscription, stream}; +use futures::{SinkExt, executor::block_on}; +use pipewire::{ + context::Context as PwContext, + main_loop::MainLoop as PwMainLoop, + node::{Node, NodeInfoRef, NodeState}, + proxy::{Listener, ProxyT}, + types::ObjectType, +}; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap}, + rc::Rc, + thread::JoinHandle, +}; + +pub fn subscription() -> iced_futures::Subscription { + Subscription::run_with_id( + "pipewire", + stream::channel(20, |sender| async { + _ = thread(sender); + + futures::future::pending().await + }), + ) +} + +pub fn thread( + on_event: futures::channel::mpsc::Sender, +) -> (JoinHandle<()>, pipewire::channel::Sender<()>) { + let (pw_tx, pw_rx) = pipewire::channel::channel(); + + let handle = std::thread::spawn(move || { + devices_from_socket(pw_rx, on_event); + }); + + (handle, pw_tx) +} + +/// Node event` +#[derive(Debug)] +pub enum NodeEvent<'a> { + /// Node info + NodeInfo(u32, &'a NodeInfoRef), + /// Node removal + Remove(u32), +} + +/// Device event +#[derive(Clone, Debug)] +pub enum DeviceEvent { + /// A new device was detected. + Add(Device), + /// A device with the given object_id was removed. + Remove(u32), +} + +/// Device information +#[must_use] +#[derive(Clone, Debug)] +pub struct Device { + pub object_id: u32, + pub variant: DeviceVariant, + pub media_class: MediaClass, + pub product_name: String, + pub node_name: String, + pub state: DeviceState, +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub enum DeviceVariant { + Alsa { alsa_card: u32 }, + Bluez5 { address: String }, + Unknown {}, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DeviceState { + Idle, + Running, + Creating, + Suspended, + Error(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MediaClass { + Source, + Sink, +} + +impl Device { + /// Attains process info from a pipewire info node. + #[must_use] + pub fn from_node(info: &NodeInfoRef) -> Option { + let props = info.props()?; + + let (variant, product_name) = if let Some(alsa_card) = + props.get("alsa.card").and_then(|v| v.parse::().ok()) + { + let device_profile_description = props.get("device.profile.description")?.to_owned(); + + let description = props.get("node.description")?; + + let description = description + .strip_suffix(&device_profile_description) + .map(str::trim_end) + .unwrap_or(description) + .replace("High Definition Audio", "HD Audio"); + + (DeviceVariant::Alsa { alsa_card }, description) + } else if let Some(address) = props + .get("api.bluez5.address") + .and_then(|v| v.parse::().ok()) + { + ( + DeviceVariant::Bluez5 { + address: address.to_owned(), + }, + props.get("node.description")?.to_owned(), + ) + } else { + ( + DeviceVariant::Unknown {}, + props.get("node.description")?.to_owned(), + ) + }; + + Some(Device { + object_id: props.get("object.id")?.parse::().ok()?, + variant, + media_class: match props.get("media.class")? { + "Audio/Sink" => MediaClass::Sink, + "Audio/Source" => MediaClass::Source, + _ => return None, + }, + product_name, + node_name: props.get("node.name")?.to_owned(), + state: match info.state() { + NodeState::Idle => DeviceState::Idle, + NodeState::Running => DeviceState::Running, + NodeState::Creating => DeviceState::Creating, + NodeState::Suspended => DeviceState::Suspended, + NodeState::Error(why) => DeviceState::Error(why.to_owned()), + }, + }) + } +} + +/// Monitors the devices from a given ``PipeWire`` socket. +/// +/// ``PipeWire`` sockets are found in `/run/user/{{UID}}/pipewire-0`. +pub fn devices_from_socket( + pw_cancel: pipewire::channel::Receiver<()>, + mut on_event: futures::channel::mpsc::Sender, +) { + let mut managed = BTreeMap::new(); + + let _res = nodes_from_socket(pw_cancel, move |main_loop, event| match event { + NodeEvent::NodeInfo(pw_id, info) => { + if let Some(device) = Device::from_node(info) { + if managed.insert(pw_id, device.object_id).is_none() { + if block_on(on_event.send(DeviceEvent::Add(device))).is_err() { + main_loop.quit(); + } + } + } + } + + NodeEvent::Remove(pw_id) => { + if let Some(object_id) = managed.remove(&pw_id) { + if block_on(on_event.send(DeviceEvent::Remove(object_id))).is_err() { + main_loop.quit(); + } + } + } + }); +} + +/// Listens to information about nodes, passing that info into a callback. +/// +/// # Errors +/// +/// Errors if the pipewire connection fails +pub fn nodes_from_socket( + pw_cancel: pipewire::channel::Receiver<()>, + on_event: impl FnMut(&PwMainLoop, NodeEvent) + 'static, +) -> Result<(), Box> { + let main_loop = PwMainLoop::new(None)?; + let context = PwContext::new(&main_loop)?; + let core = context.connect(None)?; + + // Exit main loop on receivering terminate message. + let _cancel_rx = pw_cancel.attach(main_loop.loop_(), { + let main_loop = main_loop.clone(); + move |_| main_loop.quit() + }); + + let registry = Rc::new(core.get_registry()?); + let registry_weak = Rc::downgrade(®istry); + + let proxies = Rc::new(RefCell::new(HashMap::new())); + let on_event = Rc::new(RefCell::new(on_event)); + + let main_loop_clone = main_loop.clone(); + + let _registry_listener = registry + .add_listener_local() + .global(move |obj| { + let Some(registry) = registry_weak.upgrade() else { + return; + }; + + let attached_proxy: Option<(Box, Box)> = match obj.type_ { + ObjectType::Node => { + let Ok(node): Result = registry.bind(obj) else { + return; + }; + + let on_event_weak = Rc::downgrade(&on_event); + let main_loop = main_loop_clone.clone(); + let id = node.upcast_ref().id(); + + let listener = node + .add_listener_local() + .info(move |info| { + if let Some(on_event) = on_event_weak.upgrade() { + on_event.borrow_mut()(&main_loop, NodeEvent::NodeInfo(id, info)); + } + }) + .register(); + + Some((Box::new(node), Box::new(listener))) + } + + _ => None, + }; + + if let Some((proxy_spe, listener)) = attached_proxy { + let proxy = proxy_spe.upcast_ref(); + let id = proxy.id(); + let (object_type, _object_version) = proxy.get_type(); + + let proxies_weak = Rc::downgrade(&proxies); + let on_event_weak = Rc::downgrade(&on_event); + let main_loop = main_loop_clone.clone(); + + let remove_listener = proxy + .add_listener_local() + .removed(move || { + if object_type == ObjectType::Node { + if let Some(on_event) = on_event_weak.upgrade() { + on_event.borrow_mut()(&main_loop, NodeEvent::Remove(id)); + } + } + + if let Some(proxies) = proxies_weak.upgrade() { + proxies.borrow_mut().remove(&id); + } + }) + .register(); + + proxies + .borrow_mut() + .insert(id, (proxy_spe, listener, remove_listener)); + } + }) + .register(); + + main_loop.run(); + Ok(()) +} diff --git a/subscriptions/sound/src/pulse.rs b/subscriptions/sound/src/pulse.rs new file mode 100644 index 0000000..5fd0d7a --- /dev/null +++ b/subscriptions/sound/src/pulse.rs @@ -0,0 +1,752 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +// Make sure not to fail if pulse not found, and reconnect? +// change to device shouldn't send osd? + +use cosmic::iced_futures::{self, Subscription, stream}; +use futures::{SinkExt, executor::block_on}; +use libpulse_binding::{ + callbacks::ListResult, + channelmap::Map, + context::{ + Context, FlagSet, State, + introspect::{CardInfo, CardProfileInfo, Introspector, ServerInfo, SinkInfo, SourceInfo}, + subscribe::{Facility, InterestMaskSet, Operation}, + }, + def::{PortAvailable, Retval}, + mainloop::{ + api::MainloopApi, + events::io::IoEventInternal, + standard::{IterateResult, Mainloop}, + }, + volume::{ChannelVolumes, Volume}, +}; +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, + convert::Infallible, + io::{Read, Write}, + os::{ + fd::{FromRawFd, IntoRawFd, RawFd}, + raw::c_void, + }, + rc::Rc, + str::FromStr, + sync::mpsc, +}; + +pub fn subscription() -> iced_futures::Subscription { + Subscription::run_with_id( + "pulse", + stream::channel(20, |sender| async { + std::thread::spawn(move || thread(sender)); + futures::future::pending().await + }), + ) +} + +pub fn thread(sender: futures::channel::mpsc::Sender) { + let Some(mut main_loop) = Mainloop::new() else { + log::error!("Failed to create PA main loop"); + return; + }; + + let Some(mut context) = Context::new(&main_loop, "cosmic-osd") else { + log::error!("Failed to create PA context"); + return; + }; + + let data = Rc::new(Data { + main_loop: RefCell::new(Mainloop { + _inner: Rc::clone(&main_loop._inner), + }), + introspector: context.introspect(), + sink_volume: Cell::new(None), + sink_mute: Cell::new(None), + source_volume: Cell::new(None), + source_mute: Cell::new(None), + default_sink_name: RefCell::new(None), + default_source_name: RefCell::new(None), + sender: RefCell::new(sender.clone()), + }); + + let data_clone = data.clone(); + context.set_subscribe_callback(Some(Box::new(move |facility, operation, index| { + data_clone.subscribe_cb(facility.unwrap(), operation, index); + }))); + + let _ = context.connect(None, FlagSet::NOFAIL, None); + + loop { + if sender.is_closed() { + return; + } + + match main_loop.iterate(false) { + IterateResult::Success(_) => {} + IterateResult::Err(_e) => { + return; + } + IterateResult::Quit(_e) => { + return; + } + } + + if context.get_state() == State::Ready { + break; + } + } + + // Inspect all available cards on startup + data.introspector.get_card_info_list({ + let data_weak = Rc::downgrade(&data); + move |card_info_res| { + if let Some(data) = data_weak.upgrade() { + data.card_info_cb(card_info_res) + } + } + }); + + data.get_server_info(); + context.subscribe( + InterestMaskSet::SERVER | InterestMaskSet::SINK | InterestMaskSet::SOURCE, + |_| {}, + ); + + if let Err((err, retval)) = main_loop.run() { + log::error!("PA main loop returned {:?}, error {}", retval, err); + } +} + +#[derive(Clone, Debug)] +pub enum Event { + Balance(Option), + CardInfo(Card), + DefaultSink(String), + DefaultSource(String), + SinkVolume(u32), + Channels(PulseChannels), + SinkMute(bool), + SourceVolume(u32), + SourceMute(bool), +} + +enum Request { + Volume(u32, f32), + Balance(u32, f32), + Quit, +} + +#[derive(Debug)] +pub struct PulseChannels { + tx: mpsc::Sender, + pipe_tx: std::fs::File, + index: u32, +} + +impl Clone for PulseChannels { + fn clone(&self) -> Self { + Self { + tx: self.tx.clone(), + pipe_tx: self + .pipe_tx + .try_clone() + .expect("failed to clone PulseChannels pipe writer"), + index: self.index, + } + } +} + +/// Data used by the [`handle_balance_io_new`] callback. +struct HandleBalanceData( + Context, + ChannelVolumes, + Map, + std::sync::mpsc::Receiver, +); + +/// Callback for creating an IO event source [`MainloopApi::io_new`]. +extern "C" fn handle_balance_io_new( + api: *const MainloopApi, + event: *mut IoEventInternal, + reader_fd: RawFd, + _flags: libpulse_binding::mainloop::events::io::FlagSet, + data: *mut c_void, +) { + // Take ownership of the data and borrow its contents. + let mut data = unsafe { Box::::from_raw(data as _) }; + let HandleBalanceData(ctx, volumes, map, rx) = data.as_mut(); + + // Return early if the context is not ready, and give the data back. + if ctx.get_state() != State::Ready { + let _ = Box::leak(data); + return; + } + + // If the first byte cannot be read, destroy this event source with its reader and data. + let mut buf = [0u8; 1]; + let mut reader = unsafe { std::fs::File::from_raw_fd(reader_fd) }; + if reader.read_exact(&mut buf).is_err() { + (unsafe { &*api }) + .io_free + .as_ref() + .expect("io_free function is missing")(event); + return; + } + + // Give ownership of the reader back. + _ = reader.into_raw_fd(); + + while let Ok(req) = rx.try_recv() { + match req { + Request::Volume(index, volume_scale) => { + let mut intro = ctx.introspect(); + + let new_scale = Volume((volume_scale * Volume::NORMAL.0 as f32).round() as u32); + + if let Some(v) = volumes.scale(new_scale) { + _ = intro.set_sink_volume_by_index( + index, + v, + Some(Box::new(|success| { + if !success { + tracing::error!("Failed to set sink balance"); + } + })), + ); + } + } + Request::Balance(index, new_balance) => { + if map.can_balance() { + if let Some(v) = volumes.set_balance(&map, new_balance) { + let mut intro = ctx.introspect(); + + _ = intro.set_sink_volume_by_index( + index, + v, + Some(Box::new(|success| { + if !success { + tracing::error!("Failed to set sink balance"); + } + })), + ); + } + } + } + Request::Quit => unsafe { &*api } + .quit + .as_ref() + .expect("quit function missing")(api, 0), + } + } + + let _ = Box::leak(data); +} + +impl PulseChannels { + fn new( + volumes: ChannelVolumes, + map: Map, + api: &MainloopApi, + index: u32, + ctx: Context, + ) -> PulseChannels { + let (reader, writer) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC) + .expect("failed to crate pipe"); + + let (tx, rx) = mpsc::channel::(); + + // Create IO event source object for handling speaker balance. + let event_source = api.io_new.as_ref().unwrap()( + api as *const _, + reader.into_raw_fd(), + libpulse_binding::mainloop::events::io::FlagSet::INPUT, + Some(handle_balance_io_new), + Box::into_raw(Box::new(HandleBalanceData(ctx, volumes, map, rx))) as *mut c_void, + ); + + if let Some(enable) = api.io_enable.as_ref() { + enable( + event_source, + libpulse_binding::mainloop::events::io::FlagSet::INPUT, + ); + } + + Self { + tx, + pipe_tx: std::fs::File::from(writer), + index, + } + } + + /// Change the active index. + #[inline] + pub fn set_index(&mut self, index: u32) { + self.index = index; + } + + /// Set the speaker balance of the active sink. + pub fn set_balance(&mut self, balance: f32) { + if let Err(err) = self.tx.send(Request::Balance(self.index, balance)) { + tracing::error!(?err, "Failed to send new balance to channel"); + } else { + self.pipe_tx + .write_all(&[1]) + .expect("PulseChannels pipe write failed"); + } + } + + /// Set the volume of the active sink. + pub fn set_volume(&mut self, volume: f32) { + if let Err(err) = self.tx.send(Request::Volume(self.index, volume)) { + tracing::error!(?err, "Failed to send new volume to channel"); + } else { + self.pipe_tx + .write_all(&[1]) + .expect("PulseChannels pipe write failed"); + } + } + + /// Request the pulse thread to quit. + pub fn quit(mut self) { + _ = self.tx.send(Request::Quit); + _ = self.pipe_tx.write_all(&[1]); + } +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct Card { + pub object_id: u32, + pub name: String, + pub product_name: String, + pub variant: DeviceVariant, + pub ports: Vec, + pub profiles: Vec, + pub active_profile: Option, +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct CardPort { + pub name: String, + pub description: String, + pub direction: Direction, + pub port_type: PortType, + pub profile_port: u32, + pub priority: u32, + pub profiles: Vec, + pub availability: Availability, +} + +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub enum Availability { + Unknown, + No, + Yes, +} + +impl From for Availability { + fn from(pa: PortAvailable) -> Self { + match pa { + PortAvailable::Unknown => Availability::Unknown, + PortAvailable::No => Availability::No, + PortAvailable::Yes => Availability::Yes, + } + } +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct CardProfile { + pub name: String, + pub description: String, + pub available: bool, + pub n_sinks: u32, + pub n_sources: u32, + pub priority: u32, +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub enum DeviceVariant { + Alsa { alsa_card: u32 }, + Bluez5 { address: String }, +} + +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub enum Direction { + Input, + Output, + Both, +} + +#[derive(Default, Clone, Debug, Hash, Eq, PartialEq)] +pub enum PortType { + Mic, + Speaker, + Headphones, + Headset, + Digital, + #[default] + Unknown, +} + +impl FromStr for PortType { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + match s { + "mic" => Ok(PortType::Mic), + "speaker" => Ok(PortType::Speaker), + "headphones" => Ok(PortType::Headphones), + "headset" => Ok(PortType::Headset), + "digital" => Ok(PortType::Digital), + _ => Ok(PortType::Unknown), + } + } +} + +struct Data { + main_loop: RefCell, + default_sink_name: RefCell>, + default_source_name: RefCell>, + sink_volume: Cell>, + sink_mute: Cell>, + source_volume: Cell>, + source_mute: Cell>, + introspector: Introspector, + sender: RefCell>, +} + +impl Data { + fn card_info_cb(self: &Rc, card_info: ListResult<&CardInfo>) { + if let ListResult::Item(card_info) = card_info { + let Some(object_id) = card_info + .proplist + .get_str("object.id") + .and_then(|v| v.parse::().ok()) + else { + return; + }; + + let variant = if let Some(alsa_card) = card_info + .proplist + .get_str("alsa.card") + .and_then(|v| v.parse::().ok()) + { + DeviceVariant::Alsa { alsa_card } + } else if let Some(address) = card_info.proplist.get_str("api.bluez5.address") { + DeviceVariant::Bluez5 { address } + } else { + return; + }; + + let card = Card { + name: card_info + .name + .as_ref() + .map(Cow::to_string) + .unwrap_or_default(), + product_name: card_info + .proplist + .get_str("device.product.name") + .unwrap_or_default(), + object_id, + variant, + ports: card_info + .ports + .iter() + .map(|port| CardPort { + name: port.name.as_ref().map(Cow::to_string).unwrap_or_default(), + description: port + .description + .as_ref() + .map(Cow::to_string) + .unwrap_or_default(), + direction: match port.direction.bits() { + x if x == libpulse_binding::direction::FlagSet::INPUT.bits() => { + Direction::Input + } + x if x == libpulse_binding::direction::FlagSet::OUTPUT.bits() => { + Direction::Output + } + _ => Direction::Both, + }, + port_type: port + .proplist + .get_str("port.type") + .as_deref() + .map(|s| PortType::from_str(s).unwrap()) + .unwrap_or_default(), + profile_port: port + .proplist + .get_str("card.profile.port") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0), + priority: port.priority, + profiles: collect_profiles(&port.profiles), + availability: port.available.into(), + }) + .collect(), + profiles: collect_profiles(&card_info.profiles), + active_profile: card_info.active_profile.as_deref().map(CardProfile::from), + }; + + if block_on(self.sender.borrow_mut().send(Event::CardInfo(card))).is_err() { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + } + + fn server_info_cb(self: &Rc, server_info: &ServerInfo) { + let new_default_sink_name = server_info + .default_sink_name + .as_ref() + .map(|x| x.clone().into_owned()); + let mut default_sink_name = self.default_sink_name.borrow_mut(); + if new_default_sink_name != *default_sink_name { + if let Some(name) = &new_default_sink_name { + _ = block_on( + self.sender + .borrow_mut() + .send(Event::DefaultSink(name.clone())), + ); + self.get_sink_info_by_name(name); + } + *default_sink_name = new_default_sink_name; + } + + let new_default_source_name = server_info + .default_source_name + .as_ref() + .map(|x| x.clone().into_owned()); + let mut default_source_name = self.default_source_name.borrow_mut(); + if new_default_source_name != *default_source_name { + if let Some(name) = &new_default_source_name { + _ = block_on( + self.sender + .borrow_mut() + .send(Event::DefaultSource(name.clone())), + ); + self.get_source_info_by_name(name); + } + *default_source_name = new_default_source_name; + } + } + + fn get_server_info(self: &Rc) { + let data = self.clone(); + self.introspector + .get_server_info(move |server_info| data.server_info_cb(server_info)); + } + + fn sink_info_cb(&self, sink_info_res: ListResult<&SinkInfo>) { + if let ListResult::Item(sink_info) = sink_info_res { + if sink_info.name.as_deref() != self.default_sink_name.borrow().as_deref() { + return; + } + let balance = (sink_info.channel_map.can_balance() + && sink_info.base_volume.is_normal()) + .then(|| sink_info.volume.get_balance(&sink_info.channel_map)); + + let volume = sink_info.volume.max().0 / (Volume::NORMAL.0 / 100); + if self.sink_mute.get() != Some(sink_info.mute) { + self.sink_mute.set(Some(sink_info.mute)); + if block_on( + self.sender + .borrow_mut() + .send(Event::SinkMute(sink_info.mute)), + ) + .is_err() + { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + if self.sink_volume.get() != Some(volume) { + self.sink_volume.set(Some(volume)); + if block_on(self.sender.borrow_mut().send(Event::SinkVolume(volume))).is_err() { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + if block_on(self.sender.borrow_mut().send(Event::Balance(balance))).is_err() { + self.main_loop.borrow_mut().quit(Retval(0)); + } + let mut main_loop = self.main_loop.borrow_mut(); + let api = main_loop.get_api(); + if let Some(mut ctx) = Context::new(&*main_loop, "balance") { + let _ = ctx.connect(None, FlagSet::NOFAIL, None); + + let channels = PulseChannels::new( + sink_info.volume, + sink_info.channel_map, + api, + sink_info.index, + ctx, + ); + + if block_on(self.sender.borrow_mut().send(Event::Channels(channels))).is_err() { + main_loop.quit(Retval(0)); + } + } + } + } + + fn source_info_cb(&self, source_info_res: ListResult<&SourceInfo>) { + if let ListResult::Item(source_info) = source_info_res { + if source_info.name.as_deref() != self.default_source_name.borrow().as_deref() { + return; + } + let volume = source_info.volume.max().0 / (Volume::NORMAL.0 / 100); + if self.source_mute.get() != Some(source_info.mute) { + self.source_mute.set(Some(source_info.mute)); + if block_on( + self.sender + .borrow_mut() + .send(Event::SourceMute(source_info.mute)), + ) + .is_err() + { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + if self.source_volume.get() != Some(volume) { + self.source_volume.set(Some(volume)); + if block_on(self.sender.borrow_mut().send(Event::SourceVolume(volume))).is_err() { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + } + } + + fn get_card_info_by_index(self: &Rc, index: u32) { + let data = self.clone(); + self.introspector + .get_card_info_by_index(index, move |card_info_res| { + data.card_info_cb(card_info_res); + }); + } + + fn get_sink_info_by_index(self: &Rc, index: u32) { + let data = self.clone(); + self.introspector.get_sink_info_by_index( + index, + move |sink_info_res: ListResult<&SinkInfo<'_>>| { + if let ListResult::Item(ref info) = sink_info_res { + if let Some(card_index) = info.card { + let data_clone = data.clone(); + data.introspector.get_card_info_by_index( + card_index, + move |card_info_res| { + data_clone.card_info_cb(card_info_res); + }, + ); + } + } + data.sink_info_cb(sink_info_res); + }, + ); + } + + fn get_sink_info_by_name(self: &Rc, name: &str) { + let data = self.clone(); + self.introspector + .get_sink_info_by_name(name, move |sink_info_res| { + if let ListResult::Item(ref info) = sink_info_res { + if let Some(card_index) = info.card { + let data_clone = data.clone(); + data.introspector.get_card_info_by_index( + card_index, + move |card_info_res| { + data_clone.card_info_cb(card_info_res); + }, + ); + } + } + data.sink_info_cb(sink_info_res); + }); + } + + fn get_source_info_by_index(self: &Rc, index: u32) { + let data = self.clone(); + self.introspector + .get_source_info_by_index(index, move |source_info_res| { + if let ListResult::Item(ref info) = source_info_res { + if let Some(card_index) = info.card { + let data_clone = data.clone(); + data.introspector.get_card_info_by_index( + card_index, + move |card_info_res| { + data_clone.card_info_cb(card_info_res); + }, + ); + } + } + data.source_info_cb(source_info_res); + }); + } + + fn get_source_info_by_name(self: &Rc, name: &str) { + let data = self.clone(); + self.introspector + .get_source_info_by_name(name, move |source_info_res| { + if let ListResult::Item(ref info) = source_info_res { + if let Some(card_index) = info.card { + let data_clone = data.clone(); + data.introspector.get_card_info_by_index( + card_index, + move |card_info_res| { + data_clone.card_info_cb(card_info_res); + }, + ); + } + } + data.source_info_cb(source_info_res); + }); + } + + fn subscribe_cb( + self: &Rc, + facility: Facility, + _operation: Option, + index: u32, + ) { + match facility { + Facility::Server => { + self.get_server_info(); + } + Facility::Sink => { + self.get_sink_info_by_index(index); + } + Facility::Source => { + self.get_source_info_by_index(index); + } + Facility::Card => { + self.get_card_info_by_index(index); + } + _ => {} + } + } +} + +fn collect_profiles(profiles: &[CardProfileInfo]) -> Vec { + profiles.iter().map(CardProfile::from).collect() +} + +impl From<&CardProfileInfo<'_>> for CardProfile { + fn from(profile: &CardProfileInfo) -> Self { + CardProfile { + name: profile + .name + .as_ref() + .map(Cow::to_string) + .unwrap_or_default(), + description: profile + .description + .as_ref() + .map(Cow::to_string) + .unwrap_or_default(), + available: profile.available, + n_sinks: profile.n_sinks, + n_sources: profile.n_sources, + priority: profile.priority, + } + } +} diff --git a/subscriptions/upower/Cargo.toml b/subscriptions/upower/Cargo.toml new file mode 100644 index 0000000..dee148d --- /dev/null +++ b/subscriptions/upower/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cosmic-settings-upower-subscription" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" +rust-version.workspace = true +publish = true + +[dependencies] +futures = "0.3.31" +iced_futures = { git = "https://github.com/pop-os/libcosmic" } +log = "0.4.28" +tokio = "1.47.1" +tokio-stream = "0.1.17" +upower_dbus = { git = "https://github.com/pop-os/dbus-settings-bindings" } +zbus = "5.11.0" diff --git a/subscriptions/upower/LICENSE.md b/subscriptions/upower/LICENSE.md new file mode 100644 index 0000000..8dc5b15 --- /dev/null +++ b/subscriptions/upower/LICENSE.md @@ -0,0 +1,359 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + 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/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/subscriptions/upower/src/lib.rs b/subscriptions/upower/src/lib.rs new file mode 100644 index 0000000..5612c44 --- /dev/null +++ b/subscriptions/upower/src/lib.rs @@ -0,0 +1,189 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +pub mod device { + use futures::{FutureExt, Stream, StreamExt}; + use iced_futures::Subscription; + use std::{fmt::Debug, hash::Hash}; + use upower_dbus::{BatteryType, DeviceProxy, UPowerProxy}; + + pub fn device_subscription( + id: I, + ) -> iced_futures::Subscription { + Subscription::run_with_id( + id, + async move { + match events().await { + Ok(stream) => stream, + Err(err) => { + log::error!("Error getting events from upower device: {}", err); + futures::future::pending().await + } + } + } + .flatten_stream(), + ) + } + + async fn display_device() -> zbus::Result<(UPowerProxy<'static>, DeviceProxy<'static>)> { + let connection = zbus::Connection::system().await?; + let upower: UPowerProxy<'_> = UPowerProxy::new(&connection).await?; + let device_path = upower.get_display_device().await?; + Ok((upower, device_path)) + } + + async fn events() -> zbus::Result> { + let (upower, device) = display_device().await?; + let devices = upower.enumerate_devices().await?; + let mut has_battery = false; + for device in devices { + let Ok(d) = DeviceProxy::builder(upower.inner().connection()).path(device) else { + continue; + }; + let Ok(d) = d.build().await else { + continue; + }; + if d.type_().await == Ok(BatteryType::Battery) + && d.power_supply().await.unwrap_or_default() + { + has_battery = true; + break; + } + } + + let initial = futures::stream::iter(if has_battery { + None + } else { + Some(DeviceDbusEvent::NoBattery) + }); + + let stream = futures::stream_select!( + upower.receive_on_battery_changed().await.map(|_| ()), + device.receive_percentage_changed().await.map(|_| ()), + device.receive_time_to_empty_changed().await.map(|_| ()), + ); + + Ok(initial.chain(stream.map(move |_| { + DeviceDbusEvent::Update { + on_battery: upower + .cached_on_battery() + .unwrap_or_default() + .unwrap_or_default(), + percent: device + .cached_percentage() + .unwrap_or_default() + .unwrap_or_default(), + time_to_empty: device + .cached_time_to_empty() + .unwrap_or_default() + .unwrap_or_default(), + } + }))) + } + + #[derive(Debug, Clone)] + pub enum DeviceDbusEvent { + NoBattery, + Update { + on_battery: bool, + percent: f64, + time_to_empty: i64, + }, + } +} + +pub mod kbdbacklight { + // TODO Test how this handles upower starting after applet does, if it ever is + // started or restarted. + + use futures::{FutureExt, Stream, StreamExt}; + use iced_futures::Subscription; + use std::{fmt::Debug, hash::Hash}; + use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; + use tokio_stream::wrappers::UnboundedReceiverStream; + use upower_dbus::{BrightnessChanged, KbdBacklightProxy}; + + pub fn kbd_backlight_subscription( + id: I, + ) -> iced_futures::Subscription { + Subscription::run_with_id( + id, + async move { + match events().await { + Ok(stream) => stream, + Err(err) => { + log::error!("Error listening to KbdBacklight: {}", err); + futures::future::pending().await + } + } + } + .flatten_stream(), + ) + } + + enum Event { + BrightnessChanged(BrightnessChanged), + Request(KeyboardBacklightRequest), + } + + async fn events() -> zbus::Result> { + let conn = zbus::Connection::system().await?; + let kbd_proxy = KbdBacklightProxy::builder(&conn).build().await?; + let (tx, rx) = unbounded_channel(); + + let max_brightness = kbd_proxy.get_max_brightness().await?; + let brightness = kbd_proxy.get_brightness().await?; + + let brightness_changed_stream = kbd_proxy.receive_brightness_changed().await?; + + let initial = futures::stream::iter([ + KeyboardBacklightUpdate::Sender(tx), + KeyboardBacklightUpdate::MaxBrightness(max_brightness), + KeyboardBacklightUpdate::Brightness(brightness), + ]); + let stream = futures::stream::select( + UnboundedReceiverStream::new(rx).map(Event::Request), + brightness_changed_stream.map(Event::BrightnessChanged), + ); + Ok(initial.chain(stream.filter_map(move |event| { + let kbd_proxy = kbd_proxy.clone(); + async move { + match event { + Event::BrightnessChanged(changed) => { + if let Ok(args) = changed.args() { + Some(KeyboardBacklightUpdate::Brightness(*args.value())) + } else { + None + } + } + Event::Request(req) => match req { + KeyboardBacklightRequest::Get => { + if let Ok(brightness) = kbd_proxy.get_brightness().await { + Some(KeyboardBacklightUpdate::Brightness(brightness)) + } else { + None + } + } + KeyboardBacklightRequest::Set(value) => { + let _ = kbd_proxy.set_brightness(value).await; + None + } + }, + } + } + }))) + } + + #[derive(Debug, Clone)] + pub enum KeyboardBacklightUpdate { + Sender(UnboundedSender), + Brightness(i32), + MaxBrightness(i32), + } + + #[derive(Debug, Clone)] + pub enum KeyboardBacklightRequest { + Get, + Set(i32), + } +}