Skip to content

[Draft] Cross-platform: bring the linux-rust app to Windows natively (one crate, two OSes) - #710

Draft
arctumn wants to merge 244 commits into
librepods-org:linux/rustfrom
arctumn:cross-platform
Draft

[Draft] Cross-platform: bring the linux-rust app to Windows natively (one crate, two OSes)#710
arctumn wants to merge 244 commits into
librepods-org:linux/rustfrom
arctumn:cross-platform

Conversation

@arctumn

@arctumn arctumn commented Aug 7, 2026

Copy link
Copy Markdown

This is a work-in-progress port that makes the linux-rust app (librepods) build and run natively on Windows while keeping Linux green from the same codebase — no Bumble, no re-pairing, no WSL. Opening as a draft for visibility and feedback; it is developed and hardware-validated on real AirPods Pro on a Windows machine.

Addresses #433 (native Windows support — no WSL).

What it does

All OS-specific integration lives behind a platform/ abstraction selected at compile time (#[cfg(target_os)]). The protocol/UI code (aacp, att, le, media_controller, the iced GUI) is identical on both platforms.

Concern Linux backend (unchanged) Windows backend (new)
L2CAP transport bluer SeqPacket LibrePodsAAP kernel driver via IOCTL
Discovery / adapter / watcher BlueZ + D-Bus Win32 / WinRT Bluetooth
LE advertisements bluer::monitor WinRT BluetoothLEAdvertisementWatcher
Audio routing / volume PulseAudio WASAPI (Core Audio)
Media control MPRIS SMTC
Microphone PipeWire virtual input LibrePodsMic virtual audio driver
System tray ksni tray-icon
Paths XDG %APPDATA%

Why two kernel drivers

Microsoft exposes no user-mode API for what they do (verified against the driver docs + a Winsock AF_BTH spike that returned WSAENETDOWN):

  • AAP control runs over a classic-Bluetooth L2CAP channel (PSM 0x1001); Windows only allows an L2CAP client connection from a kernel profile driver (BRB_L2CA_OPEN_CHANNEL). So a signed KMDF driver bridges it to user space.
  • Hi-res microphone — Windows has no user-mode API to create a virtual audio device; ours is a WDM/ACX driver. Both drivers are open-source and, for personal use, test-signed.

Highlights validated on hardware

  • Battery (L/R/Case), noise control (Off/ANC/Transparency/Adaptive), ear-detection auto-pause (SMTC), device info, LE battery via BLE.
  • Hi-res AAC-ELD microphone as a native Windows input for any app — the Windows counterpart to [Linux/rust] Implemented high-res microphone support #655 (FFmpeg/libavcodec LGPL decode, 48 kHz, auto-activate).
  • Conversational Awareness volume ducking, Adaptive/Personalized Volume, Allow-Off, Adaptive noise strength (0x2E).
  • A headless daemon (librepodsd) owns the exclusive driver + AAP session and serves thin UI clients (tray + the iced app) over named-pipe IPC — so audio stays clean and the UIs coexist.
  • BLE proximity connect prompt — asks before connecting, never steals the AirPods from your phone.

Scope / not asking to merge yet

  • Windows code is #[cfg(windows)]-gated; Linux paths are unchanged and stay green at every step (cargo build both targets).
  • The kernel drivers require Test Mode for personal use; distribution needs an EV cert / WHQL — hence draft.
  • Protocol-doc follow-up: AAP Definitions.md was missing battery status 0x05 (charging in case) and the 0xFF-absent rule — added here.

Feedback welcome on the platform/ abstraction shape and whether upstream wants to host the Windows drivers in-tree or as a companion.

How this compares to MagicPods

The closest Windows counterpart is MagicPods — a mature, polished app worth acknowledging. This is a fair-as-I-can feature comparison (correct me where I'm wrong); the point isn't to knock it, it's to show what a fully-FOSS, cross-platform alternative brings.

LibrePods (this PR) MagicPods
License / price Free, GPL — fully FOSS (app + both kernel drivers) $9.99 Store (free via translations); app open-source
Platforms Linux + Windows from one Rust codebase Windows (separate Linux build)
Battery (L / R / Case)
Noise control (Off / ANC / Transparency / Adaptive) ✓ (kernel L2CAP driver) ✓ (MagicAAP driver)
Ear-detection auto-pause ✓ (SMTC)
Adaptive noise strength (0x2E), Allow-Off, Adaptive/Personalized Volume partial
Conversational Awareness volume ducking (host-side)
Hi-res AAC-ELD microphone as a native input ✓ (own WDM/ACX virtual mic driver) — (system HFP / 16 kHz)
Heart-rate monitoring (AirPods Pro 3) ✓ experimental (port of #702)
Rename device
Connection island / toasts / animations
Multi-vendor (Galaxy Buds / Sony / Beats) — (AirPods + Nothing)
Low-latency gaming mode
Driver signing test-signed for personal use; EV / WHQL for distribution Test Mode (Official) or a community-signed build

Where MagicPods is ahead (multi-vendor breadth, low-latency mode, Store polish) is noted honestly; where this project is ahead is being 100% free and open — drivers included — with a hi-res mic, host-side CA ducking, experimental heart-rate, and a single codebase that keeps Linux and Windows in lockstep.

Native WinUI 3 client (optional front-end)

Alongside the cross-platform iced app, this branch adds an optional native C# / WinUI 3 (Windows App SDK) front-end (librepods-winui.exe) — another thin IPC client of the same librepodsd. It is the default UI on Windows (switchable back to iced from Settings), fully async, self-contained/unpackaged on .NET 10, with native tray, toasts and an iOS-style connection island. All hardware-validated on real AirPods Pro.

WinUI 3 client — device page (wide, 2-column)

Devices pane Narrow single-column
Navigation pane (devices + settings) Responsive: collapses to one column when narrow
Settings Tray menu
Settings (theme, default front-end) Tray menu (battery + Noise Control + Mute)
Toast notification Connection island
Native Windows toast (ANC change) iOS-style connection island

arctumn and others added 30 commits August 5, 2026 14:57
Begin the cross-platform (Windows + Linux) refactor by adding a
`src/platform/` module whose backend is selected at compile time via
`#[cfg(target_os)]`. The first OS seam to move behind it is filesystem
paths: the XDG logic from `utils.rs` becomes the `AppPaths` trait's Linux
impl (`platform/linux/paths.rs`), with a `directories`-based Windows stub.

Call sites keep the same `get_*_path` free-function names, so only their
`use` lines change. No behaviour change on Linux; `cargo check` is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace direct use of `bluer::Address` across the crate with
`platform::DeviceId`. On Linux `DeviceId` is a type alias for
`bluer::Address`, so every `Display`/`FromStr`/`Hash`/`Serialize` impl
carries over and behaviour is unchanged. The Windows backend gets a real
`[u8; 6]` MAC newtype with matching `XX:XX:...` formatting and `u64`
(`BluetoothAddress`) conversions, so `devices.json` MAC keys stay
compatible across platforms.

Only type references and `use` lines change; `cargo check` is green on
Linux with no new warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open-source Windows kernel driver that opens the Apple Accessory Protocol
L2CAP channel (PSM 0x1001) to AirPods — which user-mode Winsock cannot — and
bridges it to user space via DeviceIoControl, so LibrePods can drive ANC,
battery, ear detection, etc. on Windows.

Binds as the function driver for the AAP SDP service the AirPods advertise
(INF matches BTHENUM\{74ec2172-0bad-4d01-8f77-997b2be0722a}), so its parent
I/O target is the Bluetooth stack and it can submit BRB_L2CA_OPEN_CHANNEL
outbound — no filter driver needed. Organised across Driver/Device/L2cap/Ioctl
plus a shared header. Compiles + links + passes inf2cat against WDK 28000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-mode Rust tool (cross-compiles to windows-gnu) that drives the
LibrePodsAAP driver over its IOCTL interface for end-to-end testing against
real AirPods: `find`, `status`, `connect`, `battery` (connect + AAP handshake
+ decode battery), `anc <mode>`, and raw `send`/`recv`. Organised into
driver.rs (DeviceIoControl wrapper) / aap.rs (protocol) / bt.rs (find AirPods
MAC) / main.rs (CLI). Builds clean; `find` correctly reports the driver as
not-yet-installed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two BSODs (bugcheck 0x3B in BTHport!IOTracing::TraceStart, reading
BRB->Hdr.Type at a NULL BRB) were caused by submitting the BRB via a WDF
memory descriptor, which left Parameters.Others.Argument1 NULL so bthport
dereferenced a null BRB. Fix: format the IRP directly and set
Parameters.Others.Argument1 = Brb, then WdfRequestSend synchronously.

Also: install.ps1 now signs with /sm (machine store) and auto-removes any
previously installed LibrePodsAAP package before re-adding.

VALIDATED on real AirPods Pro (H2): connect + AAP handshake + battery read
(L/R/Case %) and ANC control (listening-mode command) all work over the
driver — no BSOD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce platform::L2capTransport (async send/recv) and platform-selected
l2cap_connect(). Linux backend wraps bluer's SeqPacket (moving the connect +
peer-CID wait out of aacp.rs/att.rs); Windows backend bridges to the
LibrePodsAAP kernel driver via DeviceIoControl (mirrors lp-driver-test).
aacp.rs/att.rs now hold Arc<dyn L2capTransport> instead of Arc<SeqPacket> —
the AAP/ATT protocol logic is now transport-agnostic. cargo check green on
Linux; no new warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move find_connected_airpods / find_other_managed_devices behind platform::,
returning a neutral DiscoveredDevice { id: DeviceId, name } instead of
bluer::Device. The Linux impl (platform/linux/discovery.rs) creates its own
bluer session; main.rs's discovery no longer needs an adapter param and drops
the bluer::ErrorKind matching (Option/Vec instead of Result). Removes
bluetooth/discovery.rs. cargo check green on Linux.

Remaining for Phase D: the org.bluez D-Bus connection watcher + adapter
power-on in main.rs (the WinRT DeviceWatcher equivalent on Windows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A tiny system-tray app (tray-icon + Win32 message loop, cross-compiles to
windows-gnu) that talks to the LibrePodsAAP driver: opens the AAP session,
shows Left/Right/Case battery and the current noise mode in the tray menu,
and switches Off/ANC/Transparency/Adaptive on click. Reuses the proven driver
IOCTL bridge + AAP parsing. windows-app/README.md is an end-to-end guide
(install driver in Test Mode + run the app + how it works). Runs and shows
battery/ANC against real AirPods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Tooltip/title show the Bluetooth device name (e.g. "AirPods Pro de Pedro"),
  stripping the Windows "- Find My" suffix.
- `connected` stays true while the link is up; it only flips false when a send
  actually fails, so the UI no longer flickers Disconnected<->value on idle
  reads. A periodic REQUEST_NOTIFS nudges fresh battery + checks liveness.
- Battery components reporting status 0x04 (not present, e.g. case out of
  range) now show "—" instead of a bogus 0%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 8s REQUEST_NOTIFS poll made Windows re-negotiate the audio profile,
cutting/switching the output (ANC "wind", device bounce). Go passive:
handshake once, then only listen for pushed packets. Liveness is checked via
GET_STATUS (reads a driver variable, no L2CAP I/O) instead of a send, so it
never disturbs the audio. Keeping the channel open still kills the HFP/A2DP
"static" — the point is to hold it gently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Core Audio (WASAPI IAudioEndpointVolume) volume to the tray menu: a live
"Volume: N%" line plus Volume +/- and Mute, controlling the default output
(the AirPods when active). Shown in the tooltip too. Uses the `windows` crate
for clean COM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…part 2)

main.rs no longer touches bluer/dbus directly. The org.bluez PropertiesChanged
watcher moves to platform/linux/watcher.rs, which runs the blocking D-Bus loop
on its own thread and forwards platform::BtConnectionEvent (Connected {id,
name, uuids} / Disconnected {id}) over an async channel; adapter power-on
becomes platform::power_on_adapter(). async_main consumes the event stream and
keeps the device-init logic. The core event loop is now platform-agnostic for
Bluetooth (WinRT DeviceWatcher can back the same API on Windows). Phase D
complete. cargo check green on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A modern eframe/egui window: device name + connection status, Left/Right/Case
battery as progress bars, ANC as a segmented button row, and a draggable
volume slider (Core Audio). Reuses the driver/aap/bt/volume modules and the
passive AAP session. Cross-compiles to windows-gnu (glow backend). COM is
initialized STA (winit's OleInitialize needs it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add windows-driver/LibrePodsAAP/prebuilt/ with the compiled .sys + .inf + .cat
so users can test-sign + install via install.ps1 without Visual Studio / WDK.
The app binaries are pure Rust and never needed C++ either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Rust app (linux-rust -> crossplatform/crossplatform-rust) and the
Windows components (windows-driver, windows-app) under a single crossplatform/
umbrella. Update flake.nix + the CI workflow paths (renamed to
ci-crossplatform-rust.yml). cargo check green from the new path.

    crossplatform/
      crossplatform-rust/   (shared Rust app, was linux-rust)
      windows-driver/       (LibrePodsAAP kernel driver + prebuilt)
      windows-app/          (librepods-tray, librepods-ui)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AAP L2CAP channel leaked when the app exited: the driver kept it open,
so Windows reported "Can't disconnect" for the AirPods until a manual
disconnect/reboot. Register a WDF_FILEOBJECT_CONFIG with EvtFileClose that
calls LpDisconnect when the app closes its handle (clean exit or crash).

- Driver.c: WDF_FILEOBJECT_CONFIG_INIT + WdfDeviceInitSetFileObjectConfig;
  AutoForwardCleanupClose = WdfFalse (our device-interface CREATE/CLOSE IRPs
  are completed here, not forwarded down to bthport).
- Ioctl.c: LpEvtFileClose -> idempotent LpDisconnect at PASSIVE_LEVEL.
- LibrePodsAAP.h: declare the callback.
- prebuilt/: refresh .sys/.cat (built clean on the Windows host + inf2cat OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-user, no admin: copies the exe to %LOCALAPPDATA%\LibrePods and drops a
Startup-folder shortcut (WScript.Shell). `-Remove` undoes it. Stable location
so it survives a WSL build-target clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the OS-specific LE advertisement source out of bluetooth/le.rs behind the
platform layer, so only the RPA/IRK match + AES decode + battery parse remain
platform-agnostic (Linux stays green; Windows gets a WinRT watcher in Phase I/J).

- platform: add LeAdvertisement{address, apple_data} and
  watch_le_advertisements() -> channel of adverts, plus
  connect_device(&DeviceId) for the auto-connect path.
- platform/linux/le_scan.rs: the old bluer::monitor Apple-0x004C pattern +
  per-device dev.events() ManufacturerData source, and the bluetoothctl-connect
  shell-out.
- bluetooth/le.rs: consume the platform stream; extract maybe_auto_connect
  (dedup by observed RPA, connect by stored identity MAC). start_le_monitor no
  longer returns bluer::Result. No more direct bluer imports here.
- main.rs: update the caller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pause media when both AirPods leave your ears and resume when one goes back in
(the Apple/MagicPods behaviour) — without touching the A2DP profile, which
Windows manages itself. The one-time REQUEST_NOTIFS already sent at handshake is
what makes the buds push ear-detection (opcode 0x06) events; we just parse them.

- aap.rs: EarStatus + parse_ear_detection (opcode 0x06, primary/secondary at
  bytes 6/7).
- media.rs: WinRT SMTC (GlobalSystemMediaTransportControlsSession) pause/play/
  is_playing; COM MTA init on the receiver thread.
- main.rs: run_receiver state machine — pause only if something was playing,
  and only resume media that WE paused (never fight a user-initiated pause).
- Cargo.toml: windows crate Foundation + Media_Control features.

Compiles + deployed; hardware validation pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmed on real AirPods Pro 3: removing both buds pauses media, putting one
back resumes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move all PulseAudio (libpulse) code out of media_controller.rs behind the new
platform::AudioRouter trait. The MediaController keeps its orchestration and now
delegates the A2DP card-profile management + sink-volume ducking to the platform.

- platform: AudioRouter trait (activate_a2dp / deactivate_a2dp / get_volume /
  set_volume, mac-addressed, 0..=100 volume) + platform::audio_router().
- platform/linux/audio.rs: LinuxAudioRouter owns the device-index +
  cached-profile state and all the libpulse helpers (card/sink introspection,
  set_card_profile, transition_sink_volume, wireplumber restart).
- media_controller.rs: drop libpulse; hold Arc<dyn AudioRouter>; activate/
  deactivate + conversational-awareness volume now go through it. (The as_f64
  on Volume::NORMAL.0 came from dbus RefArg being in scope; use a plain cast.)

Linux cargo check green. MPRIS/media control still in-file (Phase G next).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move all MPRIS/D-Bus media control out of media_controller.rs behind the new
platform::MediaControl trait. MediaController keeps orchestration and delegates
is_playing / pause / pause_all / resume / next / previous to the platform.

- platform: MediaControl trait (sync/blocking) + platform::media_control().
- platform/linux/media.rs: LinuxMediaControl = the MPRIS enumeration + play/
  pause/skip logic (list_mpris_services, kdeconnect filter, PlaybackStatus).
- media_controller.rs: hold Arc<dyn MediaControl>; the pause/resume/skip methods
  now wrap the trait in spawn_blocking. Zero direct dbus/libpulse remain here.

Linux cargo check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Isolate the ksni/StatusNotifier tray so nothing outside the Linux backend
depends on it. MyTray stays the platform-neutral view-model; its ksni::Tray impl
(menu + battery-icon rendering) and the ksni/ab_glyph imports are now
cfg(target_os = "linux").

- platform: TrayHandle type (Linux = ksni::Handle<MyTray>) + platform::spawn_tray.
- platform/linux/tray.rs: ksni spawn (graceful — logs instead of unwrap-panic).
- ui/tray.rs: gate the ksni::Tray impl + generate_icon + get_app_settings_path.
- airpods.rs / le.rs / main.rs: use platform::TrayHandle; drop direct ksni imports.

ksni is now referenced only from Linux-gated code. Linux cargo check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate the Linux-only system-library deps and provide Windows stub backends for
every platform trait, so `cargo check --target x86_64-pc-windows-gnu` passes
(0 errors) while Linux stays green. This is the milestone where the whole
librepods app builds for Windows — real WinRT/WASAPI/SMTC/tray-icon backends
replace the stubs in Phase J.

- Cargo.toml: move bluer/dbus/libpulse-binding/ksni under
  [target.'cfg(target_os="linux")'.dependencies].
- bluetooth/aacp.rs, att.rs: drop bluer::{Error,Result} for std::io::{Error,
  Result} (io::Error::from(io::Error) is identity; the protocol is now
  platform-neutral).
- devices/airpods.rs: local adapter MAC via platform::local_adapter_address()
  instead of a direct bluer::Session (added Linux impl + Windows stub).
- platform/windows/{watcher,discovery,le_scan,audio,media,tray}.rs: stub
  backends implementing the AudioRouter/MediaControl traits + adapter/discovery/
  LE/tray plumbing (idle channels, no-op A2DP, WindowsTrayHandle).
- platform: export the Windows backends + TrayHandle alias.
- windows/transport.rs: CreateFileW hTemplateFile must be a HANDLE null_mut(),
  not 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…for Windows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ackends

Replace the media/audio stubs with working backends, reusing the approach proven
in librepods-tray.

- platform/windows/media.rs: MediaControl via System Media Transport Controls
  (GlobalSystemMediaTransportControlsSessionManager) — is_playing / pause_playing
  (tracks paused sessions by AppUserModelId) / pause_all / resume / Next+Previous.
  COM (MTA) ensured per call since these run on spawn_blocking threads.
- platform/windows/audio.rs: AudioRouter volume via Core Audio
  (IAudioEndpointVolume on the default render endpoint); A2DP stays no-op (Windows
  manages it).
- Cargo.toml: windows crate (Media_Control + Foundation + Win32_Media_Audio) for
  the Windows target.

cargo check --target x86_64-pc-windows-gnu green; Linux green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ss (WinRT)

- platform/windows/le_scan.rs: BluetoothLEAdvertisementWatcher (active scan) with
  a Received handler that pulls Apple (0x004C) manufacturer data out of each
  advert and forwards LeAdvertisement{address, apple_data} to the shared
  bluetooth/le.rs decoder — same RPA/battery/ear-detection pipeline as Linux.
  Address u64 -> DeviceId via the existing From<u64>. Watcher leaked to stay
  alive for the app lifetime.
- platform/windows/watcher.rs: local_adapter_address via WinRT
  BluetoothAdapter.BluetoothAddress (was a None stub).
- Cargo.toml: add Devices_Bluetooth, Devices_Bluetooth_Advertisement,
  Storage_Streams windows features.

cargo check --target x86_64-pc-windows-gnu green; Linux green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arctumn added 9 commits August 8, 2026 18:17
…pp version

- OverlayText also translates interpolated daemon strings (Connected · Left/Right/
  Case, battery-low, case opened/closed, renamed) — not just the fixed ones.
- Device info: model + firmware always visible; only the serial sits behind the
  eye + blur (the serial is the sensitive field, per the Linux app).
- Show the app version in the About card (for bug reports).
Full translations of all 90 UI strings; the Strings\**\*.resw glob picks them up
and the OS display language auto-selects them (en-US stays the fallback).
…Android

Available for the noise-control card / island / tray. Wiring them into the UI is
a visual pass (icon appearance + RadioButton content) done next with the app open.
Documents Windows support alongside Linux/Android, incl. the hi-res two-way audio
(native mic) that is Windows-only, localized UI, and the experimental HR status.
daemon/ipc: Command::Disconnect releases the AAP session (recv loop checks
connect_requested and bails, drops the driver, no auto-reconnect); Command::SetName
sends the 0x1A rename (aap::build_rename). winui: DeviceHeader gets a pencil that
swaps the name for a text box + submit/cancel (Enter/Esc), and a Disconnect button
shown while connected. Localized in en/pt/fr/es. Also drop the localized-UI row
from the README feature matrix.
….Content

XamlParseException on startup: HeaderDisconnectBtn was defined as .Content but its
x:Uid sits on a TextBlock (which has no Content property). Renamed the key to
.Text in all four languages. Also switched AccentButtonStyle to StaticResource
(the correct reference for a non-theme style).
…w name

Rename reached the AirPods but the app reverted the name: the daemon kept pushing
snapshots with the old dev_name. Make Ctx.dev_name an Arc<Mutex<String>> and set it
on SetName (+ push), so the new name persists in the UI (the OS's cached BT name
may still need a re-pair, but the app shows what the user typed).
…imestamps

Disconnect now also toggles the AirPods' A2DP/HFP services OFF on the local radio
(a true Windows disconnect, not just releasing the AAP session); Connect toggles
them ON. Both run off-thread. Also prefix every daemon.log line with a UTC
HH:MM:SS.mmm timestamp for correlation.
…_descriptors

Match the working Android client (upstream PR librepods-org#702): the heart-rate enable now
sends control 0x30 (HRM_STATE=on) between the AACP 1.3 session init and the
stream start, and drops the request_all_descriptors frames (an iOS-capture
guess that never helped and Android does not send).

A btvs + Wireshark capture (rtbuddy/aacp dissector) confirms the AirPods ACK
service 19 every cycle but emit zero data frames on the A3063 test unit even
with 0x30 on the wire — so 0x30 is necessary to match Android but not
sufficient standalone here; the remaining gap is non-protocol (fit / another
Apple host holding the service / firmware). Kept because it mirrors the
working client.
arctumn and others added 20 commits August 8, 2026 23:16
Mirror the working Android HeartRateMonitor loop, which the PR librepods-org#702 author
confirmed: the enable is complete (no keepalive, no HealthKit), at least one bud
must be in-ear, and a full disconnect/reconnect is worth trying.

- Only succeed on a decoded reading; ignore the bare service-19 ACKs (the failure
  mode is ACK-but-no-data), and re-send the full enable every ~8 s while HR is on
  instead of stopping after one attempt.
- After 4 ACK-only attempts, ask run_receiver to rebuild the L2CAP channel (drop +
  reopen in-process, so no driver code-38) and re-arm HR on it — the Android
  requestTransportRecovery.
- Cap that at 3 rebuilds, then give up ("Heart rate unavailable") so we don't
  reconnect forever; reset the budget when HR is re-toggled or on a new connect.
- Stop head tracking up front (beforeFirstStart), matching the author's order.

Tested (bud in-ear, iPhone off): the AirPods ACK service 19 every cycle but never
stream a data frame across several rebuilds — type19 stays 0. Firmware is the
leading suspect vs the author's working unit.
The WinUI resource step loads Microsoft.Build.Packaging.Pri.Tasks.dll, a
.NET Framework assembly that `dotnet build` (MSBuild hosted on .NET Core)
cannot load → MSB4062. Build the WinUI project with Visual Studio's
msbuild.exe (microsoft/setup-msbuild), which is .NET Framework and loads it;
keep the .NET 10 SDK for the net10.0 target resolver. Also make artifact
collection locate librepods-winui.exe instead of hardcoding the TFM/RID path.
The upstream PR librepods-org#702 author flagged our incrementing sequence id as the one
remaining byte-level difference from his HEART_RATE_START_1S. Pin it to his exact
value (0x2363 = varint e3 46) so our start frame is byte-for-byte identical, and
so retries re-send the same constant like his client does. Tested: no change
(still ACK-only), but it removes the last byte difference.
Tested whether AirPods stream heart-rate only over an ERTM channel (Android/
Fluoride opens AAP sockets in ERTM; ours was Basic). Opened the channel with
BRB_L2CA_OPEN_ENHANCED_CHANNEL + CM_RETRANSMISSION_AND_FLOW across two builds
(timeouts 0, then 2000/12000 with a zeroed config and MPS>0). Result: the Windows
bthport stack never serializes the ERTM option to the wire for a client profile
driver (the Configure Request always went out Basic — MTU+FlushTO only), and the
AirPods counter-propose Basic anyway. So ERTM is not the HR differentiator, and
requesting it just breaks the connect. Reverted to plain BRB_L2CA_OPEN_CHANNEL.

install.ps1: regenerate the catalog over the *signed* .sys (signing changes the
file, so the pre-generated .cat hash would mismatch). DriverVer bumped so a
rebuilt package supersedes the installed one.
The BLE watcher fires on every advertisement (several/sec), so the 20 s time
debounce still re-asked "connect?" forever while the AirPods sat nearby, and it
ignored a dismissed prompt. Prompt once when they appear, then stay quiet until
they've been absent (no advertisement for >45 s — cased or out of range) and
return — the natural "ask again on the next case-open" behaviour.
… gated)

AirPods Pro 3 restrict the heart-rate stream to Apple hosts: on Windows the buds
accept and ACK the enable but never send readings — an intentional Apple-
ecosystem restriction, not a LibrePods bug (ERTM / encryption / Device-ID spoof
all ruled out; see docs/windows/heart-rate.md). So the card no longer ships on by
default.

- AppSettings: persist an EnableHeartRate opt-in (off by default).
- SettingsPage: new "Experimental" card — a warning InfoBar ("very likely won't
  work on Windows") + a toggle; raises HeartRateVisibilityChanged.
- DevicePage: the HeartRateCard is collapsed unless opted in; RefreshHeartRate-
  Visibility() flips it live. MainWindow wires the two together.
- Localized the new strings (en-US / pt-PT / es-ES / fr-FR).
- docs/windows/heart-rate.md: why it doesn't work + what was ruled out.
The Lua dissectors at github.com/pabloaul/apple-wireshark name most AACP
message types, and ship rtbuddy.proto -- the SensorDataWX protobuf schema we
had been reconstructing by hand. Validated here against three captures: the
RTBuddy length field matched the real payload on 100% of 4073 frames.

Replaces the "Undocumented Opcodes Observed" table with names for all but one
of the 23 entries. Notable:

  0x22 / 0x23  Case Info Request / Case Info
  0x59         Dynamic End Of Charge
  0x1B         Timestamp (ISO 8601 local date-time)
  0x54         Set Band Edges
  0x29         Set Country Code
  0x2B         Stream State Info
  0x4F         Unified Accessory Restore Protocol (its own dissector)

Correction: 0x44 is **Send Smart Routing 2.0 Info**, audio routing. An earlier
revision called it sensor subscription because it appeared shortly before stream
changes. The name fits the observations better than the sensor reading ever did
-- it shows up at session and audio state changes, and its payload never
correlated with any stream starting.

Two leads recorded for the unresolved 0x01 vs 0x05 charging question: 0x22/0x23
give case state directly rather than inferring it from the battery packet, and
0x59 "Dynamic End Of Charge" is present in the capture where 0x05 appeared and
absent from the hands-off captures where it did not. Flagged as a correlation to
test, not a conclusion.

(cherry picked from commit 7dde1fc)
…icon

A pass over the WinUI client's visuals + tray, driven by dogfooding.

Device artwork (Services/DeviceArt.cs — model number → asset family):
- DeviceHeader shows the model's render instead of the generic airpods.png.
- BatteryCard shows per-component bud/case artwork above each gauge.
- The connect island uses the model render, with the last-seen model cached
  (AppSettings.LastModel) so it's right before the 0x1D metadata arrives.

Noise control: the vertical RadioButtons become a segmented row of icon buttons
(Off / NC / Transparency / Adaptive) with the active mode named below.

Island: the message mode shows the mode icon for a noise-control change (not the
device), and the whole popup now follows the app theme.

Nav pane: the device item gains a model icon + a compact battery %, shown in the
expanded pane (handy once multiple devices are listed).

Tray: renamed TrayIcon -> TrayController; the WinUI MenuFlyout (which clipped to
~1 char in H.NotifyIcon's SecondWindow host) and a native Win32 menu (no per-item
icons, no theme) are both unusable here, so the context menu is now a custom
themed window (Popup/TrayMenuWindow + TrayMenuView) with an icon per row, the
model image, battery, ANC checkmarks, mute/open/quit — dismissed on focus loss.
The icon badge is a large battery number; the tooltip gains the ANC mode.

Fixes: the "Connect?" prompt no longer shows while already connected; the
one-column device layout drops the phantom right gap (collapsed Col1 kept its
ColumnSpacing).

New app/tray icon (transparent background).

Settings: a UI-language selector (persisted, applied at startup). NOTE: runtime
language switching is limited on the unpackaged build — PrimaryLanguageOverride
does not re-resolve x:Uid there; a code-based localization pass is the follow-up.
The raster ANC art (anc_*.png) is fixed-colour — white washed out on the light
acrylic, black vanished on dark. Show a Segoe Fluent FontIcon (follows the theme
foreground) instead, matching the tray menu's mode glyphs.
The anc_*.png icons had inconsistent padding/weight so they framed unevenly next
to the (glyph) Off button. Use uniform Segoe Fluent FontIcons — consistent
framing, theme-following (incl. on the accent-selected button), matching the
island + tray mode glyphs.
x:Uid + PrimaryLanguageOverride don't re-resolve on the unpackaged build, so the
language selector needed a full restart (and didn't even work). Replace it with an
Angular-style translate service:

- Services/Loc.cs — a singleton INotifyPropertyChanged that parses the embedded
  Strings/<culture>/Resources.resw into a culture -> key -> value map. Indexer
  Loc["Key"] returns the current-culture string; SetCulture() raises the indexer
  PropertyChanged so every binding re-fetches → the whole UI switches LIVE.
- All UI text now binds to {Binding [Key_Prop], Source={StaticResource Loc}}
  instead of x:Uid (keys are the resw names with '.' → '_' for the indexer path).
- Localize.Get (code-picked strings) delegates to Loc.
- The language selector calls Loc.SetCulture — no more restart hint.
- The .resw are embedded (LogicalName loc.<culture>) alongside the existing PRI
  resources, so translations aren't duplicated.

Works on the unpackaged build (no MRT / x:Uid / PrimaryLanguageOverride).
…otify

WinUI (unlike WPF) doesn't refresh {Binding [key]} on an "Item[]" notification,
so switching language updated nothing outside the active page. Raise the empty-
string "all properties changed" notification so every binding re-fetches. Also
make the embedded-resw lookup scan the manifest names (not a hard-coded
LogicalName) so a prefixed/mangled name can't silently yield an empty map.
Confirmed: the whole app switches language live now.
The built-in NavigationView Settings item is OS-localized; re-label it from Loc
(live) so it follows the in-app language. Update the language hint to say it
applies immediately (no restart).
App.xaml instantiated a SECOND Loc via <services:Loc x:Key=Loc>, distinct from the
static Loc.Instance used by code — so SetCulture changed one while the other stayed,
leaving code strings in one language and XAML bindings in another. Inject the single
Loc.Instance into Application.Resources instead.
A daemon overlay (e.g. "Connected · battery") is a one-shot string resolved when
it arrives, so one shown before a language switch lingered in the old language.
Close it on a culture change; the next overlay renders in the new language.
…sources write

Mutating Application.Resources in the App ctor threw a COMException. Revert to
declaring the one <services:Loc x:Key=Loc/> in App.xaml (which works), but drop the
static new() so it's the ONLY instance; its ctor publishes Loc.Instance for code.
No second instance → no culture split, and no runtime resource mutation.
…itch

- ToggleSwitch On/Off content was OS-localized (system language); bind OnContent/
  OffContent to Loc (Toggle_On/Toggle_Off keys, 4 languages) so it follows the app.
- Code-picked strings (header status, mic state, ANC mode name) are only set when a
  snapshot arrives, so they lingered in the old language after a switch. Re-render
  the last snapshot on a culture change.
The BLE proximity watcher prompted "Connect?" and waited for a click. The buds
being audio-connected in Windows doesn't open our AAP L2CAP session (separate
channels), so the app sat on "Disconnected" until a manual Connect. Instead
auto-connect on the proximity edge-trigger: set connect_requested (run_receiver
opens the session) and nudge the OS audio up. Still edge-triggered per visit, so
no reconnect spam.
winui: model artwork, richer island/nav, custom tray menu, new icon
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant