mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
52 Commits
windows/he
...
linux/rust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c852b726de | ||
|
|
902b12a227 | ||
|
|
6ded8ff3ff | ||
|
|
376c54247b | ||
|
|
e2d17b8bae | ||
|
|
6f0323ee6b | ||
|
|
4737cbfc2c | ||
|
|
093554da07 | ||
|
|
a01e16792a | ||
|
|
253ed65afc | ||
|
|
b1f3856d0f | ||
|
|
99a689a0f8 | ||
|
|
0c9a2bd743 | ||
|
|
6585cf648c | ||
|
|
99e5b71676 | ||
|
|
47f02136cd | ||
|
|
29c422528a | ||
|
|
9d10eed85d | ||
|
|
9f7f4347ec | ||
|
|
b7cd80edac | ||
|
|
2049431817 | ||
|
|
23cf5728e9 | ||
|
|
381b09725b | ||
|
|
3853e8ec9a | ||
|
|
bf6630dbd1 | ||
|
|
a2cda688d4 | ||
|
|
934df2419a | ||
|
|
64470c4d34 | ||
|
|
fa8bc11060 | ||
|
|
c5a824c384 | ||
|
|
99beeb5907 | ||
|
|
17b545481e | ||
|
|
320964e9d7 | ||
|
|
51b3d4692a | ||
|
|
3a0cc2e7f4 | ||
|
|
26cee5c8a5 | ||
|
|
a007d9cd80 | ||
|
|
b47469803b | ||
|
|
925c930073 | ||
|
|
ccee82026d | ||
|
|
99940b98ae | ||
|
|
fec226336c | ||
|
|
e5c2419ef6 | ||
|
|
221680ff32 | ||
|
|
9da4c938ed | ||
|
|
7dd029faa6 | ||
|
|
ae5a701257 | ||
|
|
0f04290fba | ||
|
|
b0561e96df | ||
|
|
c0ae061cc7 | ||
|
|
cf2a242d7c | ||
|
|
43bfbda21e |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
12
default.nix
Normal file
12
default.nix
Normal file
@@ -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
|
||||
143
flake.lock
generated
Normal file
143
flake.lock
generated
Normal file
@@ -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
|
||||
}
|
||||
141
flake.nix
Normal file
141
flake.nix
Normal file
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
7
linux-rust/.gitignore
vendored
Normal file
7
linux-rust/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
**/*.flatpak
|
||||
repo
|
||||
dist
|
||||
build-dir
|
||||
vendor
|
||||
.cargo
|
||||
.flatpak-builder
|
||||
5667
linux-rust/Cargo.lock
generated
Normal file
5667
linux-rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
linux-rust/Cargo.toml
Normal file
31
linux-rust/Cargo.toml
Normal file
@@ -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
|
||||
70
linux-rust/Justfile
Normal file
70
linux-rust/Justfile
Normal file
@@ -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}"
|
||||
BIN
linux-rust/assets/font/DejaVuSans.ttf
Normal file
BIN
linux-rust/assets/font/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
linux-rust/assets/font/sf_pro.otf
Normal file
BIN
linux-rust/assets/font/sf_pro.otf
Normal file
Binary file not shown.
BIN
linux-rust/assets/icon.png
Normal file
BIN
linux-rust/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
6
linux-rust/assets/me.kavishdevar.librepods.desktop
Normal file
6
linux-rust/assets/me.kavishdevar.librepods.desktop
Normal file
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=LibrePods
|
||||
Exec=librepods
|
||||
Icon=me.kavishdevar.librepods
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
23
linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml
Normal file
23
linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>me.kavishdevar.librepods</id>
|
||||
|
||||
<name>LibrePods</name>
|
||||
<summary>Liberate AirPods from Apple's ecosystem</summary>
|
||||
|
||||
<metadata_license>CC-BY-SA-4.0</metadata_license>
|
||||
<project_license>AGPL-3.0-only</project_license>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
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
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">me.kavishdevar.librepods.desktop</launchable>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/kavishdevar/librepods/refs/heads/main/linux/imgs/main-app.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
</component>
|
||||
43
linux-rust/flatpak/me.kavishdevar.librepods.yaml
Normal file
43
linux-rust/flatpak/me.kavishdevar.librepods.yaml
Normal file
@@ -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
|
||||
|
||||
1201
linux-rust/src/bluetooth/aacp.rs
Normal file
1201
linux-rust/src/bluetooth/aacp.rs
Normal file
File diff suppressed because it is too large
Load Diff
275
linux-rust/src/bluetooth/att.rs
Normal file
275
linux-rust/src/bluetooth/att.rs
Normal file
@@ -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<ATTHandles> 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<mpsc::Sender<Vec<u8>>>,
|
||||
listeners: HashMap<u16, Vec<mpsc::UnboundedSender<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ATTManagerState {
|
||||
fn new() -> Self {
|
||||
ATTManagerState {
|
||||
sender: None,
|
||||
listeners: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ATTManager {
|
||||
state: Arc<Mutex<ATTManagerState>>,
|
||||
response_rx: Arc<Mutex<mpsc::UnboundedReceiver<Vec<u8>>>>,
|
||||
response_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
tasks: Arc<Mutex<JoinSet<()>>>,
|
||||
}
|
||||
|
||||
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<Vec<u8>>) {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<SeqPacket>) {
|
||||
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<Vec<u8>>, sp: Arc<SeqPacket>) {
|
||||
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.");
|
||||
}
|
||||
49
linux-rust/src/bluetooth/discovery.rs
Normal file
49
linux-rust/src/bluetooth/discovery.rs
Normal file
@@ -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<bluer::Device> {
|
||||
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<String>,
|
||||
) -> bluer::Result<Vec<bluer::Device>> {
|
||||
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",
|
||||
)))
|
||||
}
|
||||
379
linux-rust/src/bluetooth/le.rs
Normal file
379
linux-rust/src/bluetooth/le.rs
Normal file
@@ -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<u8> = addr
|
||||
.split(':')
|
||||
.map(|s| u8::from_str_radix(s, 16).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.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<ksni::Handle<MyTray>>) -> bluer::Result<()> {
|
||||
let session = Session::new().await?;
|
||||
let adapter = session.default_adapter().await?;
|
||||
adapter.set_powered(true).await?;
|
||||
|
||||
let all_devices: HashMap<String, DeviceData> = 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<Address, String> = HashMap::new();
|
||||
let mut failed_macs: HashSet<Address> = HashSet::new();
|
||||
let connecting_macs = Arc::new(Mutex::new(HashSet::<Address>::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<String>;
|
||||
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<String, bool>,
|
||||
> = 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 <mac> 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(())
|
||||
}
|
||||
48
linux-rust/src/bluetooth/managers.rs
Normal file
48
linux-rust/src/bluetooth/managers.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::bluetooth::aacp::AACPManager;
|
||||
use crate::bluetooth::att::ATTManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DeviceManagers {
|
||||
att: Option<Arc<ATTManager>>,
|
||||
aacp: Option<Arc<AACPManager>>,
|
||||
}
|
||||
|
||||
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<Arc<AACPManager>> {
|
||||
self.aacp.clone()
|
||||
}
|
||||
|
||||
pub fn get_att(&self) -> Option<Arc<ATTManager>> {
|
||||
self.att.clone()
|
||||
}
|
||||
}
|
||||
5
linux-rust/src/bluetooth/mod.rs
Normal file
5
linux-rust/src/bluetooth/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod aacp;
|
||||
pub mod att;
|
||||
pub(crate) mod discovery;
|
||||
pub mod le;
|
||||
pub mod managers;
|
||||
354
linux-rust/src/devices/airpods.rs
Normal file
354
linux-rust/src/devices/airpods.rs
Normal file
@@ -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<Mutex<MediaController>>,
|
||||
// pub command_tx: Option<tokio::sync::mpsc::UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl AirPodsDevice {
|
||||
pub async fn new(
|
||||
mac_address: Address,
|
||||
tray_handle: Option<Handle<MyTray>>,
|
||||
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
|
||||
) -> 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,
|
||||
}
|
||||
152
linux-rust/src/devices/enums.rs
Normal file
152
linux-rust/src/devices/enums.rs
Normal file
@@ -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<DeviceInformation>,
|
||||
}
|
||||
|
||||
#[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<AirPodsNoiseControlMode>,
|
||||
pub conversation_awareness_enabled: bool,
|
||||
pub personalized_volume_enabled: bool,
|
||||
pub allow_off_mode: bool,
|
||||
pub battery: Vec<BatteryInfo>,
|
||||
}
|
||||
|
||||
#[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<NothingAncMode>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
3
linux-rust/src/devices/mod.rs
Normal file
3
linux-rust/src/devices/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod airpods;
|
||||
pub mod enums;
|
||||
pub(crate) mod nothing;
|
||||
179
linux-rust/src/devices/nothing.rs
Normal file
179
linux-rust/src/devices/nothing.rs
Normal file
@@ -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<BluetoothUIMessage>,
|
||||
) -> Self {
|
||||
let mut att_manager = ATTManager::new();
|
||||
att_manager
|
||||
.connect(mac_address)
|
||||
.await
|
||||
.expect("Failed to connect");
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
|
||||
att_manager
|
||||
.register_listener(ATTHandles::NothingEverythingRead, tx)
|
||||
.await;
|
||||
|
||||
let devices: HashMap<String, DeviceData> = 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
322
linux-rust/src/main.rs
Normal file
322
linux-rust/src/main.rs
Normal file
@@ -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::<BluetoothUIMessage>();
|
||||
|
||||
let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
|
||||
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<BluetoothUIMessage>,
|
||||
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
||||
) -> bluer::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut managed_devices_mac: Vec<String> = 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<String, DeviceData> = 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::<String, HashMap<String, Variant<Box<dyn RefArg>>>, Vec<String>>()
|
||||
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::<Vec<String>>("org.bluez.Device1", "UUIDs") else {
|
||||
return true;
|
||||
};
|
||||
let target_uuid = "74ec2172-0bad-4d01-8f77-997b2be0722a";
|
||||
|
||||
let Ok(addr_str) = proxy.get::<String>("org.bluez.Device1", "Address") else {
|
||||
return true;
|
||||
};
|
||||
let Ok(addr) = addr_str.parse::<Address>() 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::<String>("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))?;
|
||||
}
|
||||
}
|
||||
1350
linux-rust/src/media_controller.rs
Normal file
1350
linux-rust/src/media_controller.rs
Normal file
File diff suppressed because it is too large
Load Diff
533
linux-rust/src/ui/airpods.rs
Normal file
533
linux-rust/src/ui/airpods.rs
Normal file
@@ -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<String, DeviceData>,
|
||||
state: &'a AirPodsState,
|
||||
aacp_manager: Arc<AACPManager>,
|
||||
// att_manager: Arc<ATTManager>
|
||||
) -> 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<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(fut);
|
||||
});
|
||||
}
|
||||
11
linux-rust/src/ui/messages.rs
Normal file
11
linux-rust/src/ui/messages.rs
Normal file
@@ -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<u8>), // mac, handle, data
|
||||
NoOp,
|
||||
}
|
||||
5
linux-rust/src/ui/mod.rs
Normal file
5
linux-rust/src/ui/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod airpods;
|
||||
pub mod messages;
|
||||
mod nothing;
|
||||
pub mod tray;
|
||||
pub mod window;
|
||||
188
linux-rust/src/ui/nothing.rs
Normal file
188
linux-rust/src/ui/nothing.rs
Normal file
@@ -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<String, DeviceData>,
|
||||
state: &'a NothingState,
|
||||
att_manager: Arc<ATTManager>,
|
||||
) -> 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<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(fut);
|
||||
});
|
||||
}
|
||||
301
linux-rust/src/ui/tray.rs
Normal file
301
linux-rust/src/ui/tray.rs
Normal file
@@ -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<bool>,
|
||||
pub battery_headphone: Option<u8>,
|
||||
pub battery_headphone_status: Option<BatteryStatus>,
|
||||
pub battery_l: Option<u8>,
|
||||
pub battery_l_status: Option<BatteryStatus>,
|
||||
pub battery_r: Option<u8>,
|
||||
pub battery_r_status: Option<BatteryStatus>,
|
||||
pub battery_c: Option<u8>,
|
||||
pub battery_c_status: Option<BatteryStatus>,
|
||||
pub connected: bool,
|
||||
pub listening_mode: Option<u8>,
|
||||
pub allow_off_option: Option<u8>,
|
||||
pub command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
|
||||
pub ui_tx: Option<UnboundedSender<BluetoothUIMessage>>,
|
||||
}
|
||||
|
||||
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<Icon> {
|
||||
let text = {
|
||||
let mut levels: Vec<u8> = 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::<serde_json::Value>(&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<u8>, status: Option<BatteryStatus>| -> 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<ksni::MenuItem<Self>> {
|
||||
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::<f32>().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,
|
||||
}
|
||||
}
|
||||
1214
linux-rust/src/ui/window.rs
Normal file
1214
linux-rust/src/ui/window.rs
Normal file
File diff suppressed because it is too large
Load Diff
136
linux-rust/src/utils.rs
Normal file
136
linux-rust/src/utils.rs
Normal file
@@ -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<MyTheme> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
shell.nix
Normal file
12
shell.nix
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user