Skip to content

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

Closed
arctumn wants to merge 250 commits into
librepods-org:linux/rustfrom
arctumn:cross-platform
Closed

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

Conversation

@arctumn

@arctumn arctumn commented Aug 7, 2026

Copy link
Copy Markdown

The short version

I use AirPods Pro on both Linux and Windows, and I wanted one app that works on both — no WSL, no re-pairing, no second codebase to keep in sync. This draft does that: it takes the existing linux-rust app (librepods) and makes it build and run natively on Windows, while the Linux build stays untouched and green from the same crate.

It's a work in progress, opened as a draft for visibility and feedback. Everything below is developed and hardware-validated on real AirPods Pro on a Windows machine — not simulated. Addresses #433 (native Windows, no WSL).

The whole idea in one line: all OS-specific integration moved behind a platform/ abstraction chosen at compile time. The protocol and UI code (aacp, att, le, media_controller, the iced GUI) is the same on both platforms — only the backend swaps underneath it.

What works today

All hardware-validated on real AirPods Pro (Windows):

  • Battery (L / R / Case), over both AAP and BLE
  • Noise control — Off / ANC / Transparency / Adaptive
  • Ear-detection auto-pause (via SMTC)
  • Device info and rename
  • 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 (48 kHz, auto-activates when an app starts recording)
  • Conversational Awareness volume ducking (host-side), Adaptive / Personalized Volume, Allow-Off, Adaptive noise strength (0x2E)
  • BLE proximity connect prompt — asks before connecting, and never steals the AirPods from your phone
  • A headless daemon (librepodsd) that owns the exclusive driver + AAP session and serves thin UI clients over named-pipe IPC, so audio stays clean and multiple UIs coexist

What doesn't work (yet), honestly

  • Heart rate (AirPods Pro 3) is implemented (a port of Add experimental AirPods heart-rate monitoring and RSSI #702) but does not produce data on current firmware. The HR packet protocol changed in the recent firmware (8x / the 9x beta) and is currently unmapped on any platform — even sending the exact command the iOS Health app sends, with an iPhone connected, yields no stream (confirmed with the original author). Our enable sequence is byte-identical to the working Android client; the buds ACK it every cycle but never stream on this firmware. So it ships off by default, behind an experimental toggle with a warning. It's not a bug on our side — it needs the new firmware protocol to be re-mapped from an iPhone capture first.
  • The two kernel drivers require Test Mode for personal use. Distributing to others needs an EV cert / WHQL signing — one of the reasons this is a draft.
  • The Windows-only pieces (hi-res mic, kernel L2CAP) don't exist on Linux by nature; those paths are #[cfg(windows)]-gated and the Linux backend is unchanged.

How it's built

The platform/ abstraction

Everything OS-specific is behind a trait boundary selected with #[cfg(target_os)]. The Linux backend is the existing code, unmoved; the Windows backend is new:

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 either of these (verified against the driver docs and 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, so ours is a WDM/ACX driver.

Both drivers are open-source and, for personal use, test-signed.

Architecture note

A single headless daemon (librepodsd) owns the exclusive driver handle and the one AAP session, and every UI (the iced app and the WinUI client below) is a thin named-pipe client of it. That's what keeps the audio clean and lets multiple front-ends coexist without fighting over the device.

For review

  • Linux paths are unchanged and stay green at every step (cargo build on both targets).
  • Protocol-doc follow-up: AAP Definitions.md was missing battery status 0x05 (charging in case) and the 0xFF-absent rule — added here.
  • Feedback especially welcome on the platform/ abstraction shape, and whether upstream wants the Windows drivers in-tree or as a companion repo.

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's the default UI on Windows (switchable back to iced in Settings), fully async, self-contained / unpackaged on .NET 10, with a 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

Feature parity across our own front-ends

Three front-ends are in play: the native WinUI client (Windows), the iced app (Linux + Windows from one crate), and the reference Android app. One structural note that explains the columns: on Windows the iced app runs its own AAP session and uses the daemon only as a raw L2CAP transport, so its feature set tracks iced-Linux — it's the WinUI client that surfaces the daemon's richer capabilities (hi-res mic, island, toasts).

Legend: ✓ yes · — not present · ◐ partial.

Feature WinUI (Windows) iced (Windows) iced (Linux) Android
Battery (L / R / Case)
Noise control (Off / ANC / Transparency / Adaptive)
Ear-detection auto-pause ✓ (SMTC) ✓ (SMTC) ✓ (MPRIS)
Conversational Awareness ducking (host-side)
Adaptive / Personalized Volume, Allow-Off
Adaptive noise strength (0x2E) 1 1
Hi-res AAC-ELD microphone (system input) 2
Heart rate (AirPods Pro 3) ◐ blocked, off by default 3 3
Hearing aid / audiogram (ATT 0x001F) 🚧 separate branch 4
Rename device
Connection island / popup
Native toast notifications
Localization ✓ (4 languages) — (English only) — (English only) ◐ (2)
System tray ✓ (native) ◐ (tray-icon) ✓ (ksni) n/a (Quick-Settings tile)
BLE connect-prompt ("never steal from phone")
Multi-vendor (Nothing earbuds)

1 The 0x2E enum exists in the iced app but has no UI yet.   2 The hi-res mic is a daemon capability (virtual audio driver + AAC-ELD decode); the WinUI client is the one that exposes it.   3 HR is blocked on current AirPods Pro 3 firmware for everyone — the protocol changed and is unmapped on any platform; it ships off behind an experimental toggle (see "What doesn't work").   4 The hearing-aid/audiogram work lives on a separate branch, not in this PR yet.

Android is still the most complete (head-tracked spatial audio, gesture / long-press config, home-screen widgets, camera control, cross-device takeover), and the desktop stack is catching up on the AirPods essentials — while already adding a couple of things Android doesn't have (the hi-res microphone, host-side Conversational-Awareness ducking).

How this compares to MagicPods

The closest Windows counterpart is MagicPods — a mature, polished app worth acknowledging. This is a fair-as-I-can 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) ⚠ implemented, blocked on current firmware (off by default)
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: it's 100% free and open — drivers included — with a hi-res mic, host-side CA ducking, and a single codebase that keeps Linux and Windows in lockstep.

Feedback welcome — on the abstraction, the drivers, or anything I've framed unfairly above.

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 and others added 26 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
L2cap.c: a comment recording that CF_LINK_ENCRYPTED was tried (it forces a
re-auth at open; the paired link is already encrypted) — no code change, still
plain BRB_L2CA_OPEN_CHANNEL. Bump DriverVer to match the last built package.
gitignore *.vcxproj.user (per-user VS build config, never shared).
Re-tested at a higher bar and folded the results into the write-up:
- Encryption: the AAP channel was opened with encryption *required*
  (CF_LINK_ENCRYPTED in the BRB ChannelFlags) and confirmed connecting,
  encrypted, with HR still ACK-only. Note the earlier 0xC000000D pitfall
  (the flag wrongly placed in the CFG_* ConfigOut/In.Flags bitmask).
- Device ID: set the *complete* Apple DID (VendorIDSource+VendorID+
  ProductID+Version) and republished via reboot; connect and HR are
  unchanged. The record alone does not establish an Apple host.
…r Close)

