From 000ac7b8b42a984c3b265c1813799eaa2b3fad0b Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 8 Feb 2023 18:38:09 -0500 Subject: [PATCH] wip: bluetooth applet --- Cargo.lock | 201 ++++++++ Cargo.toml | 5 +- cosmic-applet-bluetooth/Cargo.toml | 23 + ...com.system76.CosmicAppletBluetooth.desktop | 12 + .../com.system76.CosmicAppletBluetooth.svg | 60 +++ .../data/resources/resources.gresource.xml | 6 + cosmic-applet-bluetooth/i18n.toml | 4 + .../i18n/en/cosmic_applet_bluetooth.ftl | 10 + cosmic-applet-bluetooth/src/app.rs | 406 ++++++++++++++++ cosmic-applet-bluetooth/src/bluetooth.rs | 440 ++++++++++++++++++ cosmic-applet-bluetooth/src/config.rs | 3 + cosmic-applet-bluetooth/src/localize.rs | 47 ++ cosmic-applet-bluetooth/src/main.rs | 23 + 13 files changed, 1238 insertions(+), 2 deletions(-) create mode 100644 cosmic-applet-bluetooth/Cargo.toml create mode 100644 cosmic-applet-bluetooth/data/com.system76.CosmicAppletBluetooth.desktop create mode 100644 cosmic-applet-bluetooth/data/icons/com.system76.CosmicAppletBluetooth.svg create mode 100644 cosmic-applet-bluetooth/data/resources/resources.gresource.xml create mode 100644 cosmic-applet-bluetooth/i18n.toml create mode 100644 cosmic-applet-bluetooth/i18n/en/cosmic_applet_bluetooth.ftl create mode 100644 cosmic-applet-bluetooth/src/app.rs create mode 100644 cosmic-applet-bluetooth/src/bluetooth.rs create mode 100644 cosmic-applet-bluetooth/src/config.rs create mode 100644 cosmic-applet-bluetooth/src/localize.rs create mode 100644 cosmic-applet-bluetooth/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 05d66b1b..c4956160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,35 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bluer" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d76ba39a871348200bbbf7dbff9fbaec30f0b988420f7391bfd9fdc5f8b5144" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.26.1", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + [[package]] name = "bumpalo" version = "3.11.1" @@ -547,6 +576,27 @@ dependencies = [ "zbus", ] +[[package]] +name = "cosmic-applet-bluetooth" +version = "0.1.0" +dependencies = [ + "anyhow", + "bluer", + "futures", + "futures-util", + "i18n-embed", + "i18n-embed-fl", + "itertools", + "libcosmic", + "log", + "once_cell", + "pretty_env_logger", + "rust-embed", + "slotmap", + "smithay-client-toolkit", + "tokio", +] + [[package]] name = "cosmic-applet-graphics" version = "0.1.0" @@ -817,6 +867,26 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" +[[package]] +name = "custom_debug" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8225047674d65dcf4321e6bd3e060bdbbe940604a99b1559827f3e61c498d1e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b35d34eb004bf2d33c093f1c55ee77829e8654644288d3b6afd8c2d99d23729" +dependencies = [ + "proc-macro2", + "syn", + "synstructure", +] + [[package]] name = "cxx" version = "1.0.86" @@ -964,6 +1034,39 @@ dependencies = [ "matches", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a1a74a0c53b22d7d994256cf3ecbeefa5eedce3cf9d362945ac523c4132180" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "derivative" version = "2.2.0" @@ -2230,6 +2333,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" + [[package]] name = "jpeg-decoder" version = "0.1.22" @@ -2331,6 +2440,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "libdbus-sys" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8d7ae751e1cb825c840ae5e682f59b098cdfd213c350ac268b61449a5f58a0" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2471,6 +2589,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3436,6 +3560,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5583e89e108996506031660fe09baa5011b9dd0341b89029313006d1fb508d70" + [[package]] name = "rustybuzz" version = "0.4.0" @@ -3469,6 +3599,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ryu" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" + [[package]] name = "safe_arch" version = "0.5.2" @@ -3543,6 +3679,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.10" @@ -3791,6 +3938,28 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "svg_fmt" version = "0.4.1" @@ -3843,6 +4012,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + [[package]] name = "sys-locale" version = "0.2.3" @@ -4020,6 +4201,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.10" @@ -4243,6 +4435,15 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "uuid" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" +dependencies = [ + "getrandom", +] + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 5d506a12..9813cca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "cosmic-app-list", "cosmic-applet-audio", "cosmic-applet-battery", + "cosmic-applet-bluetooth", "cosmic-applet-graphics", "cosmic-applet-network", "cosmic-applet-notifications", @@ -12,5 +13,5 @@ members = [ "cosmic-applet-workspaces", ] -[profile.release] -lto = "fat" +#[profile.release] +#lto = "fat" diff --git a/cosmic-applet-bluetooth/Cargo.toml b/cosmic-applet-bluetooth/Cargo.toml new file mode 100644 index 00000000..c382ee7f --- /dev/null +++ b/cosmic-applet-bluetooth/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cosmic-applet-bluetooth" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" + +[dependencies] +once_cell = "1.16.0" +bluer = { version = "0.15", features = ["bluetoothd"] } +futures-util = "0.3.21" +libcosmic = { git = "https://github.com/pop-os/libcosmic/", branch = "master", default-features = false, features = ["wayland", "applet", "tokio"] } +sctk = { package = "smithay-client-toolkit", git = "https://github.com/Smithay/client-toolkit", rev = "3776d4a" } +futures = "0.3" +log = "0.4" +pretty_env_logger = "0.4" +itertools = "0.10.3" +slotmap = "1.0.6" +tokio = { version = "1.15.0", features = ["full"] } +anyhow = "1.0" +# Application i18n +i18n-embed = { version = "0.13.4", features = ["fluent-system", "desktop-requester"] } +i18n-embed-fl = "0.6.4" +rust-embed = "6.3.0" diff --git a/cosmic-applet-bluetooth/data/com.system76.CosmicAppletBluetooth.desktop b/cosmic-applet-bluetooth/data/com.system76.CosmicAppletBluetooth.desktop new file mode 100644 index 00000000..e65db372 --- /dev/null +++ b/cosmic-applet-bluetooth/data/com.system76.CosmicAppletBluetooth.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Cosmic Applet Network +Comment=Write a GTK + Rust application +Type=Application +Exec=cosmic-applet-network +Terminal=false +Categories=GNOME;GTK; +Keywords=Gnome;GTK; +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=com.system76.CosmicAppletNetwork.svg +StartupNotify=true +NoDisplay=true diff --git a/cosmic-applet-bluetooth/data/icons/com.system76.CosmicAppletBluetooth.svg b/cosmic-applet-bluetooth/data/icons/com.system76.CosmicAppletBluetooth.svg new file mode 100644 index 00000000..c2bd5b1b --- /dev/null +++ b/cosmic-applet-bluetooth/data/icons/com.system76.CosmicAppletBluetooth.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cosmic-applet-bluetooth/data/resources/resources.gresource.xml b/cosmic-applet-bluetooth/data/resources/resources.gresource.xml new file mode 100644 index 00000000..2cf0970f --- /dev/null +++ b/cosmic-applet-bluetooth/data/resources/resources.gresource.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/cosmic-applet-bluetooth/i18n.toml b/cosmic-applet-bluetooth/i18n.toml new file mode 100644 index 00000000..05c50ba2 --- /dev/null +++ b/cosmic-applet-bluetooth/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" \ No newline at end of file diff --git a/cosmic-applet-bluetooth/i18n/en/cosmic_applet_bluetooth.ftl b/cosmic-applet-bluetooth/i18n/en/cosmic_applet_bluetooth.ftl new file mode 100644 index 00000000..378a87ec --- /dev/null +++ b/cosmic-applet-bluetooth/i18n/en/cosmic_applet_bluetooth.ftl @@ -0,0 +1,10 @@ +bluetooth = Bluetooth +other-devices = Other Bluetooth Devices +settings = Bluetooth Settings... +connected = Connected +confirm-pin = Please confirm that the following PIN matches the one displayed on {$deviceName} +confirm = Confirm +cancel = Cancel +unsuccessful = Pairing Unsuccessful +check-device = Make sure {$deviceName} is turned on, in range, and is ready to pair. +try-again = Try Again \ No newline at end of file diff --git a/cosmic-applet-bluetooth/src/app.rs b/cosmic-applet-bluetooth/src/app.rs new file mode 100644 index 00000000..dbb77d59 --- /dev/null +++ b/cosmic-applet-bluetooth/src/app.rs @@ -0,0 +1,406 @@ +use std::f32::consts::E; + +use crate::bluetooth::{BluerDeviceStatus, BluerRequest, BluerState}; +use cosmic::applet::APPLET_BUTTON_THEME; +use cosmic::iced_style; +use cosmic::widget::ListColumn; +use cosmic::{ + applet::CosmicAppletHelper, + iced::{ + wayland::{ + popup::{destroy_popup, get_popup}, + SurfaceIdWrapper, + }, + widget::{column, container, row, scrollable, text, text_input, Column}, + Alignment, Application, Color, Command, Length, Subscription, + }, + iced_native::{ + alignment::{Horizontal, Vertical}, + layout::Limits, + renderer::BorderRadius, + window, + }, + iced_style::{application, button::StyleSheet, svg}, + theme::{Button, Svg}, + widget::{button, divider, icon, toggler}, + Element, Theme, +}; +use tokio::sync::mpsc::Sender; + +use crate::bluetooth::{bluetooth_subscription, BluerEvent}; +use crate::{config, fl}; + +pub fn run() -> cosmic::iced::Result { + let helper = CosmicAppletHelper::default(); + CosmicBluetoothApplet::run(helper.window_settings()) +} + +#[derive(Debug)] +enum NewConnectionState { + EnterPassword { device: (), password: String }, + Waiting(()), + Failure(()), +} + +// impl Into<()> for NewConnectionState { +// fn into(self) -> AccessPoint { +// match self { +// NewConnectionState::EnterPassword { +// access_point, +// password, +// } => access_point, +// NewConnectionState::Waiting(access_point) => access_point, +// NewConnectionState::Failure(access_point) => access_point, +// } +// } +// } + +#[derive(Default)] +struct CosmicBluetoothApplet { + icon_name: String, + theme: Theme, + popup: Option, + id_ctr: u32, + applet_helper: CosmicAppletHelper, + bluer_state: BluerState, + bluer_sender: Option>, + // UI state + show_visible_devices: bool, + new_connection: Option, +} + +#[derive(Debug, Clone)] +enum Message { + TogglePopup, + ToggleVisibleDevices(bool), + Errored(String), + Ignore, + BluetoothEvent(BluerEvent), + Request(BluerRequest), +} + +impl Application for CosmicBluetoothApplet { + type Message = Message; + type Theme = Theme; + type Executor = cosmic::SingleThreadExecutor; + type Flags = (); + + fn new(_flags: ()) -> (Self, Command) { + ( + CosmicBluetoothApplet { + icon_name: "bluetooth-symbolic".to_string(), + ..Default::default() + }, + Command::none(), + ) + } + + fn title(&self) -> String { + config::APP_ID.to_string() + } + + fn update(&mut self, message: Message) -> Command { + match message { + Message::TogglePopup => { + if let Some(p) = self.popup.take() { + return destroy_popup(p); + } else { + // TODO request update of state maybe + self.id_ctr += 1; + let new_id = window::Id::new(self.id_ctr); + self.popup.replace(new_id); + + let mut popup_settings = self.applet_helper.get_popup_settings( + window::Id::new(0), + new_id, + None, + None, + None, + ); + + popup_settings.positioner.size_limits = Limits::NONE + .min_height(1) + .min_width(1) + .max_height(800) + .max_width(400); + return get_popup(popup_settings); + } + } + Message::Errored(_) => todo!(), + Message::Ignore => {} + // Message::SelectDevice(device) => { + // let tx = if let Some(tx) = self.nm_sender.as_ref() { + // tx + // } else { + // return Command::none(); + // }; + + // let _ = tx.unbounded_send(NetworkManagerRequest::SelectAccessPoint( + // access_point.ssid.clone(), + // )); + + // self.new_connection + // .replace(NewConnectionState::EnterPassword { + // access_point, + // password: String::new(), + // }); + // } + Message::ToggleVisibleDevices(enabled) => { + self.new_connection.take(); + self.show_visible_devices = enabled; + } + Message::BluetoothEvent(e) => match e { + BluerEvent::RequestResponse { + req: _req, + state, + err_msg, + } => { + if let Some(err_msg) = err_msg { + eprintln!("bluetooth request error: {}", err_msg); + } + dbg!(&state); + self.bluer_state = state; + // TODO special handling for some requests + } + BluerEvent::Init { sender, state } => { + self.bluer_sender.replace(sender); + self.bluer_state = state; + } + BluerEvent::DevicesChanged { state } => { + self.bluer_state = state; + } + BluerEvent::Finished => { + // TODO exit? + todo!() + } + }, + Message::Request(r) => { + match &r { + BluerRequest::SetBluetoothEnabled(enabled) => { + self.bluer_state.bluetooth_enabled = *enabled; + if !*enabled { + self.bluer_state = BluerState::default(); + } + } + BluerRequest::ConnectDevice(add) => { + self.bluer_state + .devices + .iter_mut() + .find(|d| d.address == *add) + .map(|d| { + d.status = BluerDeviceStatus::Connecting; + }); + } + BluerRequest::DisconnectDevice(add) => { + self.bluer_state + .devices + .iter_mut() + .find(|d| d.address == *add) + .map(|d| { + d.status = BluerDeviceStatus::Disconnecting; + }); + } + BluerRequest::PairDevice(add) => { + self.bluer_state + .devices + .iter_mut() + .find(|d| d.address == *add) + .map(|d| { + d.status = BluerDeviceStatus::Pairing; + }); + } + _ => {} // TODO + } + if let Some(tx) = self.bluer_sender.as_mut().cloned() { + return Command::perform( + async move { + let _ = tx.send(r).await; + }, + |_| Message::Ignore, // Error handling + ); + } + } + } + Command::none() + } + fn view(&self, id: SurfaceIdWrapper) -> Element { + let button_style = Button::Custom { + active: |t| iced_style::button::Appearance { + border_radius: BorderRadius::from(0.0), + ..t.active(&Button::Text) + }, + hover: |t| iced_style::button::Appearance { + border_radius: BorderRadius::from(0.0), + ..t.hovered(&Button::Text) + }, + }; + match id { + SurfaceIdWrapper::LayerSurface(_) => unimplemented!(), + SurfaceIdWrapper::Window(_) => self + .applet_helper + .icon_button(&self.icon_name) + .on_press(Message::TogglePopup) + .into(), + SurfaceIdWrapper::Popup(_) => { + let mut known_bluetooth = column![]; + for dev in &self.bluer_state.devices { + let mut row = row![].align_items(Alignment::Center); + row = row.push( + text(dev.name.clone()) + .horizontal_alignment(Horizontal::Left) + .vertical_alignment(Vertical::Center) + .width(Length::Fill), + ); + match &dev.status { + BluerDeviceStatus::Connected => { + row = row.push( + text(fl!("connected")) + .horizontal_alignment(Horizontal::Right) + .vertical_alignment(Vertical::Center), + ); + } + BluerDeviceStatus::Paired => {} + BluerDeviceStatus::Connecting | BluerDeviceStatus::Disconnecting => { + row = row.push( + icon("process-working-symbolic", 24) + .style(Svg::Custom(|theme| svg::Appearance { + color: Some(theme.palette().text), + })) + .width(Length::Units(24)) + .height(Length::Units(24)), + ); + } + BluerDeviceStatus::Disconnected | BluerDeviceStatus::Pairing => continue, + }; + + known_bluetooth = known_bluetooth.push( + button(APPLET_BUTTON_THEME) + .custom(vec![row.into()]) + .on_press(match dev.status { + BluerDeviceStatus::Connected => { + Message::Request(BluerRequest::DisconnectDevice(dev.address)) + } + BluerDeviceStatus::Disconnected => { + Message::Request(BluerRequest::PairDevice(dev.address)) + } + BluerDeviceStatus::Paired => { + Message::Request(BluerRequest::ConnectDevice(dev.address)) + } + BluerDeviceStatus::Connecting => { + Message::Request(BluerRequest::CancelConnect(dev.address)) + } + BluerDeviceStatus::Disconnecting => Message::Ignore, // Start connecting? + BluerDeviceStatus::Pairing => Message::Ignore, // Cancel pairing? + }) + .width(Length::Fill), + ); + } + + let mut content = column![ + container( + toggler(fl!("bluetooth"), self.bluer_state.bluetooth_enabled, |m| { + Message::Request(BluerRequest::SetBluetoothEnabled(m)) + },) + .width(Length::Fill) + ) + .padding([0, 12]), + divider::horizontal::light(), + known_bluetooth, + ] + .align_items(Alignment::Center) + .spacing(8) + .padding([8, 0]); + let dropdown_icon = if self.show_visible_devices { + "go-down-symbolic" + } else { + "go-next-symbolic" + }; + let available_connections_btn = button(Button::Secondary) + .custom( + vec![ + text(fl!("other-devices")) + .size(14) + .width(Length::Fill) + .height(Length::Units(24)) + .vertical_alignment(Vertical::Center) + .into(), + container( + icon(dropdown_icon, 14) + .style(Svg::Custom(|theme| svg::Appearance { + color: Some(theme.palette().text), + })) + .width(Length::Units(14)) + .height(Length::Units(14)), + ) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .width(Length::Units(24)) + .height(Length::Units(24)) + .into(), + ] + .into(), + ) + .padding([8, 24]) + .style(button_style.clone()) + .on_press(Message::ToggleVisibleDevices(!self.show_visible_devices)); + content = content.push(available_connections_btn); + if self.show_visible_devices { + let mut list_column = Vec::with_capacity(self.bluer_state.devices.len()); + + if self.bluer_state.bluetooth_enabled { + let mut visible_devices = column![]; + for dev in self.bluer_state.devices.iter().filter(|d| { + matches!( + d.status, + BluerDeviceStatus::Disconnected | BluerDeviceStatus::Pairing + ) + }) { + let mut row = row![].width(Length::Fill).align_items(Alignment::Center); + row = row.push( + text(dev.name.clone()).horizontal_alignment(Horizontal::Left), + ); + visible_devices = visible_devices.push( + button(APPLET_BUTTON_THEME) + .custom(vec![row.width(Length::Fill).into()]) + .on_press(Message::Request(BluerRequest::PairDevice( + dev.address.clone(), + ))) + .width(Length::Fill), + ); + } + list_column.push(visible_devices.into()); + } + let num_dev = list_column.len(); + if num_dev > 5 { + content = content.push( + scrollable(Column::with_children(list_column)) + .height(Length::Units(300)), + ); + } else { + content = content.push(Column::with_children(list_column)); + } + } + self.applet_helper.popup_container(content).into() + } + } + } + + fn subscription(&self) -> Subscription { + bluetooth_subscription(0).map(|e| Message::BluetoothEvent(e.1)) + } + + fn theme(&self) -> Theme { + self.theme + } + + fn close_requested(&self, _id: SurfaceIdWrapper) -> Self::Message { + Message::Ignore + } + + fn style(&self) -> ::Style { + ::Style::Custom(|theme| application::Appearance { + background_color: Color::from_rgba(0.0, 0.0, 0.0, 0.0), + text_color: theme.cosmic().on_bg_color().into(), + }) + } +} diff --git a/cosmic-applet-bluetooth/src/bluetooth.rs b/cosmic-applet-bluetooth/src/bluetooth.rs new file mode 100644 index 00000000..9c7b366f --- /dev/null +++ b/cosmic-applet-bluetooth/src/bluetooth.rs @@ -0,0 +1,440 @@ +use std::{collections::HashMap, fmt::Debug, hash::Hash, sync::Arc, time::Duration}; + +use bluer::{Adapter, AdapterProperty, Address, DeviceProperty, Session}; +use cosmic::iced::{self, subscription}; + +use futures::StreamExt; +use tokio::{ + spawn, + sync::{ + mpsc::{channel, Receiver, Sender}, + Mutex, + }, + task::JoinHandle, + time::timeout, +}; + +pub fn bluetooth_subscription( + id: I, +) -> iced::Subscription<(I, BluerEvent)> { + subscription::unfold(id, State::Ready, move |state| start_listening(id, state)) +} + +#[derive(Debug)] +pub enum State { + Ready, + Waiting { session_state: BluerSessionState }, + Finished, +} + +async fn start_listening(id: I, state: State) -> (Option<(I, BluerEvent)>, State) { + match state { + State::Ready => { + let session = match Session::new().await { + Ok(s) => s, + Err(_) => return (None, State::Finished), + }; + let (tx, rx) = channel(100); + + let session_state = match BluerSessionState::new(session, rx).await { + Ok(s) => s, + Err(_) => return (None, State::Finished), + }; + + let state = session_state.bluer_state().await; + return ( + Some(( + id, + BluerEvent::Init { + sender: tx, + state: state.clone(), + }, + )), + State::Waiting { session_state }, + ); + } + State::Waiting { mut session_state } => { + let mut session_rx = match session_state.rx.take() { + Some(rx) => rx, + None => { + // try restarting the stream + session_state.process_changes(); + match session_state.rx.take() { + Some(rx) => rx, + None => { + return (None, State::Finished); // fail if we can't restart the stream + } + } + } + }; + + let event = if let Some(event) = session_rx.recv().await { + match event { + BluerSessionEvent::ChangesProcessed(state) => { + return ( + Some((id, BluerEvent::DevicesChanged { state })), + State::Waiting { session_state }, + ); + } + BluerSessionEvent::RequestResponse { + req, + state, + err_msg, + } => Some(( + id, + BluerEvent::RequestResponse { + req, + state, + err_msg, + }, + )), + _ => None, + } + } else { + return (None, State::Finished); + }; + + session_state.rx = Some(session_rx); + (event, State::Waiting { session_state }) + } + State::Finished => iced::futures::future::pending().await, + } +} + +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub enum BluerRequest { + SetBluetoothEnabled(bool), + PairDevice(Address), + ConnectDevice(Address), + DisconnectDevice(Address), + CancelConnect(Address), +} + +#[derive(Debug, Clone)] +pub enum BluerEvent { + RequestResponse { + req: BluerRequest, + state: BluerState, + err_msg: Option, + }, + Init { + sender: Sender, + state: BluerState, + }, + DevicesChanged { + state: BluerState, + }, + Finished, +} + +#[derive(Debug, Clone, Default)] +pub struct BluerState { + pub devices: Vec, + pub bluetooth_enabled: bool, +} + +#[derive(Debug, Clone)] +pub enum BluerDeviceStatus { + Connected, + Disconnected, + Paired, + Connecting, + Disconnecting, + Pairing, +} + +#[derive(Debug, Clone)] +pub struct BluerDevice { + pub name: String, + pub address: Address, + pub status: BluerDeviceStatus, + pub properties: Vec, +} + +pub enum BluerSessionEvent { + RequestResponse { + req: BluerRequest, + state: BluerState, + err_msg: Option, + }, + ChangesProcessed(BluerState), + ChangeStreamEnded, // TODO can we just restart the stream in a new task? +} + +#[derive(Debug)] +pub struct BluerSessionState { + session: Session, + pub adapter: Adapter, + pub devices: Arc>>, + pub rx: Option>, + tx: Option>, + active_requests: Arc>>>>, +} + +impl BluerSessionState { + pub(crate) async fn new( + session: Session, + request_rx: Receiver, + ) -> anyhow::Result { + let adapter = session.default_adapter().await?; + let devices = build_device_list(&adapter).await; + + let mut self_ = Self { + session, + adapter: adapter, + devices: Arc::new(Mutex::new(devices)), + rx: None, + tx: None, + active_requests: Arc::new(Mutex::new(HashMap::new())), + }; + self_.process_changes(); + self_.process_requests(request_rx); + + Ok(self_) + } + + pub(crate) async fn devices(&self) -> Vec { + self.devices.lock().await.clone() + } + + pub(crate) async fn clear(&mut self) { + self.devices.lock().await.clear(); + } + + pub(crate) fn start_monitoring(&mut self) { + self.process_changes(); + } + + pub(crate) fn process_changes(&mut self) { + let (tx, rx) = tokio::sync::mpsc::channel(100); + self.tx = Some(tx.clone()); + let devices_clone = self.devices.clone(); + let adapter_clone = self.adapter.clone(); + let _monitor_devices: tokio::task::JoinHandle> = + spawn(async move { + let mut change_stream = adapter_clone.discover_devices_with_changes().await?; + + let mut cur = None; + let mut devices_changed = false; + let mut milli_timeout = 10; + 'outer: loop { + while let Ok(event) = + timeout(Duration::from_millis(milli_timeout), change_stream.next()).await + { + let event = match event { + Some(e) => e, + None => break 'outer, // No more events to receive... + }; + let mut devices = devices_clone.lock().await; + match event { + bluer::AdapterEvent::DeviceAdded(address) => { + let device = match adapter_clone.device(address) { + Ok(d) => d, + Err(_) => continue, + }; + + let mut status = if device.is_connected().await? { + BluerDeviceStatus::Connected + } else if device.is_paired().await? { + BluerDeviceStatus::Paired + } else { + BluerDeviceStatus::Disconnected + }; + + if let Some(pos) = + devices.iter().position(|device| device.address == address) + { + cur = Some(pos); + continue; + }; + // only send a DevicesChanged event if we have actually added a device + devices_changed = true; + + devices.push(BluerDevice { + name: device + .name() + .await + .unwrap_or_default() + .unwrap_or_default(), + address: device.address(), + status, + properties: Vec::new(), + }); + cur = Some(devices.len() - 1); + } + bluer::AdapterEvent::DeviceRemoved(address) => { + if let Some(pos) = + devices.iter().position(|device| device.address == address) + { + devices_changed = true; + cur = None; + devices.remove(pos); + }; + } + bluer::AdapterEvent::PropertyChanged(prop) => { + let bluer_device = match cur.and_then(|i| devices.get_mut(i)) { + Some(d) => d, + None => continue, + }; + devices_changed = true; + } + } + } + if devices_changed { + devices_changed = false; + dbg!(&devices_clone); + let _ = tx + .send(BluerSessionEvent::ChangesProcessed(BluerState { + devices: build_device_list(&adapter_clone).await, + bluetooth_enabled: true, + })) + .await; + // reset timeout + milli_timeout = 10; + } else { + // slow down if no changes occur + milli_timeout = (milli_timeout * 2).max(5120); + } + } + eprintln!("Change stream ended"); + Ok(()) + }); + self.rx.replace(rx); + } + + pub(crate) fn process_requests(&self, request_rx: Receiver) { + let active_requests = self.active_requests.clone(); + let adapter = self.adapter.clone(); + let devices = self.devices.clone(); + let tx = self.tx.clone().unwrap(); // TODO error handling + let _handle: JoinHandle> = spawn(async move { + let mut request_rx = request_rx; + + while let Some(req) = request_rx.recv().await { + let req_clone = req.clone(); + let req_clone_2 = req.clone(); + let active_requests_clone = active_requests.clone(); + let devices_clone = devices.clone(); + let tx_clone = tx.clone(); + let adapter_clone = adapter.clone(); + let handle = spawn(async move { + let mut err_msg = None; + match &req_clone { + BluerRequest::SetBluetoothEnabled(enabled) => { + let res = adapter_clone.set_powered(*enabled).await; + if let Err(e) = res { + err_msg = Some(e.to_string()); + } + if *enabled { + let res = adapter_clone.set_discoverable(*enabled).await; + if let Err(e) = res { + err_msg = Some(e.to_string()); + } + } + } + BluerRequest::PairDevice(address) => { + let res = adapter_clone.device(address.clone()); + if let Err(err) = res { + err_msg = Some(err.to_string()); + } else if let Ok(device) = res { + let res = device.pair().await; + if let Err(err) = res { + err_msg = Some(err.to_string()); + } + } + } + BluerRequest::ConnectDevice(address) => { + let res = adapter_clone.device(address.clone()); + if let Err(err) = res { + err_msg = Some(err.to_string()); + } else if let Ok(device) = res { + let res = device.connect().await; + if let Err(err) = res { + err_msg = Some(err.to_string()); + } + } + } + BluerRequest::DisconnectDevice(address) => { + let res = adapter_clone.device(address.clone()); + if let Err(err) = res { + err_msg = Some(err.to_string()); + } else if let Ok(device) = res { + let res = device.disconnect().await; + if let Err(err) = res { + err_msg = Some(err.to_string()); + } + } + } + BluerRequest::CancelConnect(_) => { + if let Some(handle) = active_requests_clone.lock().await.get(&req_clone) + { + handle.abort(); + } else { + err_msg = Some("No active connection request found".to_string()); + } + } + }; + + let state = BluerState { + devices: build_device_list(&adapter_clone).await, + bluetooth_enabled: adapter_clone.is_powered().await.unwrap_or_default(), + }; + + let _ = tx_clone + .send(BluerSessionEvent::RequestResponse { + req: req_clone, + state, + err_msg, + }) + .await; + + let mut active_requests_clone = active_requests_clone.lock().await; + let _ = active_requests_clone.remove(&req_clone_2); + + Ok(()) + }); + + active_requests.lock().await.insert(req, handle); + } + Ok(()) + }); + } + + pub(crate) async fn bluer_state(&self) -> BluerState { + BluerState { + devices: build_device_list(&self.adapter).await, + // TODO is this a proper way of checking if bluetooth is enabled? + bluetooth_enabled: self.adapter.is_powered().await.unwrap_or_default(), + } + } +} + +async fn build_device_list(adapter: &Adapter) -> Vec { + let addrs = adapter.device_addresses().await.unwrap_or_default(); + let mut devices = Vec::with_capacity(addrs.len()); + + for address in addrs { + let device = match adapter.device(address) { + Ok(device) => device, + Err(_) => continue, + }; + let name = device.name().await.unwrap_or_default().unwrap_or_default(); + let is_paired = device.is_paired().await.unwrap_or_default(); + let is_connected = device.is_connected().await.unwrap_or_default(); + let properties = device.all_properties().await.unwrap_or_default(); + let status = if is_connected { + BluerDeviceStatus::Connected + } else if is_paired { + BluerDeviceStatus::Paired + } else { + BluerDeviceStatus::Disconnected + }; + devices.push(BluerDevice { + name, + address, + status, + properties, + }); + } + devices +} diff --git a/cosmic-applet-bluetooth/src/config.rs b/cosmic-applet-bluetooth/src/config.rs new file mode 100644 index 00000000..42153094 --- /dev/null +++ b/cosmic-applet-bluetooth/src/config.rs @@ -0,0 +1,3 @@ +pub const APP_ID: &str = "com.system76.CosmicAppletNetwork"; +pub const PROFILE: &str = ""; +pub const VERSION: &str = "0.1.0"; diff --git a/cosmic-applet-bluetooth/src/localize.rs b/cosmic-applet-bluetooth/src/localize.rs new file mode 100644 index 00000000..baa05d0d --- /dev/null +++ b/cosmic-applet-bluetooth/src/localize.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-only + +use i18n_embed::{ + fluent::{fluent_language_loader, FluentLanguageLoader}, + DefaultLocalizer, LanguageLoader, Localizer, +}; +use once_cell::sync::Lazy; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: Lazy = Lazy::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id) + }}; + + ($message_id:literal, $($args:expr),*) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *) + }}; +} + +// Get the `Localizer` to be used for localizing this library. +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +pub fn localize() { + let localizer = localizer(); + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); + + if let Err(error) = localizer.select(&requested_languages) { + eprintln!("Error while loading language for App List {}", error); + } +} diff --git a/cosmic-applet-bluetooth/src/main.rs b/cosmic-applet-bluetooth/src/main.rs new file mode 100644 index 00000000..408bcbec --- /dev/null +++ b/cosmic-applet-bluetooth/src/main.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +mod app; +mod bluetooth; +mod config; +mod localize; + +use log::info; + +use crate::config::{APP_ID, PROFILE, VERSION}; +use crate::localize::localize; + +fn main() -> cosmic::iced::Result { + // Initialize logger + pretty_env_logger::init(); + info!("Iced Workspaces Applet ({})", APP_ID); + info!("Version: {} ({})", VERSION, PROFILE); + + // Prepare i18n + localize(); + + app::run() +}