├── .envrc ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── assets └── demo.gif ├── .gitignore ├── Cross.toml ├── src ├── lib.rs ├── macros.rs ├── bz │ ├── pairing.rs │ ├── scanner.rs │ ├── controller.rs │ ├── agent.rs │ └── device.rs ├── main.rs ├── launcher.rs ├── notification.rs ├── icons.rs ├── menu.rs └── app.rs ├── Cargo.toml ├── CONTRIBUTING.md ├── flake.nix ├── flake.lock ├── locales └── app.yml ├── README.md └── LICENSE.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: e_tho 2 | liberapay: e-tho -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e-tho/bzmenu/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | 3 | /target/ 4 | 5 | result 6 | 7 | **/*.rs.bk 8 | 9 | .vscode/ 10 | 11 | **/*.rs.bk 12 | 13 | /doc/ 14 | 15 | .idea/ 16 | *.swp 17 | *.swo 18 | *~ 19 | 20 | .DS_Store 21 | Thumbs.db 22 | .aider* 23 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | pre-build = [ 3 | "dpkg --add-architecture $CROSS_DEB_ARCH", 4 | "apt-get update && apt-get install -y libdbus-1-dev:$CROSS_DEB_ARCH pkg-config:$CROSS_DEB_ARCH", 5 | ] 6 | 7 | [target.x86_64-unknown-linux-gnu] 8 | image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main" 9 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rust_i18n; 3 | #[macro_use] 4 | mod macros; 5 | i18n!("locales", fallback = "en"); 6 | 7 | pub mod app; 8 | pub mod icons; 9 | pub mod launcher; 10 | pub mod menu; 11 | pub mod notification; 12 | pub mod bz { 13 | pub mod agent; 14 | pub mod controller; 15 | pub mod device; 16 | pub mod pairing; 17 | pub mod scanner; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - "*" 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: dtolnay/rust-toolchain@stable 13 | with: 14 | toolchain: stable 15 | components: clippy rustfmt 16 | 17 | - name: Install system dependencies 18 | run: | 19 | sudo apt-get update 20 | sudo apt-get install -y libdbus-1-dev pkg-config 21 | 22 | - name: Linting 23 | run: | 24 | cargo clippy --workspace --all-features -- -D warnings 25 | cargo fmt --all -- --check 26 | 27 | - name: Debug builds 28 | run: cargo build -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! try_send_notification { 3 | ($manager:expr, $summary:expr, $body:expr, $icon:expr, $timeout:expr, $id:expr) => {{ 4 | let _ = $manager 5 | .send_notification($summary, $body, $icon, $timeout, $id) 6 | .map_err(|e| eprintln!("Notification failed: {e}")); 7 | }}; 8 | } 9 | 10 | #[macro_export] 11 | macro_rules! try_send_notification_with_id { 12 | ($manager:expr, $summary:expr, $body:expr, $icon:expr, $timeout:expr, $id:expr) => {{ 13 | match $manager.send_notification($summary, $body, $icon, $timeout, $id) { 14 | Ok(id) => Some(id), 15 | Err(e) => { 16 | eprintln!("Notification failed: {e}"); 17 | None 18 | } 19 | } 20 | }}; 21 | } 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bzmenu" 3 | version = "0.3.0" 4 | edition = "2018" 5 | description = "Launcher-driven Bluetooth manager for Linux" 6 | authors = ["e-tho"] 7 | license = "GPL-3.0-or-later" 8 | readme = "README.md" 9 | homepage = "https://github.com/e-tho/bzmenu" 10 | repository = "https://github.com/e-tho/bzmenu" 11 | 12 | [dependencies] 13 | bluer = { version = "0.17", features = ["bluetoothd"] } 14 | anyhow = "1.0" 15 | tokio = { version = "1", features = [ 16 | "rt-multi-thread", 17 | "macros", 18 | "sync", 19 | "time", 20 | ] } 21 | futures-util = { version = "0.3", default-features = false, features = [ 22 | "alloc", 23 | ] } 24 | log = "0.4" 25 | env_logger = "0.11" 26 | clap = { version = "3", features = ["derive"] } 27 | nix = { version = "0.29", features = ["process", "signal"] } 28 | libc = "0.2" 29 | process-wrap = { version = "8.2", features = ["std"] } 30 | signal-hook = "0.3" 31 | notify-rust = "4" 32 | shlex = "1.3" 33 | rust-i18n = "3" 34 | sys-locale = "0.3" 35 | 36 | [profile.release] 37 | strip = true 38 | lto = true 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Pull requests are welcome! Please follow these guidelines: 4 | 5 | ## Code Quality 6 | 7 | Before submitting, make sure you: 8 | 9 | - **Run Clippy** to check for linting issues: 10 | 11 | ```shell 12 | cargo clippy --workspace --all-features -- -D warnings 13 | ``` 14 | 15 | - **Format the code** to ensure consistency within the project: 16 | 17 | ```shell 18 | cargo fmt --all 19 | ``` 20 | 21 | ## Message Guidelines 22 | 23 | - Use **sentence case** and **present tense** 24 | - Start with **imperative verbs** (e.g., "Add", "Fix") 25 | - Keep the message **concise**, **direct**, and **focused on intent** (e.g., avoid file names or code excerpts) 26 | - For **non-functional** changes, include keywords like `docs`, `flake`, `locale`, or `workflow`, depending on the area affected 27 | - Optionally, use a `for ...` suffix to clarify intent when it adds value 28 | 29 | ### Example 30 | 31 | ``` 32 | Add contribution guidelines and reference them in docs 33 | ``` 34 | 35 | ## Packaging Notes 36 | 37 | The officially maintained builds are the Nix flake and the `bzmenu-git` AUR package. If you're contributing packaging for other systems, please treat these as the reference implementations in terms of structure, dependencies, and build behavior. 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | - "v[0-9]+.[0-9]+.[0-9]+-*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: cargo-bins/cargo-binstall@main 17 | - uses: dtolnay/rust-toolchain@stable 18 | with: 19 | toolchain: stable 20 | 21 | - name: Setup the build env 22 | run: | 23 | sudo apt-get update && \ 24 | sudo apt-get install -y \ 25 | podman \ 26 | qemu-user-static \ 27 | pkg-config \ 28 | libdbus-1-dev \ 29 | binutils-aarch64-linux-gnu 30 | cargo binstall --no-confirm cross 31 | 32 | - name: Build for x86_64 linux gnu 33 | run: | 34 | cargo build --release 35 | cp target/release/bzmenu bzmenu-x86_64-linux-gnu 36 | strip bzmenu-x86_64-linux-gnu 37 | 38 | - name: Build for aarch64 linux gnu 39 | run: | 40 | CROSS_CONTAINER_ENGINE=podman cross build --target aarch64-unknown-linux-gnu --release 41 | cp target/aarch64-unknown-linux-gnu/release/bzmenu bzmenu-aarch64-linux-gnu 42 | aarch64-linux-gnu-strip bzmenu-aarch64-linux-gnu 43 | 44 | - name: Create Release 45 | uses: softprops/action-gh-release@v1 46 | with: 47 | files: bzmenu-* 48 | generate_release_notes: true 49 | prerelease: ${{ contains(github.ref, '-') }} 50 | -------------------------------------------------------------------------------- /src/bz/pairing.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use bluer::Adapter; 3 | use log::{debug, info}; 4 | use std::sync::Arc; 5 | 6 | use crate::bz::device::Device; 7 | 8 | pub struct PairingManager { 9 | adapter: Arc, 10 | } 11 | 12 | impl PairingManager { 13 | pub fn adapter(&self) -> &Arc { 14 | &self.adapter 15 | } 16 | 17 | pub fn new(adapter: Arc) -> Self { 18 | Self { adapter } 19 | } 20 | 21 | pub async fn pair_device(&self, device: &Device) -> Result<()> { 22 | debug!("Initiating pairing with {}: {}", device.addr, device.alias); 23 | device.pair().await?; 24 | info!("Successfully paired with {}: {}", device.addr, device.alias); 25 | Ok(()) 26 | } 27 | 28 | pub async fn connect_device(&self, device: &Device) -> Result<()> { 29 | debug!("Connecting to {}: {}", device.addr, device.alias); 30 | device.connect().await?; 31 | info!( 32 | "Successfully connected to {}: {}", 33 | device.addr, device.alias 34 | ); 35 | Ok(()) 36 | } 37 | 38 | pub async fn disconnect_device(&self, device: &Device) -> Result<()> { 39 | debug!("Disconnecting from {}: {}", device.addr, device.alias); 40 | device.disconnect().await?; 41 | info!( 42 | "Successfully disconnected from {}: {}", 43 | device.addr, device.alias 44 | ); 45 | Ok(()) 46 | } 47 | 48 | pub async fn forget_device(&self, device: &Device) -> Result<()> { 49 | debug!("Removing device {}: {}", device.addr, device.alias); 50 | device.forget().await?; 51 | info!( 52 | "Successfully removed device {}: {}", 53 | device.addr, device.alias 54 | ); 55 | Ok(()) 56 | } 57 | } 58 | 59 | pub trait PairingConfirmationHandler: Send + Sync { 60 | fn request_confirmation( 61 | &self, 62 | device_address: &str, 63 | passkey: &str, 64 | on_confirm: Box, 65 | on_reject: Box, 66 | ) -> Result<()>; 67 | } 68 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Launcher-driven Bluetooth manager for Linux"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | rust-overlay.url = "github:oxalica/rust-overlay"; 7 | flake-utils.url = "github:numtide/flake-utils"; 8 | }; 9 | 10 | outputs = 11 | { 12 | self, 13 | nixpkgs, 14 | rust-overlay, 15 | flake-utils, 16 | ... 17 | }: 18 | flake-utils.lib.eachDefaultSystem ( 19 | system: 20 | let 21 | overlays = [ (import rust-overlay) ]; 22 | 23 | pkgs = import nixpkgs { 24 | inherit system overlays; 25 | }; 26 | in 27 | { 28 | packages.default = pkgs.rustPlatform.buildRustPackage { 29 | pname = "bzmenu"; 30 | version = self.shortRev or self.dirtyShortRev or "unknown"; 31 | 32 | src = ./.; 33 | 34 | cargoLock = { 35 | lockFile = ./Cargo.lock; 36 | }; 37 | 38 | nativeBuildInputs = with pkgs; [ 39 | pkg-config 40 | ]; 41 | 42 | buildInputs = with pkgs; [ 43 | dbus.dev 44 | ]; 45 | 46 | doCheck = true; 47 | CARGO_BUILD_INCREMENTAL = "false"; 48 | RUST_BACKTRACE = "full"; 49 | 50 | meta = { 51 | description = "Launcher-driven Bluetooth manager for Linux"; 52 | homepage = "https://github.com/e-tho/bzmenu"; 53 | license = pkgs.lib.licenses.gpl3Plus; 54 | maintainers = [ 55 | { 56 | github = "e-tho"; 57 | } 58 | ]; 59 | mainProgram = "bzmenu"; 60 | }; 61 | }; 62 | 63 | devShells.default = 64 | with pkgs; 65 | mkShell { 66 | nativeBuildInputs = [ 67 | pkg-config 68 | (rust-bin.stable.latest.default.override { 69 | extensions = [ "rust-src" ]; 70 | }) 71 | ]; 72 | 73 | buildInputs = [ 74 | dbus.dev 75 | ]; 76 | 77 | inherit (self.packages.${system}.default) CARGO_BUILD_INCREMENTAL RUST_BACKTRACE; 78 | }; 79 | } 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1758277210, 24 | "narHash": "sha256-iCGWf/LTy+aY0zFu8q12lK8KuZp7yvdhStehhyX1v8w=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1744536153, 40 | "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1758508617, 66 | "narHash": "sha256-kx2uELmVnAbiekj/YFfWR26OXqXedImkhe2ocnbumTA=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "d2bac276ac7e669a1f09c48614538a37e3eb6d0f", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /src/bz/scanner.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use bluer::Adapter; 3 | use log::{debug, info, warn}; 4 | use std::sync::atomic::{AtomicBool, Ordering}; 5 | use std::sync::Arc; 6 | use tokio::{spawn, sync::Mutex, task::JoinHandle, time::Duration}; 7 | 8 | #[derive(Clone)] 9 | pub struct Scanner { 10 | adapter: Arc, 11 | is_scanning: Arc, 12 | scan_task: Arc>>>, 13 | } 14 | 15 | impl Scanner { 16 | pub fn new(adapter: Arc, is_scanning: Arc) -> Self { 17 | Self { 18 | adapter, 19 | is_scanning, 20 | scan_task: Arc::new(Mutex::new(None)), 21 | } 22 | } 23 | 24 | pub async fn start_discovery(&self, timeout_sec: u64) -> Result<()> { 25 | if self.is_scanning.load(Ordering::Relaxed) { 26 | warn!("Bluetooth discovery already in progress"); 27 | return Ok(()); 28 | } 29 | 30 | info!("Starting Bluetooth discovery for {timeout_sec} seconds..."); 31 | 32 | let discovery_stream = self.adapter.discover_devices().await?; 33 | self.is_scanning.store(true, Ordering::Relaxed); 34 | 35 | let is_scanning = self.is_scanning.clone(); 36 | 37 | let task = spawn(async move { 38 | tokio::time::sleep(Duration::from_secs(timeout_sec)).await; 39 | 40 | is_scanning.store(false, Ordering::Relaxed); 41 | info!("Discovery completed after {timeout_sec} seconds"); 42 | 43 | drop(discovery_stream); 44 | }); 45 | 46 | let mut scan_task_guard = self.scan_task.lock().await; 47 | *scan_task_guard = Some(task); 48 | 49 | Ok(()) 50 | } 51 | 52 | pub async fn stop_discovery(&self) -> Result<()> { 53 | if !self.is_scanning.load(Ordering::Relaxed) { 54 | warn!("No Bluetooth discovery in progress to stop"); 55 | return Ok(()); 56 | } 57 | 58 | info!("Stopping Bluetooth discovery..."); 59 | 60 | self.is_scanning.store(false, Ordering::Relaxed); 61 | 62 | let mut scan_task_guard = self.scan_task.lock().await; 63 | if let Some(task) = scan_task_guard.take() { 64 | task.abort(); 65 | debug!("Discovery task aborted"); 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | pub async fn is_discovery_completed(&self) -> bool { 72 | !self.is_scanning.load(Ordering::Relaxed) 73 | } 74 | 75 | pub async fn wait_for_discovery_completion(&self) -> Result<()> { 76 | if !self.is_scanning.load(Ordering::Relaxed) { 77 | return Ok(()); 78 | } 79 | 80 | debug!("Waiting for discovery to complete..."); 81 | 82 | while self.is_scanning.load(Ordering::Relaxed) { 83 | tokio::time::sleep(Duration::from_millis(500)).await; 84 | } 85 | 86 | debug!("Discovery process completed"); 87 | Ok(()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/bz/controller.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use bluer::{Adapter, Session}; 3 | use log::info; 4 | use std::sync::{atomic::AtomicBool, Arc}; 5 | 6 | use crate::bz::device::Device; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct Controller { 10 | pub adapter: Arc, 11 | pub name: String, 12 | pub alias: String, 13 | pub is_powered: bool, 14 | pub is_pairable: bool, 15 | pub is_discoverable: bool, 16 | pub is_scanning: Arc, 17 | pub paired_devices: Vec, 18 | pub new_devices: Vec, 19 | } 20 | 21 | impl Controller { 22 | pub async fn new(session: Arc) -> Result { 23 | let adapter_names = session.adapter_names().await?; 24 | let adapter_name = adapter_names 25 | .first() 26 | .ok_or_else(|| anyhow!("No Bluetooth adapter found"))?; 27 | 28 | let adapter = session.adapter(adapter_name)?; 29 | let adapter_arc = Arc::new(adapter); 30 | 31 | let name = adapter_arc.name().to_owned(); 32 | let alias = adapter_arc.alias().await?; 33 | let is_powered = adapter_arc.is_powered().await?; 34 | let is_pairable = adapter_arc.is_pairable().await?; 35 | let is_discoverable = adapter_arc.is_discoverable().await?; 36 | let is_scanning = adapter_arc.is_discovering().await?; 37 | 38 | let (paired_devices, new_devices) = Self::get_devices(&adapter_arc).await?; 39 | 40 | info!("Bluetooth adapter {name} initialized"); 41 | 42 | Ok(Self { 43 | adapter: adapter_arc, 44 | name, 45 | alias, 46 | is_powered, 47 | is_pairable, 48 | is_discoverable, 49 | is_scanning: Arc::new(AtomicBool::new(is_scanning)), 50 | paired_devices, 51 | new_devices, 52 | }) 53 | } 54 | 55 | pub async fn refresh(&mut self) -> Result<()> { 56 | self.is_powered = self.adapter.is_powered().await?; 57 | self.is_pairable = self.adapter.is_pairable().await?; 58 | self.is_discoverable = self.adapter.is_discoverable().await?; 59 | 60 | let (paired_devices, new_devices) = Self::get_devices(&self.adapter).await?; 61 | self.paired_devices = paired_devices; 62 | self.new_devices = new_devices; 63 | 64 | Ok(()) 65 | } 66 | 67 | pub async fn power_on(&self) -> Result<()> { 68 | self.adapter.set_powered(true).await?; 69 | Ok(()) 70 | } 71 | 72 | pub async fn power_off(&self) -> Result<()> { 73 | self.adapter.set_powered(false).await?; 74 | Ok(()) 75 | } 76 | 77 | pub async fn set_discoverable(&self, discoverable: bool) -> Result<()> { 78 | self.adapter.set_discoverable(discoverable).await?; 79 | Ok(()) 80 | } 81 | 82 | pub async fn set_pairable(&self, pairable: bool) -> Result<()> { 83 | self.adapter.set_pairable(pairable).await?; 84 | Ok(()) 85 | } 86 | 87 | async fn get_devices(adapter: &Adapter) -> Result<(Vec, Vec)> { 88 | let mut paired_devices = Vec::new(); 89 | let mut new_devices = Vec::new(); 90 | 91 | let device_addresses = adapter.device_addresses().await?; 92 | 93 | for addr in device_addresses { 94 | if let Ok(device) = Device::new(adapter, &addr).await { 95 | if device.is_paired { 96 | paired_devices.push(device); 97 | } else { 98 | new_devices.push(device); 99 | } 100 | } 101 | } 102 | 103 | paired_devices.sort_by_key(|d| d.addr); 104 | new_devices.sort_by_key(|d| d.addr); 105 | 106 | Ok((paired_devices, new_devices)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/bz/agent.rs: -------------------------------------------------------------------------------- 1 | use crate::bz::pairing::PairingConfirmationHandler; 2 | use anyhow::Result; 3 | use bluer::agent::{Agent, AgentHandle, ReqError}; 4 | use bluer::Session; 5 | use futures_util::FutureExt; 6 | use log::{debug, info}; 7 | use std::{ 8 | sync::{ 9 | atomic::{AtomicBool, Ordering}, 10 | Arc, 11 | }, 12 | time::Duration, 13 | }; 14 | use tokio::sync::{ 15 | mpsc::{unbounded_channel, UnboundedSender}, 16 | Mutex, 17 | }; 18 | use tokio::time::timeout; 19 | 20 | pub struct AgentManager { 21 | session: Arc, 22 | confirmation_required: Arc, 23 | passkey_sender: UnboundedSender, 24 | _agent_handle: AgentHandle, 25 | } 26 | 27 | impl AgentManager { 28 | pub async fn new( 29 | session: Arc, 30 | pairing_handler: Arc, 31 | ) -> Result { 32 | let (passkey_sender, passkey_receiver) = unbounded_channel::(); 33 | let _passkey_receiver = Arc::new(Mutex::new(passkey_receiver)); 34 | let confirmation_required = Arc::new(AtomicBool::new(false)); 35 | 36 | let agent = { 37 | let confirmation_required_clone = confirmation_required.clone(); 38 | let passkey_sender_clone = passkey_sender.clone(); 39 | let pairing_handler = pairing_handler.clone(); 40 | 41 | Agent { 42 | request_default: true, 43 | request_confirmation: Some(Box::new(move |req| { 44 | let confirmation_required = confirmation_required_clone.clone(); 45 | let _passkey_sender = passkey_sender_clone.clone(); 46 | let pairing_handler = pairing_handler.clone(); 47 | 48 | async move { 49 | confirmation_required.store(true, Ordering::Relaxed); 50 | 51 | let device_address = req.device.to_string(); 52 | let passkey_str = format!("{:06}", req.passkey); 53 | 54 | info!( 55 | "Confirm passkey {passkey_str} for device {device_address}? (yes/no)" 56 | ); 57 | 58 | let (tx, mut rx) = tokio::sync::mpsc::channel::(1); 59 | 60 | let device_address_clone = device_address.clone(); 61 | 62 | let _ = pairing_handler.request_confirmation( 63 | &device_address, 64 | &passkey_str, 65 | Box::new({ 66 | let tx = tx.clone(); 67 | let device_addr = device_address_clone.clone(); 68 | move || { 69 | debug!("User confirmed pairing for device {device_addr}"); 70 | let _ = tx.blocking_send(true); 71 | } 72 | }), 73 | Box::new({ 74 | let tx = tx.clone(); 75 | let device_addr = device_address_clone.clone(); 76 | move || { 77 | debug!("User rejected pairing for device {device_addr}"); 78 | let _ = tx.blocking_send(false); 79 | } 80 | }), 81 | ); 82 | 83 | let result = match timeout(Duration::from_secs(30), rx.recv()).await { 84 | Ok(Some(true)) => Ok(()), 85 | _ => Err(ReqError::Rejected), 86 | }; 87 | 88 | confirmation_required.store(false, Ordering::Relaxed); 89 | result 90 | } 91 | .boxed() 92 | })), 93 | ..Default::default() 94 | } 95 | }; 96 | 97 | let agent_handle = session.register_agent(agent).await?; 98 | 99 | info!("Bluetooth agent registered"); 100 | 101 | Ok(Self { 102 | session, 103 | confirmation_required, 104 | passkey_sender, 105 | _agent_handle: agent_handle, 106 | }) 107 | } 108 | 109 | pub fn session(&self) -> Arc { 110 | self.session.clone() 111 | } 112 | 113 | pub fn confirm_passkey(&self) -> Result<()> { 114 | self.passkey_sender.send(true)?; 115 | self.confirmation_required.store(false, Ordering::Relaxed); 116 | Ok(()) 117 | } 118 | 119 | pub fn reject_passkey(&self) -> Result<()> { 120 | self.passkey_sender.send(false)?; 121 | self.confirmation_required.store(false, Ordering::Relaxed); 122 | Ok(()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use bzmenu::{app::App, icons::Icons, launcher::LauncherType, menu::Menu}; 3 | use clap::{builder::EnumValueParser, Arg, Command}; 4 | use rust_i18n::{i18n, set_locale}; 5 | use std::{env, sync::Arc}; 6 | use sys_locale::get_locale; 7 | 8 | i18n!("locales", fallback = "en"); 9 | 10 | fn validate_launcher_command(command: &str) -> Result { 11 | if command.contains("{placeholder}") { 12 | eprintln!("WARNING: {{placeholder}} is deprecated. Use {{hint}} instead."); 13 | } 14 | if command.contains("{prompt}") { 15 | eprintln!("WARNING: {{prompt}} is deprecated. Use {{hint}} instead."); 16 | } 17 | 18 | Ok(command.to_string()) 19 | } 20 | 21 | #[tokio::main] 22 | async fn main() -> Result<()> { 23 | env_logger::init(); 24 | 25 | let locale = get_locale().unwrap_or_else(|| String::from("en")); 26 | set_locale(&locale); 27 | 28 | let matches = Command::new(env!("CARGO_PKG_NAME")) 29 | .version(env!("CARGO_PKG_VERSION")) 30 | .author(env!("CARGO_PKG_AUTHORS")) 31 | .about(env!("CARGO_PKG_DESCRIPTION")) 32 | .arg( 33 | Arg::new("launcher") 34 | .short('l') 35 | .long("launcher") 36 | .required(true) 37 | .takes_value(true) 38 | .value_parser(EnumValueParser::::new()) 39 | .conflicts_with("menu") 40 | .help("Launcher to use (replaces deprecated --menu)"), 41 | ) 42 | .arg( 43 | Arg::new("menu") // deprecated 44 | .short('m') 45 | .long("menu") 46 | .takes_value(true) 47 | .value_parser(EnumValueParser::::new()) 48 | .hide(true) 49 | .help("DEPRECATED: use --launcher instead"), 50 | ) 51 | .arg( 52 | Arg::new("launcher_command") 53 | .long("launcher-command") 54 | .takes_value(true) 55 | .required_if_eq("launcher", "custom") 56 | .conflicts_with("menu_command") 57 | .value_parser(validate_launcher_command) 58 | .help("Launcher command to use when --launcher is set to custom"), 59 | ) 60 | .arg( 61 | Arg::new("menu_command") // deprecated 62 | .long("menu-command") 63 | .takes_value(true) 64 | .required_if_eq("menu", "custom") 65 | .hide(true) 66 | .value_parser(validate_launcher_command) 67 | .help("DEPRECATED: use --launcher-command instead"), 68 | ) 69 | .arg( 70 | Arg::new("icon") 71 | .short('i') 72 | .long("icon") 73 | .takes_value(true) 74 | .possible_values(["font", "xdg"]) 75 | .default_value("font") 76 | .help("Choose the type of icons to use"), 77 | ) 78 | .arg( 79 | Arg::new("spaces") 80 | .short('s') 81 | .long("spaces") 82 | .takes_value(true) 83 | .default_value("1") 84 | .help("Number of spaces between icon and text when using font icons"), 85 | ) 86 | .arg( 87 | Arg::new("scan_duration") 88 | .long("scan-duration") 89 | .takes_value(true) 90 | .default_value("10") 91 | .help("Duration of Bluetooth device discovery in seconds"), 92 | ) 93 | .get_matches(); 94 | 95 | let launcher_type: LauncherType = if matches.contains_id("launcher") { 96 | matches 97 | .get_one::("launcher") 98 | .cloned() 99 | .unwrap() 100 | } else if matches.contains_id("menu") { 101 | eprintln!("WARNING: --menu flag is deprecated. Please use --launcher instead."); 102 | matches.get_one::("menu").cloned().unwrap() 103 | } else { 104 | LauncherType::Dmenu 105 | }; 106 | 107 | let command_str = if matches.contains_id("launcher_command") { 108 | matches.get_one::("launcher_command").cloned() 109 | } else if matches.contains_id("menu_command") { 110 | eprintln!( 111 | "WARNING: --menu-command flag is deprecated. Please use --launcher-command instead." 112 | ); 113 | matches.get_one::("menu_command").cloned() 114 | } else { 115 | None 116 | }; 117 | 118 | let icon_type = matches.get_one::("icon").cloned().unwrap(); 119 | let icons = Arc::new(Icons::new()); 120 | let menu = Menu::new(launcher_type, icons.clone()); 121 | 122 | let spaces = matches 123 | .get_one::("spaces") 124 | .and_then(|s| s.parse::().ok()) 125 | .ok_or_else(|| anyhow!("Invalid value for --spaces. Must be a positive integer."))?; 126 | 127 | let scan_duration = matches 128 | .get_one::("scan_duration") 129 | .and_then(|s| s.parse::().ok()) 130 | .unwrap_or(10); 131 | 132 | run_app_loop( 133 | &menu, 134 | &command_str, 135 | &icon_type, 136 | spaces, 137 | icons, 138 | scan_duration, 139 | ) 140 | .await?; 141 | Ok(()) 142 | } 143 | 144 | async fn run_app_loop( 145 | menu: &Menu, 146 | command_str: &Option, 147 | icon_type: &str, 148 | spaces: usize, 149 | icons: Arc, 150 | scan_duration: u64, 151 | ) -> Result<()> { 152 | let mut app = App::new(icons.clone(), scan_duration).await?; 153 | 154 | loop { 155 | match app.run(menu, command_str, icon_type, spaces).await { 156 | Ok(_) => { 157 | if !app.reset_mode { 158 | break; 159 | } 160 | } 161 | Err(err) => { 162 | eprintln!("Error during app execution: {err:?}"); 163 | if !app.reset_mode { 164 | return Err(anyhow!("Fatal error in application: {err}")); 165 | } 166 | } 167 | } 168 | 169 | if app.reset_mode { 170 | app = App::new(icons.clone(), scan_duration).await?; 171 | app.reset_mode = false; 172 | } 173 | } 174 | 175 | Ok(()) 176 | } 177 | -------------------------------------------------------------------------------- /locales/app.yml: -------------------------------------------------------------------------------- 1 | _version: 2 2 | 3 | menus: 4 | main: 5 | options: 6 | scan: 7 | name: 8 | en: "Scan for Devices" 9 | fr: "Rechercher des appareils" 10 | de: "Nach Geräten scannen" 11 | settings: 12 | name: 13 | en: "Settings" 14 | fr: "Paramètres" 15 | de: "Einstellungen" 16 | device: 17 | name: 18 | en: "Device" 19 | fr: "Appareil" 20 | de: "Gerät" 21 | device: 22 | hint: 23 | en: "Manage %{device_name}" 24 | fr: "Gérer %{device_name}" 25 | de: "%{device_name} bearbeiten" 26 | options: 27 | connect: 28 | name: 29 | en: "Connect" 30 | fr: "Connecter" 31 | de: "Verbinden" 32 | disconnect: 33 | name: 34 | en: "Disconnect" 35 | fr: "Déconnecter" 36 | de: "Trennen" 37 | trust: 38 | name: 39 | en: "Trust Device" 40 | fr: "Faire confiance à l'appareil" 41 | de: "Gerät vertrauen" 42 | revoke_trust: 43 | name: 44 | en: "Revoke Trust" 45 | fr: "Révoquer la confiance" 46 | de: "Gerät nicht mehr vertrauen" 47 | forget: 48 | name: 49 | en: "Forget Device" 50 | fr: "Oublier l'appareil" 51 | de: "Gerät vergessen" 52 | settings: 53 | options: 54 | enable_discoverable: 55 | name: 56 | en: "Make Device Visible" 57 | fr: "Rendre l'appareil visible" 58 | de: "Gerät sichtbar machen" 59 | disable_discoverable: 60 | name: 61 | en: "Make Device Hidden" 62 | fr: "Rendre l'appareil invisible" 63 | de: "Gerät verstecken" 64 | enable_pairable: 65 | name: 66 | en: "Allow Pairing" 67 | fr: "Autoriser l'appairage" 68 | de: "Koppeln erlauben" 69 | disable_pairable: 70 | name: 71 | en: "Disallow Pairing" 72 | fr: "Interdire l'appairage" 73 | de: "Koppeln verbieten" 74 | disable_adapter: 75 | name: 76 | en: "Disable Bluetooth" 77 | fr: "Désactiver Bluetooth" 78 | de: "Bluetooth deaktivieren" 79 | adapter: 80 | options: 81 | power_on_device: 82 | name: 83 | en: "Enable Bluetooth" 84 | fr: "Activer Bluetooth" 85 | de: "Bluetooth aktivieren" 86 | bluetooth: 87 | options: 88 | enable_discoverable: 89 | name: 90 | en: "Make Discoverable" 91 | fr: "Rendre visible" 92 | de: "Entdeckbar machen" 93 | disable_discoverable: 94 | name: 95 | en: "Make Hidden" 96 | fr: "Rendre invisible" 97 | de: "Versteckt machen" 98 | enable_pairable: 99 | name: 100 | en: "Enable Pairing" 101 | fr: "Activer l'appairage" 102 | de: "Koppeln aktivieren" 103 | disable_pairable: 104 | name: 105 | en: "Disable Pairing" 106 | fr: "Désactiver l'appairage" 107 | de: "Koppeln deaktivieren" 108 | pairing_request: 109 | en: "Bluetooth Pairing Request" 110 | fr: "Demande d'appairage Bluetooth" 111 | de: "Bluetooth-Verbindungsanfrage" 112 | confirm_passkey: 113 | en: "Confirm passkey: %{passkey}" 114 | fr: "Confirmer le code : %{passkey}" 115 | de: "Code %{passkey} bestätigen" 116 | confirm: 117 | en: "Confirm" 118 | fr: "Confirmer" 119 | de: "Bestätigen" 120 | cancel: 121 | en: "Cancel" 122 | fr: "Annuler" 123 | de: "Abbrechen" 124 | 125 | notifications: 126 | bt: 127 | scan_already_in_progress: 128 | en: "Bluetooth scan already in progress" 129 | fr: "Recherche Bluetooth déjà en cours" 130 | de: "Bluetooth-Scan läuft bereits" 131 | scan_in_progress: 132 | en: "Searching for Bluetooth devices. Click to stop." 133 | fr: "Recherche d'appareils Bluetooth en cours. Cliquez pour arrêter." 134 | de: "Es wird nach Bluetooth-Geräten gesucht. Klicken, um Abzubrechen." 135 | scan_completed: 136 | en: "Bluetooth scan completed" 137 | fr: "Recherche Bluetooth terminée" 138 | de: "Bluetooth-Scan fertig" 139 | scan_stop_action: 140 | en: "Stop Scan" 141 | fr: "Arrêter la recherche" 142 | de: "Scan abbrechen" 143 | device_connected: 144 | en: "Connected to %{device_name}" 145 | fr: "Connecté à %{device_name}" 146 | de: "Mit %{device_name} verbunden" 147 | device_disconnected: 148 | en: "Disconnected from %{device_name}" 149 | fr: "Déconnecté de %{device_name}" 150 | de: "Von %{device_name} getrennt" 151 | device_paired: 152 | en: "Device paired: %{device_name}" 153 | fr: "Appareil appairé: %{device_name}" 154 | de: "Mit Gerät gekoppelt: %{device_name}" 155 | device_trusted: 156 | en: "Device trusted: %{device_name}" 157 | fr: "Appareil approuvé: %{device_name}" 158 | de: "Gerät wird vertraut: %{device_name}" 159 | device_untrusted: 160 | en: "Device untrusted: %{device_name}" 161 | fr: "Appareil non approuvé: %{device_name}" 162 | de: "Gerät wird nicht vertraut: %{device_name}" 163 | device_forgotten: 164 | en: "Device removed: %{device_name}" 165 | fr: "Appareil supprimé: %{device_name}" 166 | de: "Gerät entfernt: %{device_name}" 167 | adapter_powered_off: 168 | en: "Bluetooth is disabled" 169 | fr: "Bluetooth est désactivé" 170 | de: "Bluetooth ist deaktiviert" 171 | adapter_enabled: 172 | en: "Bluetooth enabled" 173 | fr: "Bluetooth activé" 174 | de: "Bluetooth aktiviert" 175 | adapter_disabled: 176 | en: "Bluetooth disabled" 177 | fr: "Bluetooth désactivé" 178 | de: "Bluetooth deaktiviert" 179 | discoverable_enabled: 180 | en: "Your device is now discoverable" 181 | fr: "Votre appareil est maintenant visible" 182 | de: "Das Gerät ist jetzt entdeckbar" 183 | discoverable_disabled: 184 | en: "Your device is now hidden" 185 | fr: "Votre appareil est maintenant invisible" 186 | de: "Das Gerät ist jetzt versteckt" 187 | pairable_enabled: 188 | en: "Pairing enabled" 189 | fr: "Appairage activé" 190 | de: "Koppeln aktiviert" 191 | pairable_disabled: 192 | en: "Pairing disabled" 193 | fr: "Appairage désactivé" 194 | de: "Koppeln deaktiviert" 195 | main_menu_exited: 196 | en: "Exited menu" 197 | fr: "Menu fermé" 198 | de: "Menü verlassen" 199 | pairing_failed: 200 | en: "Failed to pair with %{device_name}: %{error}" 201 | fr: "Échec de l'appairage avec %{device_name} : %{error}" 202 | de: "Konnte nicht koppeln mit Gerät %{device_name}: %{error}" 203 | device_out_of_range: 204 | en: "Device '%{device_name}' is not available or out of range" 205 | fr: "L'appareil '%{device_name}' n'est pas disponible ou hors de portée" 206 | de: "Gerät '%{device_name}' ist nicht erreichbar oder außer Reichweite" 207 | connection_failed: 208 | en: "Connection to '%{device_name}' failed: %{error}" 209 | fr: "Échec de la connexion à '%{device_name}' : %{error}" 210 | de: "Konnte nicht verbinden mit Gerät %{device_name}: %{error}" 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