The floating island called Window.Close() from its slide-out timer tick. Closing a
WinUI 3 Window that carries a DesktopAcrylicBackdrop + themed content fail-fasts inside
the XAML theme-resource teardown (Microsoft_UI_Xaml!OverrideXamlResourcePropertyBag,
0xC000027B) — a native crash SafeClose's try/catch could not stop. Any daemon overlay
(connect card, rename toast, the experimental heart-rate toggle) triggered it ~4s later.

SafeClose now hides the single window (AppWindow.Hide) and resets the animation state
instead of closing it, so the next overlay slides the same instance back in — the reuse
the design already intended. Extracted StartSlideIn() so a reused window restarts its
slide (the first show still waits for OnViewLoaded, when the XamlRoot/scale exist).
… reconnect

Renaming the AirPods updates the daemon immediately, but Windows keeps the old OS-level
name until the device disconnects and reconnects. Without a heads-up the unchanged name
reads as a failed rename. On a successful rename the header now pops a light-dismiss
TeachingTip anchored to the name explaining this. Localized in all four languages
(Rename_WindowsNoticeTitle / Rename_WindowsNoticeBody).
The tray launcher defaulted to the iced cross-platform app. On Windows we prefer the
native WinUI 3 client, so flip the default: only an explicit 'iced' in ui.pref selects
iced now; everything else (incl. no pref file) resolves to WinUI. The iced app still
launches when chosen, and launch() already falls back to it if librepods-winui.exe
isn't installed — so 'Open App' always works.
@arctumn

arctumn commented Aug 11, 2026

Copy link
Copy Markdown
Author

Superseded by #716 — the Windows work is now a self-contained windows/ folder added to main, fully decoupled from the Linux crate (no shared-crate changes, CI path-scoped so it never gates Linux). Closing in favour of #716.

@arctumn arctumn closed this Aug 11, 2026
@arctumn
arctumn deleted the cross-platform branch August 11, 2026 13:17
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