diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index ed06b894..853ec03d 100644 --- a/.gitignore +++ b/.gitignore @@ -659,3 +659,10 @@ obj/ # End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux linux/.qmlls.ini + +# Nix +result +result-* + +# direnv +.direnv diff --git a/default.nix b/default.nix new file mode 100644 index 00000000..c48b1fb8 --- /dev/null +++ b/default.nix @@ -0,0 +1,12 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) { src = ./.; }).defaultNix diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..f6f27ad4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,143 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1765145449, + "narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=", + "owner": "ipetkov", + "repo": "crane", + "rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1765495779, + "narHash": "sha256-MhA7wmo/7uogLxiewwRRmIax70g6q1U/YemqTGoFHlM=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "5635c32d666a59ec9a55cab87e898889869f7b71", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1765425892, + "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1761765539, + "narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "719359f4562934ae99f5443f20aa06c2ffff91fc", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1761236834, + "narHash": "sha256-+pthv6hrL5VLW2UqPdISGuLiUZ6SnAXdd2DdUE+fV2Q=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "d5faa84122bc0a1fd5d378492efce4e289f8eac1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "systems": "systems", + "treefmt-nix": "treefmt-nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1762938485, + "narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..5994a980 --- /dev/null +++ b/flake.nix @@ -0,0 +1,141 @@ +{ + description = "AirPods liberated from Apple's ecosystem"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + crane.url = "github:ipetkov/crane"; + flake-parts.url = "github:hercules-ci/flake-parts"; + flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"; + systems.url = "github:nix-systems/default"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + }; + + outputs = + inputs@{ + self, + crane, + flake-parts, + systems, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = import systems; + imports = [ + inputs.treefmt-nix.flakeModule + ]; + + perSystem = + { + self', + pkgs, + lib, + ... + }: + let + buildInputs = + with pkgs; + [ + dbus + libpulseaudio + alsa-lib + bluez + + # https://github.com/max-privatevoid/iced/blob/master/DEPENDENCIES.md + expat + fontconfig + freetype + freetype.dev + libGL + pkg-config + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + wayland + libxkbcommon + vulkan-loader + ] + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.libiconv + ]; + + nativeBuildInputs = with pkgs; [ + pkg-config + makeWrapper + ]; + + craneLib = crane.mkLib pkgs; + unfilteredRoot = ./linux-rust/.; + src = lib.fileset.toSource { + root = unfilteredRoot; + fileset = lib.fileset.unions [ + # Default files from crane (Rust and cargo files) + (craneLib.fileset.commonCargoSources unfilteredRoot) + (lib.fileset.maybeMissing ./linux-rust/assets/font) + ]; + }; + + commonArgs = { + inherit buildInputs nativeBuildInputs src; + strictDeps = true; + + # RUST_BACKTRACE = "1"; + }; + + librepods = craneLib.buildPackage ( + commonArgs + // { + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + doCheck = false; + + # Wrap the binary after build to set runtime library path + postInstall = '' + wrapProgram $out/bin/librepods \ + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath buildInputs} + ''; + + meta = { + description = "AirPods liberated from Apple's ecosystem"; + homepage = "https://github.com/kavishdevar/librepods"; + license = pkgs.lib.licenses.gpl3Only; + maintainers = [ "kavishdevar" ]; + platforms = pkgs.lib.platforms.unix; + mainProgram = "librepods"; + }; + } + ); + in + { + checks = { + inherit librepods; + }; + + packages.default = librepods; + apps.default = { + type = "app"; + program = lib.getExe librepods; + }; + + devShells.default = craneLib.devShell { + name = "librepods-dev"; + checks = self'.checks; + + # NOTE: cargo and rustc are provided by default. + buildInputs = + with pkgs; + [ + rust-analyzer + ] + ++ buildInputs; + + LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; + }; + + treefmt = { + programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler; + programs.nixfmt.package = pkgs.nixfmt-rfc-style; + }; + }; + }; +} diff --git a/linux-rust/.gitignore b/linux-rust/.gitignore new file mode 100644 index 00000000..629852f6 --- /dev/null +++ b/linux-rust/.gitignore @@ -0,0 +1,7 @@ +**/*.flatpak +repo +dist +build-dir +vendor +.cargo +.flatpak-builder diff --git a/linux-rust/Cargo.lock b/linux-rust/Cargo.lock new file mode 100644 index 00000000..84388905 --- /dev/null +++ b/linux-rust/Cargo.lock @@ -0,0 +1,5667 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "cesu8", + "jni", + "jni-sys", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix 0.29.0", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling", + "rustix 1.1.2", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4274ea815e013e0f9f04a2633423e14194e408a0576c943ce3d14ca56c50031c" +dependencies = [ + "thiserror 1.0.69", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cosmic-text" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" +dependencies = [ + "bitflags 2.10.0", + "fontdb", + "log", + "rangemap", + "rayon", + "rustc-hash 1.1.0", + "rustybuzz", + "self_cell", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.10.0", + "libloading 0.8.9", + "winapi", +] + +[[package]] +name = "dark-light" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" +dependencies = [ + "dconf_rs", + "detect-desktop-environment", + "dirs", + "objc", + "rust-ini", + "web-sys", + "winreg", + "zbus 4.4.0", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.110", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dbus" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190b6255e8ab55a7b568df5a883e9497edc3e4821c06396612048b430e5ad1e9" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4c83437187544ba5142427746835061b330446ca8902eabd70e4afb8f76de0" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[package]] +name = "dconf_rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" + +[[package]] +name = "detect-desktop-environment" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" +dependencies = [ + "drm-sys", + "rustix 0.38.44", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986" +dependencies = [ + "libc", + "linux-raw-sys 0.6.5", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "font-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.10.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iced" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f" +dependencies = [ + "iced_core", + "iced_futures", + "iced_renderer", + "iced_widget", + "iced_winit", + "image 0.24.9", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_core" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "dark-light", + "glam", + "log", + "num-traits", + "once_cell", + "palette", + "rustc-hash 2.1.1", + "smol_str", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "iced_futures" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c04a6745ba2e80f32cf01e034fd00d853aa4f4cd8b91888099cb7aaee0d5d7c" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.1", + "tokio", + "wasm-bindgen-futures", + "wasm-timer", +] + +[[package]] +name = "iced_glyphon" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.1", + "wgpu", +] + +[[package]] +name = "iced_graphics" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image 0.24.9", + "kamadak-exif", + "log", + "once_cell", + "raw-window-handle", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_renderer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73558208059f9e622df2bf434e044ee2f838ce75201a023cf0ca3e1244f46c2a" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_runtime" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348b5b2c61c934d88ca3b0ed1ed913291e923d086a66fa288ce9669da9ef62b5" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c625d368284fcc43b0b36b176f76eff1abebe7959dd58bd8ce6897d641962a50" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.1", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "futures", + "glam", + "guillotiere", + "iced_glyphon", + "iced_graphics", + "log", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" +dependencies = [ + "iced_renderer", + "iced_runtime", + "num-traits", + "once_cell", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44cd4e1c594b6334f409282937bf972ba14d31fedf03c23aa595d982a2fda28" +dependencies = [ + "iced_futures", + "iced_graphics", + "iced_runtime", + "log", + "rustc-hash 2.1.1", + "thiserror 1.0.69", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "winapi", + "window_clipboard", + "winit", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png 0.17.16", + "qoi", + "tiff 0.9.1", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff 0.10.3", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.16", + "image 0.25.8", + "itertools", + "nalgebra", + "num", + "rand", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ksni" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cc9a5e60d55371fd681051b05e9b58e1d818f5085f6364afe872c9347311f91" +dependencies = [ + "futures-util", + "paste", + "serde", + "tokio", + "zbus 5.12.0", +] + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libdbus-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cbe856efeb50e4681f010e9aaa2bf0a644e10139e54cde10fc83a307c23bd9f" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libpulse-binding" +version = "2.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libpulse-sys", + "num-derive", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "librepods" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "aes", + "bluer", + "clap", + "dbus", + "env_logger", + "futures", + "hex", + "iced", + "image 0.25.8", + "imageproc", + "ksni", + "libpulse-binding", + "log", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set", + "bitflags 2.10.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +dependencies = [ + "libredox", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "palette_derive", + "phf", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.7", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + +[[package]] +name = "rangemap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "skrifa" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "cfg_aliases 0.2.1", + "core-graphics 0.24.0", + "drm", + "fastrand", + "foreign-types", + "js-sys", + "log", + "memmap2", + "objc2", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 0.38.44", + "tiny-xlib", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.110", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime 0.7.3", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-timer" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.11.2", + "pin-utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.2", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.2", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.10.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +dependencies = [ + "rustix 1.1.2", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009936b22a61d342859b5f0ea64681cbb35a358ab548e2a44a8cf0dac2d980b8" + +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.10.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.10.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types 0.1.3", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.10.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window_clipboard" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d692d46038c433f9daee7ad8757e002a4248c20b0a3fbc991d99521d3bcb6d" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.30.1", + "ordered-stream", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.12.0", + "zbus_names 4.2.0", + "zvariant 5.8.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", + "zbus_names 4.2.0", + "zvariant 5.8.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant 5.8.0", +] + +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive 5.8.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.110", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.110", + "winnow", +] diff --git a/linux-rust/Cargo.toml b/linux-rust/Cargo.toml new file mode 100644 index 00000000..f827a9d2 --- /dev/null +++ b/linux-rust/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "librepods" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = {version = "1.47.1", features = ["full"]} +bluer = { version = "0.17.4", features = ["full"] } +env_logger = {version = "0.11.8", features = ["auto-color"]} +uuid = "1.18.1" +log = "0.4.28" +dbus = "0.9.9" +hex = "0.4.3" +iced = { version = "0.13.1", features = ["tokio", "image"] } +libpulse-binding = "2.30.1" +ksni = "0.3.1" +image = "0.25.8" +imageproc = "0.25.0" +ab_glyph = "0.2.32" +clap = { version = "4.5.50", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +aes = "0.8.4" +futures = "0.3.31" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 8 +panic = "abort" +strip = true diff --git a/linux-rust/Justfile b/linux-rust/Justfile new file mode 100644 index 00000000..d98adcc9 --- /dev/null +++ b/linux-rust/Justfile @@ -0,0 +1,70 @@ +APP_NAME := "librepods" +DESKTOP_FILE := "assets/me.kavishdevar.librepods.desktop" +ICON_FILE := "assets/icon.png" + +default: build-appimage + +build: + cargo build --release + +prepare: + #!/usr/bin/env bash + set -euo pipefail + + tmpdir="$(mktemp -d)" + echo "Building AppDir in: $tmpdir" + + mkdir -p "$tmpdir/usr/bin" + mkdir -p "$tmpdir/usr/share/applications" + mkdir -p "$tmpdir/usr/share/icons/hicolor/256x256/apps" + + cp target/release/{{APP_NAME}} "$tmpdir/usr/bin/" + cp assets/icon.png "$tmpdir/usr/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png" + cp {{DESKTOP_FILE}} "$tmpdir/{{APP_NAME}}.desktop" + + printf '%s\n' \ + '#!/bin/bash' \ + 'HERE="$(dirname "$(readlink -f "$0")")"' \ + 'exec "$HERE/usr/bin/librepods" "$@"' \ + > "$tmpdir/AppRun" + + chmod +x "$tmpdir/AppRun" + echo "$tmpdir" > .appdir_path + +bundle: + #!/usr/bin/env bash + set -euo pipefail + tmpdir="$(cat .appdir_path)" + + linuxdeploy \ + --appdir "$tmpdir" \ + --executable "$tmpdir/usr/bin/{{APP_NAME}}" \ + --desktop-file "$tmpdir/{{APP_NAME}}.desktop" \ + --icon-file "$tmpdir/usr/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png" + +build-appimage: build prepare bundle + #!/usr/bin/env bash + set -euo pipefail + tmpdir="$(cat .appdir_path)" + mkdir -p dist + appimagetool "$tmpdir" "dist/LibrePods-x86_64.AppImage" + rm -rf "$tmpdir" .appdir_path + echo "Done!" + + +tarball version: + #!/usr/bin/env bash + set -euo pipefail + cargo vendor vendor + mkdir -p dist .cargo + cat > .cargo/config.toml <<'EOF' + [source.crates-io] + replace-with = "vendored-sources" + [source.vendored-sources] + directory = "vendor" + EOF + TAR="librepods-v{{version}}-source.tar.gz" + tar -czf "dist/${TAR}" \ + --transform "s,^,librepods-v{{version}}/," \ + Cargo.toml Cargo.lock src vendor .cargo assets flatpak + echo "Created: dist/${TAR}" diff --git a/linux-rust/assets/font/DejaVuSans.ttf b/linux-rust/assets/font/DejaVuSans.ttf new file mode 100644 index 00000000..fb0bd945 Binary files /dev/null and b/linux-rust/assets/font/DejaVuSans.ttf differ diff --git a/linux-rust/assets/font/sf_pro.otf b/linux-rust/assets/font/sf_pro.otf new file mode 100644 index 00000000..dd28280f Binary files /dev/null and b/linux-rust/assets/font/sf_pro.otf differ diff --git a/linux-rust/assets/icon.png b/linux-rust/assets/icon.png new file mode 100644 index 00000000..4a9b37c7 Binary files /dev/null and b/linux-rust/assets/icon.png differ diff --git a/linux-rust/assets/me.kavishdevar.librepods.desktop b/linux-rust/assets/me.kavishdevar.librepods.desktop new file mode 100644 index 00000000..f17a9424 --- /dev/null +++ b/linux-rust/assets/me.kavishdevar.librepods.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Name=LibrePods +Exec=librepods +Icon=me.kavishdevar.librepods +Type=Application +Categories=Utility; diff --git a/linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml b/linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml new file mode 100644 index 00000000..d04c404c --- /dev/null +++ b/linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml @@ -0,0 +1,23 @@ + + + me.kavishdevar.librepods + + LibrePods + Liberate AirPods from Apple's ecosystem + + CC-BY-SA-4.0 + AGPL-3.0-only + + +

+ Key - Noise Control Modes: Easily switch between noise control modes without having to reach out to your AirPods to long - Ear Detection: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out - Battery Status: Accurate battery levels - Conversational Awareness: Volume automatically lowers when you speak - Hearing Aid: Setup Hearing Aid, even in an unsupported region +