bzmenu

3 |

4 | Ko-fi 5 | Liberapay 6 |

7 |

8 | Demonstration of bzmenu: a launcher-driven Bluetooth manager for Linux 9 |

10 |

11 | iwmenu: a launcher-driven Wi-Fi manager for Linux 12 | pwmenu: a launcher-driven audio manager for Linux 13 |

14 |
15 | 16 | ## About 17 | 18 | `bzmenu` (**B**lue**Z** **Menu**) manages Bluetooth through your launcher of choice. 19 | 20 | ## Dependencies 21 | 22 | ### Build 23 | 24 | - [`Rust`](https://www.rust-lang.org) (includes `cargo`) 25 | - [`pkg-config`](https://www.freedesktop.org/wiki/Software/pkg-config) – For detecting required libraries 26 | - [`dbus`](https://www.freedesktop.org/wiki/Software/dbus) – For D-Bus integration headers 27 | 28 | ### Runtime 29 | 30 | - [`bluetoothd`](http://www.bluez.org) – BlueZ daemon 31 | - [`dbus`](https://www.freedesktop.org/wiki/Software/dbus) – For communicating with `bluetoothd` 32 | - A launcher with `stdin` mode support 33 | - [Notification daemon](https://specifications.freedesktop.org/notification-spec/latest) – For pairing confirmations and other system notifications (e.g. `dunst`, `fnott`, included with DEs or can be installed manually) 34 | 35 | #### Optional 36 | 37 | - [NerdFonts](https://www.nerdfonts.com) – For font-based icons (default mode) 38 | - [XDG icon theme](https://specifications.freedesktop.org/icon-theme-spec/latest) – For image-based icons (used with `-i xdg`, included with DEs or can be installed manually) 39 | 40 | ## Compatibility 41 | 42 | | Launcher | Font Icons | XDG Icons | Notes | 43 | | --------------------------------------------- | :--------: | :-------: | ------------------------------------------------------------------------------------- | 44 | | [Fuzzel](https://codeberg.org/dnkl/fuzzel) | ✅ | ✅ | XDG icons supported since v1.13.0 | 45 | | [Rofi](https://github.com/davatorium/rofi) | ✅ | 🔄 | XDG icon support pending via [PR #2122](https://github.com/davatorium/rofi/pull/2122) | 46 | | [Walker](https://github.com/abenz1267/walker) | ✅ | ✅ | XDG icons supported since v0.12.21 | 47 | | [dmenu](https://tools.suckless.org/dmenu) | ✅ | ❌ | No XDG icon support | 48 | | Custom (stdin) | ✅ | ❔ | Depends on launcher implementation | 49 | 50 | > [!TIP] 51 | > If your preferred launcher isn't directly supported, use `custom` mode with appropriate command flags. 52 | 53 | ## Installation 54 | 55 | ### Build from source 56 | 57 | Run the following commands: 58 | 59 | ```shell 60 | git clone https://github.com/e-tho/bzmenu 61 | cd bzmenu 62 | cargo build --release 63 | ``` 64 | 65 | An executable file will be generated at `target/release/bzmenu`, which you can then copy to a directory in your `$PATH`. 66 | 67 | ### Nix 68 | 69 | Install from nixpkgs: 70 | 71 | ```nix 72 | { pkgs, ... }: 73 | { 74 | environment.systemPackages = [ pkgs.bzmenu ]; 75 | } 76 | ``` 77 | 78 |
79 | Alternative: Install from flake 80 | 81 | Add flake as an input: 82 | 83 | ```nix 84 | inputs.bzmenu.url = "github:e-tho/bzmenu"; 85 | ``` 86 | 87 | Install from flake: 88 | 89 | ```nix 90 | { inputs, ... }: 91 | { 92 | environment.systemPackages = [ inputs.bzmenu.packages.${pkgs.system}.default ]; 93 | } 94 | ``` 95 | 96 |
97 | 98 | ### Alpine Linux 99 | 100 | Install from the testing repository: 101 | 102 | ```shell 103 | apk add bzmenu 104 | ``` 105 | 106 | ### Arch Linux 107 | 108 | Install from AUR with your favorite helper: 109 | 110 | ```shell 111 | paru -S bzmenu # or bzmenu-git 112 | ``` 113 | 114 | ## Usage 115 | 116 | ### Supported launchers 117 | 118 | Specify an application using `-l` or `--launcher` flag. 119 | 120 | ```shell 121 | bzmenu -l fuzzel 122 | ``` 123 | 124 | ### Custom launchers 125 | 126 | Specify `custom` as the launcher and set your command using the `--launcher-command` flag. Ensure your launcher supports `stdin` mode, and that it is properly configured in the command. 127 | 128 | ```shell 129 | bzmenu -l custom --launcher-command "my_custom_launcher --flag" 130 | ``` 131 | 132 | #### Prompt and Placeholder support 133 | 134 | Use `{hint}` as the value for the relevant flag in your command; it will be substituted with the appropriate text as needed. 135 | 136 | ```shell 137 | bzmenu -l custom --launcher-command "my_custom_launcher --placeholder-flag '{hint}'" # or --prompt-flag '{hint}:' 138 | ``` 139 | 140 | #### Example to enable all features 141 | 142 | This example demonstrates enabling all available features in custom mode with `fuzzel`. 143 | 144 | ```shell 145 | bzmenu -l custom --launcher-command "fuzzel -d --placeholder '{hint}'" 146 | ``` 147 | 148 | ### Available Options 149 | 150 | | Flag | Description | Supported Values | Default Value | 151 | | -------------------- | --------------------------------------------------------- | --------------------------------------------- | ------------- | 152 | | `-l`, `--launcher` | Specify the launcher to use (**required**). | `dmenu`, `rofi`, `fuzzel`, `walker`, `custom` | `None` | 153 | | `--launcher-command` | Specify the command to use when `custom` launcher is set. | Any valid shell command | `None` | 154 | | `-i`, `--icon` | Specify the icon type to use. | `font`, `xdg` | `font` | 155 | | `-s`, `--spaces` | Specify icon to text space count (font icons only). | Any positive integer | `1` | 156 | | `--scan-duration` | Specify the duration of device discovery in seconds. | Any positive integer | `10` | 157 | 158 | ## Contributing 159 | 160 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. 161 | 162 | ## License 163 | 164 | This project is licensed under the terms of the GNU General Public License version 3, or (at your option) any later version. 165 | 166 | ## Support this project 167 | 168 | If you find this project useful and would like to help me dedicate more time to its development, consider supporting my work. 169 | 170 | [![Ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/e_tho) 171 | [![Liberapay](https://img.shields.io/badge/Liberapay-F6C915?style=for-the-badge&logo=liberapay&logoColor=black)](https://liberapay.com/e-tho) 172 | -------------------------------------------------------------------------------- /src/launcher.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Context, Result}; 2 | use clap::ArgEnum; 3 | use nix::{ 4 | libc, 5 | sys::signal::{kill, killpg, Signal}, 6 | unistd::Pid, 7 | }; 8 | use process_wrap::std::{ProcessGroup, StdCommandWrap}; 9 | use signal_hook::iterator::Signals; 10 | use std::{ 11 | io::Write, 12 | process::{exit, Command, Stdio}, 13 | sync::{ 14 | atomic::{AtomicI32, Ordering}, 15 | Once, 16 | }, 17 | thread, 18 | }; 19 | 20 | #[derive(Debug, Clone, ArgEnum)] 21 | pub enum LauncherType { 22 | Fuzzel, 23 | Rofi, 24 | Dmenu, 25 | Walker, 26 | Custom, 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub enum LauncherCommand { 31 | Fuzzel { 32 | icon_type: String, 33 | placeholder: Option, 34 | }, 35 | Rofi { 36 | icon_type: String, 37 | placeholder: Option, 38 | }, 39 | Dmenu { 40 | prompt: Option, 41 | }, 42 | Walker { 43 | placeholder: Option, 44 | }, 45 | Custom { 46 | program: String, 47 | args: Vec, 48 | }, 49 | } 50 | 51 | static CURRENT_LAUNCHER_PID: AtomicI32 = AtomicI32::new(-1); 52 | static SIGNAL_HANDLER_INIT: Once = Once::new(); 53 | 54 | pub struct Launcher; 55 | 56 | impl Launcher { 57 | pub fn run(cmd: LauncherCommand, input: Option<&str>) -> Result> { 58 | let command = match cmd { 59 | LauncherCommand::Fuzzel { 60 | icon_type, 61 | placeholder, 62 | } => { 63 | let mut cmd = Command::new("fuzzel"); 64 | cmd.arg("-d").arg("--minimal-lines"); 65 | if icon_type == "font" { 66 | cmd.arg("-I"); 67 | } 68 | if let Some(hint_text) = placeholder { 69 | cmd.arg("--placeholder").arg(hint_text); 70 | } 71 | cmd 72 | } 73 | LauncherCommand::Rofi { 74 | icon_type, 75 | placeholder, 76 | } => { 77 | let mut cmd = Command::new("rofi"); 78 | cmd.arg("-m").arg("-1").arg("-dmenu"); 79 | if icon_type == "xdg" { 80 | cmd.arg("-show-icons"); 81 | } 82 | if let Some(hint_text) = placeholder { 83 | cmd.arg("-theme-str") 84 | .arg(format!("entry {{ placeholder: \"{hint_text}\"; }}")); 85 | } 86 | cmd 87 | } 88 | LauncherCommand::Dmenu { prompt } => { 89 | let mut cmd = Command::new("dmenu"); 90 | if let Some(hint_text) = prompt { 91 | cmd.arg("-p").arg(format!("{hint_text}: ")); 92 | } 93 | cmd 94 | } 95 | LauncherCommand::Walker { placeholder } => { 96 | let mut cmd = Command::new("walker"); 97 | cmd.arg("-d").arg("-k"); 98 | if let Some(hint_text) = placeholder { 99 | cmd.arg("-p").arg(hint_text); 100 | } 101 | cmd 102 | } 103 | LauncherCommand::Custom { program, args } => { 104 | let mut cmd = Command::new(&program); 105 | cmd.args(&args); 106 | cmd 107 | } 108 | }; 109 | 110 | Self::run_command(command, input) 111 | } 112 | 113 | fn substitute_placeholders(template: &str, hint: Option<&str>) -> Result { 114 | if !template.contains('{') { 115 | return Ok(template.to_string()); 116 | } 117 | 118 | let mut result = template.to_string(); 119 | 120 | if let Some(h) = hint { 121 | result = result.replace("{hint}", h); 122 | result = result.replace("{placeholder}", h); 123 | result = result.replace("{prompt}", &format!("{h}: ")); 124 | } else { 125 | result = result.replace("{hint}", ""); 126 | result = result.replace("{placeholder}", ""); 127 | result = result.replace("{prompt}", ""); 128 | } 129 | 130 | Ok(result) 131 | } 132 | 133 | fn parse_command(command_str: &str) -> Result<(String, Vec)> { 134 | let parts = 135 | shlex::split(command_str).ok_or_else(|| anyhow!("Invalid shell syntax in command"))?; 136 | 137 | if parts.is_empty() { 138 | return Err(anyhow!("Empty command string")); 139 | } 140 | 141 | let program = parts[0].clone(); 142 | let args = parts[1..].to_vec(); 143 | 144 | Ok((program, args)) 145 | } 146 | 147 | fn run_command(mut command: Command, input: Option<&str>) -> Result> { 148 | command.stdin(Stdio::piped()).stdout(Stdio::piped()); 149 | 150 | let mut command_wrap = StdCommandWrap::from(command); 151 | command_wrap.wrap(ProcessGroup::leader()); 152 | 153 | let mut child = command_wrap 154 | .spawn() 155 | .context("Failed to spawn launcher command")?; 156 | 157 | let pid = child.id() as i32; 158 | 159 | SIGNAL_HANDLER_INIT.call_once(|| { 160 | thread::spawn(|| { 161 | let mut signals = Signals::new([libc::SIGTERM, libc::SIGINT]).unwrap(); 162 | if let Some(_signal) = signals.forever().next() { 163 | let current_pid = CURRENT_LAUNCHER_PID.load(Ordering::Relaxed); 164 | if current_pid > 0 && kill(Pid::from_raw(current_pid), None).is_ok() { 165 | let _ = killpg(Pid::from_raw(current_pid), Signal::SIGTERM); 166 | } 167 | exit(0); 168 | } 169 | }); 170 | }); 171 | 172 | CURRENT_LAUNCHER_PID.store(pid, Ordering::Relaxed); 173 | 174 | if let Some(input_data) = input { 175 | if let Some(stdin) = child.stdin().as_mut() { 176 | stdin.write_all(input_data.as_bytes())?; 177 | } 178 | } 179 | 180 | let output = child.wait_with_output()?; 181 | let trimmed_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); 182 | 183 | CURRENT_LAUNCHER_PID.store(-1, Ordering::Relaxed); 184 | 185 | if trimmed_output.is_empty() { 186 | Ok(None) 187 | } else { 188 | Ok(Some(trimmed_output)) 189 | } 190 | } 191 | 192 | pub fn create_command( 193 | launcher_type: &LauncherType, 194 | command_str: &Option, 195 | icon_type: &str, 196 | hint: Option<&str>, 197 | ) -> Result { 198 | let hint_text = hint.filter(|h| !h.is_empty()).map(|h| h.to_string()); 199 | 200 | match launcher_type { 201 | LauncherType::Fuzzel => Ok(LauncherCommand::Fuzzel { 202 | icon_type: icon_type.to_string(), 203 | placeholder: hint_text, 204 | }), 205 | LauncherType::Rofi => Ok(LauncherCommand::Rofi { 206 | icon_type: icon_type.to_string(), 207 | placeholder: hint_text, 208 | }), 209 | LauncherType::Dmenu => Ok(LauncherCommand::Dmenu { prompt: hint_text }), 210 | LauncherType::Walker => Ok(LauncherCommand::Walker { 211 | placeholder: hint_text, 212 | }), 213 | LauncherType::Custom => { 214 | if let Some(cmd) = command_str { 215 | let processed_cmd = Self::substitute_placeholders(cmd, hint)?; 216 | let (program, args) = Self::parse_command(&processed_cmd)?; 217 | 218 | Ok(LauncherCommand::Custom { program, args }) 219 | } else { 220 | Err(anyhow!("No custom launcher command provided")) 221 | } 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/notification.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use notify_rust::{CloseReason, Hint, Notification, NotificationHandle, Timeout}; 3 | use std::{ 4 | collections::HashMap, 5 | sync::{ 6 | atomic::{AtomicBool, Ordering}, 7 | Arc, Mutex, 8 | }, 9 | thread::{sleep, spawn}, 10 | }; 11 | 12 | use crate::bz::pairing::PairingConfirmationHandler; 13 | use crate::icons::Icons; 14 | 15 | pub struct NotificationManager { 16 | icons: Arc, 17 | handles: Arc>>, 18 | } 19 | 20 | impl PairingConfirmationHandler for NotificationManager { 21 | fn request_confirmation( 22 | &self, 23 | device_address: &str, 24 | passkey: &str, 25 | on_confirm: Box, 26 | on_reject: Box, 27 | ) -> Result<()> { 28 | self.send_pairing_confirmation(device_address, passkey, on_confirm, on_reject) 29 | } 30 | } 31 | 32 | impl Clone for NotificationManager { 33 | fn clone(&self) -> Self { 34 | Self { 35 | icons: Arc::clone(&self.icons), 36 | handles: Arc::clone(&self.handles), 37 | } 38 | } 39 | } 40 | 41 | impl NotificationManager { 42 | pub fn new(icons: Arc) -> Self { 43 | Self { 44 | icons, 45 | handles: Arc::new(Mutex::new(HashMap::new())), 46 | } 47 | } 48 | 49 | pub fn with_icons_default() -> Self { 50 | Self::new(Arc::new(Icons::default())) 51 | } 52 | 53 | pub fn send_notification( 54 | &self, 55 | summary: Option, 56 | body: Option, 57 | icon: Option<&str>, 58 | timeout: Option, 59 | id: Option, 60 | ) -> Result { 61 | let icon_name = self.icons.get_xdg_icon(icon.unwrap_or("bluetooth")); 62 | 63 | let mut notification = Notification::new(); 64 | notification 65 | .summary(summary.as_deref().unwrap_or("BlueZ Menu")) 66 | .body(body.as_deref().unwrap_or("")) 67 | .icon(&icon_name) 68 | .timeout(timeout.unwrap_or(Timeout::Milliseconds(3000))); 69 | 70 | if let Some(notification_id) = id { 71 | notification.id(notification_id); 72 | } 73 | 74 | let handle = notification.show()?; 75 | let notification_id = handle.id(); 76 | 77 | let mut handles = self 78 | .handles 79 | .lock() 80 | .map_err(|e| anyhow!("Failed to acquire lock on notification handles: {e}"))?; 81 | handles.insert(notification_id, handle); 82 | 83 | Ok(notification_id) 84 | } 85 | 86 | pub fn close_notification(&self, id: u32) -> Result<()> { 87 | let mut handles = self 88 | .handles 89 | .lock() 90 | .map_err(|e| anyhow!("Failed to acquire lock on notification handles: {e}"))?; 91 | 92 | if let Some(handle) = handles.remove(&id) { 93 | handle.close(); 94 | Ok(()) 95 | } else { 96 | Err(anyhow!("Notification ID {id} not found")) 97 | } 98 | } 99 | 100 | pub fn send_pairing_confirmation( 101 | &self, 102 | device_address: &str, 103 | passkey: &str, 104 | on_confirm: impl FnOnce() + Send + 'static, 105 | on_reject: impl FnOnce() + Send + 'static, 106 | ) -> Result<()> { 107 | let icon_name = self.icons.get_xdg_icon("bluetooth"); 108 | 109 | let summary = t!("menus.bluetooth.pairing_request"); 110 | let body = t!( 111 | "menus.bluetooth.confirm_passkey", 112 | device_name = device_address, 113 | passkey = passkey 114 | ); 115 | let confirm_text = t!("menus.bluetooth.confirm"); 116 | let cancel_text = t!("menus.bluetooth.cancel"); 117 | 118 | let mut binding = Notification::new(); 119 | let notification = binding 120 | .summary(&summary) 121 | .body(&body) 122 | .icon(&icon_name) 123 | .timeout(Timeout::Milliseconds(30000)) 124 | .action("default", &confirm_text) 125 | .action("confirm", &confirm_text) 126 | .action("reject", &cancel_text); 127 | 128 | match notification.show() { 129 | Ok(handle) => { 130 | spawn(move || { 131 | handle.wait_for_action(|action| match action { 132 | "default" | "confirm" => on_confirm(), 133 | "reject" | "__closed" => on_reject(), 134 | _ => on_reject(), 135 | }); 136 | }); 137 | Ok(()) 138 | } 139 | Err(err) => Err(anyhow!("Failed to show notification: {err}")), 140 | } 141 | } 142 | 143 | // TODO: Follow https://github.com/hoodie/notify-rust/issues/199 144 | // "Allow an on_close handler without consuming the NotificationHandle" 145 | // This would simplify our implementation by avoiding the need for a separate thread 146 | // and allowing us to update the notification directly after setting up the on_close handler. 147 | pub fn send_progress_notification( 148 | &self, 149 | duration_sec: u64, 150 | on_cancel: impl FnOnce() + Send + 'static, 151 | progress_body: String, 152 | progress_icon: Option<&str>, 153 | ) -> Result { 154 | let notification_handle = Notification::new() 155 | .summary("BlueZ Menu") 156 | .body(&progress_body) 157 | .icon( 158 | &self 159 | .icons 160 | .get_xdg_icon(progress_icon.unwrap_or("bluetooth")), 161 | ) 162 | .timeout(Timeout::Never) 163 | .hint(Hint::Transient(true)) 164 | .hint(Hint::Category("progress".to_string())) 165 | .hint(Hint::CustomInt("value".to_string(), 0)) 166 | .show()?; 167 | 168 | let id = notification_handle.id(); 169 | 170 | let notification_manager = self.clone(); 171 | let progress_body_clone = progress_body.clone(); 172 | let progress_icon_str = progress_icon.map(String::from); 173 | 174 | spawn(move || { 175 | notification_manager.track_progress( 176 | id, 177 | duration_sec, 178 | notification_handle, 179 | on_cancel, 180 | progress_body_clone, 181 | progress_icon_str.as_deref(), 182 | ); 183 | }); 184 | 185 | Ok(id) 186 | } 187 | 188 | fn track_progress( 189 | &self, 190 | id: u32, 191 | duration_sec: u64, 192 | notification_handle: NotificationHandle, 193 | on_cancel: impl FnOnce() + Send + 'static, 194 | progress_body: String, 195 | progress_icon: Option<&str>, 196 | ) { 197 | let start_time = std::time::Instant::now(); 198 | let update_interval = std::time::Duration::from_millis(500); 199 | let total_duration = std::time::Duration::from_secs(duration_sec); 200 | 201 | let cancelled = Arc::new(AtomicBool::new(false)); 202 | let cancelled_for_loop = cancelled.clone(); 203 | 204 | let on_cancel_wrapped = Arc::new(Mutex::new(Some(Box::new(on_cancel)))); 205 | let on_cancel_for_close = on_cancel_wrapped.clone(); 206 | let cancelled_for_close = cancelled.clone(); 207 | 208 | spawn(move || { 209 | notification_handle.on_close(|reason| { 210 | if let CloseReason::Dismissed = reason { 211 | if let Ok(mut callback_opt) = on_cancel_for_close.lock() { 212 | if let Some(callback) = callback_opt.take() { 213 | callback(); 214 | } 215 | } 216 | cancelled_for_close.store(true, Ordering::SeqCst); 217 | } 218 | }); 219 | }); 220 | 221 | while !cancelled_for_loop.load(Ordering::SeqCst) { 222 | let elapsed = start_time.elapsed(); 223 | if elapsed >= total_duration { 224 | break; 225 | } 226 | 227 | let progress = ((elapsed.as_secs_f64() / total_duration.as_secs_f64()) * 100.0) as i32; 228 | 229 | let update_result = Notification::new() 230 | .id(id) 231 | .summary("BlueZ Menu") 232 | .body(&progress_body) 233 | .icon( 234 | &self 235 | .icons 236 | .get_xdg_icon(progress_icon.unwrap_or("bluetooth")), 237 | ) 238 | .timeout(Timeout::Never) 239 | .hint(Hint::Transient(true)) 240 | .hint(Hint::Category("progress".to_string())) 241 | .hint(Hint::CustomInt("value".to_string(), progress.clamp(0, 100))) 242 | .show(); 243 | 244 | if update_result.is_err() { 245 | if let Ok(mut callback_opt) = on_cancel_wrapped.lock() { 246 | if let Some(callback) = callback_opt.take() { 247 | callback(); 248 | } 249 | } 250 | break; 251 | } 252 | 253 | sleep(update_interval); 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/bz/device.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use bluer::{Adapter, Address, Device as BluerDevice}; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct Device { 8 | device: BluerDevice, 9 | adapter: Arc, 10 | pub addr: Address, 11 | pub icon: Option, 12 | pub device_type: String, 13 | pub alias: String, 14 | pub is_paired: bool, 15 | pub is_trusted: bool, 16 | pub is_connected: bool, 17 | pub battery_percentage: Option, 18 | } 19 | 20 | impl Device { 21 | pub async fn new(adapter: &Adapter, addr: &Address) -> Result { 22 | let device = adapter.device(*addr)?; 23 | 24 | let alias = device.alias().await?; 25 | let icon_name = device.icon().await?.unwrap_or_default(); 26 | let icon = if !icon_name.is_empty() { 27 | Some(icon_name.clone()) 28 | } else { 29 | None 30 | }; 31 | 32 | let device_type = Self::determine_device_type(&device).await?; 33 | 34 | let is_paired = device.is_paired().await?; 35 | let is_trusted = device.is_trusted().await?; 36 | let is_connected = device.is_connected().await?; 37 | let battery_percentage = device.battery_percentage().await.ok().flatten(); 38 | 39 | Ok(Self { 40 | device, 41 | adapter: Arc::new(adapter.clone()), 42 | addr: *addr, 43 | icon, 44 | device_type, 45 | alias, 46 | is_paired, 47 | is_trusted, 48 | is_connected, 49 | battery_percentage, 50 | }) 51 | } 52 | 53 | async fn determine_device_type(device: &BluerDevice) -> Result { 54 | if let Ok(Some(class_value)) = device.class().await { 55 | let major_class = (class_value >> 8) & 0x1F; 56 | let minor_class = (class_value >> 2) & 0x3F; 57 | 58 | let device_type = match major_class { 59 | 0x01 => "computer", // Computer 60 | 0x02 => match minor_class { 61 | 0x01 => "phone", // Cellular 62 | 0x02 => "modem", // Cordless 63 | 0x03 => "phone", // Smartphone/PDA 64 | 0x04 => "computer", // Desktop 65 | 0x05 => "computer", // Server 66 | 0x06 => "laptop", // Laptop 67 | 0x07 => "tablet", // Tablet 68 | _ => "phone", // Generic phone 69 | }, 70 | 0x03 => "network", // LAN/Network Access Point 71 | 0x04 => match minor_class { 72 | 0x01 => "headphones", // Headset 73 | 0x02 => "headphones", // Hands-free 74 | 0x04 => "microphone", // Microphone 75 | 0x05 => "speaker", // Speaker 76 | 0x06 => "headphones", // Headphones 77 | 0x08 => "speaker", // Car audio 78 | 0x09 => "tv", // Video display 79 | 0x0A => "speaker", // Loudspeaker 80 | _ => "audio", // Generic audio 81 | }, 82 | 0x05 => match minor_class { 83 | 0x01 => "keyboard", // Keyboard 84 | 0x02 => "mouse", // Mouse 85 | 0x03 => "trackball", // Trackball 86 | 0x04 => "joystick", // Joystick 87 | 0x05 => "gamepad", // Gamepad/Controller 88 | 0x06 => "tablet", // Digitizer tablet 89 | 0x07 => "mouse", // Card reader 90 | 0x08 => "pen", // Digital pen 91 | _ => "peripheral", // Generic peripheral 92 | }, 93 | 0x06 => match minor_class { 94 | 0x01 | 0x02 => "printer", // Printer 95 | 0x04 | 0x08 => "camera", // Camera 96 | 0x10 => "display", // Display 97 | 0x20 => "tv", // Television 98 | _ => "imaging", // Generic imaging 99 | }, 100 | 0x07 => match minor_class { 101 | 0x01 => "watch", // Wrist watch 102 | 0x02 => "glasses", // Smart glasses 103 | 0x03 => "wearable", // Generic wearable 104 | 0x04 => "headphones", // Sports watch 105 | _ => "wearable", // Generic wearable 106 | }, 107 | _ => "", // No type identified, continue with other methods 108 | }; 109 | 110 | if !device_type.is_empty() { 111 | return Ok(device_type.to_string()); 112 | } 113 | } 114 | 115 | if let Ok(Some(appearance)) = device.appearance().await { 116 | let device_type = match appearance { 117 | 0 => "", // Unknown 118 | 64 => "phone", // Generic Phone 119 | 128 => "computer", // Generic Computer 120 | 192 => "watch", // Generic Watch 121 | 256 => "display", // Generic Display 122 | 512 => "remote", // Generic Remote Control 123 | 576 => "scanner", // Generic Eye-glasses 124 | 640 => "tag", // Generic Tag 125 | 704 => "keyring", // Generic Keyring 126 | 768 => "media", // Generic Media Player 127 | 832 => "barcode", // Generic Barcode Scanner 128 | 896 => "thermometer", // Generic Thermometer 129 | 960 => "peripheral", // Generic HID 130 | 961 => "keyboard", // Keyboard 131 | 962 => "mouse", // Mouse 132 | 963 => "joystick", // Joystick 133 | 964 => "gamepad", // Gamepad 134 | 976 => "digitizer", // Digitizer Tablet 135 | 1024 => "reader", // Generic Card Reader 136 | 1088 => "pen", // Digital Pen 137 | 1152 => "scanner", // Generic Barcode Scanner 138 | 1216 => "speaker", // Generic Audio 139 | 1280 => "headphones", // Generic Audio: Headset 140 | 1344 => "speaker", // Generic Audio: Speaker 141 | 1408 => "microphone", // Generic Audio: Microphone 142 | 1472 => "audio", // Generic Audio: Hearing aid 143 | 1600..=1663 => "health", // Health devices 144 | 1664..=1727 => "sensor", // Environmental sensors 145 | _ => "", // Unrecognized, continue 146 | }; 147 | 148 | if !device_type.is_empty() { 149 | return Ok(device_type.to_string()); 150 | } 151 | } 152 | 153 | if let Ok(Some(uuids)) = device.uuids().await { 154 | for uuid in uuids { 155 | let uuid_str = uuid.to_string(); 156 | 157 | match &uuid_str[..] { 158 | // Audio 159 | "0000110b-0000-1000-8000-00805f9b34fb" => return Ok("audio".to_string()), // A/V Remote Control 160 | "0000110c-0000-1000-8000-00805f9b34fb" => return Ok("headphones".to_string()), // A/V Remote Control Target 161 | "0000110e-0000-1000-8000-00805f9b34fb" => return Ok("headphones".to_string()), // A/V Remote Control Controller 162 | "0000110f-0000-1000-8000-00805f9b34fb" => return Ok("speaker".to_string()), // Advanced Audio Distribution 163 | "00001112-0000-1000-8000-00805f9b34fb" => return Ok("headphones".to_string()), // Headset 164 | "00001117-0000-1000-8000-00805f9b34fb" => return Ok("speaker".to_string()), // AVCTP 165 | "00001131-0000-1000-8000-00805f9b34fb" => return Ok("headphones".to_string()), // Phone book server 166 | "00001132-0000-1000-8000-00805f9b34fb" => return Ok("phone".to_string()), // Message Access Server 167 | 168 | // Peripherals 169 | "00001124-0000-1000-8000-00805f9b34fb" => return Ok("keyboard".to_string()), // HID 170 | "00001812-0000-1000-8000-00805f9b34fb" => return Ok("peripheral".to_string()), // HID over GATT 171 | 172 | // Sensors 173 | "0000180d-0000-1000-8000-00805f9b34fb" => return Ok("health".to_string()), // Heart Rate 174 | "0000180f-0000-1000-8000-00805f9b34fb" => return Ok("battery".to_string()), // Battery Service 175 | 176 | // Generic, continue 177 | "00001800-0000-1000-8000-00805f9b34fb" => {} // Generic Access 178 | "00001801-0000-1000-8000-00805f9b34fb" => {} // Generic Attribute 179 | "0000180a-0000-1000-8000-00805f9b34fb" => {} // Device Information 180 | 181 | _ => {} 182 | } 183 | } 184 | } 185 | 186 | if let Ok(Some(icon_str)) = device.icon().await { 187 | let device_type = match icon_str.as_str() { 188 | "audio-card" | "audio-speakers" => "speaker", 189 | "audio-headphones" | "audio-headset" => "headphones", 190 | "input-keyboard" => "keyboard", 191 | "input-mouse" => "mouse", 192 | "input-gaming" | "input-joystick" => "gamepad", 193 | "phone" => "phone", 194 | "computer" => "computer", 195 | "computer-laptop" => "laptop", 196 | "video-display" | "tv" => "tv", 197 | _ => icon_str.as_str(), 198 | }; 199 | 200 | return Ok(device_type.to_string()); 201 | } 202 | 203 | Ok("device".to_string()) 204 | } 205 | 206 | pub async fn connect(&self) -> Result<()> { 207 | self.device.connect().await?; 208 | Ok(()) 209 | } 210 | 211 | pub async fn disconnect(&self) -> Result<()> { 212 | self.device.disconnect().await?; 213 | Ok(()) 214 | } 215 | 216 | pub async fn pair(&self) -> Result<()> { 217 | self.device.pair().await?; 218 | Ok(()) 219 | } 220 | 221 | pub async fn set_trusted(&self, trusted: bool) -> Result<()> { 222 | self.device.set_trusted(trusted).await?; 223 | Ok(()) 224 | } 225 | 226 | pub async fn forget(&self) -> Result<()> { 227 | self.adapter.remove_device(self.addr).await?; 228 | Ok(()) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/icons.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[derive(Clone)] 4 | pub struct IconDefinition { 5 | single: String, 6 | list: String, 7 | } 8 | 9 | impl IconDefinition { 10 | pub fn simple(icon: &str) -> Self { 11 | Self { 12 | single: icon.to_string(), 13 | list: icon.to_string(), 14 | } 15 | } 16 | 17 | pub fn with_fallbacks(single: Option<&str>, list: &str) -> Self { 18 | let single_icon = match single { 19 | Some(icon) => icon.to_string(), 20 | None => list.split(',').next().unwrap_or("").trim().to_string(), 21 | }; 22 | 23 | Self { 24 | single: single_icon, 25 | list: list.to_string(), 26 | } 27 | } 28 | } 29 | 30 | #[derive(Clone)] 31 | pub struct Icons { 32 | generic_icons: HashMap<&'static str, char>, 33 | font_icons: HashMap<&'static str, char>, 34 | xdg_icons: HashMap<&'static str, IconDefinition>, 35 | } 36 | 37 | impl Icons { 38 | pub fn new() -> Self { 39 | let mut generic_icons = HashMap::new(); 40 | let mut font_icons = HashMap::new(); 41 | let mut xdg_icons = HashMap::new(); 42 | 43 | generic_icons.insert("connected", '\u{23FA}'); 44 | 45 | font_icons.insert("bluetooth", '\u{f293}'); 46 | font_icons.insert("bluetooth_connected", '\u{f294}'); 47 | font_icons.insert("connected", '\u{f294}'); 48 | font_icons.insert("disconnected", '\u{f295}'); 49 | font_icons.insert("connect", '\u{f0337}'); 50 | font_icons.insert("disconnect", '\u{f0338}'); 51 | font_icons.insert("scan", '\u{f46a}'); 52 | font_icons.insert("settings", '\u{f08bb}'); 53 | font_icons.insert("disable_adapter", '\u{f00b2}'); 54 | font_icons.insert("power_on_device", '\u{f0425}'); 55 | font_icons.insert("trust", '\u{f0cc8}'); 56 | font_icons.insert("revoke_trust", '\u{f099c}'); 57 | font_icons.insert("forget", '\u{f0377}'); 58 | 59 | font_icons.insert("enable_pairable", '\u{f0339}'); 60 | font_icons.insert("disable_pairable", '\u{f033a}'); 61 | 62 | font_icons.insert("enable_discoverable", '\u{f06d0}'); 63 | font_icons.insert("disable_discoverable", '\u{f06d1}'); 64 | 65 | font_icons.insert("device", '\u{f0fb0}'); 66 | font_icons.insert("phone", '\u{f011c}'); 67 | font_icons.insert("headphones", '\u{f02cb}'); 68 | font_icons.insert("keyboard", '\u{f030c}'); 69 | font_icons.insert("mouse", '\u{f037d}'); 70 | font_icons.insert("speaker", '\u{f04c3}'); 71 | font_icons.insert("gamepad", '\u{f0eb5}'); 72 | font_icons.insert("computer", '\u{f0aab}'); 73 | font_icons.insert("laptop", '\u{f0322}'); 74 | font_icons.insert("tablet", '\u{f04f7}'); 75 | font_icons.insert("watch", '\u{f0897}'); 76 | font_icons.insert("tv", '\u{f0379}'); 77 | font_icons.insert("display", '\u{f0379}'); 78 | 79 | font_icons.insert("battery_100", '\u{f0079}'); 80 | font_icons.insert("battery_90", '\u{f0082}'); 81 | font_icons.insert("battery_80", '\u{f0081}'); 82 | font_icons.insert("battery_70", '\u{f0080}'); 83 | font_icons.insert("battery_60", '\u{f007f}'); 84 | font_icons.insert("battery_50", '\u{f007e}'); 85 | font_icons.insert("battery_40", '\u{f007d}'); 86 | font_icons.insert("battery_30", '\u{f007c}'); 87 | font_icons.insert("battery_20", '\u{f007b}'); 88 | font_icons.insert("battery_10", '\u{f007a}'); 89 | 90 | font_icons.insert("ok", '\u{f05e1}'); 91 | font_icons.insert("error", '\u{f05d6}'); 92 | font_icons.insert("paired", '\u{f119f}'); 93 | font_icons.insert("trusted", '\u{f0cc8}'); 94 | 95 | xdg_icons.insert( 96 | "bluetooth", 97 | IconDefinition::with_fallbacks( 98 | None, 99 | "bluetooth-symbolic,network-bluetooth-symbolic,bluetooth", 100 | ), 101 | ); 102 | xdg_icons.insert( 103 | "connected", 104 | IconDefinition::with_fallbacks( 105 | None, 106 | "bluetooth-active-symbolic,network-bluetooth-activated-symbolic,bluetooth-active", 107 | ), 108 | ); 109 | xdg_icons.insert( 110 | "disconnected", 111 | IconDefinition::with_fallbacks(None, "bluetooth-disabled-symbolic,network-bluetooth-inactive-symbolic,bluetooth-disabled"), 112 | ); 113 | xdg_icons.insert( 114 | "connect", 115 | IconDefinition::with_fallbacks( 116 | Some("network-connect-symbolic"), 117 | "entries-linked-symbolic,network-connect-symbolic,link-symbolic", 118 | ), 119 | ); 120 | xdg_icons.insert( 121 | "disconnect", 122 | IconDefinition::with_fallbacks( 123 | Some("network-disconnect-symbolic"), 124 | "entries-unlinked-symbolic,network-disconnect-symbolic,media-eject-symbolic", 125 | ), 126 | ); 127 | xdg_icons.insert( 128 | "scan", 129 | IconDefinition::with_fallbacks( 130 | None, 131 | "sync-synchronizing-symbolic,emblem-synchronizing-symbolic,view-refresh-symbolic", 132 | ), 133 | ); 134 | xdg_icons.insert( 135 | "scan_in_progress", 136 | IconDefinition::simple("bluetooth-acquiring-symbolic"), 137 | ); 138 | xdg_icons.insert( 139 | "settings", 140 | IconDefinition::simple("preferences-system-symbolic"), 141 | ); 142 | xdg_icons.insert( 143 | "disable_adapter", 144 | IconDefinition::with_fallbacks( 145 | None, 146 | "bluetooth-disabled-symbolic,network-bluetooth-inactive-symbolic", 147 | ), 148 | ); 149 | xdg_icons.insert( 150 | "power_on_device", 151 | IconDefinition::simple("bluetooth-symbolic"), 152 | ); 153 | xdg_icons.insert("trust", IconDefinition::simple("emblem-default-symbolic")); 154 | xdg_icons.insert( 155 | "revoke_trust", 156 | IconDefinition::simple("action-unavailable-symbolic"), 157 | ); 158 | xdg_icons.insert("forget", IconDefinition::simple("list-remove-symbolic")); 159 | 160 | xdg_icons.insert( 161 | "enable_pairable", 162 | IconDefinition::simple("changes-allow-symbolic"), 163 | ); 164 | xdg_icons.insert( 165 | "disable_pairable", 166 | IconDefinition::simple("changes-prevent-symbolic"), 167 | ); 168 | xdg_icons.insert( 169 | "enable_discoverable", 170 | IconDefinition::with_fallbacks( 171 | None, 172 | "view-reveal-symbolic,view-visible-symbolic,object-visible-symbolic", 173 | ), 174 | ); 175 | xdg_icons.insert( 176 | "disable_discoverable", 177 | IconDefinition::with_fallbacks( 178 | None, 179 | "view-conceal-symbolic,view-hidden-symbolic,object-hidden-symbolic", 180 | ), 181 | ); 182 | 183 | xdg_icons.insert("device", IconDefinition::simple("drive-harddisk-symbolic")); 184 | xdg_icons.insert( 185 | "phone", 186 | IconDefinition::with_fallbacks(None, "phone-symbolic,drive-harddisk-symbolic"), 187 | ); 188 | xdg_icons.insert( 189 | "headphones", 190 | IconDefinition::with_fallbacks( 191 | None, 192 | "audio-headphones-symbolic,drive-harddisk-symbolic", 193 | ), 194 | ); 195 | xdg_icons.insert( 196 | "keyboard", 197 | IconDefinition::with_fallbacks(None, "input-keyboard-symbolic,drive-harddisk-symbolic"), 198 | ); 199 | xdg_icons.insert( 200 | "mouse", 201 | IconDefinition::with_fallbacks(None, "input-mouse-symbolic,drive-harddisk-symbolic"), 202 | ); 203 | xdg_icons.insert( 204 | "speaker", 205 | IconDefinition::with_fallbacks(None, "audio-speakers-symbolic,drive-harddisk-symbolic"), 206 | ); 207 | xdg_icons.insert( 208 | "gamepad", 209 | IconDefinition::with_fallbacks( 210 | None, 211 | "input-gaming-symbolic,input-gamepad-symbolic,drive-harddisk-symbolic", 212 | ), 213 | ); 214 | xdg_icons.insert( 215 | "computer", 216 | IconDefinition::with_fallbacks(None, "computer-symbolic,drive-harddisk-symbolic"), 217 | ); 218 | xdg_icons.insert( 219 | "laptop", 220 | IconDefinition::with_fallbacks( 221 | None, 222 | "laptop-symbolic,computer-laptop-symbolic,computer-symbolic,drive-harddisk-symbolic", 223 | ), 224 | ); 225 | xdg_icons.insert( 226 | "tablet", 227 | IconDefinition::with_fallbacks(None, "tablet-symbolic,drive-harddisk-symbolic"), 228 | ); 229 | xdg_icons.insert( 230 | "watch", 231 | IconDefinition::with_fallbacks(None, "smartwatch-symbolic,drive-harddisk-symbolic"), 232 | ); 233 | xdg_icons.insert( 234 | "tv", 235 | IconDefinition::with_fallbacks( 236 | None, 237 | "video-display-symbolic,preferences-desktop-display-randr-symbolic,drive-harddisk-symbolic", 238 | ), 239 | ); 240 | xdg_icons.insert("display", IconDefinition::with_fallbacks(None,"video-display-symbolic,preferences-desktop-display-randr-symbolic,drive-harddisk-symbolic")); 241 | 242 | xdg_icons.insert( 243 | "battery_100", 244 | IconDefinition::with_fallbacks( 245 | Some("battery-full-symbolic"), 246 | "battery-100-symbolic,battery-full-symbolic", 247 | ), 248 | ); 249 | xdg_icons.insert( 250 | "battery_90", 251 | IconDefinition::with_fallbacks( 252 | Some("battery-good-symbolic"), 253 | "battery-090-symbolic,battery-good-symbolic", 254 | ), 255 | ); 256 | xdg_icons.insert( 257 | "battery_80", 258 | IconDefinition::with_fallbacks( 259 | Some("battery-good-symbolic"), 260 | "battery-080-symbolic,battery-good-symbolic", 261 | ), 262 | ); 263 | xdg_icons.insert( 264 | "battery_70", 265 | IconDefinition::with_fallbacks( 266 | Some("battery-good-symbolic"), 267 | "battery-070-symbolic,battery-good-symbolic", 268 | ), 269 | ); 270 | xdg_icons.insert( 271 | "battery_60", 272 | IconDefinition::with_fallbacks( 273 | Some("battery-good-symbolic"), 274 | "battery-060-symbolic,battery-good-symbolic", 275 | ), 276 | ); 277 | xdg_icons.insert( 278 | "battery_50", 279 | IconDefinition::with_fallbacks( 280 | Some("battery-medium-symbolic"), 281 | "battery-050-symbolic,battery-medium-symbolic", 282 | ), 283 | ); 284 | xdg_icons.insert( 285 | "battery_40", 286 | IconDefinition::with_fallbacks( 287 | Some("battery-medium-symbolic"), 288 | "battery-040-symbolic,battery-medium-symbolic", 289 | ), 290 | ); 291 | xdg_icons.insert( 292 | "battery_30", 293 | IconDefinition::with_fallbacks( 294 | Some("battery-low-symbolic"), 295 | "battery-030-symbolic,battery-low-symbolic", 296 | ), 297 | ); 298 | xdg_icons.insert( 299 | "battery_20", 300 | IconDefinition::with_fallbacks( 301 | Some("battery-low-symbolic"), 302 | "battery-020-symbolic,battery-low-symbolic", 303 | ), 304 | ); 305 | xdg_icons.insert( 306 | "battery_10", 307 | IconDefinition::with_fallbacks( 308 | Some("battery-caution-symbolic"), 309 | "battery-010-symbolic,battery-caution-symbolic", 310 | ), 311 | ); 312 | 313 | xdg_icons.insert("ok", IconDefinition::simple("emblem-default-symbolic")); 314 | xdg_icons.insert("error", IconDefinition::simple("dialog-error-symbolic")); 315 | xdg_icons.insert("paired", IconDefinition::simple("emblem-checked-symbolic")); 316 | xdg_icons.insert("trusted", IconDefinition::simple("security-high-symbolic")); 317 | 318 | Icons { 319 | font_icons, 320 | xdg_icons, 321 | generic_icons, 322 | } 323 | } 324 | 325 | pub fn get_icon(&self, key: &str, icon_type: &str) -> String { 326 | match icon_type { 327 | "font" => self 328 | .font_icons 329 | .get(key) 330 | .map(|&icon| icon.to_string()) 331 | .unwrap_or_default(), 332 | "xdg" => self 333 | .xdg_icons 334 | .get(key) 335 | .map(|icon_definition| icon_definition.list.clone()) 336 | .unwrap_or_default(), 337 | "generic" => self 338 | .generic_icons 339 | .get(key) 340 | .map(|&icon| icon.to_string()) 341 | .unwrap_or_default(), 342 | _ => String::new(), 343 | } 344 | } 345 | 346 | pub fn get_xdg_icon(&self, key: &str) -> String { 347 | self.xdg_icons 348 | .get(key) 349 | .map(|icon_def| icon_def.single.clone()) 350 | .unwrap_or_default() 351 | } 352 | 353 | pub fn get_xdg_icon_list(&self, key: &str) -> String { 354 | self.xdg_icons 355 | .get(key) 356 | .map(|icon_def| icon_def.list.clone()) 357 | .unwrap_or_default() 358 | } 359 | 360 | pub fn get_icon_text(&self, items: Vec<(&str, T)>, icon_type: &str, spaces: usize) -> String 361 | where 362 | T: AsRef, 363 | { 364 | items 365 | .into_iter() 366 | .map(|(icon_key, text)| { 367 | let icon = self.get_icon(icon_key, icon_type); 368 | let text = text.as_ref(); 369 | match icon_type { 370 | "font" => format!("{}{}{}", icon, " ".repeat(spaces), text), 371 | "xdg" => format!("{text}\0icon\x1f{icon}"), 372 | _ => text.to_string(), 373 | } 374 | }) 375 | .collect::>() 376 | .join("\n") 377 | } 378 | 379 | pub fn format_with_spacing(icon: char, spaces: usize, before: bool) -> String { 380 | if before { 381 | format!("{}{}", " ".repeat(spaces), icon) 382 | } else { 383 | format!("{}{}", icon, " ".repeat(spaces)) 384 | } 385 | } 386 | 387 | pub fn format_display_with_icon( 388 | &self, 389 | name: &str, 390 | icon: &str, 391 | icon_type: &str, 392 | spaces: usize, 393 | ) -> String { 394 | match icon_type { 395 | "xdg" => format!("{name}\0icon\x1f{icon}"), 396 | "font" | "generic" => format!("{}{}{}", icon, " ".repeat(spaces), name), 397 | _ => name.to_string(), 398 | } 399 | } 400 | 401 | pub fn get_device_icon(&self, device_type: &str, icon_type: &str) -> String { 402 | let icon_key = match device_type { 403 | "phone" | "smartphone" => "phone", 404 | "audio" | "headset" | "headphones" => "headphones", 405 | "keyboard" => "keyboard", 406 | "mouse" | "pointing" => "mouse", 407 | "speaker" => "speaker", 408 | "gamepad" | "joystick" => "gamepad", 409 | "computer" | "desktop" => "computer", 410 | "laptop" => "laptop", 411 | "tablet" => "tablet", 412 | "watch" | "wearable" => "watch", 413 | "tv" | "television" | "display" => "tv", 414 | _ => "device", 415 | }; 416 | 417 | self.get_icon(icon_key, icon_type) 418 | } 419 | 420 | pub fn get_battery_icon(&self, percentage: u8, icon_type: &str) -> Option { 421 | let icon_key = match percentage { 422 | 91..=100 => "battery_100", 423 | 81..=90 => "battery_90", 424 | 71..=80 => "battery_80", 425 | 61..=70 => "battery_70", 426 | 51..=60 => "battery_60", 427 | 41..=50 => "battery_50", 428 | 31..=40 => "battery_40", 429 | 21..=30 => "battery_30", 430 | 11..=20 => "battery_20", 431 | 0..=10 => "battery_10", 432 | _ => return None, 433 | }; 434 | 435 | Some(self.get_icon(icon_key, icon_type)) 436 | } 437 | } 438 | 439 | impl Default for Icons { 440 | fn default() -> Self { 441 | Self::new() 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/menu.rs: -------------------------------------------------------------------------------- 1 | use crate::bz::{controller::Controller, device::Device}; 2 | use crate::icons::Icons; 3 | use crate::launcher::{Launcher, LauncherType}; 4 | use anyhow::Result; 5 | use rust_i18n::t; 6 | use std::borrow::Cow; 7 | use std::sync::Arc; 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum MainMenuOptions { 11 | Scan, 12 | Settings, 13 | Device(String), 14 | } 15 | 16 | impl MainMenuOptions { 17 | pub fn from_string(option: &str) -> Option { 18 | match option { 19 | s if s == t!("menus.main.options.scan.name") => Some(MainMenuOptions::Scan), 20 | s if s == t!("menus.main.options.settings.name") => Some(MainMenuOptions::Settings), 21 | other => Some(MainMenuOptions::Device(other.to_string())), 22 | } 23 | } 24 | 25 | pub fn to_str(&self) -> Cow<'static, str> { 26 | match self { 27 | MainMenuOptions::Scan => t!("menus.main.options.scan.name"), 28 | MainMenuOptions::Settings => t!("menus.main.options.settings.name"), 29 | MainMenuOptions::Device(_) => t!("menus.main.options.device.name"), 30 | } 31 | } 32 | } 33 | 34 | #[derive(Debug, Clone, Copy, PartialEq)] 35 | pub enum DeviceMenuOptions { 36 | Connect, 37 | Disconnect, 38 | Trust, 39 | RevokeTrust, 40 | Forget, 41 | } 42 | 43 | impl DeviceMenuOptions { 44 | pub fn from_string(option: &str) -> Option { 45 | match option { 46 | s if s == t!("menus.device.options.connect.name") => Some(DeviceMenuOptions::Connect), 47 | s if s == t!("menus.device.options.disconnect.name") => { 48 | Some(DeviceMenuOptions::Disconnect) 49 | } 50 | s if s == t!("menus.device.options.trust.name") => Some(DeviceMenuOptions::Trust), 51 | s if s == t!("menus.device.options.revoke_trust.name") => { 52 | Some(DeviceMenuOptions::RevokeTrust) 53 | } 54 | s if s == t!("menus.device.options.forget.name") => Some(DeviceMenuOptions::Forget), 55 | _ => None, 56 | } 57 | } 58 | 59 | pub fn to_str(&self) -> Cow<'static, str> { 60 | match self { 61 | DeviceMenuOptions::Connect => t!("menus.device.options.connect.name"), 62 | DeviceMenuOptions::Disconnect => t!("menus.device.options.disconnect.name"), 63 | DeviceMenuOptions::Trust => t!("menus.device.options.trust.name"), 64 | DeviceMenuOptions::RevokeTrust => t!("menus.device.options.revoke_trust.name"), 65 | DeviceMenuOptions::Forget => t!("menus.device.options.forget.name"), 66 | } 67 | } 68 | } 69 | 70 | #[derive(Debug, Clone, Copy)] 71 | pub enum SettingsMenuOptions { 72 | ToggleDiscoverable, 73 | TogglePairable, 74 | DisableAdapter, 75 | } 76 | 77 | impl SettingsMenuOptions { 78 | pub fn from_string(option: &str) -> Option { 79 | match option { 80 | s if s == t!("menus.settings.options.toggle_discoverable.name") => { 81 | Some(SettingsMenuOptions::ToggleDiscoverable) 82 | } 83 | s if s == t!("menus.settings.options.toggle_pairable.name") => { 84 | Some(SettingsMenuOptions::TogglePairable) 85 | } 86 | s if s == t!("menus.settings.options.disable_adapter.name") => { 87 | Some(SettingsMenuOptions::DisableAdapter) 88 | } 89 | _ => None, 90 | } 91 | } 92 | 93 | pub fn to_str(&self) -> Cow<'static, str> { 94 | match self { 95 | SettingsMenuOptions::ToggleDiscoverable => { 96 | t!("menus.settings.options.toggle_discoverable.name") 97 | } 98 | SettingsMenuOptions::TogglePairable => { 99 | t!("menus.settings.options.toggle_pairable.name") 100 | } 101 | SettingsMenuOptions::DisableAdapter => { 102 | t!("menus.settings.options.disable_adapter.name") 103 | } 104 | } 105 | } 106 | } 107 | 108 | #[derive(Debug, Clone, Copy)] 109 | pub enum AdapterMenuOptions { 110 | PowerOnDevice, 111 | } 112 | 113 | impl AdapterMenuOptions { 114 | pub fn from_string(option: &str) -> Option { 115 | if option == t!("menus.adapter.options.power_on_device.name") { 116 | Some(AdapterMenuOptions::PowerOnDevice) 117 | } else { 118 | None 119 | } 120 | } 121 | 122 | pub fn to_str(&self) -> Cow<'static, str> { 123 | match self { 124 | AdapterMenuOptions::PowerOnDevice => t!("menus.adapter.options.power_on_device.name"), 125 | } 126 | } 127 | } 128 | 129 | #[derive(Debug, Clone, Copy)] 130 | pub enum BluetoothMenuOptions { 131 | EnableDiscoverable, 132 | DisableDiscoverable, 133 | EnablePairable, 134 | DisablePairable, 135 | } 136 | 137 | impl BluetoothMenuOptions { 138 | pub fn from_string(option: &str) -> Option { 139 | match option { 140 | s if s == t!("menus.bluetooth.options.enable_discoverable.name") => { 141 | Some(BluetoothMenuOptions::EnableDiscoverable) 142 | } 143 | s if s == t!("menus.bluetooth.options.disable_discoverable.name") => { 144 | Some(BluetoothMenuOptions::DisableDiscoverable) 145 | } 146 | s if s == t!("menus.bluetooth.options.enable_pairable.name") => { 147 | Some(BluetoothMenuOptions::EnablePairable) 148 | } 149 | s if s == t!("menus.bluetooth.options.disable_pairable.name") => { 150 | Some(BluetoothMenuOptions::DisablePairable) 151 | } 152 | _ => None, 153 | } 154 | } 155 | 156 | pub fn to_str(&self) -> Cow<'static, str> { 157 | match self { 158 | BluetoothMenuOptions::EnableDiscoverable => { 159 | t!("menus.bluetooth.options.enable_discoverable.name") 160 | } 161 | BluetoothMenuOptions::DisableDiscoverable => { 162 | t!("menus.bluetooth.options.disable_discoverable.name") 163 | } 164 | BluetoothMenuOptions::EnablePairable => { 165 | t!("menus.bluetooth.options.enable_pairable.name") 166 | } 167 | BluetoothMenuOptions::DisablePairable => { 168 | t!("menus.bluetooth.options.disable_pairable.name") 169 | } 170 | } 171 | } 172 | } 173 | 174 | #[derive(Clone)] 175 | pub struct Menu { 176 | pub launcher_type: LauncherType, 177 | pub icons: Arc, 178 | } 179 | 180 | impl Menu { 181 | pub fn new(launcher_type: LauncherType, icons: Arc) -> Self { 182 | Self { 183 | launcher_type, 184 | icons, 185 | } 186 | } 187 | 188 | pub fn run_launcher( 189 | &self, 190 | launcher_command: &Option, 191 | input: Option<&str>, 192 | icon_type: &str, 193 | hint: Option<&str>, 194 | ) -> Result> { 195 | let cmd = Launcher::create_command(&self.launcher_type, launcher_command, icon_type, hint)?; 196 | 197 | Launcher::run(cmd, input) 198 | } 199 | 200 | pub fn clean_menu_output(&self, output: &str, icon_type: &str) -> String { 201 | let output_trimmed = output.trim(); 202 | 203 | if icon_type == "font" { 204 | output_trimmed 205 | .chars() 206 | .skip_while(|c| !c.is_ascii_alphanumeric()) 207 | .collect::() 208 | .trim() 209 | .to_string() 210 | } else if icon_type == "xdg" { 211 | output_trimmed 212 | .split('\0') 213 | .next() 214 | .unwrap_or("") 215 | .trim() 216 | .to_string() 217 | } else { 218 | output_trimmed.to_string() 219 | } 220 | } 221 | 222 | pub fn get_icon_text(&self, items: Vec<(&str, T)>, icon_type: &str, spaces: usize) -> String 223 | where 224 | T: AsRef, 225 | { 226 | items 227 | .into_iter() 228 | .map(|(icon_key, text)| { 229 | let icon = self.icons.get_icon(icon_key, icon_type); 230 | let text = text.as_ref(); 231 | match icon_type { 232 | "font" => format!("{}{}{}", icon, " ".repeat(spaces), text), 233 | "xdg" => format!("{text}\0icon\x1f{icon}"), 234 | _ => text.to_string(), 235 | } 236 | }) 237 | .collect::>() 238 | .join("\n") 239 | } 240 | 241 | pub fn format_device_display(&self, device: &Device, icon_type: &str, spaces: usize) -> String { 242 | let mut display_name = device.alias.to_string(); 243 | 244 | let mut status_indicators = String::new(); 245 | 246 | if let Some(battery) = device.battery_percentage { 247 | if icon_type == "font" { 248 | if let Some(battery_icon) = self.icons.get_battery_icon(battery, icon_type) { 249 | status_indicators.push_str(&format!(" [{battery_icon}]")); 250 | } 251 | } else if icon_type == "xdg" { 252 | status_indicators.push_str(&format!(" [{battery}%]")); 253 | } 254 | } 255 | 256 | if device.is_connected { 257 | status_indicators 258 | .push_str(&format!(" {}", self.icons.get_icon("connected", "generic"))); 259 | } 260 | 261 | if device.is_trusted { 262 | status_indicators.push_str(&format!(" {}", self.icons.get_icon("trusted", "generic"))); 263 | } 264 | 265 | display_name.push_str(&status_indicators); 266 | 267 | let icon = self.icons.get_device_icon(&device.device_type, icon_type); 268 | 269 | self.icons 270 | .format_display_with_icon(&display_name, &icon, icon_type, spaces) 271 | } 272 | 273 | pub async fn show_main_menu( 274 | &self, 275 | launcher_command: &Option, 276 | controller: &Controller, 277 | icon_type: &str, 278 | spaces: usize, 279 | ) -> Result> { 280 | let scan_text = MainMenuOptions::Scan.to_str(); 281 | let settings_text = MainMenuOptions::Settings.to_str(); 282 | 283 | let options_start = vec![("scan", scan_text.as_ref())]; 284 | let mut input = self.get_icon_text(options_start, icon_type, spaces); 285 | 286 | for device in &controller.paired_devices { 287 | let device_display = self.format_device_display(device, icon_type, spaces); 288 | input.push_str(&format!("\n{device_display}")); 289 | } 290 | 291 | for device in &controller.new_devices { 292 | let device_display = self.format_device_display(device, icon_type, spaces); 293 | input.push_str(&format!("\n{device_display}")); 294 | } 295 | 296 | let options_end = vec![("settings", settings_text.as_ref())]; 297 | let settings_input = self.get_icon_text(options_end, icon_type, spaces); 298 | input.push_str(&format!("\n{settings_input}")); 299 | 300 | let menu_output = self.run_launcher(launcher_command, Some(&input), icon_type, None)?; 301 | 302 | if let Some(output) = menu_output { 303 | let cleaned_output = self.clean_menu_output(&output, icon_type); 304 | 305 | if cleaned_output == scan_text.as_ref() { 306 | return Ok(Some(MainMenuOptions::Scan)); 307 | } else if cleaned_output == settings_text.as_ref() { 308 | return Ok(Some(MainMenuOptions::Settings)); 309 | } else { 310 | return Ok(Some(MainMenuOptions::Device(cleaned_output))); 311 | } 312 | } 313 | 314 | Ok(None) 315 | } 316 | 317 | pub async fn show_device_options( 318 | &self, 319 | launcher_command: &Option, 320 | icon_type: &str, 321 | spaces: usize, 322 | available_options: Vec, 323 | device_name: &str, 324 | ) -> Result> { 325 | let mut input = String::new(); 326 | 327 | for option in &available_options { 328 | let icon_key = match option { 329 | DeviceMenuOptions::Connect => "connect", 330 | DeviceMenuOptions::Disconnect => "disconnect", 331 | DeviceMenuOptions::Trust => "trust", 332 | DeviceMenuOptions::RevokeTrust => "revoke_trust", 333 | DeviceMenuOptions::Forget => "forget", 334 | }; 335 | 336 | let option_text = 337 | self.get_icon_text(vec![(icon_key, option.to_str())], icon_type, spaces); 338 | input.push_str(&format!("{option_text}\n")); 339 | } 340 | 341 | let hint = t!("menus.device.hint", device_name = device_name); 342 | 343 | let menu_output = 344 | self.run_launcher(launcher_command, Some(&input), icon_type, Some(&hint))?; 345 | 346 | if let Some(output) = menu_output { 347 | let cleaned_output = self.clean_menu_output(&output, icon_type); 348 | return Ok(DeviceMenuOptions::from_string(&cleaned_output)); 349 | } 350 | 351 | Ok(None) 352 | } 353 | 354 | pub fn get_paired_device_options(&self, device: &Device) -> Vec { 355 | let mut options = Vec::new(); 356 | 357 | if device.is_connected { 358 | options.push(DeviceMenuOptions::Disconnect); 359 | } else { 360 | options.push(DeviceMenuOptions::Connect); 361 | } 362 | 363 | if device.is_trusted { 364 | options.push(DeviceMenuOptions::RevokeTrust); 365 | } else { 366 | options.push(DeviceMenuOptions::Trust); 367 | } 368 | 369 | options.push(DeviceMenuOptions::Forget); 370 | 371 | options 372 | } 373 | 374 | pub async fn show_settings_menu( 375 | &self, 376 | launcher_command: &Option, 377 | controller: &Controller, 378 | icon_type: &str, 379 | spaces: usize, 380 | ) -> Result> { 381 | let (discoverable_text, discoverable_icon) = if controller.is_discoverable { 382 | ( 383 | t!("menus.settings.options.disable_discoverable.name"), 384 | "disable_discoverable", 385 | ) 386 | } else { 387 | ( 388 | t!("menus.settings.options.enable_discoverable.name"), 389 | "enable_discoverable", 390 | ) 391 | }; 392 | 393 | let (pairable_text, pairable_icon) = if controller.is_pairable { 394 | ( 395 | t!("menus.settings.options.disable_pairable.name"), 396 | "disable_pairable", 397 | ) 398 | } else { 399 | ( 400 | t!("menus.settings.options.enable_pairable.name"), 401 | "enable_pairable", 402 | ) 403 | }; 404 | 405 | let disable_adapter_text = t!("menus.settings.options.disable_adapter.name"); 406 | 407 | let options = vec![ 408 | (discoverable_icon, discoverable_text.as_ref()), 409 | (pairable_icon, pairable_text.as_ref()), 410 | ("disable_adapter", disable_adapter_text.as_ref()), 411 | ]; 412 | 413 | let input = self.get_icon_text(options, icon_type, spaces); 414 | 415 | let menu_output = self.run_launcher(launcher_command, Some(&input), icon_type, None)?; 416 | 417 | if let Some(output) = menu_output { 418 | let cleaned_output = self.clean_menu_output(&output, icon_type); 419 | 420 | if cleaned_output == discoverable_text.as_ref() { 421 | return Ok(Some(SettingsMenuOptions::ToggleDiscoverable)); 422 | } else if cleaned_output == pairable_text.as_ref() { 423 | return Ok(Some(SettingsMenuOptions::TogglePairable)); 424 | } else if cleaned_output == disable_adapter_text.as_ref() { 425 | return Ok(Some(SettingsMenuOptions::DisableAdapter)); 426 | } 427 | } 428 | 429 | Ok(None) 430 | } 431 | 432 | pub fn prompt_enable_adapter( 433 | &self, 434 | launcher_command: &Option, 435 | icon_type: &str, 436 | spaces: usize, 437 | ) -> Option { 438 | let options = vec![( 439 | "power_on_device", 440 | AdapterMenuOptions::PowerOnDevice.to_str(), 441 | )]; 442 | 443 | let input = self.get_icon_text(options, icon_type, spaces); 444 | 445 | if let Ok(Some(output)) = self.run_launcher(launcher_command, Some(&input), icon_type, None) 446 | { 447 | let cleaned_output = self.clean_menu_output(&output, icon_type); 448 | return AdapterMenuOptions::from_string(&cleaned_output); 449 | } 450 | 451 | None 452 | } 453 | 454 | pub fn prompt_passkey_confirmation( 455 | &self, 456 | launcher_command: &Option, 457 | device_name: &str, 458 | passkey: &str, 459 | icon_type: &str, 460 | ) -> Result { 461 | let hint = t!( 462 | "menus.bluetooth.confirm_passkey", 463 | device_name = device_name, 464 | passkey = passkey 465 | ); 466 | 467 | let options = vec![ 468 | ("confirm", t!("menus.bluetooth.confirm")), 469 | ("cancel", t!("menus.bluetooth.cancel")), 470 | ]; 471 | 472 | let input = self.get_icon_text(options, icon_type, 1); 473 | 474 | let menu_output = 475 | self.run_launcher(launcher_command, Some(&input), icon_type, Some(&hint))?; 476 | 477 | if let Some(output) = menu_output { 478 | let cleaned_output = self.clean_menu_output(&output, icon_type); 479 | return Ok(cleaned_output == t!("menus.bluetooth.confirm")); 480 | } 481 | 482 | Ok(false) 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | bz::{ 3 | agent::AgentManager, 4 | controller::Controller, 5 | pairing::{PairingConfirmationHandler, PairingManager}, 6 | scanner::Scanner, 7 | }, 8 | icons::Icons, 9 | menu::{AdapterMenuOptions, DeviceMenuOptions, MainMenuOptions, Menu, SettingsMenuOptions}, 10 | notification::NotificationManager, 11 | }; 12 | use anyhow::Result; 13 | use bluer::Session; 14 | use log::{debug, error, info}; 15 | use rust_i18n::t; 16 | use std::sync::atomic::Ordering; 17 | use std::sync::Arc; 18 | use tokio::runtime::Builder; 19 | 20 | pub struct App { 21 | pub running: bool, 22 | pub reset_mode: bool, 23 | session: Arc, 24 | controller: Controller, 25 | agent_manager: AgentManager, 26 | scanner: Scanner, 27 | pairing_manager: PairingManager, 28 | notification_manager: Arc, 29 | scan_duration: u64, 30 | } 31 | 32 | impl App { 33 | pub fn get_session(&self) -> Arc { 34 | self.session.clone() 35 | } 36 | 37 | pub fn get_agent_manager(&self) -> &AgentManager { 38 | &self.agent_manager 39 | } 40 | 41 | pub async fn new(icons: Arc, scan_duration: u64) -> Result { 42 | let session = Arc::new(Session::new().await?); 43 | let notification_manager = Arc::new(NotificationManager::new(icons.clone())); 44 | 45 | let agent_manager = AgentManager::new( 46 | session.clone(), 47 | notification_manager.clone() as Arc, 48 | ) 49 | .await?; 50 | 51 | let controller = Controller::new(session.clone()).await?; 52 | 53 | let scanner = Scanner::new(controller.adapter.clone(), controller.is_scanning.clone()); 54 | 55 | let pairing_manager = PairingManager::new(controller.adapter.clone()); 56 | 57 | if !controller.is_powered { 58 | info!("{}", t!("notifications.bt.adapter_powered_off")); 59 | } 60 | 61 | Ok(Self { 62 | running: true, 63 | reset_mode: false, 64 | session, 65 | controller, 66 | agent_manager, 67 | scanner, 68 | pairing_manager, 69 | notification_manager, 70 | scan_duration, 71 | }) 72 | } 73 | 74 | pub fn quit(&mut self) { 75 | self.running = false; 76 | } 77 | 78 | pub async fn run( 79 | &mut self, 80 | menu: &Menu, 81 | menu_command: &Option, 82 | icon_type: &str, 83 | spaces: usize, 84 | ) -> Result> { 85 | if !self.controller.is_powered { 86 | self.handle_adapter_options(menu, menu_command, icon_type, spaces) 87 | .await?; 88 | if self.running { 89 | self.controller.refresh().await?; 90 | } else { 91 | return Ok(None); 92 | } 93 | } 94 | 95 | while self.running { 96 | self.controller.refresh().await?; 97 | 98 | match menu 99 | .show_main_menu(menu_command, &self.controller, icon_type, spaces) 100 | .await? 101 | { 102 | Some(main_menu_option) => { 103 | self.handle_main_options( 104 | menu, 105 | menu_command, 106 | icon_type, 107 | spaces, 108 | main_menu_option, 109 | ) 110 | .await?; 111 | } 112 | None => { 113 | debug!("{}", t!("notifications.bt.main_menu_exited")); 114 | self.running = false; 115 | } 116 | } 117 | } 118 | 119 | Ok(None) 120 | } 121 | 122 | async fn handle_main_options( 123 | &mut self, 124 | menu: &Menu, 125 | menu_command: &Option, 126 | icon_type: &str, 127 | spaces: usize, 128 | main_menu_option: MainMenuOptions, 129 | ) -> Result> { 130 | match main_menu_option { 131 | MainMenuOptions::Scan => { 132 | self.perform_device_scan().await?; 133 | } 134 | MainMenuOptions::Settings => { 135 | self.handle_settings_menu(menu, menu_command, icon_type, spaces) 136 | .await?; 137 | } 138 | MainMenuOptions::Device(output) => { 139 | if let Some(device) = self 140 | .handle_device_selection(menu, menu_command, &output, icon_type, spaces) 141 | .await? 142 | { 143 | return Ok(Some(device.addr.to_string())); 144 | } 145 | } 146 | } 147 | Ok(None) 148 | } 149 | 150 | async fn handle_settings_menu( 151 | &mut self, 152 | menu: &Menu, 153 | menu_command: &Option, 154 | icon_type: &str, 155 | spaces: usize, 156 | ) -> Result<()> { 157 | let mut stay_in_settings = true; 158 | 159 | while stay_in_settings { 160 | self.controller.refresh().await?; 161 | 162 | if let Some(option) = menu 163 | .show_settings_menu(menu_command, &self.controller, icon_type, spaces) 164 | .await? 165 | { 166 | if matches!(option, SettingsMenuOptions::DisableAdapter) { 167 | self.handle_settings_options(menu, menu_command, icon_type, spaces, option) 168 | .await?; 169 | stay_in_settings = false; 170 | } else { 171 | self.handle_settings_options(menu, menu_command, icon_type, spaces, option) 172 | .await?; 173 | } 174 | } else { 175 | stay_in_settings = false; 176 | debug!("Exited settings menu"); 177 | } 178 | } 179 | 180 | Ok(()) 181 | } 182 | 183 | async fn handle_settings_options( 184 | &mut self, 185 | menu: &Menu, 186 | menu_command: &Option, 187 | icon_type: &str, 188 | spaces: usize, 189 | option: SettingsMenuOptions, 190 | ) -> Result<()> { 191 | match option { 192 | SettingsMenuOptions::ToggleDiscoverable => { 193 | let new_state = !self.controller.is_discoverable; 194 | self.controller.set_discoverable(new_state).await?; 195 | 196 | let msg = if new_state { 197 | t!("notifications.bt.discoverable_enabled") 198 | } else { 199 | t!("notifications.bt.discoverable_disabled") 200 | }; 201 | 202 | info!("{msg}"); 203 | try_send_notification!( 204 | self.notification_manager, 205 | None, 206 | Some(msg.to_string()), 207 | Some("bluetooth"), 208 | None, 209 | None 210 | ); 211 | } 212 | SettingsMenuOptions::TogglePairable => { 213 | let new_state = !self.controller.is_pairable; 214 | self.controller.set_pairable(new_state).await?; 215 | 216 | let msg = if new_state { 217 | t!("notifications.bt.pairable_enabled") 218 | } else { 219 | t!("notifications.bt.pairable_disabled") 220 | }; 221 | 222 | info!("{msg}"); 223 | try_send_notification!( 224 | self.notification_manager, 225 | None, 226 | Some(msg.to_string()), 227 | Some("bluetooth"), 228 | None, 229 | None 230 | ); 231 | } 232 | SettingsMenuOptions::DisableAdapter => { 233 | self.perform_adapter_disable(menu, menu_command, icon_type, spaces) 234 | .await?; 235 | } 236 | } 237 | Ok(()) 238 | } 239 | 240 | async fn handle_adapter_options( 241 | &mut self, 242 | menu: &Menu, 243 | menu_command: &Option, 244 | icon_type: &str, 245 | spaces: usize, 246 | ) -> Result<()> { 247 | if let Some(option) = menu.prompt_enable_adapter(menu_command, icon_type, spaces) { 248 | match option { 249 | AdapterMenuOptions::PowerOnDevice => { 250 | self.controller.power_on().await?; 251 | self.controller.refresh().await?; 252 | 253 | info!("{}", t!("notifications.bt.adapter_enabled")); 254 | try_send_notification!( 255 | self.notification_manager, 256 | None, 257 | Some(t!("notifications.bt.adapter_enabled").to_string()), 258 | Some("bluetooth"), 259 | None, 260 | None 261 | ); 262 | } 263 | } 264 | } else { 265 | info!("{}", t!("notifications.bt.adapter_menu_exited")); 266 | self.running = false; 267 | } 268 | 269 | Ok(()) 270 | } 271 | 272 | async fn handle_device_menu( 273 | &mut self, 274 | menu: &Menu, 275 | menu_command: &Option, 276 | device: &crate::bz::device::Device, 277 | icon_type: &str, 278 | spaces: usize, 279 | ) -> Result<()> { 280 | let mut device_clone = device.clone(); 281 | let mut stay_in_device_menu = true; 282 | 283 | while stay_in_device_menu { 284 | if let Ok(refreshed_device) = 285 | crate::bz::device::Device::new(&self.controller.adapter, &device_clone.addr).await 286 | { 287 | device_clone = refreshed_device; 288 | } else { 289 | error!("Device {} is no longer available", device_clone.alias); 290 | break; 291 | } 292 | 293 | let available_options = if device_clone.is_paired { 294 | menu.get_paired_device_options(&device_clone) 295 | } else { 296 | vec![DeviceMenuOptions::Connect] 297 | }; 298 | 299 | match menu 300 | .show_device_options( 301 | menu_command, 302 | icon_type, 303 | spaces, 304 | available_options, 305 | &device_clone.alias, 306 | ) 307 | .await? 308 | { 309 | Some(option) => { 310 | match option { 311 | DeviceMenuOptions::Connect => { 312 | if !device_clone.is_connected { 313 | self.perform_device_connection(&device_clone).await?; 314 | } 315 | } 316 | DeviceMenuOptions::Disconnect => { 317 | if device_clone.is_connected { 318 | self.perform_device_disconnection(&device_clone).await?; 319 | } 320 | } 321 | DeviceMenuOptions::Trust => { 322 | if !device_clone.is_trusted { 323 | self.perform_trust_device(&device_clone, true).await?; 324 | } 325 | } 326 | DeviceMenuOptions::RevokeTrust => { 327 | if device_clone.is_trusted { 328 | self.perform_trust_device(&device_clone, false).await?; 329 | } 330 | } 331 | DeviceMenuOptions::Forget => { 332 | self.perform_forget_device(&device_clone).await?; 333 | stay_in_device_menu = false; 334 | } 335 | } 336 | 337 | self.controller.refresh().await?; 338 | } 339 | None => { 340 | stay_in_device_menu = false; 341 | debug!("Exited device menu for {}", device_clone.alias); 342 | } 343 | } 344 | } 345 | 346 | Ok(()) 347 | } 348 | 349 | async fn handle_device_selection( 350 | &mut self, 351 | menu: &Menu, 352 | menu_command: &Option, 353 | output: &str, 354 | icon_type: &str, 355 | spaces: usize, 356 | ) -> Result> { 357 | let cleaned_output = menu.clean_menu_output(output, icon_type); 358 | 359 | let paired_device_clone = self 360 | .controller 361 | .paired_devices 362 | .iter() 363 | .find(|device| { 364 | let formatted = menu.format_device_display(device, icon_type, spaces); 365 | menu.clean_menu_output(&formatted, icon_type) == cleaned_output 366 | }) 367 | .cloned(); 368 | 369 | let new_device_clone = self 370 | .controller 371 | .new_devices 372 | .iter() 373 | .find(|device| { 374 | let formatted = menu.format_device_display(device, icon_type, spaces); 375 | menu.clean_menu_output(&formatted, icon_type) == cleaned_output 376 | }) 377 | .cloned(); 378 | 379 | if let Some(device) = paired_device_clone.or(new_device_clone) { 380 | self.handle_device_menu(menu, menu_command, &device, icon_type, spaces) 381 | .await?; 382 | return Ok(Some(device)); 383 | } 384 | 385 | Ok(None) 386 | } 387 | 388 | async fn perform_device_scan(&mut self) -> Result<()> { 389 | if self.controller.is_scanning.load(Ordering::Relaxed) { 390 | let msg = t!("notifications.bt.scan_already_in_progress"); 391 | info!("{msg}"); 392 | try_send_notification!( 393 | self.notification_manager, 394 | None, 395 | Some(msg.to_string()), 396 | Some("bluetooth"), 397 | None, 398 | None 399 | ); 400 | return Ok(()); 401 | } 402 | 403 | let scan_duration = self.scan_duration; 404 | 405 | self.scanner.start_discovery(scan_duration).await?; 406 | 407 | let scanner_clone = self.scanner.clone(); 408 | 409 | let progress_msg = t!("notifications.bt.scan_in_progress"); 410 | let completed_msg = t!("notifications.bt.scan_completed"); 411 | 412 | let id = self.notification_manager.send_progress_notification( 413 | scan_duration, 414 | move || { 415 | debug!("User cancelled Bluetooth scan"); 416 | 417 | let rt = Builder::new_current_thread().enable_all().build().unwrap(); 418 | 419 | rt.block_on(async { 420 | let _ = scanner_clone.stop_discovery().await; 421 | }); 422 | }, 423 | progress_msg.to_string(), 424 | Some("scan_in_progress"), 425 | )?; 426 | 427 | self.scanner.wait_for_discovery_completion().await?; 428 | 429 | self.controller.refresh().await?; 430 | 431 | let _ = self.notification_manager.send_notification( 432 | None, 433 | Some(completed_msg.to_string()), 434 | Some("ok"), 435 | None, 436 | Some(id), 437 | ); 438 | 439 | Ok(()) 440 | } 441 | 442 | async fn perform_device_connection(&self, device: &crate::bz::device::Device) -> Result<()> { 443 | debug!("Connecting to device: {}", device.alias); 444 | 445 | let result = if !device.is_paired { 446 | self.pairing_manager.pair_device(device).await 447 | } else { 448 | Ok(()) 449 | }; 450 | 451 | if let Err(err) = result { 452 | let msg = t!( 453 | "notifications.bt.pairing_failed", 454 | device_name = device.alias, 455 | error = err.to_string() 456 | ); 457 | 458 | info!("{msg}"); 459 | try_send_notification!( 460 | self.notification_manager, 461 | None, 462 | Some(msg.to_string()), 463 | Some("bluetooth"), 464 | None, 465 | None 466 | ); 467 | return Ok(()); 468 | } 469 | 470 | let connection_result = self.pairing_manager.connect_device(device).await; 471 | 472 | match connection_result { 473 | Ok(_) => { 474 | let msg = t!( 475 | "notifications.bt.device_connected", 476 | device_name = device.alias 477 | ); 478 | 479 | info!("{msg}"); 480 | try_send_notification!( 481 | self.notification_manager, 482 | None, 483 | Some(msg.to_string()), 484 | Some("bluetooth"), 485 | None, 486 | None 487 | ); 488 | Ok(()) 489 | } 490 | Err(err) => { 491 | let msg = if err.to_string().contains("Page Timeout") { 492 | t!( 493 | "notifications.bt.device_out_of_range", 494 | device_name = device.alias 495 | ) 496 | } else { 497 | t!( 498 | "notifications.bt.connection_failed", 499 | device_name = device.alias, 500 | error = err.to_string() 501 | ) 502 | }; 503 | 504 | info!("{msg}"); 505 | 506 | try_send_notification!( 507 | self.notification_manager, 508 | None, 509 | Some(msg.to_string()), 510 | Some("bluetooth"), 511 | None, 512 | None 513 | ); 514 | 515 | Ok(()) 516 | } 517 | } 518 | } 519 | 520 | async fn perform_device_disconnection(&self, device: &crate::bz::device::Device) -> Result<()> { 521 | debug!("Disconnecting from device: {}", device.alias); 522 | 523 | self.pairing_manager.disconnect_device(device).await?; 524 | 525 | let msg = t!( 526 | "notifications.bt.device_disconnected", 527 | device_name = device.alias 528 | ); 529 | 530 | info!("{msg}"); 531 | try_send_notification!( 532 | self.notification_manager, 533 | None, 534 | Some(msg.to_string()), 535 | Some("bluetooth"), 536 | None, 537 | None 538 | ); 539 | 540 | Ok(()) 541 | } 542 | 543 | async fn perform_trust_device( 544 | &self, 545 | device: &crate::bz::device::Device, 546 | trust: bool, 547 | ) -> Result<()> { 548 | info!( 549 | "{} trust for device: {}", 550 | if trust { "Enabling" } else { "Revoking" }, 551 | device.alias 552 | ); 553 | 554 | device.set_trusted(trust).await?; 555 | 556 | let msg = if trust { 557 | t!( 558 | "notifications.bt.device_trusted", 559 | device_name = device.alias 560 | ) 561 | } else { 562 | t!( 563 | "notifications.bt.device_untrusted", 564 | device_name = device.alias 565 | ) 566 | }; 567 | 568 | info!("{msg}"); 569 | try_send_notification!( 570 | self.notification_manager, 571 | None, 572 | Some(msg.to_string()), 573 | Some("bluetooth"), 574 | None, 575 | None 576 | ); 577 | 578 | Ok(()) 579 | } 580 | 581 | async fn perform_forget_device(&self, device: &crate::bz::device::Device) -> Result<()> { 582 | info!("Forgetting device: {}", device.alias); 583 | 584 | self.pairing_manager.forget_device(device).await?; 585 | 586 | let msg = t!( 587 | "notifications.bt.device_forgotten", 588 | device_name = device.alias 589 | ); 590 | 591 | info!("{msg}"); 592 | try_send_notification!( 593 | self.notification_manager, 594 | None, 595 | Some(msg.to_string()), 596 | Some("bluetooth"), 597 | None, 598 | None 599 | ); 600 | 601 | Ok(()) 602 | } 603 | 604 | async fn perform_adapter_disable( 605 | &mut self, 606 | menu: &Menu, 607 | menu_command: &Option, 608 | icon_type: &str, 609 | spaces: usize, 610 | ) -> Result<()> { 611 | self.controller.power_off().await?; 612 | 613 | let msg = t!("notifications.bt.adapter_disabled").to_string(); 614 | info!("{msg}"); 615 | try_send_notification!( 616 | self.notification_manager, 617 | None, 618 | Some(msg), 619 | Some("bluetooth"), 620 | None, 621 | None 622 | ); 623 | 624 | self.handle_adapter_options(menu, menu_command, icon_type, spaces) 625 | .await?; 626 | 627 | Ok(()) 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 [Free Software Foundation, Inc.](http://fsf.org/) 5 | 6 | Everyone is permitted to copy and distribute verbatim copies of this license 7 | document, but changing it is not allowed. 8 | 9 | ## Preamble 10 | 11 | The GNU General Public License is a free, copyleft license for software and 12 | other kinds of works. 13 | 14 | The licenses for most software and other practical works are designed to take 15 | away your freedom to share and change the works. By contrast, the GNU General 16 | Public License is intended to guarantee your freedom to share and change all 17 | versions of a program--to make sure it remains free software for all its users. 18 | We, the Free Software Foundation, use the GNU General Public License for most 19 | of our software; it applies also to any other work released this way by its 20 | authors. You can apply it to your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not price. Our 23 | General Public Licenses are designed to make sure that you have the freedom to 24 | distribute copies of free software (and charge for them if you wish), that you 25 | receive source code or can get it if you want it, that you can change the 26 | software or use pieces of it in new free programs, and that you know you can do 27 | these things. 28 | 29 | To protect your rights, we need to prevent others from denying you these rights 30 | or asking you to surrender the rights. Therefore, you have certain 31 | responsibilities if you distribute copies of the software, or if you modify it: 32 | responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether gratis or for 35 | a fee, you must pass on to the recipients the same freedoms that you received. 36 | You must make sure that they, too, receive or can get the source code. And you 37 | must show them these terms so they know their rights. 38 | 39 | Developers that use the GNU GPL protect your rights with two steps: 40 | 41 | 1. assert copyright on the software, and 42 | 2. offer you this License giving you legal permission to copy, distribute 43 | and/or modify it. 44 | 45 | For the developers' and authors' protection, the GPL clearly explains that 46 | there is no warranty for this free software. For both users' and authors' sake, 47 | the GPL requires that modified versions be marked as changed, so that their 48 | problems will not be attributed erroneously to authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run modified 51 | versions of the software inside them, although the manufacturer can do so. This 52 | is fundamentally incompatible with the aim of protecting users' freedom to 53 | change the software. The systematic pattern of such abuse occurs in the area of 54 | products for individuals to use, which is precisely where it is most 55 | unacceptable. Therefore, we have designed this version of the GPL to prohibit 56 | the practice for those products. If such problems arise substantially in other 57 | domains, we stand ready to extend this provision to those domains in future 58 | versions of the GPL, as needed to protect the freedom of users. 59 | 60 | Finally, every program is threatened constantly by software patents. States 61 | should not allow patents to restrict development and use of software on 62 | general-purpose computers, but in those that do, we wish to avoid the special 63 | danger that patents applied to a free program could make it effectively 64 | proprietary. To prevent this, the GPL assures that patents cannot be used to 65 | render the program non-free. 66 | 67 | The precise terms and conditions for copying, distribution and modification 68 | follow. 69 | 70 | ## TERMS AND CONDITIONS 71 | 72 | ### 0. Definitions. 73 | 74 | *This License* refers to version 3 of the GNU General Public License. 75 | 76 | *Copyright* also means copyright-like laws that apply to other kinds of works, 77 | such as semiconductor masks. 78 | 79 | *The Program* refers to any copyrightable work licensed under this License. 80 | Each licensee is addressed as *you*. *Licensees* and *recipients* may be 81 | individuals or organizations. 82 | 83 | To *modify* a work means to copy from or adapt all or part of the work in a 84 | fashion requiring copyright permission, other than the making of an exact copy. 85 | The resulting work is called a *modified version* of the earlier work or a work 86 | *based on* the earlier work. 87 | 88 | A *covered work* means either the unmodified Program or a work based on the 89 | Program. 90 | 91 | To *propagate* a work means to do anything with it that, without permission, 92 | would make you directly or secondarily liable for infringement under applicable 93 | copyright law, except executing it on a computer or modifying a private copy. 94 | Propagation includes copying, distribution (with or without modification), 95 | making available to the public, and in some countries other activities as well. 96 | 97 | To *convey* a work means any kind of propagation that enables other parties to 98 | make or receive copies. Mere interaction with a user through a computer 99 | network, with no transfer of a copy, is not conveying. 100 | 101 | An interactive user interface displays *Appropriate Legal Notices* to the 102 | extent that it includes a convenient and prominently visible feature that 103 | 104 | 1. displays an appropriate copyright notice, and 105 | 2. tells the user that there is no warranty for the work (except to the 106 | extent that warranties are provided), that licensees may convey the work 107 | under this License, and how to view a copy of this License. 108 | 109 | If the interface presents a list of user commands or options, such as a menu, a 110 | prominent item in the list meets this criterion. 111 | 112 | ### 1. Source Code. 113 | 114 | The *source code* for a work means the preferred form of the work for making 115 | modifications to it. *Object code* means any non-source form of a work. 116 | 117 | A *Standard Interface* means an interface that either is an official standard 118 | defined by a recognized standards body, or, in the case of interfaces specified 119 | for a particular programming language, one that is widely used among developers 120 | working in that language. 121 | 122 | The *System Libraries* of an executable work include anything, other than the 123 | work as a whole, that (a) is included in the normal form of packaging a Major 124 | Component, but which is not part of that Major Component, and (b) serves only 125 | to enable use of the work with that Major Component, or to implement a Standard 126 | Interface for which an implementation is available to the public in source code 127 | form. A *Major Component*, in this context, means a major essential component 128 | (kernel, window system, and so on) of the specific operating system (if any) on 129 | which the executable work runs, or a compiler used to produce the work, or an 130 | object code interpreter used to run it. 131 | 132 | The *Corresponding Source* for a work in object code form means all the source 133 | code needed to generate, install, and (for an executable work) run the object 134 | code and to modify the work, including scripts to control those activities. 135 | However, it does not include the work's System Libraries, or general-purpose 136 | tools or generally available free programs which are used unmodified in 137 | performing those activities but which are not part of the work. For example, 138 | Corresponding Source includes interface definition files associated with source 139 | files for the work, and the source code for shared libraries and dynamically 140 | linked subprograms that the work is specifically designed to require, such as 141 | by intimate data communication or control flow between those subprograms and 142 | other parts of the work. 143 | 144 | The Corresponding Source need not include anything that users can regenerate 145 | automatically from other parts of the Corresponding Source. 146 | 147 | The Corresponding Source for a work in source code form is that same work. 148 | 149 | ### 2. Basic Permissions. 150 | 151 | All rights granted under this License are granted for the term of copyright on 152 | the Program, and are irrevocable provided the stated conditions are met. This 153 | License explicitly affirms your unlimited permission to run the unmodified 154 | Program. The output from running a covered work is covered by this License only 155 | if the output, given its content, constitutes a covered work. This License 156 | acknowledges your rights of fair use or other equivalent, as provided by 157 | copyright law. 158 | 159 | You may make, run and propagate covered works that you do not convey, without 160 | conditions so long as your license otherwise remains in force. You may convey 161 | covered works to others for the sole purpose of having them make modifications 162 | exclusively for you, or provide you with facilities for running those works, 163 | provided that you comply with the terms of this License in conveying all 164 | material for which you do not control copyright. Those thus making or running 165 | the covered works for you must do so exclusively on your behalf, under your 166 | direction and control, on terms that prohibit them from making any copies of 167 | your copyrighted material outside their relationship with you. 168 | 169 | Conveying under any other circumstances is permitted solely under the 170 | conditions stated below. Sublicensing is not allowed; section 10 makes it 171 | unnecessary. 172 | 173 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 174 | 175 | No covered work shall be deemed part of an effective technological measure 176 | under any applicable law fulfilling obligations under article 11 of the WIPO 177 | copyright treaty adopted on 20 December 1996, or similar laws prohibiting or 178 | restricting circumvention of such measures. 179 | 180 | When you convey a covered work, you waive any legal power to forbid 181 | circumvention of technological measures to the extent such circumvention is 182 | effected by exercising rights under this License with respect to the covered 183 | work, and you disclaim any intention to limit operation or modification of the 184 | work as a means of enforcing, against the work's users, your or third parties' 185 | legal rights to forbid circumvention of technological measures. 186 | 187 | ### 4. Conveying Verbatim Copies. 188 | 189 | You may convey verbatim copies of the Program's source code as you receive it, 190 | in any medium, provided that you conspicuously and appropriately publish on 191 | each copy an appropriate copyright notice; keep intact all notices stating that 192 | this License and any non-permissive terms added in accord with section 7 apply 193 | to the code; keep intact all notices of the absence of any warranty; and give 194 | all recipients a copy of this License along with the Program. 195 | 196 | You may charge any price or no price for each copy that you convey, and you may 197 | offer support or warranty protection for a fee. 198 | 199 | ### 5. Conveying Modified Source Versions. 200 | 201 | You may convey a work based on the Program, or the modifications to produce it 202 | from the Program, in the form of source code under the terms of section 4, 203 | provided that you also meet all of these conditions: 204 | 205 | - a) The work must carry prominent notices stating that you modified it, and 206 | giving a relevant date. 207 | - b) The work must carry prominent notices stating that it is released under 208 | this License and any conditions added under section 7. This requirement 209 | modifies the requirement in section 4 to *keep intact all notices*. 210 | - c) You must license the entire work, as a whole, under this License to 211 | anyone who comes into possession of a copy. This License will therefore 212 | apply, along with any applicable section 7 additional terms, to the whole 213 | of the work, and all its parts, regardless of how they are packaged. This 214 | License gives no permission to license the work in any other way, but it 215 | does not invalidate such permission if you have separately received it. 216 | - d) If the work has interactive user interfaces, each must display 217 | Appropriate Legal Notices; however, if the Program has interactive 218 | interfaces that do not display Appropriate Legal Notices, your work need 219 | not make them do so. 220 | 221 | A compilation of a covered work with other separate and independent works, 222 | which are not by their nature extensions of the covered work, and which are not 223 | combined with it such as to form a larger program, in or on a volume of a 224 | storage or distribution medium, is called an *aggregate* if the compilation and 225 | its resulting copyright are not used to limit the access or legal rights of the 226 | compilation's users beyond what the individual works permit. Inclusion of a 227 | covered work in an aggregate does not cause this License to apply to the other 228 | parts of the aggregate. 229 | 230 | ### 6. Conveying Non-Source Forms. 231 | 232 | You may convey a covered work in object code form under the terms of sections 4 233 | and 5, provided that you also convey the machine-readable Corresponding Source 234 | under the terms of this License, in one of these ways: 235 | 236 | - a) Convey the object code in, or embodied in, a physical product (including 237 | a physical distribution medium), accompanied by the Corresponding Source 238 | fixed on a durable physical medium customarily used for software 239 | interchange. 240 | - b) Convey the object code in, or embodied in, a physical product (including 241 | a physical distribution medium), accompanied by a written offer, valid for 242 | at least three years and valid for as long as you offer spare parts or 243 | customer support for that product model, to give anyone who possesses the 244 | object code either 245 | 1. a copy of the Corresponding Source for all the software in the product 246 | that is covered by this License, on a durable physical medium 247 | customarily used for software interchange, for a price no more than your 248 | reasonable cost of physically performing this conveying of source, or 249 | 2. access to copy the Corresponding Source from a network server at no 250 | charge. 251 | - c) Convey individual copies of the object code with a copy of the written 252 | offer to provide the Corresponding Source. This alternative is allowed only 253 | occasionally and noncommercially, and only if you received the object code 254 | with such an offer, in accord with subsection 6b. 255 | - d) Convey the object code by offering access from a designated place 256 | (gratis or for a charge), and offer equivalent access to the Corresponding 257 | Source in the same way through the same place at no further charge. You 258 | need not require recipients to copy the Corresponding Source along with the 259 | object code. If the place to copy the object code is a network server, the 260 | Corresponding Source may be on a different server operated by you or a 261 | third party) that supports equivalent copying facilities, provided you 262 | maintain clear directions next to the object code saying where to find the 263 | Corresponding Source. Regardless of what server hosts the Corresponding 264 | Source, you remain obligated to ensure that it is available for as long as 265 | needed to satisfy these requirements. 266 | - e) Convey the object code using peer-to-peer transmission, provided you 267 | inform other peers where the object code and Corresponding Source of the 268 | work are being offered to the general public at no charge under subsection 269 | 6d. 270 | 271 | A separable portion of the object code, whose source code is excluded from the 272 | Corresponding Source as a System Library, need not be included in conveying the 273 | object code work. 274 | 275 | A *User Product* is either 276 | 277 | 1. a *consumer product*, which means any tangible personal property which is 278 | normally used for personal, family, or household purposes, or 279 | 2. anything designed or sold for incorporation into a dwelling. 280 | 281 | In determining whether a product is a consumer product, doubtful cases shall be 282 | resolved in favor of coverage. For a particular product received by a 283 | particular user, *normally used* refers to a typical or common use of that 284 | class of product, regardless of the status of the particular user or of the way 285 | in which the particular user actually uses, or expects or is expected to use, 286 | the product. A product is a consumer product regardless of whether the product 287 | has substantial commercial, industrial or non-consumer uses, unless such uses 288 | represent the only significant mode of use of the product. 289 | 290 | *Installation Information* for a User Product means any methods, procedures, 291 | authorization keys, or other information required to install and execute 292 | modified versions of a covered work in that User Product from a modified 293 | version of its Corresponding Source. The information must suffice to ensure 294 | that the continued functioning of the modified object code is in no case 295 | prevented or interfered with solely because modification has been made. 296 | 297 | If you convey an object code work under this section in, or with, or 298 | specifically for use in, a User Product, and the conveying occurs as part of a 299 | transaction in which the right of possession and use of the User Product is 300 | transferred to the recipient in perpetuity or for a fixed term (regardless of 301 | how the transaction is characterized), the Corresponding Source conveyed under 302 | this section must be accompanied by the Installation Information. But this 303 | requirement does not apply if neither you nor any third party retains the 304 | ability to install modified object code on the User Product (for example, the 305 | work has been installed in ROM). 306 | 307 | The requirement to provide Installation Information does not include a 308 | requirement to continue to provide support service, warranty, or updates for a 309 | work that has been modified or installed by the recipient, or for the User 310 | Product in which it has been modified or installed. Access to a network may be 311 | denied when the modification itself materially and adversely affects the 312 | operation of the network or violates the rules and protocols for communication 313 | across the network. 314 | 315 | Corresponding Source conveyed, and Installation Information provided, in accord 316 | with this section must be in a format that is publicly documented (and with an 317 | implementation available to the public in source code form), and must require 318 | no special password or key for unpacking, reading or copying. 319 | 320 | ### 7. Additional Terms. 321 | 322 | *Additional permissions* are terms that supplement the terms of this License by 323 | making exceptions from one or more of its conditions. Additional permissions 324 | that are applicable to the entire Program shall be treated as though they were 325 | included in this License, to the extent that they are valid under applicable 326 | law. If additional permissions apply only to part of the Program, that part may 327 | be used separately under those permissions, but the entire Program remains 328 | governed by this License without regard to the additional permissions. 329 | 330 | When you convey a copy of a covered work, you may at your option remove any 331 | additional permissions from that copy, or from any part of it. (Additional 332 | permissions may be written to require their own removal in certain cases when 333 | you modify the work.) You may place additional permissions on material, added 334 | by you to a covered work, for which you have or can give appropriate copyright 335 | permission. 336 | 337 | Notwithstanding any other provision of this License, for material you add to a 338 | covered work, you may (if authorized by the copyright holders of that material) 339 | supplement the terms of this License with terms: 340 | 341 | - a) Disclaiming warranty or limiting liability differently from the terms of 342 | sections 15 and 16 of this License; or 343 | - b) Requiring preservation of specified reasonable legal notices or author 344 | attributions in that material or in the Appropriate Legal Notices displayed 345 | by works containing it; or 346 | - c) Prohibiting misrepresentation of the origin of that material, or 347 | requiring that modified versions of such material be marked in reasonable 348 | ways as different from the original version; or 349 | - d) Limiting the use for publicity purposes of names of licensors or authors 350 | of the material; or 351 | - e) Declining to grant rights under trademark law for use of some trade 352 | names, trademarks, or service marks; or 353 | - f) Requiring indemnification of licensors and authors of that material by 354 | anyone who conveys the material (or modified versions of it) with 355 | contractual assumptions of liability to the recipient, for any liability 356 | that these contractual assumptions directly impose on those licensors and 357 | authors. 358 | 359 | All other non-permissive additional terms are considered *further restrictions* 360 | within the meaning of section 10. If the Program as you received it, or any 361 | part of it, contains a notice stating that it is governed by this License along 362 | with a term that is a further restriction, you may remove that term. If a 363 | license document contains a further restriction but permits relicensing or 364 | conveying under this License, you may add to a covered work material governed 365 | by the terms of that license document, provided that the further restriction 366 | does not survive such relicensing or conveying. 367 | 368 | If you add terms to a covered work in accord with this section, you must place, 369 | in the relevant source files, a statement of the additional terms that apply to 370 | those files, or a notice indicating where to find the applicable terms. 371 | 372 | Additional terms, permissive or non-permissive, may be stated in the form of a 373 | separately written license, or stated as exceptions; the above requirements 374 | apply either way. 375 | 376 | ### 8. Termination. 377 | 378 | You may not propagate or modify a covered work except as expressly provided 379 | under this License. Any attempt otherwise to propagate or modify it is void, 380 | and will automatically terminate your rights under this License (including any 381 | patent licenses granted under the third paragraph of section 11). 382 | 383 | However, if you cease all violation of this License, then your license from a 384 | particular copyright holder is reinstated 385 | 386 | - a) provisionally, unless and until the copyright holder explicitly and 387 | finally terminates your license, and 388 | - b) permanently, if the copyright holder fails to notify you of the 389 | violation by some reasonable means prior to 60 days after the cessation. 390 | 391 | Moreover, your license from a particular copyright holder is reinstated 392 | permanently if the copyright holder notifies you of the violation by some 393 | reasonable means, this is the first time you have received notice of violation 394 | of this License (for any work) from that copyright holder, and you cure the 395 | violation prior to 30 days after your receipt of the notice. 396 | 397 | Termination of your rights under this section does not terminate the licenses 398 | of parties who have received copies or rights from you under this License. If 399 | your rights have been terminated and not permanently reinstated, you do not 400 | qualify to receive new licenses for the same material under section 10. 401 | 402 | ### 9. Acceptance Not Required for Having Copies. 403 | 404 | You are not required to accept this License in order to receive or run a copy 405 | of the Program. Ancillary propagation of a covered work occurring solely as a 406 | consequence of using peer-to-peer transmission to receive a copy likewise does 407 | not require acceptance. However, nothing other than this License grants you 408 | permission to propagate or modify any covered work. These actions infringe 409 | copyright if you do not accept this License. Therefore, by modifying or 410 | propagating a covered work, you indicate your acceptance of this License to do 411 | so. 412 | 413 | ### 10. Automatic Licensing of Downstream Recipients. 414 | 415 | Each time you convey a covered work, the recipient automatically receives a 416 | license from the original licensors, to run, modify and propagate that work, 417 | subject to this License. You are not responsible for enforcing compliance by 418 | third parties with this License. 419 | 420 | An *entity transaction* is a transaction transferring control of an 421 | organization, or substantially all assets of one, or subdividing an 422 | organization, or merging organizations. If propagation of a covered work 423 | results from an entity transaction, each party to that transaction who receives 424 | a copy of the work also receives whatever licenses to the work the party's 425 | predecessor in interest had or could give under the previous paragraph, plus a 426 | right to possession of the Corresponding Source of the work from the 427 | predecessor in interest, if the predecessor has it or can get it with 428 | reasonable efforts. 429 | 430 | You may not impose any further restrictions on the exercise of the rights 431 | granted or affirmed under this License. For example, you may not impose a 432 | license fee, royalty, or other charge for exercise of rights granted under this 433 | License, and you may not initiate litigation (including a cross-claim or 434 | counterclaim in a lawsuit) alleging that any patent claim is infringed by 435 | making, using, selling, offering for sale, or importing the Program or any 436 | portion of it. 437 | 438 | ### 11. Patents. 439 | 440 | A *contributor* is a copyright holder who authorizes use under this License of 441 | the Program or a work on which the Program is based. The work thus licensed is 442 | called the contributor's *contributor version*. 443 | 444 | A contributor's *essential patent claims* are all patent claims owned or 445 | controlled by the contributor, whether already acquired or hereafter acquired, 446 | that would be infringed by some manner, permitted by this License, of making, 447 | using, or selling its contributor version, but do not include claims that would 448 | be infringed only as a consequence of further modification of the contributor 449 | version. For purposes of this definition, *control* includes the right to grant 450 | patent sublicenses in a manner consistent with the requirements of this 451 | License. 452 | 453 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent 454 | license under the contributor's essential patent claims, to make, use, sell, 455 | offer for sale, import and otherwise run, modify and propagate the contents of 456 | its contributor version. 457 | 458 | In the following three paragraphs, a *patent license* is any express agreement 459 | or commitment, however denominated, not to enforce a patent (such as an express 460 | permission to practice a patent or covenant not to sue for patent 461 | infringement). To *grant* such a patent license to a party means to make such 462 | an agreement or commitment not to enforce a patent against the party. 463 | 464 | If you convey a covered work, knowingly relying on a patent license, and the 465 | Corresponding Source of the work is not available for anyone to copy, free of 466 | charge and under the terms of this License, through a publicly available 467 | network server or other readily accessible means, then you must either 468 | 469 | 1. cause the Corresponding Source to be so available, or 470 | 2. arrange to deprive yourself of the benefit of the patent license for this 471 | particular work, or 472 | 3. arrange, in a manner consistent with the requirements of this License, to 473 | extend the patent license to downstream recipients. 474 | 475 | *Knowingly relying* means you have actual knowledge that, but for the patent 476 | license, your conveying the covered work in a country, or your recipient's use 477 | of the covered work in a country, would infringe one or more identifiable 478 | patents in that country that you have reason to believe are valid. 479 | 480 | If, pursuant to or in connection with a single transaction or arrangement, you 481 | convey, or propagate by procuring conveyance of, a covered work, and grant a 482 | patent license to some of the parties receiving the covered work authorizing 483 | them to use, propagate, modify or convey a specific copy of the covered work, 484 | then the patent license you grant is automatically extended to all recipients 485 | of the covered work and works based on it. 486 | 487 | A patent license is *discriminatory* if it does not include within the scope of 488 | its coverage, prohibits the exercise of, or is conditioned on the non-exercise 489 | of one or more of the rights that are specifically granted under this License. 490 | You may not convey a covered work if you are a party to an arrangement with a 491 | third party that is in the business of distributing software, under which you 492 | make payment to the third party based on the extent of your activity of 493 | conveying the work, and under which the third party grants, to any of the 494 | parties who would receive the covered work from you, a discriminatory patent 495 | license 496 | 497 | - a) in connection with copies of the covered work conveyed by you (or copies 498 | made from those copies), or 499 | - b) primarily for and in connection with specific products or compilations 500 | that contain the covered work, unless you entered into that arrangement, or 501 | that patent license was granted, prior to 28 March 2007. 502 | 503 | Nothing in this License shall be construed as excluding or limiting any implied 504 | license or other defenses to infringement that may otherwise be available to 505 | you under applicable patent law. 506 | 507 | ### 12. No Surrender of Others' Freedom. 508 | 509 | If conditions are imposed on you (whether by court order, agreement or 510 | otherwise) that contradict the conditions of this License, they do not excuse 511 | you from the conditions of this License. If you cannot convey a covered work so 512 | as to satisfy simultaneously your obligations under this License and any other 513 | pertinent obligations, then as a consequence you may not convey it at all. For 514 | example, if you agree to terms that obligate you to collect a royalty for 515 | further conveying from those to whom you convey the Program, the only way you 516 | could satisfy both those terms and this License would be to refrain entirely 517 | from conveying the Program. 518 | 519 | ### 13. Use with the GNU Affero General Public License. 520 | 521 | Notwithstanding any other provision of this License, you have permission to 522 | link or combine any covered work with a work licensed under version 3 of the 523 | GNU Affero General Public License into a single combined work, and to convey 524 | the resulting work. The terms of this License will continue to apply to the 525 | part which is the covered work, but the special requirements of the GNU Affero 526 | General Public License, section 13, concerning interaction through a network 527 | will apply to the combination as such. 528 | 529 | ### 14. Revised Versions of this License. 530 | 531 | The Free Software Foundation may publish revised and/or new versions of the GNU 532 | General Public License from time to time. Such new versions will be similar in 533 | spirit to the present version, but may differ in detail to address new problems 534 | or concerns. 535 | 536 | Each version is given a distinguishing version number. If the Program specifies 537 | that a certain numbered version of the GNU General Public License *or any later 538 | version* applies to it, you have the option of following the terms and 539 | conditions either of that numbered version or of any later version published by 540 | the Free Software Foundation. If the Program does not specify a version number 541 | of the GNU General Public License, you may choose any version ever published by 542 | the Free Software Foundation. 543 | 544 | If the Program specifies that a proxy can decide which future versions of the 545 | GNU General Public License can be used, that proxy's public statement of 546 | acceptance of a version permanently authorizes you to choose that version for 547 | the Program. 548 | 549 | Later license versions may give you additional or different permissions. 550 | However, no additional obligations are imposed on any author or copyright 551 | holder as a result of your choosing to follow a later version. 552 | 553 | ### 15. Disclaimer of Warranty. 554 | 555 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE 556 | LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER 557 | PARTIES PROVIDE THE PROGRAM *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER 558 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 559 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 560 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 561 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 562 | CORRECTION. 563 | 564 | ### 16. Limitation of Liability. 565 | 566 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 567 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 568 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 569 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE 570 | THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED 571 | INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE 572 | PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY 573 | HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 574 | 575 | ### 17. Interpretation of Sections 15 and 16. 576 | 577 | If the disclaimer of warranty and limitation of liability provided above cannot 578 | be given local legal effect according to their terms, reviewing courts shall 579 | apply local law that most closely approximates an absolute waiver of all civil 580 | liability in connection with the Program, unless a warranty or assumption of 581 | liability accompanies a copy of the Program in return for a fee. 582 | 583 | ## END OF TERMS AND CONDITIONS ### 584 | 585 | ### How to Apply These Terms to Your New Programs 586 | 587 | If you develop a new program, and you want it to be of the greatest possible 588 | use to the public, the best way to achieve this is to make it free software 589 | which everyone can redistribute and change under these terms. 590 | 591 | To do so, attach the following notices to the program. It is safest to attach 592 | them to the start of each source file to most effectively state the exclusion 593 | of warranty; and each file should have at least the *copyright* line and a 594 | pointer to where the full notice is found. 595 | 596 | 597 | Copyright (C) 598 | 599 | This program is free software: you can redistribute it and/or modify 600 | it under the terms of the GNU General Public License as published by 601 | the Free Software Foundation, either version 3 of the License, or 602 | (at your option) any later version. 603 | 604 | This program is distributed in the hope that it will be useful, 605 | but WITHOUT ANY WARRANTY; without even the implied warranty of 606 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 607 | GNU General Public License for more details. 608 | 609 | You should have received a copy of the GNU General Public License 610 | along with this program. If not, see . 611 | 612 | Also add information on how to contact you by electronic and paper mail. 613 | 614 | If the program does terminal interaction, make it output a short notice like 615 | this when it starts in an interactive mode: 616 | 617 | Copyright (C) 618 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 619 | This is free software, and you are welcome to redistribute it 620 | under certain conditions; type `show c' for details. 621 | 622 | The hypothetical commands `show w` and `show c` should show the appropriate 623 | parts of the General Public License. Of course, your program's commands might 624 | be different; for a GUI interface, you would use an *about box*. 625 | 626 | You should also get your employer (if you work as a programmer) or school, if 627 | any, to sign a *copyright disclaimer* for the program, if necessary. For more 628 | information on this, and how to apply and follow the GNU GPL, see 629 | [http://www.gnu.org/licenses/](http://www.gnu.org/licenses/). 630 | 631 | The GNU General Public License does not permit incorporating your program into 632 | proprietary programs. If your program is a subroutine library, you may consider 633 | it more useful to permit linking proprietary applications with the library. If 634 | this is what you want to do, use the GNU Lesser General Public License instead 635 | of this License. But first, please read 636 | [http://www.gnu.org/philosophy/why-not-lgpl.html](http://www.gnu.org/philosophy/why-not-lgpl.html). --------------------------------------------------------------------------------