+
+ + me.kavishdevar.librepods.desktop + + + https://raw.githubusercontent.com/kavishdevar/librepods/refs/heads/main/linux/imgs/main-app.png + + +
diff --git a/linux-rust/flatpak/me.kavishdevar.librepods.yaml b/linux-rust/flatpak/me.kavishdevar.librepods.yaml new file mode 100644 index 00000000..00a7b35c --- /dev/null +++ b/linux-rust/flatpak/me.kavishdevar.librepods.yaml @@ -0,0 +1,43 @@ +app-id: me.kavishdevar.librepods +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable + +command: librepods + +finish-args: + - --socket=wayland + - --socket=fallback-x11 + - --socket=pulseaudio + - --system-talk-name=org.bluez + - --allow=bluetooth + - --share=network + - --socket=session-bus + +build-options: + append-path: /usr/lib/sdk/rust-stable/bin + env: + CARGO_HOME: /run/build/librepods/cargo + CARGO_NET_OFFLINE: 'true' + RUSTUP_HOME: /usr/lib/sdk/rust-stable + +modules: + - name: librepods + buildsystem: simple + build-options: + env: + CARGO_NET_OFFLINE: 'true' + build-commands: + - cargo build --release --frozen --offline --verbose + - install -Dm755 target/release/librepods ${FLATPAK_DEST}/bin/librepods + - install -Dm644 assets/icon.png ${FLATPAK_DEST}/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png + - install -Dm644 assets/me.kavishdevar.librepods.desktop ${FLATPAK_DEST}/share/applications/${FLATPAK_ID}.desktop + - install -Dm644 flatpak/me.kavishdevar.librepods.metainfo.xml ${FLATPAK_DEST}/share/metainfo/${FLATPAK_ID}.metainfo.xml + sources: + - type: archive +# path: ../dist/librepods-vlocal-source.tar.gz + url: https://github.com/kavishdevar/librepods/releases/download/linux-v0.1.0/librepods-v0.1.0-source.tar.gz + sha256: 78828d6113dcdc37be9aa006d7a437ec1705978669cddb9342824ec9546a7b4e + diff --git a/linux-rust/src/bluetooth/aacp.rs b/linux-rust/src/bluetooth/aacp.rs new file mode 100644 index 00000000..a2b6d491 --- /dev/null +++ b/linux-rust/src/bluetooth/aacp.rs @@ -0,0 +1,1201 @@ +use crate::devices::airpods::AirPodsInformation; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::utils::get_devices_path; +use bluer::{ + Address, AddressType, Error, Result, + l2cap::{SeqPacket, Socket, SocketAddr}, +}; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use serde_json; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinSet; +use tokio::time::{Instant, sleep}; + +const PSM: u16 = 0x1001; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(200); +const HEADER_BYTES: [u8; 4] = [0x04, 0x00, 0x04, 0x00]; + +pub mod opcodes { + pub const SET_FEATURE_FLAGS: u8 = 0x4D; + pub const REQUEST_NOTIFICATIONS: u8 = 0x0F; + pub const BATTERY_INFO: u8 = 0x04; + pub const CONTROL_COMMAND: u8 = 0x09; + pub const EAR_DETECTION: u8 = 0x06; + pub const CONVERSATION_AWARENESS: u8 = 0x4B; + pub const INFORMATION: u8 = 0x1D; + pub const RENAME: u8 = 0x1E; + pub const PROXIMITY_KEYS_REQ: u8 = 0x30; + pub const PROXIMITY_KEYS_RSP: u8 = 0x31; + pub const STEM_PRESS: u8 = 0x19; + pub const EQ_DATA: u8 = 0x53; + pub const CONNECTED_DEVICES: u8 = 0x2E; + pub const AUDIO_SOURCE: u8 = 0x0E; + pub const SMART_ROUTING: u8 = 0x10; + pub const SMART_ROUTING_RESP: u8 = 0x11; + pub const SEND_CONNECTED_MAC: u8 = 0x14; + pub const HEADTRACKING: u8 = 0x17; + pub const TIPI_3: u8 = 0x0C; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ControlCommandStatus { + pub identifier: ControlCommandIdentifiers, + pub value: Vec, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ControlCommandIdentifiers { + MicMode = 0x01, + ButtonSendMode = 0x05, + VoiceTrigger = 0x12, + SingleClickMode = 0x14, + DoubleClickMode = 0x15, + ClickHoldMode = 0x16, + DoubleClickInterval = 0x17, + ClickHoldInterval = 0x18, + ListeningModeConfigs = 0x1A, + OneBudAncMode = 0x1B, + CrownRotationDirection = 0x1C, + ListeningMode = 0x0D, + AutoAnswerMode = 0x1E, + ChimeVolume = 0x1F, + VolumeSwipeInterval = 0x23, + CallManagementConfig = 0x24, + VolumeSwipeMode = 0x25, + AdaptiveVolumeConfig = 0x26, + SoftwareMuteConfig = 0x27, + ConversationDetectConfig = 0x28, + Ssl = 0x29, + HearingAid = 0x2C, + AutoAncStrength = 0x2E, + HpsGainSwipe = 0x2F, + HrmState = 0x30, + InCaseToneConfig = 0x31, + SiriMultitoneConfig = 0x32, + HearingAssistConfig = 0x33, + AllowOffOption = 0x34, + StemConfig = 0x39, + SleepDetectionConfig = 0x35, + AllowAutoConnect = 0x36, + EarDetectionConfig = 0x0A, + AutomaticConnectionConfig = 0x20, + OwnsConnection = 0x06, +} + +impl ControlCommandIdentifiers { + fn from_u8(value: u8) -> Option { + match value { + 0x01 => Some(Self::MicMode), + 0x05 => Some(Self::ButtonSendMode), + 0x12 => Some(Self::VoiceTrigger), + 0x14 => Some(Self::SingleClickMode), + 0x15 => Some(Self::DoubleClickMode), + 0x16 => Some(Self::ClickHoldMode), + 0x17 => Some(Self::DoubleClickInterval), + 0x18 => Some(Self::ClickHoldInterval), + 0x1A => Some(Self::ListeningModeConfigs), + 0x1B => Some(Self::OneBudAncMode), + 0x1C => Some(Self::CrownRotationDirection), + 0x0D => Some(Self::ListeningMode), + 0x1E => Some(Self::AutoAnswerMode), + 0x1F => Some(Self::ChimeVolume), + 0x23 => Some(Self::VolumeSwipeInterval), + 0x24 => Some(Self::CallManagementConfig), + 0x25 => Some(Self::VolumeSwipeMode), + 0x26 => Some(Self::AdaptiveVolumeConfig), + 0x27 => Some(Self::SoftwareMuteConfig), + 0x28 => Some(Self::ConversationDetectConfig), + 0x29 => Some(Self::Ssl), + 0x2C => Some(Self::HearingAid), + 0x2E => Some(Self::AutoAncStrength), + 0x2F => Some(Self::HpsGainSwipe), + 0x30 => Some(Self::HrmState), + 0x31 => Some(Self::InCaseToneConfig), + 0x32 => Some(Self::SiriMultitoneConfig), + 0x33 => Some(Self::HearingAssistConfig), + 0x34 => Some(Self::AllowOffOption), + 0x39 => Some(Self::StemConfig), + 0x35 => Some(Self::SleepDetectionConfig), + 0x36 => Some(Self::AllowAutoConnect), + 0x0A => Some(Self::EarDetectionConfig), + 0x20 => Some(Self::AutomaticConnectionConfig), + 0x06 => Some(Self::OwnsConnection), + _ => None, + } + } +} + +impl std::fmt::Display for ControlCommandIdentifiers { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + ControlCommandIdentifiers::MicMode => "Mic Mode", + ControlCommandIdentifiers::ButtonSendMode => "Button Send Mode", + ControlCommandIdentifiers::VoiceTrigger => "Voice Trigger", + ControlCommandIdentifiers::SingleClickMode => "Single Click Mode", + ControlCommandIdentifiers::DoubleClickMode => "Double Click Mode", + ControlCommandIdentifiers::ClickHoldMode => "Click Hold Mode", + ControlCommandIdentifiers::DoubleClickInterval => "Double Click Interval", + ControlCommandIdentifiers::ClickHoldInterval => "Click Hold Interval", + ControlCommandIdentifiers::ListeningModeConfigs => "Listening Mode Configs", + ControlCommandIdentifiers::OneBudAncMode => "One Bud ANC Mode", + ControlCommandIdentifiers::CrownRotationDirection => "Crown Rotation Direction", + ControlCommandIdentifiers::ListeningMode => "Listening Mode", + ControlCommandIdentifiers::AutoAnswerMode => "Auto Answer Mode", + ControlCommandIdentifiers::ChimeVolume => "Chime Volume", + ControlCommandIdentifiers::VolumeSwipeInterval => "Volume Swipe Interval", + ControlCommandIdentifiers::CallManagementConfig => "Call Management Config", + ControlCommandIdentifiers::VolumeSwipeMode => "Volume Swipe Mode", + ControlCommandIdentifiers::AdaptiveVolumeConfig => "Adaptive Volume Config", + ControlCommandIdentifiers::SoftwareMuteConfig => "Software Mute Config", + ControlCommandIdentifiers::ConversationDetectConfig => "Conversation Detect Config", + ControlCommandIdentifiers::Ssl => "SSL", + ControlCommandIdentifiers::HearingAid => "Hearing Aid", + ControlCommandIdentifiers::AutoAncStrength => "Auto ANC Strength", + ControlCommandIdentifiers::HpsGainSwipe => "HPS Gain Swipe", + ControlCommandIdentifiers::HrmState => "HRM State", + ControlCommandIdentifiers::InCaseToneConfig => "In Case Tone Config", + ControlCommandIdentifiers::SiriMultitoneConfig => "Siri Multitone Config", + ControlCommandIdentifiers::HearingAssistConfig => "Hearing Assist Config", + ControlCommandIdentifiers::AllowOffOption => "Allow Off Option", + ControlCommandIdentifiers::StemConfig => "Stem Config", + ControlCommandIdentifiers::SleepDetectionConfig => "Sleep Detection Config", + ControlCommandIdentifiers::AllowAutoConnect => "Allow Auto Connect", + ControlCommandIdentifiers::EarDetectionConfig => "Ear Detection Config", + ControlCommandIdentifiers::AutomaticConnectionConfig => "Automatic Connection Config", + ControlCommandIdentifiers::OwnsConnection => "Owns Connection", + }; + write!(f, "{}", name) + } +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum ProximityKeyType { + Irk = 0x01, + EncKey = 0x04, +} + +impl ProximityKeyType { + fn from_u8(value: u8) -> Option { + match value { + 0x01 => Some(Self::Irk), + 0x04 => Some(Self::EncKey), + _ => None, + } + } +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StemPressType { + SinglePress = 0x05, + DoublePress = 0x06, + TriplePress = 0x07, + LongPress = 0x08, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StemPressBudType { + Left = 0x01, + Right = 0x02, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioSourceType { + None = 0x00, + Call = 0x01, + Media = 0x02, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BatteryComponent { + Headphone = 1, + Left = 4, + Right = 2, + Case = 8, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BatteryStatus { + Charging = 1, + NotCharging = 2, + Disconnected = 4, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EarDetectionStatus { + InEar = 0x00, + OutOfEar = 0x01, + InCase = 0x02, + Disconnected = 0x03, +} + +impl AudioSourceType { + fn from_u8(value: u8) -> Option { + match value { + 0x00 => Some(Self::None), + 0x01 => Some(Self::Call), + 0x02 => Some(Self::Media), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AudioSource { + pub mac: String, + pub r#type: AudioSourceType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatteryInfo { + pub component: BatteryComponent, + pub level: u8, + pub status: BatteryStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectedDevice { + pub mac: String, + pub info1: u8, + pub info2: u8, + pub r#type: Option, +} + +#[derive(Debug, Clone)] +pub enum AACPEvent { + BatteryInfo(Vec), + ControlCommand(ControlCommandStatus), + EarDetection(Vec, Vec), + ConversationalAwareness(u8), + ProximityKeys(Vec<(u8, Vec)>), + AudioSource(AudioSource), + ConnectedDevices(Vec, Vec), + OwnershipToFalseRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AirPodsLEKeys { + pub irk: String, + pub enc_key: String, +} + +pub struct AACPManagerState { + pub sender: Option>>, + pub control_command_status_list: Vec, + pub control_command_subscribers: + HashMap>>>, + pub owns: bool, + pub old_connected_devices: Vec, + pub connected_devices: Vec, + pub audio_source: Option, + pub battery_info: Vec, + pub conversational_awareness_status: u8, + pub old_ear_detection_status: Vec, + pub ear_detection_status: Vec, + event_tx: Option>, + pub devices: HashMap, + pub airpods_mac: Option
, +} + +impl AACPManagerState { + fn new() -> Self { + let devices: HashMap = std::fs::read_to_string(get_devices_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + AACPManagerState { + sender: None, + control_command_status_list: Vec::new(), + control_command_subscribers: HashMap::new(), + owns: false, + old_connected_devices: Vec::new(), + connected_devices: Vec::new(), + audio_source: None, + battery_info: Vec::new(), + conversational_awareness_status: 0, + old_ear_detection_status: Vec::new(), + ear_detection_status: Vec::new(), + event_tx: None, + devices, + airpods_mac: None, + } + } +} + +#[derive(Clone)] +pub struct AACPManager { + pub state: Arc>, + tasks: Arc>>, +} + +impl AACPManager { + pub fn new() -> Self { + AACPManager { + state: Arc::new(Mutex::new(AACPManagerState::new())), + tasks: Arc::new(Mutex::new(JoinSet::new())), + } + } + + pub async fn connect(&mut self, addr: Address) { + info!("AACPManager connecting to {} on PSM {:#06X}...", addr, PSM); + let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM); + + { + let mut state = self.state.lock().await; + state.airpods_mac = Some(addr); + } + + let socket = match Socket::new_seq_packet() { + Ok(s) => s, + Err(e) => { + error!("Failed to create L2CAP socket: {}", e); + return; + } + }; + + let seq_packet = + match tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await { + Ok(Ok(s)) => Arc::new(s), + Ok(Err(e)) => { + error!("L2CAP connect failed: {}", e); + return; + } + Err(_) => { + error!("L2CAP connect timed out"); + return; + } + }; + + // Wait for connection to be fully established + let start = Instant::now(); + loop { + match seq_packet.peer_addr() { + Ok(peer) if peer.cid != 0 => break, + Ok(_) => { /* still waiting */ } + Err(e) => { + if e.raw_os_error() == Some(107) { + // ENOTCONN + error!("Peer has disconnected during connection setup."); + return; + } + error!("Error getting peer address: {}", e); + } + } + if start.elapsed() >= CONNECT_TIMEOUT { + error!("Timed out waiting for L2CAP connection to be fully established."); + return; + } + sleep(POLL_INTERVAL).await; + } + + info!("L2CAP connection established with {}", addr); + + let (tx, rx) = mpsc::channel(128); + + let manager_clone = self.clone(); + { + let mut state = self.state.lock().await; + state.sender = Some(tx); + } + + let mut tasks = self.tasks.lock().await; + tasks.spawn(recv_thread(manager_clone, seq_packet.clone())); + tasks.spawn(send_thread(rx, seq_packet)); + } + + async fn send_packet(&self, data: &[u8]) -> Result<()> { + let state = self.state.lock().await; + if let Some(sender) = &state.sender { + sender.send(data.to_vec()).await.map_err(|e| { + error!("Failed to send packet to channel: {}", e); + Error::from(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "L2CAP send channel closed", + )) + }) + } else { + error!("Cannot send packet, sender is not available."); + Err(Error::from(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "L2CAP stream not connected", + ))) + } + } + + async fn send_data_packet(&self, data: &[u8]) -> Result<()> { + let packet = [HEADER_BYTES.as_slice(), data].concat(); + self.send_packet(&packet).await + } + + pub async fn set_event_channel(&self, tx: mpsc::UnboundedSender) { + let mut state = self.state.lock().await; + state.event_tx = Some(tx); + } + + pub async fn subscribe_to_control_command( + &self, + identifier: ControlCommandIdentifiers, + tx: mpsc::UnboundedSender>, + ) { + let mut state = self.state.lock().await; + state + .control_command_subscribers + .entry(identifier) + .or_default() + .push(tx); + // send initial value if available + if let Some(status) = state + .control_command_status_list + .iter() + .find(|s| s.identifier == identifier) + { + let _ = state + .control_command_subscribers + .get(&identifier) + .unwrap() + .last() + .unwrap() + .send(status.value.clone()); + } + } + + pub async fn receive_packet(&self, packet: &[u8]) { + if !packet.starts_with(&HEADER_BYTES) { + debug!( + "Received packet does not start with expected header: {}", + hex::encode(packet) + ); + return; + } + if packet.len() < 5 { + debug!("Received packet too short: {}", hex::encode(packet)); + return; + } + + let opcode = packet[4]; + let payload = &packet[4..]; + + match opcode { + opcodes::BATTERY_INFO => { + if payload.len() < 3 { + error!("Battery Info packet too short: {}", hex::encode(payload)); + return; + } + let count = payload[2] as usize; + if payload.len() < 3 + count * 5 { + error!( + "Battery Info packet length mismatch: {}", + hex::encode(payload) + ); + return; + } + let mut batteries = Vec::with_capacity(count); + for i in 0..count { + let base_index = 3 + i * 5; + batteries.push(BatteryInfo { + component: match payload[base_index] { + 0x01 => BatteryComponent::Headphone, + 0x02 => BatteryComponent::Right, + 0x04 => BatteryComponent::Left, + 0x08 => BatteryComponent::Case, + _ => { + error!("Unknown battery component: {:#04x}", payload[base_index]); + continue; + } + }, + level: payload[base_index + 2], + status: match payload[base_index + 3] { + 0x01 => BatteryStatus::Charging, + 0x02 => BatteryStatus::NotCharging, + 0x04 => BatteryStatus::Disconnected, + _ => { + error!("Unknown battery status: {:#04x}", payload[base_index + 3]); + continue; + } + }, + }); + } + let mut state = self.state.lock().await; + state.battery_info = batteries.clone(); + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::BatteryInfo(batteries)); + } + info!("Received Battery Info: {:?}", state.battery_info); + } + opcodes::CONTROL_COMMAND => { + if payload.len() < 7 { + error!("Control Command packet too short: {}", hex::encode(payload)); + return; + } + let identifier_byte = payload[2]; + let value_bytes = &payload[3..7]; + + let last_non_zero = value_bytes.iter().rposition(|&b| b != 0); + let value = match last_non_zero { + Some(i) => value_bytes[..=i].to_vec(), + None => vec![0], + }; + + if let Some(identifier) = ControlCommandIdentifiers::from_u8(identifier_byte) { + let status = ControlCommandStatus { + identifier, + value: value.clone(), + }; + let mut state = self.state.lock().await; + if let Some(existing) = state + .control_command_status_list + .iter_mut() + .find(|s| s.identifier == identifier) + { + existing.value = value.clone(); + } else { + state.control_command_status_list.push(status.clone()); + } + if identifier == ControlCommandIdentifiers::OwnsConnection { + state.owns = value_bytes[0] != 0; + } + if let Some(subscribers) = state.control_command_subscribers.get(&identifier) { + for sub in subscribers { + let _ = sub.send(value.clone()); + } + } + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::ControlCommand(status)); + } + info!( + "Received Control Command: {:?}, value: {}", + identifier, + hex::encode(&value) + ); + } else { + error!( + "Unknown Control Command identifier: {:#04x}", + identifier_byte + ); + } + } + opcodes::EAR_DETECTION => { + let primary_status = packet[6]; + let secondary_status = packet[7]; + let mut statuses = Vec::new(); + statuses.push(match primary_status { + 0x00 => EarDetectionStatus::InEar, + 0x01 => EarDetectionStatus::OutOfEar, + 0x02 => EarDetectionStatus::InCase, + 0x03 => EarDetectionStatus::Disconnected, + _ => { + error!("Unknown ear detection status: {:#04x}", primary_status); + EarDetectionStatus::OutOfEar + } + }); + statuses.push(match secondary_status { + 0x00 => EarDetectionStatus::InEar, + 0x01 => EarDetectionStatus::OutOfEar, + 0x02 => EarDetectionStatus::InCase, + 0x03 => EarDetectionStatus::Disconnected, + _ => { + error!("Unknown ear detection status: {:#04x}", secondary_status); + EarDetectionStatus::OutOfEar + } + }); + let mut state = self.state.lock().await; + state.old_ear_detection_status = state.ear_detection_status.clone(); + state.ear_detection_status = statuses.clone(); + + if let Some(ref tx) = state.event_tx { + debug!( + "Sending Ear Detection event: old: {:?}, new: {:?}", + state.old_ear_detection_status, statuses + ); + let _ = tx.send(AACPEvent::EarDetection( + state.old_ear_detection_status.clone(), + statuses, + )); + } + info!( + "Received Ear Detection Status: {:?}", + state.ear_detection_status + ); + } + opcodes::CONVERSATION_AWARENESS => { + if packet.len() == 10 { + let status = packet[9]; + let mut state = self.state.lock().await; + state.conversational_awareness_status = status; + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::ConversationalAwareness(status)); + } + info!("Received Conversation Awareness: {}", status); + } else { + info!( + "Received Conversation Awareness packet with unexpected length: {}", + packet.len() + ); + } + } + opcodes::INFORMATION => { + if payload.len() < 6 { + error!("Information packet too short: {}", hex::encode(payload)); + return; + } + let data = &payload[4..]; + let mut index = 0; + while index < data.len() && data[index] != 0x00 { + index += 1; + } + let mut strings = Vec::new(); + while index < data.len() { + while index < data.len() && data[index] == 0x00 { + index += 1; + } + if index >= data.len() { + break; + } + let start = index; + while index < data.len() && data[index] != 0x00 { + index += 1; + } + let str_bytes = &data[start..index]; + if let Ok(s) = std::str::from_utf8(str_bytes) { + strings.push(s.to_string()); + } + } + strings.remove(0); + let info = AirPodsInformation { + name: strings.first().cloned().unwrap_or_default(), + model_number: strings.get(1).cloned().unwrap_or_default(), + manufacturer: strings.get(2).cloned().unwrap_or_default(), + serial_number: strings.get(3).cloned().unwrap_or_default(), + version1: strings.get(4).cloned().unwrap_or_default(), + version2: strings.get(5).cloned().unwrap_or_default(), + hardware_revision: strings.get(6).cloned().unwrap_or_default(), + updater_identifier: strings.get(7).cloned().unwrap_or_default(), + left_serial_number: strings.get(8).cloned().unwrap_or_default(), + right_serial_number: strings.get(9).cloned().unwrap_or_default(), + version3: strings.get(10).cloned().unwrap_or_default(), + le_keys: AirPodsLEKeys { + irk: "".to_string(), + enc_key: "".to_string(), + }, + }; + let mut state = self.state.lock().await; + if let Some(mac) = state.airpods_mac + && let Some(device_data) = state.devices.get_mut(&mac.to_string()) + { + device_data.name = info.name.clone(); + device_data.information = Some(DeviceInformation::AirPods(info.clone())); + } + let json = serde_json::to_string(&state.devices).unwrap(); + if let Some(parent) = get_devices_path().parent() + && let Err(e) = tokio::fs::create_dir_all(&parent).await + { + error!("Failed to create directory for devices: {}", e); + return; + } + if let Err(e) = tokio::fs::write(&get_devices_path(), json).await { + error!("Failed to save devices: {}", e); + } + info!("Received Information: {:?}", info); + } + + opcodes::PROXIMITY_KEYS_RSP => { + if payload.len() < 4 { + error!( + "Proximity Keys Response packet too short: {}", + hex::encode(payload) + ); + return; + } + let key_count = payload[2] as usize; + debug!("Proximity Keys Response contains {} keys.", key_count); + let mut offset = 3; + let mut keys = Vec::new(); + for _ in 0..key_count { + if offset + 3 >= payload.len() { + error!( + "Proximity Keys Response packet too short while parsing keys: {}", + hex::encode(payload) + ); + return; + } + let key_type = payload[offset]; + let key_length = payload[offset + 2] as usize; + offset += 4; + if offset + key_length > payload.len() { + error!( + "Proximity Keys Response packet too short for key data: {}", + hex::encode(payload) + ); + return; + } + let key_data = payload[offset..offset + key_length].to_vec(); + keys.push((key_type, key_data)); + offset += key_length; + } + info!( + "Received Proximity Keys Response: {:?}", + keys.iter() + .map(|(kt, kd)| (kt, hex::encode(kd))) + .collect::>() + ); + let mut state = self.state.lock().await; + for (key_type, key_data) in &keys { + if let Some(kt) = ProximityKeyType::from_u8(*key_type) + && let Some(mac) = state.airpods_mac + { + let mac_str = mac.to_string(); + let device_data = + state.devices.entry(mac_str.clone()).or_insert(DeviceData { + name: mac_str.clone(), + type_: DeviceType::AirPods, + information: None, + }); + match kt { + ProximityKeyType::Irk => match device_data.information.as_mut() { + Some(DeviceInformation::AirPods(info)) => { + info.le_keys.irk = hex::encode(key_data); + } + _ => { + error!("Device information is not AirPods for adding LE IRK."); + } + }, + ProximityKeyType::EncKey => match device_data.information.as_mut() { + Some(DeviceInformation::AirPods(info)) => { + info.le_keys.enc_key = hex::encode(key_data); + } + _ => { + error!( + "Device information is not AirPods for adding LE encryption key." + ); + } + }, + } + } + } + let json = serde_json::to_string(&state.devices).unwrap(); + if let Some(parent) = get_devices_path().parent() + && let Err(e) = tokio::fs::create_dir_all(&parent).await + { + error!("Failed to create directory for devices: {}", e); + return; + } + if let Err(e) = tokio::fs::write(&get_devices_path(), json).await { + error!("Failed to save devices: {}", e); + } + } + opcodes::STEM_PRESS => info!("Received Stem Press packet."), + opcodes::AUDIO_SOURCE => { + if payload.len() < 9 { + error!("Audio Source packet too short: {}", hex::encode(payload)); + return; + } + let mac = format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + payload[7], payload[6], payload[5], payload[4], payload[3], payload[2] + ); + let typ = AudioSourceType::from_u8(payload[8]).unwrap_or(AudioSourceType::None); + let audio_source = AudioSource { mac, r#type: typ }; + let mut state = self.state.lock().await; + state.audio_source = Some(audio_source.clone()); + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::AudioSource(audio_source)); + } + info!("Received Audio Source: {:?}", state.audio_source); + } + opcodes::CONNECTED_DEVICES => { + if payload.len() < 3 { + error!( + "Connected Devices packet too short: {}", + hex::encode(payload) + ); + return; + } + let count = payload[2] as usize; + if payload.len() < 3 + count * 8 { + error!( + "Connected Devices packet length mismatch: {}", + hex::encode(payload) + ); + return; + } + let mut devices = Vec::with_capacity(count); + for i in 0..count { + let base = 5 + i * 8; + let mac = format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + payload[base], + payload[base + 1], + payload[base + 2], + payload[base + 3], + payload[base + 4], + payload[base + 5] + ); + let info1 = payload[base + 6]; + let info2 = payload[base + 7]; + devices.push(ConnectedDevice { + mac, + info1, + info2, + r#type: None, + }); + } + let mut state = self.state.lock().await; + state.old_connected_devices = state.connected_devices.clone(); + state.connected_devices = devices.clone(); + if let Some(ref tx) = state.event_tx { + let _ = tx.send(AACPEvent::ConnectedDevices( + state.old_connected_devices.clone(), + devices, + )); + } + info!("Received Connected Devices: {:?}", state.connected_devices); + } + opcodes::SMART_ROUTING_RESP => { + let packet_string = String::from_utf8_lossy(&payload[2..]); + info!("Received Smart Routing Response: {}", packet_string); + if packet_string.contains("SetOwnershipToFalse") { + info!("Received OwnershipToFalse request"); + if let Some(ref tx) = self.state.lock().await.event_tx { + let _ = tx.send(AACPEvent::OwnershipToFalseRequest); + } + } + } + opcodes::EQ_DATA => { + debug!("Received EQ Data"); + } + _ => debug!("Received unknown packet with opcode {:#04x}", opcode), + } + } + + pub async fn send_notification_request(&self) -> Result<()> { + let opcode = [opcodes::REQUEST_NOTIFICATIONS, 0x00]; + let data = [0xFF, 0xFF, 0xFF, 0xFF]; + let packet = [opcode.as_slice(), data.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_set_feature_flags_packet(&self) -> Result<()> { + let opcode = [opcodes::SET_FEATURE_FLAGS, 0x00]; + // let data = [0xD7, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let data = [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // adaptive volume is actually useful, seeing if it works + let packet = [opcode.as_slice(), data.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_handshake(&self) -> Result<()> { + let packet = [ + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + self.send_packet(&packet).await + } + + pub async fn send_proximity_keys_request( + &self, + key_types: Vec, + ) -> Result<()> { + let opcode = [opcodes::PROXIMITY_KEYS_REQ, 0x00]; + let mut data = Vec::with_capacity(2); + data.push(key_types.iter().fold(0u8, |acc, kt| acc | (*kt as u8))); + data.push(0x00); + let packet = [opcode.as_slice(), data.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_rename_packet(&self, name: &str) -> Result<()> { + let name_bytes = name.as_bytes(); + let size = name_bytes.len(); + let mut packet = Vec::with_capacity(5 + size); + packet.push(opcodes::RENAME); + packet.push(0x00); + packet.push(size as u8); + packet.push(0x00); + packet.extend_from_slice(name_bytes); + self.send_data_packet(&packet).await + } + + pub async fn send_control_command( + &self, + identifier: ControlCommandIdentifiers, + value: &[u8], + ) -> Result<()> { + let opcode = [opcodes::CONTROL_COMMAND, 0x00]; + let mut data = vec![identifier as u8]; + for i in 0..4 { + data.push(value.get(i).copied().unwrap_or(0)); + } + let packet = [opcode.as_slice(), data.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_media_information_new_device( + &self, + self_mac_address: &str, + target_mac_address: &str, + ) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(112); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + + buffer.extend_from_slice(&[0x68, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE5, 0x4A]); + buffer.extend_from_slice(b"playingApp"); + buffer.push(0x42); + buffer.extend_from_slice(b"NA"); + buffer.push(0x52); + buffer.extend_from_slice(b"hostStreamingState"); + buffer.push(0x42); + buffer.extend_from_slice(b"NO"); + buffer.push(0x49); + buffer.extend_from_slice(b"btAddress"); + buffer.push(0x51); + buffer.extend_from_slice(self_mac_address.as_bytes()); + buffer.push(0x46); + buffer.extend_from_slice(b"btName"); + buffer.push(0x43); + buffer.extend_from_slice(b"Mac"); + buffer.push(0x58); + buffer.extend_from_slice(b"otherDevice"); + buffer.extend_from_slice(b"AudioCategory"); + buffer.extend_from_slice(&[0x30, 0x64]); + + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_hijack_request(&self, target_mac_address: &str) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(106); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + buffer.extend_from_slice(&[0x62, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE5]); + buffer.push(0x4A); + buffer.extend_from_slice(b"localscore"); + buffer.extend_from_slice(&[0x30, 0x64]); + buffer.push(0x46); + buffer.extend_from_slice(b"reason"); + buffer.push(0x48); + buffer.extend_from_slice(b"Hijackv2"); + buffer.push(0x51); + buffer.extend_from_slice(b"audioRoutingScore"); + buffer.extend_from_slice(&[0x31, 0x2D, 0x01, 0x5F]); + buffer.extend_from_slice(b"audioRoutingSetOwnershipToFalse"); + buffer.push(0x01); + buffer.push(0x4B); + buffer.extend_from_slice(b"remotescore"); + buffer.push(0xA5); + + while buffer.len() < 106 { + buffer.push(0x00); + } + + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_media_information( + &self, + self_mac_address: &str, + target_mac_address: &str, + streaming_state: bool, + ) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(138); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + buffer.extend_from_slice(&[0x82, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE5, 0x4A]); + buffer.extend_from_slice(b"PlayingApp"); + buffer.push(0x56); + buffer.extend_from_slice(b"com.google.ios.youtube"); + buffer.push(0x52); + buffer.extend_from_slice(b"HostStreamingState"); + buffer.push(0x42); + buffer.extend_from_slice(if streaming_state { b"YES" } else { b"NO" }); + buffer.push(0x49); + buffer.extend_from_slice(b"btAddress"); + buffer.push(0x51); + buffer.extend_from_slice(self_mac_address.as_bytes()); + buffer.extend_from_slice(b"btName"); + buffer.push(0x43); + buffer.extend_from_slice(b"Mac"); + buffer.push(0x58); + buffer.extend_from_slice(b"otherDevice"); + buffer.extend_from_slice(b"AudioCategory"); + buffer.extend_from_slice(&[0x31, 0x2D, 0x01]); + + while buffer.len() < 138 { + buffer.push(0x00); + } + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_smart_routing_show_ui(&self, target_mac_address: &str) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(134); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + buffer.extend_from_slice(&[0x7E, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE6, 0x5B]); + buffer.extend_from_slice(b"SmartRoutingKeyShowNearbyUI"); + buffer.push(0x01); + buffer.push(0x4A); + buffer.extend_from_slice(b"localscore"); + buffer.extend_from_slice(&[0x31, 0x2D]); + buffer.push(0x01); + buffer.push(0x46); + buffer.extend_from_slice(b"reasonHhijackv2"); + buffer.push(0x51); + buffer.extend_from_slice(b"audioRoutingScore"); + buffer.push(0xA2); + buffer.push(0x5F); + buffer.extend_from_slice(b"audioRoutingSetOwnershipToFalse"); + buffer.push(0x01); + buffer.push(0x4B); + buffer.extend_from_slice(b"remotescore"); + buffer.push(0xA2); + + while buffer.len() < 134 { + buffer.push(0x00); + } + + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_hijack_reversed(&self, target_mac_address: &str) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(97); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + buffer.extend_from_slice(&[0x59, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE3]); + buffer.push(0x5F); + buffer.extend_from_slice(b"audioRoutingSetOwnershipToFalse"); + buffer.push(0x01); + buffer.push(0x59); + buffer.extend_from_slice(b"audioRoutingShowReverseUI"); + buffer.push(0x01); + buffer.push(0x46); + buffer.extend_from_slice(b"reason"); + buffer.push(0x53); + buffer.extend_from_slice(b"ReverseBannerTapped"); + + while buffer.len() < 97 { + buffer.push(0x00); + } + + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_add_tipi_device( + &self, + self_mac_address: &str, + target_mac_address: &str, + ) -> Result<()> { + let opcode = [opcodes::SMART_ROUTING, 0x00]; + let mut buffer = Vec::with_capacity(86); + let target_mac_bytes: Vec = target_mac_address + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect(); + buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::>()); + buffer.extend_from_slice(&[0x4E, 0x00]); + buffer.extend_from_slice(&[0x01, 0xE5]); + buffer.extend_from_slice(&[0x48, 0x69]); + buffer.extend_from_slice(b"idleTime"); + buffer.extend_from_slice(&[0x08, 0x47]); + buffer.extend_from_slice(b"newTipi"); + buffer.extend_from_slice(&[0x01, 0x49]); + buffer.extend_from_slice(b"btAddress"); + buffer.push(0x51); + buffer.extend_from_slice(self_mac_address.as_bytes()); + buffer.push(0x46); + buffer.extend_from_slice(b"btName"); + buffer.push(0x43); + buffer.extend_from_slice(b"Mac"); + buffer.push(0x50); + buffer.extend_from_slice(b"nearbyAudioScore"); + buffer.push(0x0E); + + let packet = [opcode.as_slice(), buffer.as_slice()].concat(); + self.send_data_packet(&packet).await + } + + pub async fn send_some_packet(&self) -> Result<()> { + self.send_data_packet(&[0x29, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + .await + } +} + +async fn recv_thread(manager: AACPManager, sp: Arc) { + let mut buf = vec![0u8; 1024]; + loop { + match sp.recv(&mut buf).await { + Ok(0) => { + info!("Remote closed the connection."); + break; + } + Ok(n) => { + let data = &buf[..n]; + debug!("Received {} bytes: {}", n, hex::encode(data)); + manager.receive_packet(data).await; + } + Err(e) => { + error!("Read error: {}", e); + debug!( + "We have probably disconnected, clearing state variables (owns=false, connected_devices=empty, control_command_status_list=empty)." + ); + let mut state = manager.state.lock().await; + state.owns = false; + state.connected_devices.clear(); + state.control_command_status_list.clear(); + break; + } + } + } + let mut state = manager.state.lock().await; + state.sender = None; +} + +async fn send_thread(mut rx: mpsc::Receiver>, sp: Arc) { + while let Some(data) = rx.recv().await { + if let Err(e) = sp.send(&data).await { + error!("Failed to send data: {}", e); + break; + } + debug!("Sent {} bytes: {}", data.len(), hex::encode(&data)); + } + info!("Send thread finished."); +} diff --git a/linux-rust/src/bluetooth/att.rs b/linux-rust/src/bluetooth/att.rs new file mode 100644 index 00000000..767abf49 --- /dev/null +++ b/linux-rust/src/bluetooth/att.rs @@ -0,0 +1,275 @@ +use bluer::l2cap::{SeqPacket, Socket, SocketAddr}; +use bluer::{Address, AddressType, Error, Result}; +use hex; +use log::{debug, error, info}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinSet; +use tokio::time::{Duration, Instant, sleep}; + +const PSM_ATT: u16 = 0x001F; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +const OPCODE_READ_REQUEST: u8 = 0x0A; +const OPCODE_WRITE_REQUEST: u8 = 0x12; +const OPCODE_HANDLE_VALUE_NTF: u8 = 0x1B; +const OPCODE_WRITE_RESPONSE: u8 = 0x13; +const RESPONSE_TIMEOUT: u64 = 5000; + +#[repr(u16)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ATTHandles { + AirPodsTransparency = 0x18, + AirPodsLoudSoundReduction = 0x1B, + AirPodsHearingAid = 0x2A, + NothingEverything = 0x8002, + NothingEverythingRead = 0x8005, // for some reason, and not the same as the write handle +} + +#[repr(u16)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ATTCCCDHandles { + Transparency = ATTHandles::AirPodsTransparency as u16 + 1, + LoudSoundReduction = ATTHandles::AirPodsLoudSoundReduction as u16 + 1, + HearingAid = ATTHandles::AirPodsHearingAid as u16 + 1, +} + +impl From for ATTCCCDHandles { + fn from(handle: ATTHandles) -> Self { + match handle { + ATTHandles::AirPodsTransparency => ATTCCCDHandles::Transparency, + ATTHandles::AirPodsLoudSoundReduction => ATTCCCDHandles::LoudSoundReduction, + ATTHandles::AirPodsHearingAid => ATTCCCDHandles::HearingAid, + ATTHandles::NothingEverything => panic!("No CCCD for NothingEverything handle"), // we don't request it + ATTHandles::NothingEverythingRead => panic!("No CCD for NothingEverythingRead handle"), // it sends notifications without CCCD + } + } +} + +struct ATTManagerState { + sender: Option>>, + listeners: HashMap>>>, +} + +impl ATTManagerState { + fn new() -> Self { + ATTManagerState { + sender: None, + listeners: HashMap::new(), + } + } +} + +#[derive(Clone)] +pub struct ATTManager { + state: Arc>, + response_rx: Arc>>>, + response_tx: mpsc::UnboundedSender>, + tasks: Arc>>, +} + +impl ATTManager { + pub fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + ATTManager { + state: Arc::new(Mutex::new(ATTManagerState::new())), + response_rx: Arc::new(Mutex::new(rx)), + response_tx: tx, + tasks: Arc::new(Mutex::new(JoinSet::new())), + } + } + + pub async fn connect(&mut self, addr: Address) -> Result<()> { + info!( + "ATTManager connecting to {} on PSM {:#06X}...", + addr, PSM_ATT + ); + let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM_ATT); + + let socket = Socket::new_seq_packet()?; + let seq_packet_result = + tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await; + let seq_packet = match seq_packet_result { + Ok(Ok(s)) => Arc::new(s), + Ok(Err(e)) => { + error!("L2CAP connect failed: {}", e); + return Err(e.into()); + } + Err(_) => { + error!("L2CAP connect timed out"); + return Err(Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Connection timeout", + ))); + } + }; + + // Wait for connection to be fully established + let start = Instant::now(); + loop { + match seq_packet.peer_addr() { + Ok(peer) if peer.cid != 0 => break, + Ok(_) => {} + Err(e) => { + if e.raw_os_error() == Some(107) { + // ENOTCONN + error!("Peer has disconnected during connection setup."); + return Err(e.into()); + } + error!("Error getting peer address: {}", e); + } + } + if start.elapsed() >= CONNECT_TIMEOUT { + error!("Timed out waiting for L2CAP connection to be fully established."); + return Err(Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Connection timeout", + ))); + } + sleep(POLL_INTERVAL).await; + } + + info!("L2CAP connection established with {}", addr); + + let (tx, rx) = mpsc::channel(128); + let state = ATTManagerState::new(); + { + let mut s = self.state.lock().await; + *s = state; + s.sender = Some(tx); + } + + let manager_clone = self.clone(); + let mut tasks = self.tasks.lock().await; + tasks.spawn(recv_thread(manager_clone, seq_packet.clone())); + tasks.spawn(send_thread(rx, seq_packet)); + + Ok(()) + } + + pub async fn register_listener(&self, handle: ATTHandles, tx: mpsc::UnboundedSender>) { + let mut state = self.state.lock().await; + state.listeners.entry(handle as u16).or_default().push(tx); + } + + pub async fn enable_notifications(&self, handle: ATTHandles) -> Result<()> { + self.write_cccd(handle.into(), &[0x01, 0x00]).await + } + + pub async fn read(&self, handle: ATTHandles) -> Result> { + let lsb = (handle as u16 & 0xFF) as u8; + let msb = ((handle as u16 >> 8) & 0xFF) as u8; + let pdu = vec![OPCODE_READ_REQUEST, lsb, msb]; + self.send_packet(&pdu).await?; + self.read_response().await + } + + pub async fn write(&self, handle: ATTHandles, value: &[u8]) -> Result<()> { + let lsb = (handle as u16 & 0xFF) as u8; + let msb = ((handle as u16 >> 8) & 0xFF) as u8; + let mut pdu = vec![OPCODE_WRITE_REQUEST, lsb, msb]; + pdu.extend_from_slice(value); + self.send_packet(&pdu).await?; + self.read_response().await?; + Ok(()) + } + + async fn write_cccd(&self, handle: ATTCCCDHandles, value: &[u8]) -> Result<()> { + let lsb = (handle as u16 & 0xFF) as u8; + let msb = ((handle as u16 >> 8) & 0xFF) as u8; + let mut pdu = vec![OPCODE_WRITE_REQUEST, lsb, msb]; + pdu.extend_from_slice(value); + self.send_packet(&pdu).await?; + self.read_response().await?; + Ok(()) + } + + async fn send_packet(&self, data: &[u8]) -> Result<()> { + let state = self.state.lock().await; + if let Some(sender) = &state.sender { + sender.send(data.to_vec()).await.map_err(|e| { + error!("Failed to send packet to channel: {}", e); + Error::from(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "L2CAP send channel closed", + )) + }) + } else { + error!("Cannot send packet, sender is not available."); + Err(Error::from(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "L2CAP stream not connected", + ))) + } + } + + async fn read_response(&self) -> Result> { + debug!("Waiting for response..."); + let mut rx = self.response_rx.lock().await; + match tokio::time::timeout(Duration::from_millis(RESPONSE_TIMEOUT), rx.recv()).await { + Ok(Some(resp)) => Ok(resp), + Ok(None) => Err(Error::from(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Response channel closed", + ))), + Err(_) => Err(Error::from(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Response timeout", + ))), + } + } +} + +async fn recv_thread(manager: ATTManager, sp: Arc) { + let mut buf = vec![0u8; 1024]; + loop { + match sp.recv(&mut buf).await { + Ok(0) => { + info!("Remote closed the connection."); + break; + } + Ok(n) => { + let data = &buf[..n]; + debug!("Received {} bytes: {}", n, hex::encode(data)); + if data.is_empty() { + continue; + } + if data[0] == OPCODE_HANDLE_VALUE_NTF { + // Notification + let handle = (data[1] as u16) | ((data[2] as u16) << 8); + let value = data[3..].to_vec(); + let state = manager.state.lock().await; + if let Some(listeners) = state.listeners.get(&handle) { + for listener in listeners { + let _ = listener.send(value.clone()); + } + } + } else if data[0] == OPCODE_WRITE_RESPONSE { + let _ = manager.response_tx.send(vec![]); + } else { + // Response + let _ = manager.response_tx.send(data[1..].to_vec()); + } + } + Err(e) => { + error!("read error: {}", e); + break; + } + } + } + let mut state = manager.state.lock().await; + state.sender = None; +} + +async fn send_thread(mut rx: mpsc::Receiver>, sp: Arc) { + while let Some(data) = rx.recv().await { + if let Err(e) = sp.send(&data).await { + error!("Failed to send data: {}", e); + break; + } + debug!("Sent {} bytes: {}", data.len(), hex::encode(&data)); + } + info!("send thread finished."); +} diff --git a/linux-rust/src/bluetooth/discovery.rs b/linux-rust/src/bluetooth/discovery.rs new file mode 100644 index 00000000..22c40109 --- /dev/null +++ b/linux-rust/src/bluetooth/discovery.rs @@ -0,0 +1,49 @@ +use bluer::Adapter; +use log::debug; +use std::io::Error; + +pub(crate) async fn find_connected_airpods(adapter: &Adapter) -> bluer::Result { + let target_uuid = uuid::Uuid::parse_str("74ec2172-0bad-4d01-8f77-997b2be0722a").unwrap(); + + let addrs = adapter.device_addresses().await?; + for addr in addrs { + let device = adapter.device(addr)?; + if device.is_connected().await.unwrap_or(false) + && let Ok(uuids) = device.uuids().await + && let Some(uuids) = uuids + && uuids.iter().any(|u| *u == target_uuid) + { + return Ok(device); + } + } + Err(bluer::Error::from(Error::new( + std::io::ErrorKind::NotFound, + "No connected AirPods found", + ))) +} + +pub async fn find_other_managed_devices( + adapter: &Adapter, + managed_macs: Vec, +) -> bluer::Result> { + let addrs = adapter.device_addresses().await?; + let mut devices = Vec::new(); + for addr in addrs { + let device = adapter.device(addr)?; + let device_mac = device.address().to_string(); + let connected = device.is_connected().await.unwrap_or(false); + debug!("Checking device: {}, connected: {}", device_mac, connected); + if connected && managed_macs.contains(&device_mac) { + debug!("Found managed device: {}", device_mac); + devices.push(device); + } + } + if !devices.is_empty() { + return Ok(devices); + } + debug!("No other managed devices found"); + Err(bluer::Error::from(Error::new( + std::io::ErrorKind::NotFound, + "No other managed devices found", + ))) +} diff --git a/linux-rust/src/bluetooth/le.rs b/linux-rust/src/bluetooth/le.rs new file mode 100644 index 00000000..7b0ef9c1 --- /dev/null +++ b/linux-rust/src/bluetooth/le.rs @@ -0,0 +1,379 @@ +use crate::bluetooth::aacp::BatteryStatus; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::tray::MyTray; +use crate::utils::{ah, get_devices_path, get_preferences_path}; +use aes::Aes128; +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockDecrypt, KeyInit}; +use bluer::monitor::{Monitor, MonitorEvent, Pattern}; +use bluer::{Address, Session}; +use futures::StreamExt; +use hex; +use log::{debug, info}; +use serde_json; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::Mutex; + +fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let cipher = Aes128::new(&GenericArray::from(*key)); + let mut block = GenericArray::from(*data); + cipher.decrypt_block(&mut block); + block.into() +} + +fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool { + let rpa: Vec = addr + .split(':') + .map(|s| u8::from_str_radix(s, 16).unwrap()) + .collect::>() + .into_iter() + .rev() + .collect(); + if rpa.len() != 6 { + return false; + } + let prand_slice = &rpa[3..6]; + let prand: [u8; 3] = prand_slice.try_into().unwrap(); + let hash_slice = &rpa[0..3]; + let hash: [u8; 3] = hash_slice.try_into().unwrap(); + let computed_hash = ah(irk, &prand); + debug!( + "Verifying RPA: addr={}, hash={:?}, computed_hash={:?}", + addr, hash, computed_hash + ); + hash == computed_hash +} + +pub async fn start_le_monitor(tray_handle: Option>) -> bluer::Result<()> { + let session = Session::new().await?; + let adapter = session.default_adapter().await?; + adapter.set_powered(true).await?; + + let all_devices: HashMap = std::fs::read_to_string(get_devices_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + + let mut verified_macs: HashMap = HashMap::new(); + let mut failed_macs: HashSet
= HashSet::new(); + let connecting_macs = Arc::new(Mutex::new(HashSet::
::new())); + + let pattern = Pattern { + data_type: 0xFF, // Manufacturer specific data + start_position: 0, + content: vec![0x4C, 0x00], // Apple manufacturer ID (76) in LE + }; + + let mm = adapter.monitor().await?; + let mut monitor_handle = mm + .register(Monitor { + monitor_type: bluer::monitor::Type::OrPatterns, + rssi_low_threshold: None, + rssi_high_threshold: None, + rssi_low_timeout: None, + rssi_high_timeout: None, + rssi_sampling_period: None, + patterns: Some(vec![pattern]), + ..Default::default() + }) + .await?; + + debug!("Started LE monitor"); + + while let Some(mevt) = monitor_handle.next().await { + if let MonitorEvent::DeviceFound(devid) = mevt { + let adapter_monitor_clone = adapter.clone(); + let dev = adapter_monitor_clone.device(devid.device)?; + let addr = dev.address(); + let addr_str = addr.to_string(); + + let matched_airpods_mac: Option; + let mut matched_enc_key: Option<[u8; 16]> = None; + + if let Some(airpods_mac) = verified_macs.get(&addr) { + matched_airpods_mac = Some(airpods_mac.clone()); + } else if failed_macs.contains(&addr) { + continue; + } else { + debug!("Checking RPA for device: {}", addr_str); + let mut found_mac = None; + for (airpods_mac, device_data) in &all_devices { + if device_data.type_ == DeviceType::AirPods + && let Some(DeviceInformation::AirPods(info)) = &device_data.information + && let Ok(irk_bytes) = hex::decode(&info.le_keys.irk) + && irk_bytes.len() == 16 + { + let irk: [u8; 16] = irk_bytes.as_slice().try_into().unwrap(); + debug!( + "Verifying RPA {} for airpods MAC {} with IRK {}", + addr_str, airpods_mac, info.le_keys.irk + ); + if verify_rpa(&addr_str, &irk) { + info!( + "Matched our device ({}) with the irk for {}", + addr, airpods_mac + ); + verified_macs.insert(addr, airpods_mac.clone()); + found_mac = Some(airpods_mac.clone()); + break; + } + } + } + + if let Some(mac) = found_mac { + matched_airpods_mac = Some(mac); + } else { + failed_macs.insert(addr); + debug!("Device {} did not match any of our irks", addr); + continue; + } + } + + if let Some(ref mac) = matched_airpods_mac + && let Some(device_data) = all_devices.get(mac) + && let Some(DeviceInformation::AirPods(info)) = &device_data.information + && let Ok(enc_key_bytes) = hex::decode(&info.le_keys.enc_key) + && enc_key_bytes.len() == 16 + { + matched_enc_key = Some(enc_key_bytes.as_slice().try_into().unwrap()); + } + + if matched_airpods_mac.is_some() { + let mut events = dev.events().await?; + let tray_handle_clone = tray_handle.clone(); + let connecting_macs_clone = Arc::clone(&connecting_macs); + tokio::spawn(async move { + while let Some(ev) = events.next().await { + match ev { + bluer::DeviceEvent::PropertyChanged(prop) => { + if let bluer::DeviceProperty::ManufacturerData(data) = prop { + if let Some(enc_key) = &matched_enc_key + && let Some(apple_data) = data.get(&76) + && apple_data.len() > 20 + { + let last_16: [u8; 16] = + apple_data[apple_data.len() - 16..].try_into().unwrap(); + let decrypted = decrypt(enc_key, &last_16); + debug!( + "Decrypted data from airpods_mac {}: {}", + matched_airpods_mac + .as_ref() + .unwrap_or(&"unknown".to_string()), + hex::encode(decrypted) + ); + + let connection_state = apple_data[10] as usize; + debug!("Connection state: {}", connection_state); + if connection_state == 0x00 { + let pref_path = get_preferences_path(); + let preferences: HashMap< + String, + HashMap, + > = std::fs::read_to_string(&pref_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let auto_connect = preferences + .get(matched_airpods_mac.as_ref().unwrap()) + .and_then(|prefs| prefs.get("autoConnect")) + .copied() + .unwrap_or(true); + debug!( + "Auto-connect preference for {}: {}", + matched_airpods_mac.as_ref().unwrap(), + auto_connect + ); + if auto_connect { + let real_address = + Address::from_str(&addr_str).unwrap(); + let mut cm = connecting_macs_clone.lock().await; + if cm.contains(&real_address) { + info!( + "Already connecting to {}, skipping duplicate attempt.", + matched_airpods_mac.as_ref().unwrap() + ); + return; + } + cm.insert(real_address); + // let adapter_clone = adapter_monitor_clone.clone(); + // let real_device = adapter_clone.device(real_address).unwrap(); + info!( + "AirPods are disconnected, attempting to connect to {}", + matched_airpods_mac.as_ref().unwrap() + ); + // if let Err(e) = real_device.connect().await { + // info!("Failed to connect to AirPods {}: {}", matched_airpods_mac.as_ref().unwrap(), e); + // } else { + // info!("Successfully connected to AirPods {}", matched_airpods_mac.as_ref().unwrap()); + // } + // call bluetoothctl connect for now, I don't know why bluer connect isn't working + let output = + tokio::process::Command::new("bluetoothctl") + .arg("connect") + .arg(matched_airpods_mac.as_ref().unwrap()) + .output() + .await; + match output { + Ok(output) => { + if output.status.success() { + info!( + "Successfully connected to AirPods {}", + matched_airpods_mac + .as_ref() + .unwrap() + ); + cm.remove(&real_address); + } else { + let stderr = String::from_utf8_lossy( + &output.stderr, + ); + info!( + "Failed to connect to AirPods {}: {}", + matched_airpods_mac + .as_ref() + .unwrap(), + stderr + ); + } + } + Err(e) => { + info!( + "Failed to execute bluetoothctl to connect to AirPods {}: {}", + matched_airpods_mac.as_ref().unwrap(), + e + ); + } + } + info!( + "Auto-connect is disabled for {}, not attempting to connect.", + matched_airpods_mac.as_ref().unwrap() + ); + } + } + + let status = apple_data[5] as usize; + let primary_left = (status >> 5) & 0x01 == 1; + let this_in_case = (status >> 6) & 0x01 == 1; + let xor_factor = primary_left ^ this_in_case; + let is_left_in_ear = if xor_factor { + (status & 0x02) != 0 + } else { + (status & 0x08) != 0 + }; + let is_right_in_ear = if xor_factor { + (status & 0x08) != 0 + } else { + (status & 0x02) != 0 + }; + let is_flipped = !primary_left; + + let left_byte_index = if is_flipped { 2 } else { 1 }; + let right_byte_index = if is_flipped { 1 } else { 2 }; + + let left_byte = decrypted[left_byte_index] as i32; + let right_byte = decrypted[right_byte_index] as i32; + let case_byte = decrypted[3] as i32; + + let (left_battery, left_charging) = if left_byte == 0xff { + (0, false) + } else { + (left_byte & 0x7F, (left_byte & 0x80) != 0) + }; + let (right_battery, right_charging) = if right_byte == 0xff + { + (0, false) + } else { + (right_byte & 0x7F, (right_byte & 0x80) != 0) + }; + let (case_battery, case_charging) = if case_byte == 0xff { + (0, false) + } else { + (case_byte & 0x7F, (case_byte & 0x80) != 0) + }; + + if let Some(handle) = &tray_handle_clone { + handle + .update(|tray: &mut MyTray| { + tray.battery_l = if left_byte == 0xff { + None + } else { + Some(left_battery as u8) + }; + tray.battery_l_status = if left_byte == 0xff { + Some(BatteryStatus::Disconnected) + } else if left_charging { + Some(BatteryStatus::Charging) + } else { + Some(BatteryStatus::NotCharging) + }; + tray.battery_r = if right_byte == 0xff { + None + } else { + Some(right_battery as u8) + }; + tray.battery_r_status = if right_byte == 0xff { + Some(BatteryStatus::Disconnected) + } else if right_charging { + Some(BatteryStatus::Charging) + } else { + Some(BatteryStatus::NotCharging) + }; + tray.battery_c = if case_byte == 0xff { + None + } else { + Some(case_battery as u8) + }; + tray.battery_c_status = if case_byte == 0xff { + Some(BatteryStatus::Disconnected) + } else if case_charging { + Some(BatteryStatus::Charging) + } else { + Some(BatteryStatus::NotCharging) + }; + }) + .await; + } + + debug!( + "Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}", + if left_byte == 0xff { + "disconnected".to_string() + } else { + format!( + "{}% (charging: {})", + left_battery, left_charging + ) + }, + if right_byte == 0xff { + "disconnected".to_string() + } else { + format!( + "{}% (charging: {})", + right_battery, right_charging + ) + }, + if case_byte == 0xff { + "disconnected".to_string() + } else { + format!( + "{}% (charging: {})", + case_battery, case_charging + ) + }, + is_left_in_ear, + is_right_in_ear + ); + } + } + } + } + } + }); + } + } + } + + Ok(()) +} diff --git a/linux-rust/src/bluetooth/managers.rs b/linux-rust/src/bluetooth/managers.rs new file mode 100644 index 00000000..078c851b --- /dev/null +++ b/linux-rust/src/bluetooth/managers.rs @@ -0,0 +1,48 @@ +use crate::bluetooth::aacp::AACPManager; +use crate::bluetooth::att::ATTManager; +use std::sync::Arc; + +pub struct DeviceManagers { + att: Option>, + aacp: Option>, +} + +impl DeviceManagers { + pub fn with_aacp(aacp: AACPManager) -> Self { + Self { + att: None, + aacp: Some(Arc::new(aacp)), + } + } + + pub fn with_att(att: ATTManager) -> Self { + Self { + att: Some(Arc::new(att)), + aacp: None, + } + } + + // keeping the att for airpods optional as it requires changes in system bluez config + pub fn with_both(aacp: AACPManager, att: ATTManager) -> Self { + Self { + att: Some(Arc::new(att)), + aacp: Some(Arc::new(aacp)), + } + } + + pub fn set_aacp(&mut self, manager: AACPManager) { + self.aacp = Some(Arc::new(manager)); + } + + pub fn set_att(&mut self, manager: ATTManager) { + self.att = Some(Arc::new(manager)); + } + + pub fn get_aacp(&self) -> Option> { + self.aacp.clone() + } + + pub fn get_att(&self) -> Option> { + self.att.clone() + } +} diff --git a/linux-rust/src/bluetooth/mod.rs b/linux-rust/src/bluetooth/mod.rs new file mode 100644 index 00000000..dfd52077 --- /dev/null +++ b/linux-rust/src/bluetooth/mod.rs @@ -0,0 +1,5 @@ +pub mod aacp; +pub mod att; +pub(crate) mod discovery; +pub mod le; +pub mod managers; diff --git a/linux-rust/src/devices/airpods.rs b/linux-rust/src/devices/airpods.rs new file mode 100644 index 00000000..bbec2d42 --- /dev/null +++ b/linux-rust/src/devices/airpods.rs @@ -0,0 +1,354 @@ +use crate::bluetooth::aacp::ControlCommandIdentifiers; +use crate::bluetooth::aacp::{AACPEvent, AACPManager, AirPodsLEKeys, ProximityKeyType}; +use crate::media_controller::MediaController; +use crate::ui::messages::BluetoothUIMessage; +use crate::ui::tray::MyTray; +use bluer::Address; +use ksni::Handle; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::time::{Duration, sleep}; + +pub struct AirPodsDevice { + pub mac_address: Address, + pub aacp_manager: AACPManager, + // pub att_manager: ATTManager, + pub media_controller: Arc>, + // pub command_tx: Option)>>, +} + +impl AirPodsDevice { + pub async fn new( + mac_address: Address, + tray_handle: Option>, + ui_tx: tokio::sync::mpsc::UnboundedSender, + ) -> Self { + info!("Creating new AirPodsDevice for {}", mac_address); + let mut aacp_manager = AACPManager::new(); + aacp_manager.connect(mac_address).await; + + // let mut att_manager = ATTManager::new(); + // att_manager.connect(mac_address).await.expect("Failed to connect ATT"); + + if let Some(handle) = &tray_handle { + handle + .update(|tray: &mut MyTray| tray.connected = true) + .await; + } + + info!("Sending handshake"); + if let Err(e) = aacp_manager.send_handshake().await { + error!("Failed to send handshake to AirPods device: {}", e); + } + + sleep(Duration::from_millis(100)).await; + + info!("Setting feature flags"); + if let Err(e) = aacp_manager.send_set_feature_flags_packet().await { + error!("Failed to set feature flags: {}", e); + } + + sleep(Duration::from_millis(100)).await; + + info!("Requesting notifications"); + if let Err(e) = aacp_manager.send_notification_request().await { + error!("Failed to request notifications: {}", e); + } + + info!("sending some packet"); + if let Err(e) = aacp_manager.send_some_packet().await { + error!("Failed to send some packet: {}", e); + } + + info!("Requesting Proximity Keys: IRK and ENC_KEY"); + if let Err(e) = aacp_manager + .send_proximity_keys_request(vec![ProximityKeyType::Irk, ProximityKeyType::EncKey]) + .await + { + error!("Failed to request proximity keys: {}", e); + } + + let session = bluer::Session::new() + .await + .expect("Failed to get bluer session"); + let adapter = session + .default_adapter() + .await + .expect("Failed to get default adapter"); + let local_mac = adapter + .address() + .await + .expect("Failed to get adapter address") + .to_string(); + + let media_controller = Arc::new(Mutex::new(MediaController::new( + mac_address.to_string(), + local_mac.clone(), + ))); + let mc_clone = media_controller.clone(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel(); + + aacp_manager.set_event_channel(tx).await; + if let Some(handle) = &tray_handle { + handle + .update(|tray: &mut MyTray| tray.command_tx = Some(command_tx.clone())) + .await; + } + + let aacp_manager_clone = aacp_manager.clone(); + tokio::spawn(async move { + while let Some((id, value)) = command_rx.recv().await { + if let Err(e) = aacp_manager_clone.send_control_command(id, &value).await { + log::error!("Failed to send control command: {}", e); + } + } + }); + + let mc_listener = media_controller.lock().await; + let aacp_manager_clone_listener = aacp_manager.clone(); + mc_listener + .start_playback_listener(aacp_manager_clone_listener, command_tx.clone()) + .await; + drop(mc_listener); + + let (listening_mode_tx, mut listening_mode_rx) = tokio::sync::mpsc::unbounded_channel(); + aacp_manager + .subscribe_to_control_command( + ControlCommandIdentifiers::ListeningMode, + listening_mode_tx, + ) + .await; + let tray_handle_clone = tray_handle.clone(); + tokio::spawn(async move { + while let Some(value) = listening_mode_rx.recv().await { + if let Some(handle) = &tray_handle_clone { + handle + .update(|tray: &mut MyTray| { + tray.listening_mode = Some(value[0]); + }) + .await; + } + } + }); + + let (allow_off_tx, mut allow_off_rx) = tokio::sync::mpsc::unbounded_channel(); + aacp_manager + .subscribe_to_control_command(ControlCommandIdentifiers::AllowOffOption, allow_off_tx) + .await; + let tray_handle_clone = tray_handle.clone(); + tokio::spawn(async move { + while let Some(value) = allow_off_rx.recv().await { + if let Some(handle) = &tray_handle_clone { + handle + .update(|tray: &mut MyTray| { + tray.allow_off_option = Some(value[0]); + }) + .await; + } + } + }); + + let (conversation_detect_tx, mut conversation_detect_rx) = + tokio::sync::mpsc::unbounded_channel(); + aacp_manager + .subscribe_to_control_command( + ControlCommandIdentifiers::ConversationDetectConfig, + conversation_detect_tx, + ) + .await; + let tray_handle_clone = tray_handle.clone(); + tokio::spawn(async move { + while let Some(value) = conversation_detect_rx.recv().await { + if let Some(handle) = &tray_handle_clone { + handle + .update(|tray: &mut MyTray| { + tray.conversation_detect_enabled = Some(value[0] == 0x01); + }) + .await; + } + } + }); + + let (owns_connection_tx, mut owns_connection_rx) = tokio::sync::mpsc::unbounded_channel(); + aacp_manager + .subscribe_to_control_command( + ControlCommandIdentifiers::OwnsConnection, + owns_connection_tx, + ) + .await; + let mc_clone_owns = media_controller.clone(); + tokio::spawn(async move { + while let Some(value) = owns_connection_rx.recv().await { + let owns = value.first().copied().unwrap_or(0) != 0; + if !owns { + info!("Lost ownership, pausing media and disconnecting audio"); + let controller = mc_clone_owns.lock().await; + controller.pause_all_media().await; + controller.deactivate_a2dp_profile().await; + } + } + }); + + let aacp_manager_clone_events = aacp_manager.clone(); + let local_mac_events = local_mac.clone(); + let ui_tx_clone = ui_tx.clone(); + let command_tx_clone = command_tx.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let event_clone = event.clone(); + match event { + AACPEvent::EarDetection(old_status, new_status) => { + debug!( + "Received EarDetection event: old_status={:?}, new_status={:?}", + old_status, new_status + ); + let controller = mc_clone.lock().await; + debug!( + "Calling handle_ear_detection with old_status: {:?}, new_status: {:?}", + old_status, new_status + ); + controller + .handle_ear_detection(old_status, new_status) + .await; + } + AACPEvent::BatteryInfo(battery_info) => { + debug!("Received BatteryInfo event: {:?}", battery_info); + if let Some(handle) = &tray_handle { + handle + .update(|tray: &mut MyTray| { + for b in &battery_info { + match b.component as u8 { + 0x01 => { + tray.battery_headphone = Some(b.level); + tray.battery_headphone_status = Some(b.status); + } + 0x02 => { + tray.battery_r = Some(b.level); + tray.battery_r_status = Some(b.status); + } + 0x04 => { + tray.battery_l = Some(b.level); + tray.battery_l_status = Some(b.status); + } + 0x08 => { + tray.battery_c = Some(b.level); + tray.battery_c_status = Some(b.status); + } + _ => {} + } + } + }) + .await; + } + debug!("Updated tray with new battery info"); + + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac_address.to_string(), + event_clone, + )); + debug!("Sent BatteryInfo event to UI"); + } + AACPEvent::ControlCommand(status) => { + debug!("Received ControlCommand event: {:?}", status); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac_address.to_string(), + event_clone, + )); + debug!("Sent ControlCommand event to UI"); + } + AACPEvent::ConversationalAwareness(status) => { + debug!("Received ConversationalAwareness event: {}", status); + let controller = mc_clone.lock().await; + controller.handle_conversational_awareness(status).await; + } + AACPEvent::ConnectedDevices(old_devices, new_devices) => { + let local_mac = local_mac_events.clone(); + let new_devices_filtered = new_devices.iter().filter(|new_device| { + let not_in_old = old_devices + .iter() + .all(|old_device| old_device.mac != new_device.mac); + let not_local = new_device.mac != local_mac; + not_in_old && not_local + }); + + for device in new_devices_filtered { + info!( + "New connected device: {}, info1: {}, info2: {}", + device.mac, device.info1, device.info2 + ); + info!( + "Sending new Tipi packet for device {}, and sending media info to the device", + device.mac + ); + let aacp_manager_clone = aacp_manager_clone_events.clone(); + let local_mac_clone = local_mac.clone(); + let device_mac_clone = device.mac.clone(); + tokio::spawn(async move { + if let Err(e) = aacp_manager_clone + .send_media_information_new_device( + &local_mac_clone, + &device_mac_clone, + ) + .await + { + error!("Failed to send media info new device: {}", e); + } + if let Err(e) = aacp_manager_clone + .send_add_tipi_device(&local_mac_clone, &device_mac_clone) + .await + { + error!("Failed to send add tipi device: {}", e); + } + }); + } + } + AACPEvent::OwnershipToFalseRequest => { + info!( + "Received ownership to false request. Setting ownership to false and pausing media." + ); + let _ = command_tx_clone + .send((ControlCommandIdentifiers::OwnsConnection, vec![0x00])); + let controller = mc_clone.lock().await; + controller.pause_all_media().await; + controller.deactivate_a2dp_profile().await; + } + _ => { + debug!("Received unhandled AACP event: {:?}", event); + let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent( + mac_address.to_string(), + event_clone, + )); + debug!("Sent unhandled AACP event to UI"); + } + } + } + }); + + AirPodsDevice { + mac_address, + aacp_manager, + // att_manager, + media_controller, + // command_tx: Some(command_tx.clone()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AirPodsInformation { + pub name: String, + pub model_number: String, + pub manufacturer: String, + pub serial_number: String, + pub version1: String, + pub version2: String, + pub hardware_revision: String, + pub updater_identifier: String, + pub left_serial_number: String, + pub right_serial_number: String, + pub version3: String, + pub le_keys: AirPodsLEKeys, +} diff --git a/linux-rust/src/devices/enums.rs b/linux-rust/src/devices/enums.rs new file mode 100644 index 00000000..5768d180 --- /dev/null +++ b/linux-rust/src/devices/enums.rs @@ -0,0 +1,152 @@ +use crate::bluetooth::aacp::BatteryInfo; +use crate::devices::airpods::AirPodsInformation; +use crate::devices::nothing::NothingInformation; +use iced::widget::combo_box; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum DeviceType { + AirPods, + Nothing, +} + +impl Display for DeviceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceType::AirPods => write!(f, "AirPods"), + DeviceType::Nothing => write!(f, "Nothing"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data")] +pub enum DeviceInformation { + AirPods(AirPodsInformation), + Nothing(NothingInformation), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceData { + pub name: String, + pub type_: DeviceType, + pub information: Option, +} + +#[derive(Clone, Debug)] +pub enum DeviceState { + AirPods(AirPodsState), + Nothing(NothingState), +} + +impl Display for DeviceState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceState::AirPods(_) => write!(f, "AirPods State"), + DeviceState::Nothing(_) => write!(f, "Nothing State"), + } + } +} + +#[derive(Clone, Debug)] +pub struct AirPodsState { + pub device_name: String, + pub noise_control_mode: AirPodsNoiseControlMode, + pub noise_control_state: combo_box::State, + pub conversation_awareness_enabled: bool, + pub personalized_volume_enabled: bool, + pub allow_off_mode: bool, + pub battery: Vec, +} + +#[derive(Clone, Debug)] +pub enum AirPodsNoiseControlMode { + Off, + NoiseCancellation, + Transparency, + Adaptive, +} + +impl Display for AirPodsNoiseControlMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AirPodsNoiseControlMode::Off => write!(f, "Off"), + AirPodsNoiseControlMode::NoiseCancellation => write!(f, "Noise Cancellation"), + AirPodsNoiseControlMode::Transparency => write!(f, "Transparency"), + AirPodsNoiseControlMode::Adaptive => write!(f, "Adaptive"), + } + } +} + +impl AirPodsNoiseControlMode { + pub fn from_byte(value: &u8) -> Self { + match value { + 0x01 => AirPodsNoiseControlMode::Off, + 0x02 => AirPodsNoiseControlMode::NoiseCancellation, + 0x03 => AirPodsNoiseControlMode::Transparency, + 0x04 => AirPodsNoiseControlMode::Adaptive, + _ => AirPodsNoiseControlMode::Off, + } + } + pub fn to_byte(&self) -> u8 { + match self { + AirPodsNoiseControlMode::Off => 0x01, + AirPodsNoiseControlMode::NoiseCancellation => 0x02, + AirPodsNoiseControlMode::Transparency => 0x03, + AirPodsNoiseControlMode::Adaptive => 0x04, + } + } +} + +#[derive(Clone, Debug)] +pub struct NothingState { + pub anc_mode: NothingAncMode, + pub anc_mode_state: combo_box::State, +} + +#[derive(Clone, Debug)] +pub enum NothingAncMode { + Off, + LowNoiseCancellation, + MidNoiseCancellation, + HighNoiseCancellation, + AdaptiveNoiseCancellation, + Transparency, +} + +impl Display for NothingAncMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NothingAncMode::Off => write!(f, "Off"), + NothingAncMode::LowNoiseCancellation => write!(f, "Low Noise Cancellation"), + NothingAncMode::MidNoiseCancellation => write!(f, "Mid Noise Cancellation"), + NothingAncMode::HighNoiseCancellation => write!(f, "High Noise Cancellation"), + NothingAncMode::AdaptiveNoiseCancellation => write!(f, "Adaptive Noise Cancellation"), + NothingAncMode::Transparency => write!(f, "Transparency"), + } + } +} +impl NothingAncMode { + pub fn from_byte(value: u8) -> Self { + match value { + 0x03 => NothingAncMode::LowNoiseCancellation, + 0x02 => NothingAncMode::MidNoiseCancellation, + 0x01 => NothingAncMode::HighNoiseCancellation, + 0x04 => NothingAncMode::AdaptiveNoiseCancellation, + 0x07 => NothingAncMode::Transparency, + 0x05 => NothingAncMode::Off, + _ => NothingAncMode::Off, + } + } + pub fn to_byte(&self) -> u8 { + match self { + NothingAncMode::LowNoiseCancellation => 0x03, + NothingAncMode::MidNoiseCancellation => 0x02, + NothingAncMode::HighNoiseCancellation => 0x01, + NothingAncMode::AdaptiveNoiseCancellation => 0x04, + NothingAncMode::Transparency => 0x07, + NothingAncMode::Off => 0x05, + } + } +} diff --git a/linux-rust/src/devices/mod.rs b/linux-rust/src/devices/mod.rs new file mode 100644 index 00000000..c5d459f7 --- /dev/null +++ b/linux-rust/src/devices/mod.rs @@ -0,0 +1,3 @@ +pub mod airpods; +pub mod enums; +pub(crate) mod nothing; diff --git a/linux-rust/src/devices/nothing.rs b/linux-rust/src/devices/nothing.rs new file mode 100644 index 00000000..1f78044b --- /dev/null +++ b/linux-rust/src/devices/nothing.rs @@ -0,0 +1,179 @@ +use crate::bluetooth::att::{ATTHandles, ATTManager}; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType}; +use crate::ui::messages::BluetoothUIMessage; +use crate::utils::get_devices_path; +use bluer::Address; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::sleep; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NothingInformation { + pub serial_number: String, + pub firmware_version: String, +} + +pub struct NothingDevice { + pub att_manager: ATTManager, + pub information: NothingInformation, +} + +impl NothingDevice { + pub async fn new( + mac_address: Address, + ui_tx: mpsc::UnboundedSender, + ) -> Self { + let mut att_manager = ATTManager::new(); + att_manager + .connect(mac_address) + .await + .expect("Failed to connect"); + + let (tx, mut rx) = mpsc::unbounded_channel::>(); + + att_manager + .register_listener(ATTHandles::NothingEverythingRead, tx) + .await; + + let devices: HashMap = std::fs::read_to_string(get_devices_path()) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let device_key = mac_address.to_string(); + let information = if let Some(device_data) = devices.get(&device_key) { + let info = device_data.information.clone(); + if let Some(DeviceInformation::Nothing(ref nothing_info)) = info { + nothing_info.clone() + } else { + NothingInformation { + serial_number: String::new(), + firmware_version: String::new(), + } + } + } else { + NothingInformation { + serial_number: String::new(), + firmware_version: String::new(), + } + }; + + // Request version information + att_manager + .write( + ATTHandles::NothingEverything, + &[ + 0x55, 0x20, 0x01, 0x42, 0xC0, 0x00, 0x00, 0x00, 0x00, + 0x00, // something, idk + ], + ) + .await + .expect("Failed to write"); + + sleep(Duration::from_millis(100)).await; + + // Request serial number + att_manager + .write( + ATTHandles::NothingEverything, + &[0x55, 0x20, 0x01, 0x06, 0xC0, 0x00, 0x00, 0x13, 0x00, 0x00], + ) + .await + .expect("Failed to write"); + + // let ui_tx_clone = ui_tx.clone(); + let information_l = information.clone(); + tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if data.starts_with(&[0x55, 0x20, 0x01, 0x42, 0x40]) { + let firmware_version = String::from_utf8_lossy(&data[8..]).to_string(); + info!( + "Received firmware version from Nothing device {}: {}", + mac_address, firmware_version + ); + let new_information = NothingInformation { + serial_number: information_l.serial_number.clone(), + firmware_version: firmware_version.clone(), + }; + let mut new_devices = devices.clone(); + new_devices.insert( + device_key.clone(), + DeviceData { + name: devices + .get(&device_key) + .map(|d| d.name.clone()) + .unwrap_or("Nothing Device".to_string()), + type_: devices + .get(&device_key) + .map(|d| d.type_.clone()) + .unwrap_or(DeviceType::Nothing), + information: Some(DeviceInformation::Nothing(new_information)), + }, + ); + let json = serde_json::to_string(&new_devices).unwrap(); + std::fs::write(get_devices_path(), json).expect("Failed to write devices file"); + } else if data.starts_with(&[0x55, 0x20, 0x01, 0x06, 0x40]) { + let serial_number_start_position = data + .iter() + .position(|&b| b == "S".as_bytes()[0]) + .unwrap_or(8); + let serial_number_end = data + .iter() + .skip(serial_number_start_position) + .position(|&b| b == 0x0A) + .map(|pos| pos + serial_number_start_position) + .unwrap_or(data.len()); + if data.get(serial_number_start_position + 1) == Some(&"H".as_bytes()[0]) { + let serial_number = String::from_utf8_lossy( + &data[serial_number_start_position..serial_number_end], + ) + .to_string(); + info!( + "Received serial number from Nothing device {}: {}", + mac_address, serial_number + ); + let new_information = NothingInformation { + serial_number: serial_number.clone(), + firmware_version: information_l.firmware_version.clone(), + }; + let mut new_devices = devices.clone(); + new_devices.insert( + device_key.clone(), + DeviceData { + name: devices + .get(&device_key) + .map(|d| d.name.clone()) + .unwrap_or("Nothing Device".to_string()), + type_: devices + .get(&device_key) + .map(|d| d.type_.clone()) + .unwrap_or(DeviceType::Nothing), + information: Some(DeviceInformation::Nothing(new_information)), + }, + ); + let json = serde_json::to_string(&new_devices).unwrap(); + std::fs::write(get_devices_path(), json) + .expect("Failed to write devices file"); + } else { + debug!( + "Serial number format unexpected from Nothing device {}: {:?}", + mac_address, data + ); + } + } + + debug!( + "Received data from (Nothing) device {}, data: {:?}", + mac_address, data + ); + } + }); + + NothingDevice { + att_manager, + information, + } + } +} diff --git a/linux-rust/src/main.rs b/linux-rust/src/main.rs new file mode 100644 index 00000000..d81f077a --- /dev/null +++ b/linux-rust/src/main.rs @@ -0,0 +1,322 @@ +mod bluetooth; +mod devices; +mod media_controller; +mod ui; +mod utils; + +use crate::bluetooth::discovery::{find_connected_airpods, find_other_managed_devices}; +use crate::bluetooth::le::start_le_monitor; +use crate::bluetooth::managers::DeviceManagers; +use crate::devices::enums::DeviceData; +use crate::ui::messages::BluetoothUIMessage; +use crate::ui::tray::MyTray; +use crate::utils::get_devices_path; +use bluer::{Address, InternalErrorKind}; +use clap::Parser; +use dbus::arg::{RefArg, Variant}; +use dbus::blocking::Connection; +use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; +use dbus::message::MatchRule; +use devices::airpods::AirPodsDevice; +use ksni::TrayMethods; +use log::info; +use std::collections::HashMap; +use std::env; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio::sync::mpsc::unbounded_channel; + +#[derive(Parser)] +struct Args { + #[arg(long, short = 'd', help = "Enable debug logging")] + debug: bool, + #[arg( + long, + help = "Disable system tray, useful if your environment doesn't support AppIndicator or StatusNotifier" + )] + no_tray: bool, + #[arg(long, help = "Start the application minimized to tray")] + start_minimized: bool, + #[arg( + long, + help = "Enable Bluetooth LE debug logging. Only use when absolutely necessary; this produces a lot of logs." + )] + le_debug: bool, + #[arg(long, short = 'v', help = "Show application version and exit")] + version: bool, +} + +fn main() -> iced::Result { + let args = Args::parse(); + + if args.version { + println!( + "You are running LibrePods version {}", + env!("CARGO_PKG_VERSION") + ); + return Ok(()); + } + + let log_level = if args.debug { "debug" } else { "info" }; + let wayland_display = env::var("WAYLAND_DISPLAY").is_ok(); + if env::var("RUST_LOG").is_err() { + if wayland_display { + unsafe { env::set_var("WGPU_BACKEND", "gl") }; + } + unsafe { + env::set_var( + "RUST_LOG", + log_level.to_owned() + + &format!( + ",winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods_rust::bluetooth::le={}", + if args.le_debug { "debug" } else { "warn" } + ), + ) + }; + } + env_logger::init(); + + let (ui_tx, ui_rx) = unbounded_channel::(); + + let device_managers: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + let device_managers_clone = device_managers.clone(); + std::thread::spawn(|| { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async_main(ui_tx, device_managers_clone)) + .unwrap(); + }); + + ui::window::start_ui(ui_rx, args.start_minimized, device_managers) +} + +async fn async_main( + ui_tx: tokio::sync::mpsc::UnboundedSender, + device_managers: Arc>>, +) -> bluer::Result<()> { + let args = Args::parse(); + + let mut managed_devices_mac: Vec = Vec::new(); // includes ony non-AirPods. AirPods handled separately. + + let devices_path = get_devices_path(); + let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| { + log::error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = serde_json::from_str(&devices_json) + .unwrap_or_else(|e| { + log::error!("Deserialization failed: {}", e); + HashMap::new() + }); + for (mac, device_data) in devices_list.iter() { + if device_data.type_ == devices::enums::DeviceType::Nothing { + managed_devices_mac.push(mac.clone()); + } + } + + let tray_handle = if args.no_tray { + None + } else { + let tray = MyTray { + conversation_detect_enabled: None, + battery_headphone: None, + battery_headphone_status: None, + battery_l: None, + battery_l_status: None, + battery_r: None, + battery_r_status: None, + battery_c: None, + battery_c_status: None, + connected: false, + listening_mode: None, + allow_off_option: None, + command_tx: None, + ui_tx: Some(ui_tx.clone()), + }; + let handle = tray.spawn().await.unwrap(); + Some(handle) + }; + + let session = bluer::Session::new().await?; + let adapter = session.default_adapter().await?; + adapter.set_powered(true).await?; + + let le_tray_clone = tray_handle.clone(); + tokio::spawn(async move { + info!("Starting LE monitor..."); + if let Err(e) = start_le_monitor(le_tray_clone).await { + log::error!("LE monitor error: {}", e); + } + }); + + info!("Listening for new connections."); + + info!("Checking for connected devices..."); + match find_connected_airpods(&adapter).await { + Ok(device) => { + let name = device + .name() + .await? + .unwrap_or_else(|| "Unknown".to_string()); + info!("Found connected AirPods: {}, initializing.", name); + let airpods_device = + AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await; + + let mut managers = device_managers.write().await; + // let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone()); + let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone()); + managers + .entry(device.address().to_string()) + .or_insert(dev_managers) + .set_aacp(airpods_device.aacp_manager); + drop(managers); + ui_tx + .send(BluetoothUIMessage::DeviceConnected( + device.address().to_string(), + )) + .unwrap(); + } + Err(_) => { + info!("No connected AirPods found."); + } + } + + match find_other_managed_devices(&adapter, managed_devices_mac.clone()).await { + Ok(devices) => { + for device in devices { + let addr_str = device.address().to_string(); + info!( + "Found connected managed device: {}, initializing.", + addr_str + ); + let type_ = devices_list.get(&addr_str).unwrap().type_.clone(); + let ui_tx_clone = ui_tx.clone(); + let device_managers = device_managers.clone(); + tokio::spawn(async move { + let mut managers = device_managers.write().await; + if type_ == devices::enums::DeviceType::Nothing { + let dev = devices::nothing::NothingDevice::new( + device.address(), + ui_tx_clone.clone(), + ) + .await; + let dev_managers = DeviceManagers::with_att(dev.att_manager.clone()); + managers + .entry(addr_str.clone()) + .or_insert(dev_managers) + .set_att(dev.att_manager); + ui_tx_clone + .send(BluetoothUIMessage::DeviceConnected(addr_str)) + .unwrap(); + } + drop(managers) + }); + } + } + Err(e) => { + log::debug!("type of error: {:?}", e.kind); + if e.kind + != bluer::ErrorKind::Internal(InternalErrorKind::Io(std::io::ErrorKind::NotFound)) + { + log::error!("Error finding other managed devices: {}", e); + } else { + info!("No other managed devices found."); + } + } + } + + let conn = Connection::new_system()?; + let rule = MatchRule::new_signal("org.freedesktop.DBus.Properties", "PropertiesChanged"); + conn.add_match(rule, move |_: (), conn, msg| { + let Some(path) = msg.path() else { + return true; + }; + if !path.contains("/org/bluez/hci") || !path.contains("/dev_") { + return true; + } + // debug!("PropertiesChanged signal for path: {}", path); + let Ok((iface, changed, _)) = + msg.read3::>>, Vec>() + else { + return true; + }; + if iface != "org.bluez.Device1" { + return true; + } + let Some(connected_var) = changed.get("Connected") else { + return true; + }; + let Some(is_connected) = connected_var.0.as_ref().as_u64() else { + return true; + }; + if is_connected == 0 { + return true; + } + let proxy = conn.with_proxy("org.bluez", path, std::time::Duration::from_millis(5000)); + let Ok(uuids) = proxy.get::>("org.bluez.Device1", "UUIDs") else { + return true; + }; + let target_uuid = "74ec2172-0bad-4d01-8f77-997b2be0722a"; + + let Ok(addr_str) = proxy.get::("org.bluez.Device1", "Address") else { + return true; + }; + let Ok(addr) = addr_str.parse::
() else { + return true; + }; + + if managed_devices_mac.contains(&addr_str) { + info!("Managed device connected: {}, initializing", addr_str); + let type_ = devices_list.get(&addr_str).unwrap().type_.clone(); + if type_ == devices::enums::DeviceType::Nothing { + let ui_tx_clone = ui_tx.clone(); + let device_managers = device_managers.clone(); + tokio::spawn(async move { + let mut managers = device_managers.write().await; + let dev = devices::nothing::NothingDevice::new(addr, ui_tx_clone.clone()).await; + let dev_managers = DeviceManagers::with_att(dev.att_manager.clone()); + managers + .entry(addr_str.clone()) + .or_insert(dev_managers) + .set_att(dev.att_manager); + drop(managers); + ui_tx_clone + .send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) + .unwrap(); + }); + } + return true; + } + + if !uuids.iter().any(|u| u.to_lowercase() == target_uuid) { + return true; + } + let name = proxy + .get::("org.bluez.Device1", "Name") + .unwrap_or_else(|_| "Unknown".to_string()); + info!("AirPods connected: {}, initializing", name); + let handle_clone = tray_handle.clone(); + let ui_tx_clone = ui_tx.clone(); + let device_managers = device_managers.clone(); + tokio::spawn(async move { + let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await; + let mut managers = device_managers.write().await; + // let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone()); + let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone()); + managers + .entry(addr_str.clone()) + .or_insert(dev_managers) + .set_aacp(airpods_device.aacp_manager); + drop(managers); + ui_tx_clone + .send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) + .unwrap(); + }); + true + })?; + + info!("Listening for Bluetooth connections via D-Bus..."); + loop { + conn.process(std::time::Duration::from_millis(1000))?; + } +} diff --git a/linux-rust/src/media_controller.rs b/linux-rust/src/media_controller.rs new file mode 100644 index 00000000..adaef4aa --- /dev/null +++ b/linux-rust/src/media_controller.rs @@ -0,0 +1,1350 @@ +use crate::bluetooth::aacp::AACPManager; +use crate::bluetooth::aacp::EarDetectionStatus; +use dbus::arg::RefArg; +use dbus::blocking::Connection; +use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::introspect::SinkInfo; +use libpulse_binding::context::{Context, FlagSet as ContextFlagSet}; +use libpulse_binding::def::Retval; +use libpulse_binding::mainloop::standard::Mainloop; +use libpulse_binding::operation::State as OperationState; +use libpulse_binding::proplist::Proplist; +use libpulse_binding::volume::{ChannelVolumes, Volume}; +use log::{debug, error, info, warn}; +use std::cell::RefCell; +use std::process::Command; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; + +#[derive(Clone)] +struct OwnedCardProfileInfo { + name: Option, +} + +#[derive(Clone)] +struct OwnedCardInfo { + index: u32, + proplist: Proplist, + profiles: Vec, +} + +#[derive(Clone)] +struct OwnedSinkInfo { + name: Option, + proplist: Proplist, + volume: ChannelVolumes, +} + +struct MediaControllerState { + connected_device_mac: String, + local_mac: String, + is_playing: bool, + paused_by_app_services: Vec, + device_index: Option, + cached_a2dp_profile: String, + old_in_ear_data: Vec, + user_played_the_media: bool, + i_paused_the_media: bool, + ear_detection_enabled: bool, + disconnect_when_not_wearing: bool, + conv_original_volume: Option, + conv_conversation_started: bool, + playback_listener_running: bool, +} + +impl MediaControllerState { + fn new() -> Self { + MediaControllerState { + connected_device_mac: String::new(), + local_mac: String::new(), + is_playing: false, + paused_by_app_services: Vec::new(), + device_index: None, + cached_a2dp_profile: String::new(), + old_in_ear_data: vec![false, false], + user_played_the_media: false, + i_paused_the_media: false, + ear_detection_enabled: true, + disconnect_when_not_wearing: true, + conv_original_volume: None, + conv_conversation_started: false, + playback_listener_running: false, + } + } +} + +#[derive(Clone)] +pub struct MediaController { + state: Arc>, +} + +impl MediaController { + pub fn new(connected_mac: String, local_mac: String) -> Self { + let mut state = MediaControllerState::new(); + state.connected_device_mac = connected_mac; + state.local_mac = local_mac; + MediaController { + state: Arc::new(Mutex::new(state)), + } + } + + pub async fn start_playback_listener( + &self, + aacp_manager: AACPManager, + control_tx: tokio::sync::mpsc::UnboundedSender<( + crate::bluetooth::aacp::ControlCommandIdentifiers, + Vec, + )>, + ) { + let mut state = self.state.lock().await; + if state.playback_listener_running { + debug!("Playback listener already running"); + return; + } + state.playback_listener_running = true; + drop(state); + + let controller_clone = self.clone(); + tokio::spawn(async move { + controller_clone + .playback_listener_loop(aacp_manager, control_tx) + .await; + }); + } + + async fn playback_listener_loop( + &self, + aacp_manager: AACPManager, + control_tx: tokio::sync::mpsc::UnboundedSender<( + crate::bluetooth::aacp::ControlCommandIdentifiers, + Vec, + )>, + ) { + info!("Starting playback listener loop"); + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + + let is_playing = tokio::task::spawn_blocking(|| Self::check_if_playing()) + .await + .unwrap_or(false); + + let mut state = self.state.lock().await; + let was_playing = state.is_playing; + state.is_playing = is_playing; + let local_mac = state.local_mac.clone(); + drop(state); + + if !was_playing && is_playing { + let aacp_state = aacp_manager.state.lock().await; + if !aacp_state + .ear_detection_status + .contains(&EarDetectionStatus::InEar) + { + info!("Media playback started but buds not in ear, skipping takeover"); + continue; + } + info!("Media playback started, taking ownership and activating a2dp"); + let _ = control_tx.send(( + crate::bluetooth::aacp::ControlCommandIdentifiers::OwnsConnection, + vec![0x01], + )); + self.activate_a2dp_profile().await; + + info!("already connected locally, hijacking connection by asking AirPods"); + + let connected_devices = aacp_state.connected_devices.clone(); + for device in connected_devices { + if device.mac != local_mac { + if let Err(e) = aacp_manager + .send_media_information(&local_mac, &device.mac, true) + .await + { + error!("Failed to send media information to {}: {}", device.mac, e); + } + if let Err(e) = aacp_manager.send_smart_routing_show_ui(&device.mac).await { + error!( + "Failed to send smart routing show ui to {}: {}", + device.mac, e + ); + } + if let Err(e) = aacp_manager.send_hijack_request(&device.mac).await { + error!("Failed to send hijack request to {}: {}", device.mac, e); + } + } + } + + debug!("completed playback takeover process"); + } + } + } + + fn check_if_playing() -> bool { + let conn = match Connection::new_session() { + Ok(c) => c, + Err(_) => return false, + }; + + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let (names,): (Vec,) = + match proxy.method_call("org.freedesktop.DBus", "ListNames", ()) { + Ok(n) => n, + Err(_) => return false, + }; + + for service in names { + if !service.starts_with("org.mpris.MediaPlayer2.") { + continue; + } + if Self::is_kdeconnect_service(&service) { + continue; + } + + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if let Ok(playback_status) = + proxy.get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") + && playback_status == "Playing" + { + return true; + } + } + false + } + + fn is_kdeconnect_service(service: &str) -> bool { + service.starts_with("org.mpris.MediaPlayer2.kdeconnect.mpris_") + } + + pub async fn handle_ear_detection( + &self, + old_statuses: Vec, + new_statuses: Vec, + ) { + debug!( + "Entering handle_ear_detection with old_statuses: {:?}, new_statuses: {:?}", + old_statuses, new_statuses + ); + + let old_in_ear_data: Vec = old_statuses + .iter() + .map(|s| *s == EarDetectionStatus::InEar) + .collect(); + let new_in_ear_data: Vec = new_statuses + .iter() + .map(|s| *s == EarDetectionStatus::InEar) + .collect(); + + let in_ear = new_in_ear_data.iter().all(|&b| b); + + let old_all_out = old_in_ear_data.iter().all(|&b| !b); + let new_has_at_least_one_in = new_in_ear_data.iter().any(|&b| b); + let new_all_out = new_in_ear_data.iter().all(|&b| !b); + + debug!( + "Computed states: in_ear={}, old_all_out={}, new_has_at_least_one_in={}, new_all_out={}", + in_ear, old_all_out, new_has_at_least_one_in, new_all_out + ); + + { + let state = self.state.lock().await; + if !state.ear_detection_enabled { + debug!("Ear detection disabled, skipping"); + return; + } + } + + if new_has_at_least_one_in && old_all_out { + debug!("Condition met: buds inserted, activating A2DP and checking play state"); + self.activate_a2dp_profile().await; + { + let mut state = self.state.lock().await; + if state.is_playing { + state.user_played_the_media = true; + debug!("Set user_played_the_media to true as media was playing"); + } + } + } else if new_all_out { + debug!("Condition met: buds removed, pausing media"); + self.pause().await; + { + let state = self.state.lock().await; + if state.disconnect_when_not_wearing { + debug!("Disconnect when not wearing enabled, deactivating A2DP"); + drop(state); + self.deactivate_a2dp_profile().await; + } + } + } + + let reset_user_played = (old_in_ear_data.iter().any(|&b| !b) + && new_in_ear_data.iter().all(|&b| b)) + || (new_in_ear_data.iter().any(|&b| !b) && old_in_ear_data.iter().all(|&b| b)); + if reset_user_played { + debug!("Transition detected, resetting user_played_the_media"); + let mut state = self.state.lock().await; + state.user_played_the_media = false; + } + + info!( + "Ear Detection - old_in_ear_data: {:?}, new_in_ear_data: {:?}", + old_in_ear_data, new_in_ear_data + ); + + let mut old_sorted = old_in_ear_data.clone(); + old_sorted.sort(); + let mut new_sorted = new_in_ear_data.clone(); + new_sorted.sort(); + if new_sorted != old_sorted { + debug!("Ear data changed, checking resume/pause logic"); + if in_ear { + debug!("Resuming media as buds are in ear"); + self.resume().await; + { + let mut state = self.state.lock().await; + state.i_paused_the_media = false; + } + } else if !old_all_out { + debug!("Pausing media as buds are not fully in ear"); + self.pause().await; + { + let mut state = self.state.lock().await; + state.i_paused_the_media = true; + } + } else { + debug!("Playing media"); + self.resume().await; + { + let mut state = self.state.lock().await; + state.i_paused_the_media = false; + } + } + } + + { + let mut state = self.state.lock().await; + state.old_in_ear_data = new_in_ear_data; + debug!("Updated old_in_ear_data to {:?}", state.old_in_ear_data); + } + } + + pub async fn activate_a2dp_profile(&self) { + debug!("Entering activate_a2dp_profile"); + let state = self.state.lock().await; + + if state.connected_device_mac.is_empty() { + warn!("Connected device MAC is empty, cannot activate A2DP profile"); + return; + } + + let device_index = state.device_index; + let mac = state.connected_device_mac.clone(); + drop(state); + + let mut current_device_index = device_index; + + if current_device_index.is_none() { + warn!("Device index not found, trying to get it."); + current_device_index = self.get_audio_device_index(&mac).await; + if let Some(idx) = current_device_index { + let mut state = self.state.lock().await; + state.device_index = Some(idx); + } else { + warn!("Could not get device index. Cannot activate A2DP profile."); + return; + } + } + + if !self.is_a2dp_profile_available().await { + warn!("A2DP profile not available, attempting to restart WirePlumber"); + if self.restart_wire_plumber().await { + let mut state = self.state.lock().await; + state.device_index = self + .get_audio_device_index(&state.connected_device_mac) + .await; + debug!( + "Updated device_index after WirePlumber restart: {:?}", + state.device_index + ); + if !self.is_a2dp_profile_available().await { + error!("A2DP profile still not available after WirePlumber restart"); + return; + } + } else { + error!("Could not restart WirePlumber, A2DP profile unavailable"); + return; + } + } + + let preferred_profile = self.get_preferred_a2dp_profile().await; + if preferred_profile.is_empty() { + error!("No suitable A2DP profile found"); + return; + } + + info!("Activating A2DP profile for AirPods: {}", preferred_profile); + let state = self.state.lock().await; + let device_index = state.device_index; + drop(state); + + if let Some(idx) = device_index { + let profile_name = preferred_profile.clone(); + let success = + tokio::task::spawn_blocking(move || set_card_profile_sync(idx, &profile_name)) + .await + .unwrap_or(false); + + if success { + info!("Successfully activated A2DP profile: {}", preferred_profile); + } else { + warn!("Failed to activate A2DP profile: {}", preferred_profile); + } + } else { + error!("Device index not available for activating profile."); + } + } + + async fn pause(&self) { + debug!("Pausing playback"); + + let paused_services = tokio::task::spawn_blocking(|| { + debug!("Listing DBus names for media players"); + let conn = Connection::new_session().unwrap(); + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let (names,): (Vec,) = proxy + .method_call("org.freedesktop.DBus", "ListNames", ()) + .unwrap(); + let mut paused_services = Vec::new(); + + for service in names { + if !service.starts_with("org.mpris.MediaPlayer2.") { + continue; + } + if Self::is_kdeconnect_service(&service) { + debug!("Skipping kdeconnect service: {}", service); + continue; + } + + debug!("Checking playback status for service: {}", service); + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if let Ok(playback_status) = + proxy.get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") + && playback_status == "Playing" + { + debug!("Service {} is playing, attempting to pause", service); + if proxy + .method_call::<(), _, &str, &str>( + "org.mpris.MediaPlayer2.Player", + "Pause", + (), + ) + .is_ok() + { + info!("Paused playback for: {}", service); + paused_services.push(service); + } else { + debug!("Failed to pause service: {}", service); + error!("Failed to pause {}", service); + } + } + } + paused_services + }) + .await + .unwrap(); + + if !paused_services.is_empty() { + debug!("Paused services: {:?}", paused_services); + info!("Paused {} media player(s) via DBus", paused_services.len()); + let mut state = self.state.lock().await; + state.paused_by_app_services = paused_services; + state.is_playing = false; + } else { + debug!("No playing media players found"); + info!("No playing media players found to pause"); + } + } + + pub async fn pause_all_media(&self) { + debug!("Pausing all media (without tracking for resume)"); + + let paused_count = tokio::task::spawn_blocking(|| { + debug!("Listing DBus names for media players"); + let conn = Connection::new_session().unwrap(); + let proxy = conn.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(5), + ); + let (names,): (Vec,) = proxy + .method_call("org.freedesktop.DBus", "ListNames", ()) + .unwrap(); + let mut paused_count = 0; + + for service in names { + if !service.starts_with("org.mpris.MediaPlayer2.") { + continue; + } + if Self::is_kdeconnect_service(&service) { + debug!("Skipping kdeconnect service: {}", service); + continue; + } + + debug!("Checking playback status for service: {}", service); + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if let Ok(playback_status) = + proxy.get::("org.mpris.MediaPlayer2.Player", "PlaybackStatus") + && playback_status == "Playing" + { + debug!("Service {} is playing, attempting to pause", service); + if proxy + .method_call::<(), _, &str, &str>( + "org.mpris.MediaPlayer2.Player", + "Pause", + (), + ) + .is_ok() + { + info!("Paused playback for: {}", service); + paused_count += 1; + } else { + debug!("Failed to pause service: {}", service); + error!("Failed to pause {}", service); + } + } + } + paused_count + }) + .await + .unwrap(); + + if paused_count > 0 { + info!( + "Paused {} media player(s) due to ownership loss", + paused_count + ); + let mut state = self.state.lock().await; + state.is_playing = false; + } else { + debug!("No playing media players found to pause"); + } + } + + async fn resume(&self) { + debug!("Entering resume method"); + debug!("Resuming playback"); + let state = self.state.lock().await; + let services = state.paused_by_app_services.clone(); + drop(state); + + if services.is_empty() { + debug!("No services to resume"); + info!("No services to resume"); + return; + } + + let resumed_count = tokio::task::spawn_blocking(move || { + let conn = Connection::new_session().unwrap(); + let mut resumed_count = 0; + for service in services { + if Self::is_kdeconnect_service(&service) { + debug!("Skipping kdeconnect service: {}", service); + continue; + } + + debug!("Attempting to resume service: {}", service); + let proxy = + conn.with_proxy(&service, "/org/mpris/MediaPlayer2", Duration::from_secs(5)); + if proxy + .method_call::<(), _, &str, &str>("org.mpris.MediaPlayer2.Player", "Play", ()) + .is_ok() + { + info!("Resumed playback for: {}", service); + resumed_count += 1; + } else { + debug!("Failed to resume service: {}", service); + warn!("Failed to resume {}", service); + } + } + resumed_count + }) + .await + .unwrap(); + + if resumed_count > 0 { + debug!("Resumed {} services", resumed_count); + info!("Resumed {} media player(s) via DBus", resumed_count); + let mut state = self.state.lock().await; + state.paused_by_app_services.clear(); + } else { + debug!("Failed to resume any services"); + error!("Failed to resume any media players via DBus"); + } + } + + async fn is_a2dp_profile_available(&self) -> bool { + debug!("Entering is_a2dp_profile_available"); + let state = self.state.lock().await; + let device_index = state.device_index; + drop(state); + + let index = match device_index { + Some(i) => i, + None => { + debug!("Device index is None, returning false"); + return false; + } + }; + + tokio::task::spawn_blocking(move || { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = + Context::new(&mainloop, "LibrePods-is_a2dp_profile_available").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return false; + } + _ => {} + } + } + + let introspector = context.introspect(); + let card_info_list = Rc::new(RefCell::new(None)); + let op = introspector.get_card_info_list({ + let card_info_list = card_info_list.clone(); + let mut list = Vec::new(); + move |result| match result { + ListResult::Item(item) => { + let profiles = item + .profiles + .iter() + .map(|p| OwnedCardProfileInfo { + name: p.name.as_ref().map(|n| n.to_string()), + }) + .collect(); + list.push(OwnedCardInfo { + index: item.index, + proplist: item.proplist.clone(), + profiles, + }); + } + ListResult::End => *card_info_list.borrow_mut() = Some(list.clone()), + ListResult::Error => *card_info_list.borrow_mut() = None, + } + }); + + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(list) = card_info_list.borrow().as_ref() + && let Some(card) = list.iter().find(|c| c.index == index) + { + let available = card.profiles.iter().any(|p| { + p.name + .as_ref() + .is_some_and(|name| name.starts_with("a2dp-sink")) + }); + debug!("A2DP profile available: {}", available); + return available; + } + debug!("A2DP profile not available"); + false + }) + .await + .unwrap_or(false) + } + + async fn get_preferred_a2dp_profile(&self) -> String { + debug!("Entering get_preferred_a2dp_profile"); + let state = self.state.lock().await; + let device_index = state.device_index; + let cached_profile = state.cached_a2dp_profile.clone(); + drop(state); + + let index = match device_index { + Some(i) => i, + None => { + debug!("Device index is None, returning empty string"); + return String::new(); + } + }; + + if !cached_profile.is_empty() && self.is_profile_available(index, &cached_profile).await { + debug!("Using cached A2DP profile: {}", cached_profile); + return cached_profile; + } + + let profiles_to_check = vec!["a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"]; + for profile in profiles_to_check { + debug!("Checking availability of profile: {}", profile); + if self.is_profile_available(index, profile).await { + debug!("Selected profile: {}", profile); + info!("Selected best available A2DP profile: {}", profile); + let mut state = self.state.lock().await; + state.cached_a2dp_profile = profile.to_string(); + return profile.to_string(); + } + } + debug!("No suitable profile found"); + String::new() + } + + async fn is_profile_available(&self, card_index: u32, profile: &str) -> bool { + debug!( + "Entering is_profile_available for card index: {}, profile: {}", + card_index, profile + ); + let profile_name = profile.to_string(); + tokio::task::spawn_blocking(move || { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-is_profile_available").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return false; + } + _ => {} + } + } + + let introspector = context.introspect(); + let card_info_list = Rc::new(RefCell::new(None)); + let op = introspector.get_card_info_list({ + let card_info_list = card_info_list.clone(); + let mut list = Vec::new(); + move |result| match result { + ListResult::Item(item) => { + let profiles = item + .profiles + .iter() + .map(|p| OwnedCardProfileInfo { + name: p.name.as_ref().map(|n| n.to_string()), + }) + .collect(); + list.push(OwnedCardInfo { + index: item.index, + proplist: item.proplist.clone(), + profiles, + }); + } + ListResult::End => *card_info_list.borrow_mut() = Some(list.clone()), + ListResult::Error => *card_info_list.borrow_mut() = None, + } + }); + + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(list) = card_info_list.borrow().as_ref() + && let Some(card) = list.iter().find(|c| c.index == card_index) + { + let available = card + .profiles + .iter() + .any(|p| p.name.as_ref() == Some(&profile_name)); + debug!("Profile {} available: {}", profile_name, available); + return available; + } + debug!("Profile {} not available", profile_name); + false + }) + .await + .unwrap_or(false) + } + + async fn restart_wire_plumber(&self) -> bool { + debug!("Entering restart_wire_plumber"); + info!("Restarting WirePlumber to rediscover A2DP profiles"); + let result = Command::new("systemctl") + .args(["--user", "restart", "wireplumber"]) + .output(); + + match result { + Ok(output) if output.status.success() => { + info!("WirePlumber restarted successfully"); + tokio::time::sleep(Duration::from_secs(2)).await; + true + } + _ => { + error!("Failed to restart WirePlumber. Do you use wireplumber?"); + false + } + } + } + + async fn get_audio_device_index(&self, mac: &str) -> Option { + debug!("Entering get_audio_device_index for MAC: {}", mac); + if mac.is_empty() { + debug!("MAC is empty, returning None"); + return None; + } + let mac_clone = mac.to_string(); + + tokio::task::spawn_blocking(move || { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-get_audio_device_index").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return None; + } + _ => {} + } + } + + let introspector = context.introspect(); + let card_info_list = Rc::new(RefCell::new(None)); + let op = introspector.get_card_info_list({ + let card_info_list = card_info_list.clone(); + let mut list = Vec::new(); + move |result| match result { + ListResult::Item(item) => { + let profiles = item + .profiles + .iter() + .map(|p| OwnedCardProfileInfo { + name: p.name.as_ref().map(|n| n.to_string()), + }) + .collect(); + list.push(OwnedCardInfo { + index: item.index, + proplist: item.proplist.clone(), + profiles, + }); + } + ListResult::End => *card_info_list.borrow_mut() = Some(list.clone()), + ListResult::Error => *card_info_list.borrow_mut() = None, + } + }); + + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(list) = card_info_list.borrow().as_ref() { + for card in list { + debug!("Checking card index {} for MAC match", card.index); + let props = &card.proplist; + if let Some(device_string) = props.get_str("device.string") + && device_string.contains(&mac_clone) + { + info!( + "Found audio device index for MAC {}: {}", + mac_clone, card.index + ); + return Some(card.index); + } + } + } + error!( + "No matching Bluetooth card found for MAC address: {}", + mac_clone + ); + None + }) + .await + .unwrap_or(None) + } + + pub async fn deactivate_a2dp_profile(&self) { + debug!("Entering deactivate_a2dp_profile"); + let mut state = self.state.lock().await; + + if state.device_index.is_none() { + state.device_index = self + .get_audio_device_index(&state.connected_device_mac) + .await; + } + + if state.connected_device_mac.is_empty() || state.device_index.is_none() { + warn!("Connected device MAC or index is empty, cannot deactivate A2DP profile"); + return; + } + let device_index = state.device_index.unwrap(); + drop(state); + + info!("Deactivating A2DP profile for AirPods by setting to off"); + + let success = + tokio::task::spawn_blocking(move || set_card_profile_sync(device_index, "off")) + .await + .unwrap_or(false); + + if success { + info!("Successfully deactivated A2DP profile"); + } else { + warn!("Failed to deactivate A2DP profile"); + } + } + + pub async fn handle_conversational_awareness(&self, status: u8) { + debug!( + "Entering handle_conversational_awareness with status: {}", + status + ); + + let mac; + { + let state = self.state.lock().await; + mac = state.connected_device_mac.clone(); + } + if mac.is_empty() { + debug!("No connected device MAC, skipping conversational awareness"); + return; + } + + let sink_name = get_sink_name_by_mac(&mac).await; + let sink = match sink_name { + Some(s) => s, + None => { + warn!( + "Could not find sink for MAC {}, skipping conversational awareness", + mac + ); + return; + } + }; + + let current_volume_opt = tokio::task::spawn_blocking({ + let sink = sink.clone(); + move || get_sink_volume_percent_by_name_sync(&sink) + }) + .await + .unwrap_or(None); + + match status { + 1 => { + let original = current_volume_opt.unwrap_or(0); + debug!("Conversation start (1). Current volume: {}", original); + { + let mut state = self.state.lock().await; + if !state.conv_conversation_started { + state.conv_original_volume = Some(original); + state.conv_conversation_started = true; + } else { + debug!( + "Conversation already started; not overwriting conv_original_volume" + ); + } + } + if original > 25 { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || transition_sink_volume(&sink_clone, 25)) + .await + .unwrap_or(false); + info!( + "Conversation start: lowered volume to 25% (original {})", + original + ); + } else { + debug!("Original volume {} <= 25, not reducing to 25", original); + } + } + 2 => { + let original = { + let state = self.state.lock().await; + state.conv_original_volume + }; + if let Some(orig) = original { + debug!("Conversation reduce (2). Original: {}", orig); + if orig > 15 { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || { + transition_sink_volume(&sink_clone, 15) + }) + .await + .unwrap_or(false); + info!( + "Conversation reduce: lowered volume to 15% (original {})", + orig + ); + } else { + debug!("Original {} <= 15, not reducing to 15", orig); + } + } else { + debug!("No original volume known for status 2, skipping"); + } + } + 3 => { + let maybe_orig = { + let state = self.state.lock().await; + (state.conv_conversation_started, state.conv_original_volume) + }; + if !maybe_orig.0 { + debug!("Received status 3 but conversation was not started; ignoring increase"); + return; + } + if let Some(orig) = maybe_orig.1 { + let target = if orig > 25 { 25 } else { orig }; + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || { + transition_sink_volume(&sink_clone, target) + }) + .await + .unwrap_or(false); + info!( + "Conversation partial increase (3): set volume to {} (original {})", + target, orig + ); + } else if let Some(orig_from_current) = current_volume_opt { + let target = if orig_from_current > 25 { + 25 + } else { + orig_from_current + }; + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || { + transition_sink_volume(&sink_clone, target) + }) + .await + .unwrap_or(false); + info!( + "Conversation partial increase (3) with fallback current: set volume to {} (measured {})", + target, orig_from_current + ); + } else { + debug!("No original volume known for status 3, skipping"); + } + } + 4 => { + let mut maybe_original = None; + { + let mut state = self.state.lock().await; + if state.conv_conversation_started { + maybe_original = state.conv_original_volume; + state.conv_original_volume = None; + state.conv_conversation_started = false; + } else { + debug!( + "Received status 4 but conversation was not started; ignoring restore" + ); + return; + } + } + if let Some(orig) = maybe_original { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || transition_sink_volume(&sink_clone, orig)) + .await + .unwrap_or(false); + info!("Conversation end (4): restored volume to original {}", orig); + } else { + debug!("No stored original volume to restore to on status 4"); + } + } + 6 => { + let mut maybe_original = None; + { + let mut state = self.state.lock().await; + if state.conv_conversation_started { + maybe_original = state.conv_original_volume; + state.conv_original_volume = None; + state.conv_conversation_started = false; + } else { + debug!( + "Received status 6 but conversation was not started; ignoring restore" + ); + return; + } + } + if let Some(orig) = maybe_original { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || transition_sink_volume(&sink_clone, orig)) + .await + .unwrap_or(false); + info!("Conversation end (6): restored volume to original {}", orig); + } else { + debug!("No stored original volume to restore to on status 6"); + } + } + 7 => { + let mut maybe_original = None; + { + let mut state = self.state.lock().await; + if state.conv_conversation_started { + maybe_original = state.conv_original_volume; + state.conv_original_volume = None; + state.conv_conversation_started = false; + } else { + debug!( + "Received status 7 but conversation was not started; ignoring restore" + ); + return; + } + } + if let Some(orig) = maybe_original { + let sink_clone = sink.clone(); + tokio::task::spawn_blocking(move || transition_sink_volume(&sink_clone, orig)) + .await + .unwrap_or(false); + info!("Conversation end (7): restored volume to original {}", orig); + } else { + debug!("No stored original volume to restore to on status 7"); + } + } + _ => { + debug!("Unknown conversational awareness status: {}", status); + } + } + } +} + +fn get_sink_volume_percent_by_name_sync(sink_name: &str) -> Option { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-get_sink_volume").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return None; + } + _ => {} + } + } + + let introspector = context.introspect(); + let sink_info_option = Rc::new(RefCell::new(None)); + let op = introspector.get_sink_info_by_name(sink_name, { + let sink_info_option = sink_info_option.clone(); + move |result: ListResult<&SinkInfo>| { + if let ListResult::Item(item) = result { + let owned_item = OwnedSinkInfo { + name: item.name.as_ref().map(|s| s.to_string()), + proplist: item.proplist.clone(), + volume: item.volume, + }; + *sink_info_option.borrow_mut() = Some(owned_item); + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(sink_info) = sink_info_option.borrow().as_ref() { + let channels = sink_info.volume.len(); + if channels == 0 { + return None; + } + let total: f64 = sink_info.volume.get().iter().map(|v| v.0 as f64).sum(); + let average_raw = total / channels as f64; + let percent = ((average_raw / Volume::NORMAL.0 as f64) * 100.0).round() as u32; + Some(percent) + } else { + None + } +} + +fn set_card_profile_sync(card_index: u32, profile_name: &str) -> bool { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-set_card_profile").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return false; + } + _ => {} + } + } + + let mut introspector = context.introspect(); + let op = introspector.set_card_profile_by_index(card_index, profile_name, None); + + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + true +} + +pub fn transition_sink_volume(sink_name: &str, target_volume: u32) -> bool { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-transition_sink_volume").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return false; + } + _ => {} + } + } + + let mut introspector = context.introspect(); + let sink_info_option = Rc::new(RefCell::new(None)); + let op = introspector.get_sink_info_by_name(sink_name, { + let sink_info_option = sink_info_option.clone(); + move |result: ListResult<&SinkInfo>| { + if let ListResult::Item(item) = result { + let owned_item = OwnedSinkInfo { + name: item.name.as_ref().map(|s| s.to_string()), + proplist: item.proplist.clone(), + volume: item.volume, + }; + *sink_info_option.borrow_mut() = Some(owned_item); + } + } + }); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + if let Some(sink_info) = sink_info_option.borrow().as_ref() { + let channels = sink_info.volume.len(); + let mut new_volumes = ChannelVolumes::default(); + let raw = + (((target_volume as f64) / 100.0) * Volume::NORMAL.0.as_f64().unwrap()).round() as u32; + let vol = Volume(raw); + new_volumes.set(channels, vol); + + let op = introspector.set_sink_volume_by_name(sink_name, &new_volumes, None); + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + true + } else { + error!("Sink not found: {}", sink_name); + false + } +} + +async fn get_sink_name_by_mac(mac: &str) -> Option { + debug!("Entering get_sink_name_by_mac for MAC: {}", mac); + if mac.is_empty() { + debug!("MAC is empty, returning None"); + return None; + } + let mac_clone = mac.to_string(); + + tokio::task::spawn_blocking(move || { + let mut mainloop = Mainloop::new().unwrap(); + let mut context = Context::new(&mainloop, "LibrePods-get_sink_name_by_mac").unwrap(); + context + .connect(None, ContextFlagSet::NOAUTOSPAWN, None) + .unwrap(); + + loop { + match mainloop.iterate(false) { + _ if context.get_state() == libpulse_binding::context::State::Ready => break, + _ if context.get_state() == libpulse_binding::context::State::Failed + || context.get_state() == libpulse_binding::context::State::Terminated => + { + return None; + } + _ => {} + } + } + + let introspector = context.introspect(); + let sink_info_list = Rc::new(RefCell::new(Some(Vec::new()))); + let op = introspector.get_sink_info_list({ + let sink_info_list = sink_info_list.clone(); + move |result: ListResult<&SinkInfo>| { + if let ListResult::Item(item) = result { + let owned_item = OwnedSinkInfo { + name: item.name.as_ref().map(|s| s.to_string()), + proplist: item.proplist.clone(), + volume: item.volume, + }; + sink_info_list + .borrow_mut() + .as_mut() + .unwrap() + .push(owned_item); + } + } + }); + + while op.get_state() == OperationState::Running { + mainloop.iterate(false); + } + mainloop.quit(Retval(0)); + + if let Some(list) = sink_info_list.borrow().as_ref() { + for sink in list { + if let Some(device_string) = sink.proplist.get_str("device.string") + && device_string + .to_uppercase() + .contains(&mac_clone.to_uppercase()) + && let Some(name) = &sink.name + { + info!("Found sink name for MAC {}: {}", mac_clone, name); + return Some(name.to_string()); + } + if let Some(bluez_path) = sink.proplist.get_str("bluez.path") { + let mac_from_path = bluez_path + .split('/') + .next_back() + .unwrap_or("") + .replace("dev_", "") + .replace('_', ":"); + if mac_from_path.eq_ignore_ascii_case(&mac_clone) + && let Some(name) = &sink.name + { + info!("Found sink name for MAC {}: {}", mac_clone, name); + return Some(name.to_string()); + } + } + } + } + error!("No matching sink found for MAC address: {}", mac_clone); + None + }) + .await + .unwrap_or(None) +} diff --git a/linux-rust/src/ui/airpods.rs b/linux-rust/src/ui/airpods.rs new file mode 100644 index 00000000..e68469ac --- /dev/null +++ b/linux-rust/src/ui/airpods.rs @@ -0,0 +1,533 @@ +use crate::bluetooth::aacp::{AACPManager, ControlCommandIdentifiers}; +use iced::Alignment::End; +use iced::border::Radius; +use iced::overlay::menu; +use iced::widget::button::Style; +use iced::widget::rule::FillMode; +use iced::widget::{ + Rule, Space, button, column, combo_box, container, row, rule, text, text_input, toggler, +}; +use iced::{Background, Border, Center, Color, Length, Padding, Theme}; +use log::error; +use std::collections::HashMap; +use std::sync::Arc; +use std::thread; +use tokio::runtime::Runtime; +// use crate::bluetooth::att::ATTManager; +use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation, DeviceState}; +use crate::ui::window::Message; + +pub fn airpods_view<'a>( + mac: &'a str, + devices_list: &HashMap, + state: &'a AirPodsState, + aacp_manager: Arc, + // att_manager: Arc +) -> iced::widget::Container<'a, Message> { + let mac = mac.to_string(); + // order: name, noise control, press and hold config, call controls (not sure if why it might be needed, adding it just in case), audio (personalized volume, conversational awareness, adaptive audio slider), connection settings, microphone, head gestures (not adding this), off listening mode, device information + + let aacp_manager_for_rename = aacp_manager.clone(); + let rename_input = container( + row![ + Space::with_width(10), + text("Name").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text_input("", &state.device_name) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme, _status| { + text_input::Style { + background: Background::Color(Color::TRANSPARENT), + border: Default::default(), + icon: Default::default(), + placeholder: theme.palette().text.scale_alpha(0.7), + value: theme.palette().text, + selection: Default::default(), + } + }) + .align_x(End) + .on_input({ + let mac = mac.clone(); + let state = state.clone(); + move |new_name| { + let aacp_manager = aacp_manager_for_rename.clone(); + run_async_in_thread({ + let new_name = new_name.clone(); + async move { + aacp_manager + .send_rename_packet(&new_name) + .await + .expect("Failed to send rename packet"); + } + }); + let mut state = state.clone(); + state.device_name = new_name.clone(); + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + } + }) + ] + .align_y(Center), + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }); + + let listening_mode = container( + row![ + text("Listening Mode").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + { + let state_clone = state.clone(); + let mac = mac.clone(); + // this combo_box doesn't go really well with the design, but I am not writing my own dropdown menu for this + combo_box( + &state.noise_control_state, + "Select Listening Mode", + Some(&state.noise_control_mode.clone()), + { + let aacp_manager = aacp_manager.clone(); + move |selected_mode| { + let aacp_manager = aacp_manager.clone(); + let selected_mode_c = selected_mode.clone(); + run_async_in_thread(async move { + aacp_manager + .send_control_command( + ControlCommandIdentifiers::ListeningMode, + &[selected_mode_c.to_byte()], + ) + .await + .expect("Failed to send Noise Control Mode command"); + }); + let mut state = state_clone.clone(); + state.noise_control_mode = selected_mode.clone(); + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + } + }, + ) + .width(Length::from(200)) + .input_style(|theme: &Theme, _status| text_input::Style { + background: Background::Color(theme.palette().primary.scale_alpha(0.2)), + border: Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.3), + radius: Radius::from(4.0), + }, + icon: Default::default(), + placeholder: theme.palette().text, + value: theme.palette().text, + selection: Default::default(), + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .menu_style(|theme: &Theme| menu::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(4.0), + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color( + theme.palette().primary.scale_alpha(0.3), + ), + }) + } + ] + .align_y(Center), + ) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }); + + let mac_audio = mac.clone(); + let mac_information = mac.clone(); + + let audio_settings_col = column![ + container( + text("Audio Settings").size(18).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + ) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }), + + container( + column![ + { + let aacp_manager_pv = aacp_manager.clone(); + row![ + column![ + text("Personalized Volume").size(16), + text("Adjusts the volume in response to your environment.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill), + ].width(Length::Fill), + toggler(state.personalized_volume_enabled) + .on_toggle( + { + let mac = mac_audio.clone(); + let state = state.clone(); + move |is_enabled| { + let aacp_manager = aacp_manager_pv.clone(); + let mac = mac.clone(); + run_async_in_thread( + async move { + aacp_manager.send_control_command( + ControlCommandIdentifiers::AdaptiveVolumeConfig, + if is_enabled { &[0x01] } else { &[0x02] } + ).await.expect("Failed to send Personalized Volume command"); + } + ); + let mut state = state.clone(); + state.personalized_volume_enabled = is_enabled; + Message::StateChanged(mac, DeviceState::AirPods(state)) + } + } + ) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(8) + }, + Rule::horizontal(8).style( + |theme: &Theme| { + rule::Style { + color: theme.palette().text, + width: 1, + radius: Radius::from(12), + fill_mode: FillMode::Full + } + } + ), + { + let aacp_manager_conv_detect = aacp_manager.clone(); + row![ + column![ + text("Conversation Awareness").size(16), + text("Lowers the volume of your audio when it detects that you are speaking.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill), + ].width(Length::Fill), + toggler(state.conversation_awareness_enabled) + .on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_conv_detect.clone(); + run_async_in_thread( + async move { + aacp_manager.send_control_command( + ControlCommandIdentifiers::ConversationDetectConfig, + if is_enabled { &[0x01] } else { &[0x02] } + ).await.expect("Failed to send Conversation Awareness command"); + } + ); + let mut state = state.clone(); + state.conversation_awareness_enabled = is_enabled; + Message::StateChanged(mac_audio.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(8) + } + ] + .spacing(4) + .padding(8) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + ]; + + let off_listening_mode_toggle = { + let aacp_manager_olm = aacp_manager.clone(); + let mac = mac.clone(); + container(row![ + column![ + text("Off Listening Mode").size(16), + text("When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(state.allow_off_mode) + .on_toggle(move |is_enabled| { + let aacp_manager = aacp_manager_olm.clone(); + run_async_in_thread( + async move { + aacp_manager.send_control_command( + ControlCommandIdentifiers::AllowOffOption, + if is_enabled { &[0x01] } else { &[0x02] } + ).await.expect("Failed to send Off Listening Mode command"); + } + ); + let mut state = state.clone(); + state.allow_off_mode = is_enabled; + Message::StateChanged(mac.to_string(), DeviceState::AirPods(state)) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(8) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + }; + + let mut information_col = column![]; + if let Some(device) = devices_list.get(mac_information.as_str()) { + if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information { + let info_rows = column![ + row![ + text("Model Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(airpods_info.model_number.clone()).size(16) + ], + row![ + text("Manufacturer").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(airpods_info.manufacturer.clone()).size(16) + ], + row![ + text("Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + button(text(airpods_info.serial_number.clone()).size(16)) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::CopyToClipboard(airpods_info.serial_number.clone())) + ], + row![ + text("Left Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + button(text(airpods_info.left_serial_number.clone()).size(16)) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::CopyToClipboard( + airpods_info.left_serial_number.clone() + )) + ], + row![ + text("Right Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + button(text(airpods_info.right_serial_number.clone()).size(16)) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(Color::TRANSPARENT)); + style + }) + .padding(0) + .on_press(Message::CopyToClipboard( + airpods_info.right_serial_number.clone() + )) + ], + row![ + text("Version 1").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(airpods_info.version1.clone()).size(16) + ], + row![ + text("Version 2").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(airpods_info.version2.clone()).size(16) + ], + row![ + text("Version 3").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(airpods_info.version3.clone()).size(16) + ] + ] + .spacing(4) + .padding(8); + + information_col = column![ + container(text("Device Information").size(18).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + })) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }), + container(info_rows) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }) + ]; + } else { + error!( + "Expected AirPodsInformation for device {}, got something else", + mac.clone() + ); + } + } + + container(column![ + rename_input, + Space::with_height(Length::from(20)), + listening_mode, + Space::with_height(Length::from(20)), + audio_settings_col, + Space::with_height(Length::from(20)), + off_listening_mode_toggle, + Space::with_height(Length::from(20)), + information_col + ]) + .padding(20) + .center_x(Length::Fill) + .height(Length::Fill) +} + +fn run_async_in_thread(fut: F) +where + F: Future + Send + 'static, +{ + thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(fut); + }); +} diff --git a/linux-rust/src/ui/messages.rs b/linux-rust/src/ui/messages.rs new file mode 100644 index 00000000..c72aeb9c --- /dev/null +++ b/linux-rust/src/ui/messages.rs @@ -0,0 +1,11 @@ +use crate::bluetooth::aacp::AACPEvent; + +#[derive(Debug, Clone)] +pub enum BluetoothUIMessage { + OpenWindow, + DeviceConnected(String), // mac + DeviceDisconnected(String), // mac + AACPUIEvent(String, AACPEvent), // mac, event + ATTNotification(String, u16, Vec), // mac, handle, data + NoOp, +} diff --git a/linux-rust/src/ui/mod.rs b/linux-rust/src/ui/mod.rs new file mode 100644 index 00000000..0fff8630 --- /dev/null +++ b/linux-rust/src/ui/mod.rs @@ -0,0 +1,5 @@ +mod airpods; +pub mod messages; +mod nothing; +pub mod tray; +pub mod window; diff --git a/linux-rust/src/ui/nothing.rs b/linux-rust/src/ui/nothing.rs new file mode 100644 index 00000000..072931d7 --- /dev/null +++ b/linux-rust/src/ui/nothing.rs @@ -0,0 +1,188 @@ +use crate::bluetooth::att::{ATTHandles, ATTManager}; +use crate::devices::enums::{DeviceData, DeviceInformation, DeviceState, NothingState}; +use crate::ui::window::Message; +use iced::border::Radius; +use iced::overlay::menu; +use iced::widget::combo_box; +use iced::widget::text_input; +use iced::widget::{Space, column, container, row, text}; +use iced::{Background, Border, Length, Theme}; +use std::collections::HashMap; +use std::sync::Arc; +use std::thread; +use tokio::runtime::Runtime; + +pub fn nothing_view<'a>( + mac: &'a str, + devices_list: &HashMap, + state: &'a NothingState, + att_manager: Arc, +) -> iced::widget::Container<'a, Message> { + let mut information_col = iced::widget::column![]; + let mac = mac.to_string(); + if let Some(device) = devices_list.get(mac.as_str()) + && let Some(DeviceInformation::Nothing(ref nothing_info)) = device.information + { + information_col = information_col + .push(text("Device Information").size(18).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + })) + .push(Space::with_height(iced::Length::from(10))) + .push(iced::widget::row![ + text("Serial Number").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(nothing_info.serial_number.clone()).size(16) + ]) + .push(iced::widget::row![ + text("Firmware Version").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + text(nothing_info.firmware_version.clone()).size(16) + ]); + } + + let noise_control_mode = container( + row![ + text("Noise Control Mode").size(16).style(|theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text); + style + }), + Space::with_width(Length::Fill), + { + let state_clone = state.clone(); + let mac = mac.clone(); + let att_manager_clone = att_manager.clone(); + combo_box( + &state.anc_mode_state, + "Select Noise Control Mode", + Some(&state.anc_mode.clone()), + { + move |selected_mode| { + let att_manager = att_manager_clone.clone(); + let selected_mode_c = selected_mode.clone(); + let mac_s = mac.clone(); + run_async_in_thread(async move { + if let Err(e) = att_manager + .write( + ATTHandles::NothingEverything, + &[ + 0x55, + 0x60, + 0x01, + 0x0F, + 0xF0, + 0x03, + 0x00, + 0x00, + 0x01, + selected_mode_c.to_byte(), + 0x00, + 0x00, + 0x00, + ], + ) + .await + { + log::error!( + "Failed to set noise cancellation mode for device {}: {}", + mac_s, + e + ); + } + }); + let mut state = state_clone.clone(); + state.anc_mode = selected_mode.clone(); + Message::StateChanged(mac.to_string(), DeviceState::Nothing(state)) + } + }, + ) + .width(Length::from(200)) + .input_style(|theme: &Theme, _status| text_input::Style { + background: Background::Color(theme.palette().primary.scale_alpha(0.2)), + border: Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.3), + radius: Radius::from(4.0), + }, + icon: Default::default(), + placeholder: theme.palette().text, + value: theme.palette().text, + selection: Default::default(), + }) + .padding(iced::Padding { + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .menu_style(|theme: &Theme| menu::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(4.0), + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color( + theme.palette().primary.scale_alpha(0.3), + ), + }) + } + ] + .align_y(iced::Alignment::Center), + ) + .padding(iced::Padding { + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + }); + + container(column![ + noise_control_mode, + Space::with_height(Length::from(20)), + container(information_col) + .style(|theme: &Theme| { + let mut style = container::Style::default(); + style.background = + Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(20); + style + }) + .padding(20) + ]) + .padding(20) + .center_x(Length::Fill) + .height(Length::Fill) +} + +fn run_async_in_thread(fut: F) +where + F: Future + Send + 'static, +{ + thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(fut); + }); +} diff --git a/linux-rust/src/ui/tray.rs b/linux-rust/src/ui/tray.rs new file mode 100644 index 00000000..b3adbc53 --- /dev/null +++ b/linux-rust/src/ui/tray.rs @@ -0,0 +1,301 @@ +// use ksni::TrayMethods; // provides the spawn method + +use ab_glyph::{Font, ScaleFont}; +use ksni::{Icon, ToolTip}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::bluetooth::aacp::{BatteryStatus, ControlCommandIdentifiers}; +use crate::ui::messages::BluetoothUIMessage; +use crate::utils::get_app_settings_path; + +#[derive(Debug)] +pub struct MyTray { + pub conversation_detect_enabled: Option, + pub battery_headphone: Option, + pub battery_headphone_status: Option, + pub battery_l: Option, + pub battery_l_status: Option, + pub battery_r: Option, + pub battery_r_status: Option, + pub battery_c: Option, + pub battery_c_status: Option, + pub connected: bool, + pub listening_mode: Option, + pub allow_off_option: Option, + pub command_tx: Option)>>, + pub ui_tx: Option>, +} + +impl ksni::Tray for MyTray { + fn id(&self) -> String { + env!("CARGO_PKG_NAME").into() + } + fn title(&self) -> String { + "AirPods".into() + } + fn icon_pixmap(&self) -> Vec { + let text = { + let mut levels: Vec = Vec::new(); + if let Some(h) = self.battery_headphone { + if self.battery_headphone_status != Some(BatteryStatus::Disconnected) { + levels.push(h); + } + } else { + if let Some(l) = self.battery_l + && self.battery_l_status != Some(BatteryStatus::Disconnected) + { + levels.push(l); + } + if let Some(r) = self.battery_r + && self.battery_r_status != Some(BatteryStatus::Disconnected) + { + levels.push(r); + } + // if let Some(c) = self.battery_c { + // if self.battery_c_status != Some(BatteryStatus::Disconnected) { + // levels.push(c); + // } + // } + } + let min_battery = levels.iter().min().copied(); + if let Some(b) = min_battery { + format!("{}", b) + } else { + "?".to_string() + } + }; + let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging)) + || matches!(self.battery_r_status, Some(BatteryStatus::Charging)); + let app_settings_path = get_app_settings_path(); + let settings = std::fs::read_to_string(&app_settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + let text_mode = settings + .clone() + .and_then(|v| v.get("tray_text_mode").cloned()) + .and_then(|ttm| serde_json::from_value(ttm).ok()) + .unwrap_or(false); + let icon = generate_icon(&text, text_mode, any_bud_charging); + vec![icon] + } + fn tool_tip(&self) -> ToolTip { + let format_component = + |label: &str, level: Option, status: Option| -> String { + match status { + Some(BatteryStatus::Disconnected) => format!("{}: -", label), + _ => { + let pct = level.map(|b| format!("{}%", b)).unwrap_or("?".to_string()); + let suffix = if status == Some(BatteryStatus::Charging) { + "⚡" + } else { + "" + }; + format!("{}: {}{}", label, pct, suffix) + } + } + }; + + let l = format_component("L", self.battery_l, self.battery_l_status); + let r = format_component("R", self.battery_r, self.battery_r_status); + let c = format_component("C", self.battery_c, self.battery_c_status); + + ToolTip { + icon_name: "".to_string(), + icon_pixmap: vec![], + title: "Battery Status".to_string(), + description: format!("{} {} {}", l, r, c), + } + } + fn menu(&self) -> Vec> { + use ksni::menu::*; + let allow_off = self.allow_off_option == Some(0x01); + let options = if allow_off { + vec![ + ("Off", 0x01), + ("Noise Cancellation", 0x02), + ("Transparency", 0x03), + ("Adaptive", 0x04), + ] + } else { + vec![ + ("Noise Cancellation", 0x02), + ("Transparency", 0x03), + ("Adaptive", 0x04), + ] + }; + let selected = self + .listening_mode + .and_then(|mode| options.iter().position(|&(_, val)| val == mode)) + .unwrap_or(0); + let options_clone = options.clone(); + vec![ + StandardItem { + label: "Open Window".into(), + icon_name: "window-new".into(), + activate: Box::new(|this: &mut Self| { + if let Some(tx) = &this.ui_tx { + let _ = tx.send(BluetoothUIMessage::OpenWindow); + } + }), + ..Default::default() + } + .into(), + RadioGroup { + selected, + select: Box::new(move |this: &mut Self, current| { + if let Some(tx) = &this.command_tx { + let value = options_clone + .get(current) + .map(|&(_, val)| val) + .unwrap_or(0x02); + let _ = tx.send((ControlCommandIdentifiers::ListeningMode, vec![value])); + } + }), + options: options + .into_iter() + .map(|(label, _)| RadioItem { + label: label.into(), + ..Default::default() + }) + .collect(), + ..Default::default() + } + .into(), + MenuItem::Separator, + CheckmarkItem { + label: "Conversation Detection".into(), + checked: self.conversation_detect_enabled.unwrap_or(false), + enabled: self.conversation_detect_enabled.is_some(), + activate: Box::new(|this: &mut Self| { + if let Some(tx) = &this.command_tx + && let Some(is_enabled) = this.conversation_detect_enabled + { + let new_state = !is_enabled; + let value = if !new_state { 0x02 } else { 0x01 }; + let _ = tx.send(( + ControlCommandIdentifiers::ConversationDetectConfig, + vec![value], + )); + this.conversation_detect_enabled = Some(new_state); + } + }), + ..Default::default() + } + .into(), + StandardItem { + label: "Exit".into(), + icon_name: "application-exit".into(), + activate: Box::new(|_| std::process::exit(0)), + ..Default::default() + } + .into(), + ] + } +} + +fn generate_icon(text: &str, text_mode: bool, charging: bool) -> Icon { + use ab_glyph::{FontRef, PxScale}; + use image::{ImageBuffer, Rgba}; + use imageproc::drawing::draw_text_mut; + + let width = 64; + let height = 64; + + let mut img = ImageBuffer::from_fn(width, height, |_, _| Rgba([0u8, 0u8, 0u8, 0u8])); + + let font_data = include_bytes!("../../assets/font/DejaVuSans.ttf"); + let font = match FontRef::try_from_slice(font_data) { + Ok(f) => f, + Err(_) => { + return Icon { + width: width as i32, + height: height as i32, + data: vec![0u8; (width * height * 4) as usize], + }; + } + }; + if !text_mode { + let percentage = text.parse::().unwrap_or(0.0) / 100.0; + + let center_x = width as f32 / 2.0; + let center_y = height as f32 / 2.0; + let inner_radius = 22.0; + let outer_radius = 28.0; + + // ring background + for y in 0..height { + for x in 0..width { + let dx = x as f32 - center_x; + let dy = y as f32 - center_y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > inner_radius && dist <= outer_radius { + img.put_pixel(x, y, Rgba([128u8, 128u8, 128u8, 255u8])); + } + } + } + + // ring + for y in 0..height { + for x in 0..width { + let dx = x as f32 - center_x; + let dy = y as f32 - center_y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > inner_radius && dist <= outer_radius { + let angle = dy.atan2(dx); + let angle_from_top = + (angle + std::f32::consts::PI / 2.0).rem_euclid(2.0 * std::f32::consts::PI); + if angle_from_top <= percentage * 2.0 * std::f32::consts::PI { + img.put_pixel(x, y, Rgba([0u8, 255u8, 0u8, 255u8])); + } + } + } + } + if charging { + let emoji = "⚡"; + let scale = PxScale::from(48.0); + let color = Rgba([0u8, 255u8, 0u8, 255u8]); + let scaled_font = font.as_scaled(scale); + let mut emoji_width = 0.0; + for c in emoji.chars() { + let glyph_id = font.glyph_id(c); + emoji_width += scaled_font.h_advance(glyph_id); + } + let x = ((width as f32 - emoji_width) / 2.0).max(0.0) as i32; + let y = ((height as f32 - scale.y) / 2.0).max(0.0) as i32; + draw_text_mut(&mut img, color, x, y, scale, &font, emoji); + } + } else { + // battery text + let scale = PxScale::from(48.0); + let color = if charging { + Rgba([0u8, 255u8, 0u8, 255u8]) + } else { + Rgba([255u8, 255u8, 255u8, 255u8]) + }; + + let scaled_font = font.as_scaled(scale); + let mut text_width = 0.0; + for c in text.chars() { + let glyph_id = font.glyph_id(c); + text_width += scaled_font.h_advance(glyph_id); + } + let x = ((width as f32 - text_width) / 2.0).max(0.0) as i32; + let y = ((height as f32 - scale.y) / 2.0).max(0.0) as i32; + + draw_text_mut(&mut img, color, x, y, scale, &font, text); + } + + let mut data = Vec::with_capacity((width * height * 4) as usize); + for pixel in img.pixels() { + data.push(pixel[3]); + data.push(pixel[0]); + data.push(pixel[1]); + data.push(pixel[2]); + } + + Icon { + width: width as i32, + height: height as i32, + data, + } +} diff --git a/linux-rust/src/ui/window.rs b/linux-rust/src/ui/window.rs new file mode 100644 index 00000000..4f89faa9 --- /dev/null +++ b/linux-rust/src/ui/window.rs @@ -0,0 +1,1214 @@ +use crate::bluetooth::aacp::{ + AACPEvent, BatteryComponent, BatteryStatus, ControlCommandIdentifiers, +}; +use crate::bluetooth::managers::DeviceManagers; +use crate::devices::enums::{ + AirPodsNoiseControlMode, AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode, + NothingState, +}; +use crate::ui::airpods::airpods_view; +use crate::ui::messages::BluetoothUIMessage; +use crate::ui::nothing::nothing_view; +use crate::utils::{MyTheme, get_app_settings_path, get_devices_path}; +use bluer::{Address, Session}; +use iced::border::Radius; +use iced::overlay::menu; +use iced::widget::button::Style; +use iced::widget::rule::FillMode; +use iced::widget::{ + Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text, + text_input, toggler, vertical_rule, +}; +use iced::{ + Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme, + daemon, window, +}; +use log::{debug, error}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::{Mutex, RwLock}; + +pub fn start_ui( + ui_rx: UnboundedReceiver, + start_minimized: bool, + device_managers: Arc>>, +) -> iced::Result { + daemon(App::title, App::update, App::view) + .subscription(App::subscription) + .theme(App::theme) + .font(include_bytes!("../../assets/font/sf_pro.otf").as_slice()) + .default_font(Font::with_name("SF Pro Text")) + .run_with(move || App::new(ui_rx, start_minimized, device_managers)) +} + +pub struct App { + window: Option, + panes: pane_grid::State, + selected_tab: Tab, + theme_state: combo_box::State, + selected_theme: MyTheme, + ui_rx: Arc>>, + bluetooth_state: BluetoothState, + paired_devices: HashMap, + device_states: HashMap, + device_managers: Arc>>, + pending_add_device: Option<(String, Address)>, + device_type_state: combo_box::State, + selected_device_type: Option, + tray_text_mode: bool, +} + +pub struct BluetoothState { + connected_devices: Vec, +} + +impl BluetoothState { + pub fn new() -> Self { + Self { + connected_devices: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + WindowOpened(window::Id), + WindowClosed(window::Id), + Resized(pane_grid::ResizeEvent), + SelectTab(Tab), + ThemeSelected(MyTheme), + CopyToClipboard(String), + BluetoothMessage(BluetoothUIMessage), + ShowNewDialogTab, + GotPairedDevices(HashMap), + StartAddDevice(String, Address), + SelectDeviceType(DeviceType), + ConfirmAddDevice, + CancelAddDevice, + StateChanged(String, DeviceState), + TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Tab { + Device(String), + Settings, + AddDevice, +} + +#[derive(Clone, Copy)] +pub enum Pane { + Sidebar, + Content, +} + +impl App { + pub fn new( + ui_rx: UnboundedReceiver, + start_minimized: bool, + device_managers: Arc>>, + ) -> (Self, Task) { + let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar); + let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content); + panes.resize(split.unwrap().1, 0.2); + + let ui_rx = Arc::new(Mutex::new(ui_rx)); + + let wait_task = Task::perform(wait_for_message(Arc::clone(&ui_rx)), |msg| msg); + + let (window, open_task) = if start_minimized { + (None, Task::none()) + } else { + let mut settings = window::Settings::default(); + settings.min_size = Some(Size::new(400.0, 300.0)); + settings.icon = window::icon::from_file("../../assets/icon.png").ok(); + let (id, open) = window::open(settings); + (Some(id), open.map(Message::WindowOpened)) + }; + + let app_settings_path = get_app_settings_path(); + let settings = std::fs::read_to_string(&app_settings_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + let selected_theme = settings + .clone() + .and_then(|v| v.get("theme").cloned()) + .and_then(|t| serde_json::from_value(t).ok()) + .unwrap_or(MyTheme::Dark); + let tray_text_mode = settings + .clone() + .and_then(|v| v.get("tray_text_mode").cloned()) + .and_then(|ttm| serde_json::from_value(ttm).ok()) + .unwrap_or(false); + + let bluetooth_state = BluetoothState::new(); + + // let dummy_device_state = DeviceState::AirPods(AirPodsState { + // conversation_awareness_enabled: false, + // }); + // let device_states = HashMap::from([ + // ("28:2D:7F:C2:05:5B".to_string(), dummy_device_state), + // ]); + + let device_states = HashMap::new(); + ( + Self { + window, + panes, + selected_tab: Tab::Device("none".to_string()), + theme_state: combo_box::State::new(vec![ + MyTheme::Light, + MyTheme::Dark, + MyTheme::Dracula, + MyTheme::Nord, + MyTheme::SolarizedLight, + MyTheme::SolarizedDark, + MyTheme::GruvboxLight, + MyTheme::GruvboxDark, + MyTheme::CatppuccinLatte, + MyTheme::CatppuccinFrappe, + MyTheme::CatppuccinMacchiato, + MyTheme::CatppuccinMocha, + MyTheme::TokyoNight, + MyTheme::TokyoNightStorm, + MyTheme::TokyoNightLight, + MyTheme::KanagawaWave, + MyTheme::KanagawaDragon, + MyTheme::KanagawaLotus, + MyTheme::Moonfly, + MyTheme::Nightfly, + MyTheme::Oxocarbon, + MyTheme::Ferra, + ]), + selected_theme, + ui_rx, + bluetooth_state, + paired_devices: HashMap::new(), + device_states, + pending_add_device: None, + device_type_state: combo_box::State::new(vec![DeviceType::Nothing]), + selected_device_type: None, + device_managers, + tray_text_mode, + }, + Task::batch(vec![open_task, wait_task]), + ) + } + + fn title(&self, _id: window::Id) -> String { + "LibrePods".to_string() + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::WindowOpened(id) => { + self.window = Some(id); + Task::none() + } + Message::WindowClosed(id) => { + if self.window == Some(id) { + self.window = None; + } + Task::none() + } + Message::Resized(event) => { + self.panes.resize(event.split, event.ratio); + Task::none() + } + Message::SelectTab(tab) => { + self.selected_tab = tab; + Task::none() + } + Message::ThemeSelected(theme) => { + self.selected_theme = theme; + let app_settings_path = get_app_settings_path(); + let settings = serde_json::json!({"theme": self.selected_theme, "tray_text_mode": self.tray_text_mode}); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + Message::CopyToClipboard(data) => iced::clipboard::write(data), + Message::BluetoothMessage(ui_message) => { + match ui_message { + BluetoothUIMessage::NoOp => { + let ui_rx = Arc::clone(&self.ui_rx); + + Task::perform(wait_for_message(ui_rx), |msg| msg) + } + BluetoothUIMessage::OpenWindow => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg); + debug!("Opening main window..."); + if let Some(window_id) = self.window { + Task::batch(vec![window::gain_focus(window_id), wait_task]) + } else { + let mut settings = window::Settings::default(); + settings.min_size = Some(Size::new(400.0, 300.0)); + settings.icon = window::icon::from_file("../../assets/icon.png").ok(); + let (new_window_task, open_task) = window::open(settings); + self.window = Some(new_window_task); + Task::batch(vec![open_task.map(Message::WindowOpened), wait_task]) + } + } + BluetoothUIMessage::DeviceConnected(mac) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg); + debug!( + "Device connected: {}. Adding to connected devices list", + mac + ); + let mut already_connected = false; + for device in &self.bluetooth_state.connected_devices { + if device == &mac { + already_connected = true; + break; + } + } + if !already_connected { + self.bluetooth_state.connected_devices.push(mac.clone()); + } + + // self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState { + // conversation_awareness_enabled: false, + // })); + + let type_ = { + let devices_json = std::fs::read_to_string(get_devices_path()) + .unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = + serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list.get(&mac).map(|d| d.type_.clone()) + }; + match type_ { + Some(DeviceType::AirPods) => { + let device_managers = self.device_managers.blocking_read(); + let device_manager = device_managers.get(&mac).unwrap(); + let aacp_manager = device_manager.get_aacp().unwrap(); + let aacp_manager_state = aacp_manager.state.clone(); + let state = aacp_manager_state.blocking_lock(); + debug!("AACP manager found for AirPods device {}", mac); + let device_name = { + let devices_json = std::fs::read_to_string(get_devices_path()) + .unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = + serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list + .get(&mac) + .map(|d| d.name.clone()) + .unwrap_or_else(|| "Unknown Device".to_string()) + }; + self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState { + device_name, + battery: state.battery_info.clone(), + noise_control_mode: state.control_command_status_list.iter().find_map(|status| { + if status.identifier == ControlCommandIdentifiers::ListeningMode { + status.value.first().map(AirPodsNoiseControlMode::from_byte) + } else { + None + } + }).unwrap_or(AirPodsNoiseControlMode::Transparency), + noise_control_state: combo_box::State::new( + { + let mut modes = vec![ + AirPodsNoiseControlMode::Transparency, + AirPodsNoiseControlMode::NoiseCancellation, + AirPodsNoiseControlMode::Adaptive + ]; + if state.control_command_status_list.iter().any(|status| { + status.identifier == ControlCommandIdentifiers::AllowOffOption && + matches!(status.value.as_slice(), [0x01]) + }) { + modes.insert(0, AirPodsNoiseControlMode::Off); + } + modes + } + ), + conversation_awareness_enabled: state.control_command_status_list.iter().any(|status| { + status.identifier == ControlCommandIdentifiers::ConversationDetectConfig && + matches!(status.value.as_slice(), [0x01]) + }), + personalized_volume_enabled: state.control_command_status_list.iter().any(|status| { + status.identifier == ControlCommandIdentifiers::AdaptiveVolumeConfig && + matches!(status.value.as_slice(), [0x01]) + }), + allow_off_mode: state.control_command_status_list.iter().any(|status| { + status.identifier == ControlCommandIdentifiers::AllowOffOption && + matches!(status.value.as_slice(), [0x01]) + }), + })); + } + Some(DeviceType::Nothing) => { + self.device_states.insert( + mac.clone(), + DeviceState::Nothing(NothingState { + anc_mode: NothingAncMode::Off, + anc_mode_state: combo_box::State::new(vec![ + NothingAncMode::Off, + NothingAncMode::Transparency, + NothingAncMode::AdaptiveNoiseCancellation, + NothingAncMode::LowNoiseCancellation, + NothingAncMode::MidNoiseCancellation, + NothingAncMode::HighNoiseCancellation, + ]), + }), + ); + } + _ => {} + } + + Task::batch(vec![wait_task]) + } + BluetoothUIMessage::DeviceDisconnected(mac) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg); + debug!("Device disconnected: {}", mac); + + self.device_states.remove(&mac); + Task::batch(vec![wait_task]) + } + BluetoothUIMessage::AACPUIEvent(mac, event) => { + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg); + debug!("AACP UI Event for {}: {:?}", mac, event); + match event { + AACPEvent::ControlCommand(status) => match status.identifier { + ControlCommandIdentifiers::ListeningMode => { + let mode = status + .value + .first() + .map(AirPodsNoiseControlMode::from_byte) + .unwrap_or(AirPodsNoiseControlMode::Transparency); + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.noise_control_mode = mode; + } + } + ControlCommandIdentifiers::ConversationDetectConfig => { + let is_enabled = match status.value.as_slice() { + [0x01] => true, + [0x02] => false, + _ => { + error!( + "Unknown Conversation Detect Config value: {:?}", + status.value + ); + false + } + }; + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.conversation_awareness_enabled = is_enabled; + } + } + ControlCommandIdentifiers::AdaptiveVolumeConfig => { + let is_enabled = match status.value.as_slice() { + [0x01] => true, + [0x02] => false, + _ => { + error!( + "Unknown Adaptive Volume Config value: {:?}", + status.value + ); + false + } + }; + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.personalized_volume_enabled = is_enabled; + } + } + ControlCommandIdentifiers::AllowOffOption => { + let is_enabled = match status.value.as_slice() { + [0x01] => true, + [0x02] => false, + _ => { + error!( + "Unknown Allow Off Option value: {:?}", + status.value + ); + false + } + }; + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.allow_off_mode = is_enabled; + state.noise_control_state = combo_box::State::new({ + let mut modes = vec![ + AirPodsNoiseControlMode::Transparency, + AirPodsNoiseControlMode::NoiseCancellation, + AirPodsNoiseControlMode::Adaptive, + ]; + if is_enabled { + modes.insert(0, AirPodsNoiseControlMode::Off); + } + modes + }); + } + } + _ => { + debug!("Unhandled Control Command Status: {:?}", status); + } + }, + AACPEvent::BatteryInfo(battery_info) => { + if let Some(DeviceState::AirPods(state)) = + self.device_states.get_mut(&mac) + { + state.battery = battery_info; + debug!("Updated battery info for {}: {:?}", mac, state.battery); + } + } + _ => {} + } + Task::batch(vec![wait_task]) + } + BluetoothUIMessage::ATTNotification(mac, handle, value) => { + debug!( + "ATT Notification for {}: handle=0x{:04X}, value={:?}", + mac, handle, value + ); + + // TODO: Handle Nothing's ANC Mode changes here + + let ui_rx = Arc::clone(&self.ui_rx); + let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg); + Task::batch(vec![wait_task]) + } + } + } + Message::ShowNewDialogTab => { + debug!("switching to Add Device tab"); + self.selected_tab = Tab::AddDevice; + Task::perform(load_paired_devices(), Message::GotPairedDevices) + } + Message::GotPairedDevices(map) => { + self.paired_devices = map; + Task::none() + } + Message::StartAddDevice(name, addr) => { + self.pending_add_device = Some((name, addr)); + self.selected_device_type = None; + Task::none() + } + Message::SelectDeviceType(device_type) => { + self.selected_device_type = Some(device_type); + Task::none() + } + Message::ConfirmAddDevice => { + if let Some((name, addr)) = self.pending_add_device.take() + && let Some(type_) = self.selected_device_type.take() + { + let devices_path = get_devices_path(); + let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let mut devices_list: HashMap = + serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list.insert( + addr.to_string(), + DeviceData { + name, + type_: type_.clone(), + information: None, + }, + ); + let updated_json = serde_json::to_string(&devices_list).unwrap_or_else(|e| { + error!("Serialization failed: {}", e); + "{}".to_string() + }); + if let Err(e) = std::fs::write(&devices_path, updated_json) { + error!("Failed to write devices file: {}", e); + } + self.selected_tab = Tab::Device(addr.to_string()); + } + Task::none() + } + Message::CancelAddDevice => { + self.pending_add_device = None; + self.selected_device_type = None; + Task::none() + } + Message::StateChanged(mac, state) => { + self.device_states.insert(mac.clone(), state); + // if airpods, update the noise control state combo box based on allow off mode + let type_ = { + let devices_json = + std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = + serde_json::from_str(&devices_json).unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + devices_list.get(&mac).map(|d| d.type_.clone()) + }; + if let Some(DeviceType::AirPods) = type_ + && let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) + { + state.noise_control_state = combo_box::State::new({ + let mut modes = vec![ + AirPodsNoiseControlMode::Transparency, + AirPodsNoiseControlMode::NoiseCancellation, + AirPodsNoiseControlMode::Adaptive, + ]; + if state.allow_off_mode { + modes.insert(0, AirPodsNoiseControlMode::Off); + } + modes + }); + } + Task::none() + } + Message::TrayTextModeChanged(is_enabled) => { + self.tray_text_mode = is_enabled; + let app_settings_path = get_app_settings_path(); + let settings = serde_json::json!({"theme": self.selected_theme, "tray_text_mode": self.tray_text_mode}); + debug!( + "Writing settings to {}: {}", + app_settings_path.to_str().unwrap(), + settings + ); + std::fs::write(app_settings_path, settings.to_string()).ok(); + Task::none() + } + } + } + + fn view(&self, _id: window::Id) -> Element<'_, Message> { + let devices_json = std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| { + error!("Failed to read devices file: {}", e); + "{}".to_string() + }); + let devices_list: HashMap = serde_json::from_str(&devices_json) + .unwrap_or_else(|e| { + error!("Deserialization failed: {}", e); + HashMap::new() + }); + let pane_grid = pane_grid::PaneGrid::new(&self.panes, |_pane_id, pane, _is_maximized| { + match pane { + Pane::Sidebar => { + let create_tab_button = |tab: Tab, label: &str, mac_addr: &str, connected: bool| -> Element<'_, Message> { + let label = label.to_string() + if connected { " 􀉣" } else { "" }; + let is_selected = self.selected_tab == tab; + let col = column![ + text(label).size(16), + text({ + if connected { + let mac = match tab { + Tab::Device(ref mac) => mac.as_str(), + _ => "", + }; + + match self.device_states.get(mac) { + Some(DeviceState::AirPods(state)) => { + let b = &state.battery; + let headphone = b.iter().find(|x| x.component == BatteryComponent::Headphone) + .map(|x| x.level); + // if headphones is not None, use only that + if let Some(level) = headphone { + let charging = b.iter().find(|x| x.component == BatteryComponent::Headphone) + .map(|x| x.status == BatteryStatus::Charging).unwrap_or(false); + format!( + "􀺹 {}%{}", + level, if charging {"\u{1002E6}"} else {""} + ) + } else { + let left = b.iter().find(|x| x.component == BatteryComponent::Left) + .map(|x| x.level).unwrap_or_default(); + let right = b.iter().find(|x| x.component == BatteryComponent::Right) + .map(|x| x.level).unwrap_or_default(); + let case = b.iter().find(|x| x.component == BatteryComponent::Case) + .map(|x| x.level).unwrap_or_default(); + let left_charging = b.iter().find(|x| x.component == BatteryComponent::Left) + .map(|x| x.status == BatteryStatus::Charging).unwrap_or(false); + let right_charging = b.iter().find(|x| x.component == BatteryComponent::Right) + .map(|x| x.status == BatteryStatus::Charging).unwrap_or(false); + let case_charging = b.iter().find(|x| x.component == BatteryComponent::Case) + .map(|x| x.status == BatteryStatus::Charging).unwrap_or(false); + format!( + "\u{1018E5} {}%{} \u{1018E8} {}%{} \u{100E6C} {}%{}", + left, if left_charging {"\u{1002E6}"} else {""}, right, if right_charging {"\u{1002E6}"} else {""}, case, if case_charging {"\u{1002E6}"} else {""} + ) + } + } + _ => "Connected".to_string(), + } + } else { + mac_addr.to_string() + } + }).size(12) + ]; + let content = container(col) + .padding(8); + let style = move |theme: &Theme, _status| { + if is_selected { + let mut style = Style::default() + .with_background(theme.palette().primary); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(12); + style + } else { + let mut style = Style::default() + .with_background(theme.palette().primary.scale_alpha(0.1)); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.1); + style.border = border.rounded(8); + style.text_color = theme.palette().text; + style + } + }; + button(content) + .style(style) + .padding(5) + .on_press(Message::SelectTab(tab)) + .width(Length::Fill) + .into() + }; + + let create_settings_button = || -> Element<'_, Message> { + let label = "Settings".to_string(); + let is_selected = self.selected_tab == Tab::Settings; + let col = column![text(label).size(16)]; + let content = container(col) + .padding(8); + let style = move |theme: &Theme, _status| { + if is_selected { + let mut style = Style::default() + .with_background(theme.palette().primary); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(12); + style + } else { + let mut style = Style::default() + .with_background(theme.palette().primary.scale_alpha(0.1)); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.1); + style.border = border.rounded(8); + style.text_color = theme.palette().text; + style + } + }; + button(content) + .style(style) + .padding(5) + .on_press(Message::SelectTab(Tab::Settings)) + .width(Length::Fill) + .into() + }; + + let mut devices = column!().spacing(4); + let mut devices_vec: Vec<(String, DeviceData)> = devices_list.clone().into_iter().collect(); + devices_vec.sort_by(|a, b| a.1.name.cmp(&b.1.name)); + for (mac, device) in devices_vec { + let name = device.name.clone(); + let tab_button = create_tab_button( + Tab::Device(mac.clone()), + &name, + &mac, + self.bluetooth_state.connected_devices.contains(&mac) + ); + devices = devices.push(tab_button); + } + + let settings = create_settings_button(); + + let content = column![ + row![ + text("Devices").size(18), + Space::with_width(Length::Fill), + button( + container(text("+").size(18)).center_x(Length::Fill).center_y(Length::Fill) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + style.border = Border { + width: 1.0, + color: theme.palette().primary.scale_alpha(0.1), + radius: Radius::from(8.0), + }; + style + } + ) + .padding(0) + .width(Length::from(28)) + .height(Length::from(28)) + .on_press(Message::ShowNewDialogTab) + ] + .align_y(Center) + .padding(4), + Space::with_height(Length::from(8)), + devices, + Space::with_height(Length::Fill), + settings + ] + .padding(12); + pane_grid::Content::new( + row![ + content, + vertical_rule(1).style( + |theme: &Theme| { + rule::Style{ + color: theme.palette().primary.scale_alpha(0.2), + width: 2, + radius: Radius::from(8.0), + fill_mode: FillMode::Full + } + } + ) + ] + ) + } + + Pane::Content => { + let device_managers = self.device_managers.blocking_read(); + let content = match &self.selected_tab { + Tab::Device(id) => { + if id == "none" { + container( + text("Select a device".to_string()).size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } else { + let device_type = devices_list.get(id).map(|d| d.type_.clone()); + let device_state = self.device_states.get(id); + debug!("Rendering device view for {}: type={:?}, state={:?}", id, device_type, device_state); + match device_type { + Some(DeviceType::AirPods) => { + + device_state.as_ref().and_then(|state| { + match state { + DeviceState::AirPods(state) => { + device_managers.get(id).and_then(|managers| { + managers.get_aacp().map(|aacp_manager| airpods_view( + id, + &devices_list, + state, + aacp_manager.clone() + )) + }) + } + _ => None, + } + }).unwrap_or_else(|| { + container( + text("Required managers or state not available for this AirPods device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + }) + } + Some(DeviceType::Nothing) => { + if let Some(DeviceState::Nothing(state)) = device_state { + if let Some(device_managers) = device_managers.get(id) { + if let Some(att_manager) = device_managers.get_att() { + nothing_view(id, &devices_list, state, att_manager.clone()) + } else { + error!("No ATT manager found for Nothing device {}", id); + container( + text("No valid ATT manager found for this Nothing device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } + } else { + error!("No manager found for Nothing device {}", id); + container( + text("No manager found for this Nothing device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } + } else { + container( + text("No state available for this Nothing device").size(16) + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + } + } + _ => { + container(text("Unsupported device").size(16)) + .center_x(Length::Fill) + .center_y(Length::Fill) + } + } + } + } + Tab::Settings => { + let tray_text_mode_toggle = container( + row![ + column![ + text("Use text in tray").size(16), + text("Use text for battery status in tray instead of a progress bar.").size(12).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().text.scale_alpha(0.7)); + style + } + ).width(Length::Fill) + ].width(Length::Fill), + toggler(self.tray_text_mode) + .on_toggle(move |is_enabled| { + Message::TrayTextModeChanged(is_enabled) + }) + .spacing(0) + .size(20) + ] + .align_y(Center) + .spacing(12) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + .align_y(Center); + + let appearance_settings_col = column![ + container( + text("Appearance").size(20).style( + |theme: &Theme| { + let mut style = text::Style::default(); + style.color = Some(theme.palette().primary); + style + } + ) + ) + .padding(Padding{ + top: 0.0, + bottom: 0.0, + left: 18.0, + right: 18.0, + }), + container( + row![ + text("Theme") + .size(16), + Space::with_width(Length::Fill), + combo_box( + &self.theme_state, + "Select theme", + Some(&self.selected_theme), + Message::ThemeSelected + ) + .input_style( + |theme: &Theme, _status| { + text_input::Style { + background: Background::Color(theme.palette().primary.scale_alpha(0.2)), + border: Border { + width: 1.0, + color: theme.palette().text.scale_alpha(0.3), + radius: Radius::from(4.0) + }, + icon: Default::default(), + placeholder: theme.palette().text, + value: theme.palette().text, + selection: Default::default(), + } + } + ) + .menu_style( + |theme: &Theme| { + menu::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(4.0) + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)), + } + } + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 10.0, + right: 10.0, + }) + .width(Length::from(200)) + ] + .align_y(Center) + ) + .padding(Padding{ + top: 5.0, + bottom: 5.0, + left: 18.0, + right: 18.0, + }) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().primary.scale_alpha(0.5); + style.border = border.rounded(16); + style + } + ) + ] + .spacing(12); + + container( + column![ + appearance_settings_col, + Space::with_height(Length::from(20)), + tray_text_mode_toggle + ] + ) + .padding(20) + .width(Length::Fill) + .height(Length::Fill) + }, + Tab::AddDevice => { + container( + column![ + text("Pick a paired device to add:").size(18), + Space::with_height(Length::from(10)), + { + let mut list_col = column![].spacing(12); + for device in self.paired_devices.clone() { + if !devices_list.contains_key(&device.1.to_string()) { + let mut item_col = column![].spacing(8); + let mut row_elements = vec![ + column![ + text(device.0.to_string()).size(16), + text(device.1.to_string()).size(12) + ].into(), + Space::with_width(Length::Fill).into(), + ]; + if !matches!(&self.pending_add_device, Some((_, addr)) if addr == &device.1) { + row_elements.push( + button( + text("Add").size(14).width(120).align_y(Center).align_x(Center) + ) + .style( + |theme: &Theme, _status| { + let mut style = Style::default(); + style.text_color = theme.palette().text; + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.5))); + style.border = Border { + width: 1.0, + color: theme.palette().primary, + radius: Radius::from(8.0), + }; + style + } + ) + .padding(8) + .on_press(Message::StartAddDevice(device.0.clone(), device.1)) + .into() + ); + } + item_col = item_col.push(row(row_elements).align_y(Center)); + + if let Some((_, pending_addr)) = &self.pending_add_device + && pending_addr == &device.1 { + item_col = item_col.push( + row![ + text("Device Type:").size(16), + Space::with_width(Length::Fill), + combo_box( + &self.device_type_state, + "Select device type", + self.selected_device_type.as_ref(), + Message::SelectDeviceType + ) + .input_style( + |theme: &Theme, _status| { + text_input::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(8.0), + }, + icon: Default::default(), + placeholder: theme.palette().text.scale_alpha(0.5), + value: theme.palette().text, + selection: theme.palette().primary + } + } + ) + .menu_style( + |theme: &Theme| { + menu::Style { + background: Background::Color(theme.palette().background), + border: Border { + width: 1.0, + color: theme.palette().text, + radius: Radius::from(8.0) + }, + text_color: theme.palette().text, + selected_text_color: theme.palette().text, + selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)), + } + } + ) + .width(Length::from(200)) + ] + ); + item_col = item_col.push( + row![ + Space::with_width(Length::Fill), + button(text("Cancel").size(16).width(Length::Fill).center()) + .on_press(Message::CancelAddDevice) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + style.text_color = theme.palette().text; + style.border = Border::default().rounded(8.0); + style + }) + .width(Length::from(120)) + .padding(4), + Space::with_width(Length::from(20)), + button(text("Add Device").size(16).width(Length::Fill).center()) + .on_press(Message::ConfirmAddDevice) + .style(|theme: &Theme, _status| { + let mut style = Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.3))); + style.text_color = theme.palette().text; + style.border = Border::default().rounded(8.0); + style + }) + .width(Length::from(120)) + .padding(4), + ] + .align_y(Center) + .width(Length::Fill) + ); + } + list_col = list_col.push( + container(item_col) + .padding(8) + .style( + |theme: &Theme| { + let mut style = container::Style::default(); + style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1))); + let mut border = Border::default(); + border.color = theme.palette().text; + style.border = border.rounded(8); + style + } + ) + ); + } + } + if self.paired_devices.iter().all(|device| devices_list.contains_key(&device.1.to_string())) && self.pending_add_device.is_none() { + list_col = list_col.push( + container( + text("No new paired devices found. All paired devices are already added.").size(16) + ) + .width(Length::Fill) + ); + } + scrollable(list_col) + .height(Length::Fill) + .width(Length::Fill) + } + ] + ) + .padding(20) + .height(Length::Fill) + .width(Length::Fill) + } + }; + + pane_grid::Content::new(content) + } + } + }) + .width(Length::Fill) + .height(Length::Fill) + .on_resize(20, Message::Resized); + + container(pane_grid).into() + } + + fn theme(&self, _id: window::Id) -> Theme { + self.selected_theme.into() + } + + fn subscription(&self) -> Subscription { + window::close_events().map(Message::WindowClosed) + } +} + +async fn wait_for_message(ui_rx: Arc>>) -> Message { + let mut rx = ui_rx.lock().await; + match rx.recv().await { + Some(msg) => Message::BluetoothMessage(msg), + None => { + error!("UI message channel closed"); + Message::BluetoothMessage(BluetoothUIMessage::NoOp) + } + } +} +async fn load_paired_devices() -> HashMap { + let mut devices = HashMap::new(); + + let session = Session::new().await.ok().unwrap(); + let adapter = session.default_adapter().await.ok().unwrap(); + let addresses = adapter.device_addresses().await.ok().unwrap(); + for addr in addresses { + let device = adapter.device(addr).ok().unwrap(); + let paired = device.is_paired().await.ok().unwrap(); + if paired { + let name = device + .name() + .await + .ok() + .flatten() + .unwrap_or_else(|| "Unknown".to_string()); + devices.insert(name, addr); + } + } + + devices +} diff --git a/linux-rust/src/utils.rs b/linux-rust/src/utils.rs new file mode 100644 index 00000000..0a57910c --- /dev/null +++ b/linux-rust/src/utils.rs @@ -0,0 +1,136 @@ +use aes::Aes128; +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockEncrypt, KeyInit}; +use iced::Theme; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +pub fn get_devices_path() -> PathBuf { + let data_dir = std::env::var("XDG_DATA_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(data_dir) + .join("librepods") + .join("devices.json") +} + +pub fn get_preferences_path() -> PathBuf { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(config_dir) + .join("librepods") + .join("preferences.json") +} + +pub fn get_app_settings_path() -> PathBuf { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default())); + PathBuf::from(config_dir) + .join("librepods") + .join("app_settings.json") +} + +fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let mut swapped_key = *key; + swapped_key.reverse(); + let mut swapped_data = *data; + swapped_data.reverse(); + let cipher = Aes128::new(&GenericArray::from(swapped_key)); + let mut block = GenericArray::from(swapped_data); + cipher.encrypt_block(&mut block); + let mut result: [u8; 16] = block.into(); + result.reverse(); + result +} + +pub fn ah(k: &[u8; 16], r: &[u8; 3]) -> [u8; 3] { + let mut r_padded = [0u8; 16]; + r_padded[..3].copy_from_slice(r); + let encrypted = e(k, &r_padded); + let mut hash = [0u8; 3]; + hash.copy_from_slice(&encrypted[..3]); + hash +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum MyTheme { + Light, + Dark, + Dracula, + Nord, + SolarizedLight, + SolarizedDark, + GruvboxLight, + GruvboxDark, + CatppuccinLatte, + CatppuccinFrappe, + CatppuccinMacchiato, + CatppuccinMocha, + TokyoNight, + TokyoNightStorm, + TokyoNightLight, + KanagawaWave, + KanagawaDragon, + KanagawaLotus, + Moonfly, + Nightfly, + Oxocarbon, + Ferra, +} + +impl std::fmt::Display for MyTheme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Light => "Light", + Self::Dark => "Dark", + Self::Dracula => "Dracula", + Self::Nord => "Nord", + Self::SolarizedLight => "Solarized Light", + Self::SolarizedDark => "Solarized Dark", + Self::GruvboxLight => "Gruvbox Light", + Self::GruvboxDark => "Gruvbox Dark", + Self::CatppuccinLatte => "Catppuccin Latte", + Self::CatppuccinFrappe => "Catppuccin Frappé", + Self::CatppuccinMacchiato => "Catppuccin Macchiato", + Self::CatppuccinMocha => "Catppuccin Mocha", + Self::TokyoNight => "Tokyo Night", + Self::TokyoNightStorm => "Tokyo Night Storm", + Self::TokyoNightLight => "Tokyo Night Light", + Self::KanagawaWave => "Kanagawa Wave", + Self::KanagawaDragon => "Kanagawa Dragon", + Self::KanagawaLotus => "Kanagawa Lotus", + Self::Moonfly => "Moonfly", + Self::Nightfly => "Nightfly", + Self::Oxocarbon => "Oxocarbon", + Self::Ferra => "Ferra", + }) + } +} + +impl From for Theme { + fn from(my_theme: MyTheme) -> Self { + match my_theme { + MyTheme::Light => Theme::Light, + MyTheme::Dark => Theme::Dark, + MyTheme::Dracula => Theme::Dracula, + MyTheme::Nord => Theme::Nord, + MyTheme::SolarizedLight => Theme::SolarizedLight, + MyTheme::SolarizedDark => Theme::SolarizedDark, + MyTheme::GruvboxLight => Theme::GruvboxLight, + MyTheme::GruvboxDark => Theme::GruvboxDark, + MyTheme::CatppuccinLatte => Theme::CatppuccinLatte, + MyTheme::CatppuccinFrappe => Theme::CatppuccinFrappe, + MyTheme::CatppuccinMacchiato => Theme::CatppuccinMacchiato, + MyTheme::CatppuccinMocha => Theme::CatppuccinMocha, + MyTheme::TokyoNight => Theme::TokyoNight, + MyTheme::TokyoNightStorm => Theme::TokyoNightStorm, + MyTheme::TokyoNightLight => Theme::TokyoNightLight, + MyTheme::KanagawaWave => Theme::KanagawaWave, + MyTheme::KanagawaDragon => Theme::KanagawaDragon, + MyTheme::KanagawaLotus => Theme::KanagawaLotus, + MyTheme::Moonfly => Theme::Moonfly, + MyTheme::Nightfly => Theme::Nightfly, + MyTheme::Oxocarbon => Theme::Oxocarbon, + MyTheme::Ferra => Theme::Ferra, + } + } +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..459e1526 --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) { src = ./.; }).shellNix