diff --git a/CHANGELOG.md b/CHANGELOG.md index 96233df..357d1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,51 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Fixed + +- **Switch output when off stopped working, permanently and silently.** The record of where + the sound came from doubles as the "we owe you a move back" flag, and its presence was + read as "already switched". Endpoint ids do not live forever — a reinstalled driver or a + re-enumerated dongle retires one — so once that record named an endpoint the machine no + longer had, it could never be discharged and never be replaced. Every later power-off and + power-on did nothing at all. A record now counts only while its endpoint still exists, + and one that never reappears is given up on after about ten seconds rather than being + kept forever. + +### Added + +- **The output switch says when it can't do its job.** No device chosen, the chosen device + unplugged, Windows refusing the change, or the device you were on having disappeared: each + raises a notification the first time and stays on the settings row until it clears. + Previously all four were `tracing` calls at a level no subscriber was listening to. +- `headset-tray.exe --explain-output`, which reports what the switch would do for a + powered-off and a powered-on headset, and why, without moving anything. +- **Split game and chat**, off by default. The headset presents two playback endpoints and + Windows keeps a separate default for calls, so the two exist precisely to be pointed at + different roles. Putting the sound *back* the way it was found cannot do that — it names + one endpoint — and setting that one endpoint into all three of Windows' roles overwrote a + communications default aimed at the chat channel, so the voices came out of the game + channel. Turn the setting on, pick a **Game channel** and a **Chat channel**, and a + headset coming back gets ordinary sound on the first and calls on the second. Left off, + nothing about the switch changes. + +## [0.1.0-alpha.2] - 2026-08-05 + +### Fixed + +- **The game/chat slider ran backwards.** The panel labelled low values CHAT and high + values GAME, so dragging toward GAME moved the mix toward chat. `0x00` is full game and + `0x14` full chat — a direction the captures never established, since they record the + range and the clamps but not which end is which. + ### Added +- **Switch output when off.** Turning the headset off moves Windows' sound to a + device you choose, and turning it back on puts it where it was. Off by default. The + trigger is the headset's link state — no power-button event exists in the protocol — so + an auto-sleep or going out of range counts too, debounced by two seconds. Setting the + default output has no documented Windows API; see + [`docs/undocumented-apis.md`](docs/undocumented-apis.md). - A light theme. It follows your Windows setting by default, with an **Appearance** override in settings. High contrast still wins over both. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25fbd9a..2c4225b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,11 +29,27 @@ target, and this is not incidental: If you need to update a dependency, check that the whole tree stays off `raw-dylib` before proposing it. +Two things follow from that target and neither is obvious from a failed build: + +- **You need MinGW-w64 on `PATH`.** The GNU target links with `gcc`, and rustup does not + supply one. `winget install BrechtSanders.WinLibs.POSIX.MSVCRT` is enough; take the + MSVCRT runtime variant, which is the C runtime this target links against. Without it the + build stops at `linker 'gcc' not found`. +- **Make GNU your default host, not just an installed target.** `rustup target add` alone + is not sufficient: build scripts and proc macros compile for the *host* triple, so a + stock MSVC-host rustup fails with `linker 'link.exe' not found` before it reaches any of + this project's code — and installing Visual Studio to satisfy that is the wrong fix. + ```powershell +rustup set default-host x86_64-pc-windows-gnu cargo build --workspace cargo test --workspace ``` +CI gets away without the first step because GitHub's Windows runners ship MinGW-w64 +already, and without the second because it passes `--target x86_64-pc-windows-gnu` +explicitly while running on a host that has both linkers. + Most tests need no hardware: the device layer has a fixture-driven fake backend, and the panel layout, protocol codec, and placement geometry are pure and fully unit-tested. The tests that do need a headset are `#[ignore]`d *and* gated behind an environment variable, diff --git a/Cargo.lock b/Cargo.lock index 16c4d88..f4dce30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ [[package]] name = "headset-cli" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "anyhow", "clap", @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "headset-device" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "headset-protocol", "serde", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "headset-protocol" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "serde", "thiserror", @@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "headset-tray" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "headset-device", "headset-protocol", diff --git a/Cargo.toml b/Cargo.toml index db924eb..b8f779d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" edition = "2021" # Matches the toolchain pinned in rust-toolchain.toml, which is what CI builds # and tests with. Not an independently verified floor: no job builds against an @@ -73,6 +73,14 @@ windows = { version = "0.58", features = [ "Win32_System_Registry", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", + # Listing playback devices and naming them, for the switch that moves the + # sound off a powered-down headset. Features of the pinned 0.58, not + # crates: `IMMDevice::OpenPropertyStore` needs the property-system feature + # and the friendly-name key lives under function discovery. Setting the + # default output has no binding here and no documentation anywhere -- see + # `crates/headset-tray/src/win32/audio.rs`. + "Win32_Devices_FunctionDiscovery", + "Win32_UI_Shell_PropertiesSystem", # DPI awareness. A feature of the pinned 0.58, not a crate. The manifest # route is unavailable on x86_64-pc-windows-gnu, which has no # /MANIFEST:EMBED, so the process opts in at runtime through diff --git a/README.md b/README.md index 91d82e4..6e11691 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,43 @@ with what you asked for, you see the truth. Right-click the icon for Refresh and Exit. The gear opens settings: start with Windows, and whether to warn you when Synapse is running (it can fight this app for the same settings). +**Switch output when off** moves Windows' sound to another device when the +headset goes quiet, and puts it back when the headset returns. Turn it on in settings and +pick the device under **Play through** — your speakers, usually. It's off until you do +both. + +Holding the power button is what turns the headset off, and that's what this reacts to. +The headset reports that its link dropped, not *why*, so an auto-sleep or walking out of +range looks the same and will also move your sound. Brief dropouts won't: nothing moves +until the link has held its new state for a couple of seconds. + +It puts you back exactly where you were, not on a guess — the dongle presents two +playback devices (Game and Chat) and only you know which one you were using. If the tray +is closed or restarted while your headset is off, it still remembers what it owes you. + +Windows offers no supported way for a program to change the default playback device, so +this uses the same undocumented interface every audio switcher uses. If it ever stops +working, nothing moves and you're left where you were. +[`docs/undocumented-apis.md`](docs/undocumented-apis.md) is the full record. + +**When it doesn't move your sound, it says so.** You get a notification the first time, +and the reason stays on the **Switch output when off** row in settings until it works +again. For the full picture — which device is chosen, which one you're owed a move back to, +and what would happen right now — run: + +```powershell +headset-tray.exe --explain-output +``` + +It only reads; it won't move anything. + +**Split game and chat** is a separate switch, off by default. The headset shows up in +Windows as two playback devices — a game channel and a chat channel — and Windows keeps a +separate default for calls, so the two are there to be used for different things. Turn it +on, pick a **Game channel** and a **Chat channel**, and whenever the headset powers on your +ordinary sound goes to the first and your calls to the second. Leave it off and the tray +won't touch which device your calls use. + The panel **follows your Windows light or dark setting** by default. **Appearance** in settings overrides that — auto, dark, or light. If you use Windows high contrast, it wins over all three. diff --git a/crates/headset-tray/src/lib.rs b/crates/headset-tray/src/lib.rs index 99eedf4..0ddade6 100644 --- a/crates/headset-tray/src/lib.rs +++ b/crates/headset-tray/src/lib.rs @@ -10,6 +10,7 @@ #![cfg_attr(not(windows), forbid(unsafe_code))] +pub mod output; pub mod state; pub mod ui; pub mod worker; @@ -21,6 +22,7 @@ pub mod settings; #[cfg(windows)] pub mod win32; +pub use output::Slot; pub use state::HeadsetState; pub use worker::Command; @@ -49,6 +51,52 @@ pub fn settings_show_warning() -> bool { settings::show_synapse_warning() } +#[cfg(windows)] +pub fn settings_switch_output() -> bool { + settings::switch_output_when_off() +} + +/// Whether the headset's game and chat channels are set apart when it returns. +#[cfg(windows)] +pub fn settings_split_game_and_chat() -> bool { + settings::split_game_and_chat() +} + +/// What stopped the output switch or the split working last time, if anything. +/// +/// The panel reads this rather than being told: the failure it describes +/// happened while the panel was closed, which is the whole reason it is +/// written down. +#[cfg(windows)] +pub fn settings_output_problem() -> Option { + settings::output_problem() +} + +/// A chosen device as `(name, is it plugged in right now)`. +/// +/// Presence is resolved here rather than in `layout` because it takes a Core +/// Audio enumeration; the wording built from it stays pure and testable in +/// `layout::output_choice_subtitle`. +#[cfg(windows)] +pub fn settings_output_choice(slot: Slot) -> Option<(String, bool)> { + let (id, name) = settings::output_choice(slot)?; + Some((name, win32::audio::is_present(&id))) +} + +#[cfg(windows)] +pub fn settings_output_choice_id(slot: Slot) -> Option { + settings::output_choice(slot).map(|(id, _)| id) +} + +/// Every playback device, as `(endpoint id, name)`. +#[cfg(windows)] +pub fn output_devices() -> Vec<(String, String)> { + win32::audio::render_outputs() + .into_iter() + .map(|d| (d.id, d.name)) + .collect() +} + #[cfg(not(windows))] pub fn settings_run_on_startup() -> bool { false @@ -63,3 +111,33 @@ pub fn settings_show_warning() -> bool { pub fn warn_vendor_software() -> bool { false } + +#[cfg(not(windows))] +pub fn settings_switch_output() -> bool { + false +} + +#[cfg(not(windows))] +pub fn settings_split_game_and_chat() -> bool { + false +} + +#[cfg(not(windows))] +pub fn settings_output_problem() -> Option { + None +} + +#[cfg(not(windows))] +pub fn settings_output_choice(_slot: Slot) -> Option<(String, bool)> { + None +} + +#[cfg(not(windows))] +pub fn settings_output_choice_id(_slot: Slot) -> Option { + None +} + +#[cfg(not(windows))] +pub fn output_devices() -> Vec<(String, String)> { + Vec::new() +} diff --git a/crates/headset-tray/src/main.rs b/crates/headset-tray/src/main.rs index da36e26..f615300 100644 --- a/crates/headset-tray/src/main.rs +++ b/crates/headset-tray/src/main.rs @@ -17,11 +17,13 @@ fn main() { "--uninstall" => run_uninstall(), "--render-panel" => run_render_panel(), "--export-icon" => run_export_icon(), + "--explain-output" => run_explain_output(), "--help" | "-h" | "/?" => report( "Headset Tray", "Usage:\n headset-tray.exe run the tray\n \ headset-tray.exe --install install and run at logon\n \ headset-tray.exe --uninstall remove startup and the uninstall entry\n \ + headset-tray.exe --explain-output say why the output switch is or is not working\n \ headset-tray.exe --export-icon regenerate the application icon", ), _ => run_tray(), @@ -111,6 +113,109 @@ fn run_export_icon() { } } +/// Explains what the output switch would do, and why, without doing it. +/// +/// The switch acts on a device event nobody can stage on demand — it needs a +/// power button held down — and it acts on facts the user cannot see: which +/// endpoint is default, which endpoint ids still exist, and what the tray +/// recorded as the way back. When it does nothing, every one of those is a +/// candidate, and until this existed the only way to tell them apart was to +/// read the registry by hand. +/// +/// Read-only: it asks `plan_output` what it would decide for a powered-off and +/// a powered-on headset and prints both, rather than moving anything. +#[cfg(windows)] +fn run_explain_output() { + use headset_tray::{output::Action, settings, win32, Slot}; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; + + // Core Audio needs an initialised apartment; the tray does this in its own + // startup, and this path does not go through it. + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } + + let devices = headset_tray::output_devices(); + let name_of = |id: &str| { + devices + .iter() + .find(|(d, _)| d == id) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| format!("{id} (no longer on this machine)")) + }; + + let mut out = Vec::new(); + out.push(format!( + "Switch output when off: {}", + if settings::switch_output_when_off() { + "on" + } else { + "off" + } + )); + out.push(match settings::output_choice(Slot::Fallback) { + Some((id, name)) => format!("Play through: {name}\n {}", name_of(&id)), + None => "Play through: nothing chosen".to_string(), + }); + out.push(format!( + "Split game and chat: {}", + if settings::split_game_and_chat() { + "on" + } else { + "off" + } + )); + for (slot, label) in [ + (Slot::Game, "Game channel: "), + (Slot::Chat, "Chat channel: "), + ] { + out.push(match settings::output_choice(slot) { + Some((id, name)) => format!("{label} {name}\n {}", name_of(&id)), + None => format!("{label} nothing chosen"), + }); + } + out.push(match settings::restore_output() { + Some(id) => format!("Owed a move back to: {}", name_of(&id)), + None => "Owed a move back to: nothing".to_string(), + }); + out.push(match settings::output_problem() { + Some(p) => format!("Last problem: {}", p.short()), + None => "Last problem: none recorded".to_string(), + }); + + out.push("\nPlayback devices Windows currently has:".into()); + for (_, name) in &devices { + out.push(format!(" {name}")); + } + + let describe = |a: Action| match a { + Action::Nothing => "nothing to do".to_string(), + Action::MoveAway { to, owing } => format!( + "move the sound to {}, remembering {}", + name_of(&to), + name_of(&owing) + ), + Action::MoveBack { to } => format!("move the sound back to {}", name_of(&to)), + Action::Split { game, chat } => format!( + "play through {}, and take calls on {}", + name_of(&game), + name_of(&chat) + ), + Action::Retry => "wait for the endpoint to reappear".to_string(), + Action::GiveUp => "give up on the endpoint owed, and say so".to_string(), + Action::Blocked(p) => format!("nothing — {}", p.detail()), + }; + out.push("\nIf the headset powered off right now:".into()); + out.push(format!( + " {}", + describe(win32::plan_output(Some(false), 0)) + )); + out.push("If the headset powered on right now:".into()); + out.push(format!(" {}", describe(win32::plan_output(Some(true), 0)))); + + report("Headset Tray output switch", &out.join("\n")); +} + /// Renders panel states to PNGs so appearance can be diffed against the /// mockups without a window or a headset. /// @@ -188,6 +293,22 @@ fn run_render_panel() { SliderParam::GameChat, )); + // The output picker. Its list comes from the machine this runs on, so the + // rendered height varies with how many playback devices are attached -- + // which is the point of rendering it at all. + for (name, slot) in [ + ("output-picker", headset_tray::Slot::Fallback), + ("output-picker-game", headset_tray::Slot::Game), + ("output-picker-chat", headset_tray::Slot::Chat), + ] { + cases.push(( + name, + base.clone(), + View::Output(slot), + SliderParam::GameChat, + )); + } + // The three noise modes, so the segment row and the dimmed level track can // be diffed the same way every other state is. for (name, mode) in [ diff --git a/crates/headset-tray/src/output.rs b/crates/headset-tray/src/output.rs new file mode 100644 index 0000000..6a1015e --- /dev/null +++ b/crates/headset-tray/src/output.rs @@ -0,0 +1,663 @@ +//! What to do about the Windows output when the headset comes or goes. +//! +//! Only the decision lives here. Reading the default endpoint, moving it, and +//! persisting the record are `win32`'s and `settings`' work; this module is +//! handed the facts and answers with an [`Action`]. Splitting it out is what +//! makes the rule testable at all — the alternative is a function that can only +//! be exercised by powering a headset off and watching a machine's sound move. +//! +//! Two rules live here, and they answer different questions. +//! +//! **Where the sound goes while the headset is off.** The rule this turns on is +//! that **a record of where the sound came from is a debt, and a debt that +//! cannot be paid is not a reason to refuse new work**. An earlier version +//! treated the record's mere presence as "already switched", which is true +//! right up until the endpoint it names stops existing — a driver reinstall or +//! a re-enumerated dongle is enough, and this device's endpoint ids do change. +//! From that moment the record could never be cleared (the endpoint was gone) +//! and could never be replaced (its presence blocked the switch), so the +//! feature was silently dead until somebody edited the registry. Hence +//! [`Facts::debt_present`]: a debt counts only while the machine still has the +//! endpoint it names. +//! +//! **Which of the headset's own endpoints gets what, once it is back.** The +//! headset presents two: a game channel and a chat channel. Windows keeps +//! separate defaults for ordinary playback and for calls, so the two channels +//! exist precisely to be pointed at different roles — and putting the sound +//! back the way it was found cannot do that, because "the way it was found" is +//! a single endpoint. Restoring that one endpoint into all three roles is worse +//! than doing nothing: it overwrites a communications default the user had set +//! correctly, so the chat channel goes quiet and the voices arrive in the game +//! channel. Hence [`Facts::split`] and the [`Action::Split`] it produces. It is +//! off by default — a headset with two channels is not a reason to assume +//! somebody wants their calls moved. + +/// Which of the three device choices something refers to. +/// +/// One enum rather than three parallel settings and three picker views: all +/// three answer the same question ("which Windows endpoint?") and differ only +/// in what the answer is for. Carrying the slot means one picker, one store, +/// and one place a fourth choice would be added. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Slot { + /// Where the sound goes while the headset is powered off. + Fallback, + /// The headset's game channel: ordinary playback while it is on. + Game, + /// The headset's chat channel: calls while it is on. + Chat, +} + +/// Something worth telling the user about, rather than logging into the void. +/// +/// The wording is deliberately split. `short` goes in the settings row, whose +/// description box is one line wide and collides with the title above it when +/// it wraps; `detail` goes in a balloon, which has room to say what to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Problem { + /// The switch is on but no fallback device was ever picked. + NoDeviceChosen, + /// The chosen fallback is not plugged in, so there is nowhere to move to. + FallbackAbsent, + /// The endpoint the sound came from never reappeared, so it is unreachable. + RestoreGone, + /// Windows refused the change. The interface behind it is undocumented, so + /// this is a thing that can simply happen. + SwitchFailed, + /// The split is on but the game and chat channels were not both picked. + NoChannelsChosen, + /// They were picked, but the endpoints they name never turned up. + ChannelsAbsent, +} + +impl Problem { + /// One short line for the settings row. Keep these under about forty + /// characters: the description box is sized for a single line. + pub fn short(self) -> &'static str { + match self { + Problem::NoDeviceChosen => "No device chosen — pick one below.", + Problem::FallbackAbsent => "The chosen device is not connected.", + Problem::RestoreGone => "Could not move the sound back.", + Problem::SwitchFailed => "Windows refused the change.", + Problem::NoChannelsChosen => "Pick a game and a chat channel below.", + Problem::ChannelsAbsent => "The chosen channels are not connected.", + } + } + + /// A stable name for the registry. + /// + /// The record has to survive the tray being closed, and it has to come back + /// as *the problem it was* rather than as a sentence somebody once wrote: + /// the panel decides which row carries it, and re-worded text must not + /// change where a stored complaint appears. An unknown name reads as no + /// complaint, which is what an older version's record degrades to. + pub fn key(self) -> &'static str { + match self { + Problem::NoDeviceChosen => "NoDeviceChosen", + Problem::FallbackAbsent => "FallbackAbsent", + Problem::RestoreGone => "RestoreGone", + Problem::SwitchFailed => "SwitchFailed", + Problem::NoChannelsChosen => "NoChannelsChosen", + Problem::ChannelsAbsent => "ChannelsAbsent", + } + } + + /// The inverse of [`Problem::key`]. + pub fn from_key(key: &str) -> Option { + [ + Problem::NoDeviceChosen, + Problem::FallbackAbsent, + Problem::RestoreGone, + Problem::SwitchFailed, + Problem::NoChannelsChosen, + Problem::ChannelsAbsent, + ] + .into_iter() + .find(|p| p.key() == key) + } + + /// Whether this is a complaint about the split rather than the switch. + /// + /// The two are separate settings with separate rows, and either can be on + /// while the other is off, so a complaint shown against the wrong one is a + /// message about a feature the user may not even be using. + pub fn is_about_split(self) -> bool { + matches!(self, Problem::NoChannelsChosen | Problem::ChannelsAbsent) + } + + /// The balloon text, which has room to say what happened and what to do. + pub fn detail(self) -> &'static str { + match self { + Problem::NoDeviceChosen => { + "The headset powered off, but no device has been chosen to play \ + through. Open Settings and pick one under \"Play through\"." + } + Problem::FallbackAbsent => { + "The headset powered off, but the device chosen to play through \ + is not connected. The sound has been left where it is." + } + Problem::RestoreGone => { + "The headset is back, but the device the sound came from is no \ + longer available, so it could not be moved back. Choosing that \ + output once in Windows will let this work again." + } + Problem::SwitchFailed => { + "Windows would not change the default playback device. Setting \ + it by hand in Sound settings still works." + } + Problem::NoChannelsChosen => { + "The headset is back, but its game and chat channels have not \ + both been chosen, so the sound was left where it is. Open \ + Settings and pick them under \"Game channel\" and \"Chat \ + channel\"." + } + Problem::ChannelsAbsent => { + "The headset is back, but the endpoints chosen as its game and \ + chat channels did not appear. If the device was re-installed, \ + pick them again in Settings." + } + } + } +} + +/// What the tray should do about the output right now. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + /// Leave everything alone. + Nothing, + /// Make `to` the default output and record `owing` as the way back. + MoveAway { to: String, owing: String }, + /// Make `to` the default output and clear the record. + MoveBack { to: String }, + /// Give ordinary playback to `game` and calls to `chat`, and clear the + /// record: the sound has been placed deliberately, so nothing is left owed. + Split { game: String, chat: String }, + /// The endpoint owed is not there yet. Ask again shortly rather than + /// concluding anything: an endpoint can take a few seconds to appear after + /// the wireless link comes up. + Retry, + /// It never appeared. Clear the record and say so — keeping a debt nothing + /// can discharge is how the feature used to wedge itself. + GiveUp, + /// Nothing was done, and the user should be told why. + Blocked(Problem), +} + +/// How many times to look for the endpoints owed before giving up on them. +/// +/// Five at [`RETRY_MS`] apart is about ten seconds, which is longer than an +/// endpoint has been observed taking to appear and short enough that a person +/// who powered their headset on is still sitting there when it resolves. +pub const RESTORE_ATTEMPTS: u8 = 5; + +/// How long to wait between those looks. +pub const RETRY_MS: u32 = 2000; + +/// Everything the decision depends on, gathered by the caller. +/// +/// Taken as a struct rather than a dozen positional arguments because most of +/// them are `Option<&str>` and `bool`, and a transposed pair would compile. +#[derive(Clone, Copy, Debug)] +pub struct Facts<'a> { + /// Whether the user turned the move-when-off switch on at all. + pub enabled: bool, + /// Whether the user asked for the game and chat channels to be set apart. + /// Independent of `enabled`: pointing calls at the chat channel is useful + /// to somebody who never wanted their sound moved anywhere. + pub split: bool, + /// The link: `Some(false)` powered down, `Some(true)` up, `None` when the + /// dongle itself is gone. + pub link: Option, + /// The endpoint id chosen to play through, if one ever was. + pub fallback: Option<&'a str>, + /// Whether that endpoint is present on the machine right now. + pub fallback_present: bool, + /// The endpoint chosen as the headset's game channel. + pub game: Option<&'a str>, + /// Whether that endpoint is present on the machine right now. + pub game_present: bool, + /// The endpoint chosen as the headset's chat channel. + pub chat: Option<&'a str>, + /// Whether that endpoint is present on the machine right now. + pub chat_present: bool, + /// The endpoint currently receiving sound. + pub current: Option<&'a str>, + /// The endpoint recorded as owed a move back, if any. + pub debt: Option<&'a str>, + /// Whether *that* endpoint is still present. The load-bearing fact. + pub debt_present: bool, + /// How many times the endpoints owed have already been looked for. + pub attempts: u8, +} + +/// The decision. +pub fn decide(f: &Facts) -> Action { + match f.link { + Some(false) if f.enabled => away(f), + // Both features have something to say about a headset coming back, so + // this arm is entered whichever of them is on; `back` sorts out which. + Some(true) => back(f), + // A vanished dongle is not a powered-down headset: its endpoints go + // with it and Windows moves the sound by itself. A powered-down headset + // with the switch turned off is nothing to do either. + _ => Action::Nothing, + } +} + +fn away(f: &Facts) -> Action { + let Some(fallback) = f.fallback else { + return Action::Blocked(Problem::NoDeviceChosen); + }; + let Some(current) = f.current else { + return Action::Nothing; + }; + if current == fallback { + // The sound is already there, so there is nothing to move and — more + // importantly — nothing to owe. Recording a debt here would move the + // user somewhere they never were when the headset came back. + return Action::Nothing; + } + // A record naming an endpoint the machine still has is a debt that can be + // paid, so it stands: overwriting it would lose the way back. A record + // naming one that no longer exists can never be paid by anyone, and must + // not be the reason this switch is skipped. See the module header. + if f.debt.is_some() && f.debt_present { + return Action::Nothing; + } + if !f.fallback_present { + return Action::Blocked(Problem::FallbackAbsent); + } + Action::MoveAway { + to: fallback.to_string(), + owing: current.to_string(), + } +} + +fn back(f: &Facts) -> Action { + // The split wins when it is on, and it wins even over a debt that could be + // paid. Paying the debt would put every role back on one endpoint, which is + // the very thing the split exists to undo — and the debt is discharged all + // the same, because the sound lands on the headset either way. + if f.split { + return split(f); + } + if !f.enabled { + return Action::Nothing; + } + let Some(debt) = f.debt else { + return Action::Nothing; + }; + if !f.debt_present { + return exhausted(f, Action::GiveUp); + } + Action::MoveBack { + to: debt.to_string(), + } +} + +fn split(f: &Facts) -> Action { + let (Some(game), Some(chat)) = (f.game, f.chat) else { + // Both or neither. Setting only the role that was chosen would leave + // the other pointing wherever it happened to point, which is a + // half-applied setting reported as a working one. + return Action::Blocked(Problem::NoChannelsChosen); + }; + if !(f.game_present && f.chat_present) { + // The link comes up before the endpoints do, every time. This is the + // ordinary case on the first look, not a failure. + return exhausted(f, Action::Blocked(Problem::ChannelsAbsent)); + } + Action::Split { + game: game.to_string(), + chat: chat.to_string(), + } +} + +/// Look again, unless this was the last look — in which case, `end`. +fn exhausted(f: &Facts, end: Action) -> Action { + if f.attempts + 1 >= RESTORE_ATTEMPTS { + end + } else { + Action::Retry + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEADSET: &str = "{0.0.0.00000000}.{headset-game}"; + const CHAT: &str = "{0.0.0.00000000}.{headset-chat}"; + const SPEAKERS: &str = "{0.0.0.00000000}.{realtek}"; + const MONITOR: &str = "{0.0.0.00000000}.{hdmi}"; + + /// Powered on, nothing owed, everything present, split off. + fn facts() -> Facts<'static> { + Facts { + enabled: true, + split: false, + link: Some(true), + fallback: Some(SPEAKERS), + fallback_present: true, + game: Some(HEADSET), + game_present: true, + chat: Some(CHAT), + chat_present: true, + current: Some(HEADSET), + debt: None, + debt_present: false, + attempts: 0, + } + } + + /// The same, with the split turned on. + fn split_facts() -> Facts<'static> { + Facts { + split: true, + ..facts() + } + } + + #[test] + fn powering_off_moves_the_sound_and_records_the_way_back() { + let f = Facts { + link: Some(false), + ..facts() + }; + assert_eq!( + decide(&f), + Action::MoveAway { + to: SPEAKERS.into(), + owing: HEADSET.into() + } + ); + } + + #[test] + fn powering_on_moves_the_sound_back_and_clears_the_record() { + let f = Facts { + current: Some(SPEAKERS), + debt: Some(HEADSET), + debt_present: true, + ..facts() + }; + assert_eq!(decide(&f), Action::MoveBack { to: HEADSET.into() }); + } + + /// The regression this module exists for. A record naming an endpoint the + /// machine no longer has used to block every later switch, permanently and + /// silently: the debt could not be paid, so it was never cleared, and its + /// presence was read as "already switched". + #[test] + fn a_debt_naming_a_vanished_endpoint_does_not_block_a_new_switch() { + let f = Facts { + link: Some(false), + current: Some(MONITOR), + debt: Some("{0.0.0.00000000}.{endpoint-that-no-longer-exists}"), + debt_present: false, + ..facts() + }; + assert_eq!( + decide(&f), + Action::MoveAway { + to: SPEAKERS.into(), + owing: MONITOR.into() + }, + "a dead record must be replaced, not obeyed" + ); + } + + #[test] + fn a_debt_that_can_still_be_paid_is_left_alone() { + let f = Facts { + link: Some(false), + current: Some(MONITOR), + debt: Some(HEADSET), + debt_present: true, + ..facts() + }; + assert_eq!( + decide(&f), + Action::Nothing, + "the way back must not be overwritten while it is still reachable" + ); + } + + #[test] + fn already_on_the_fallback_owes_nothing() { + let f = Facts { + link: Some(false), + current: Some(SPEAKERS), + ..facts() + }; + assert_eq!(decide(&f), Action::Nothing); + } + + /// Even with a dead record in hand: the guard above it must not turn into a + /// path that records the fallback as the endpoint to return to. + #[test] + fn already_on_the_fallback_owes_nothing_even_with_a_dead_record() { + let f = Facts { + link: Some(false), + current: Some(SPEAKERS), + debt: Some("{gone}"), + debt_present: false, + ..facts() + }; + assert_eq!(decide(&f), Action::Nothing); + } + + #[test] + fn a_missing_endpoint_is_retried_and_then_given_up_on() { + let base = Facts { + current: Some(SPEAKERS), + debt: Some(HEADSET), + debt_present: false, + ..facts() + }; + for attempts in 0..RESTORE_ATTEMPTS - 1 { + assert_eq!( + decide(&Facts { attempts, ..base }), + Action::Retry, + "attempt {attempts} must not be the last word" + ); + } + assert_eq!( + decide(&Facts { + attempts: RESTORE_ATTEMPTS - 1, + ..base + }), + Action::GiveUp, + "a debt nothing can discharge must end, or it wedges the feature" + ); + } + + #[test] + fn turning_it_on_without_choosing_a_device_says_so() { + let f = Facts { + link: Some(false), + fallback: None, + fallback_present: false, + ..facts() + }; + assert_eq!(decide(&f), Action::Blocked(Problem::NoDeviceChosen)); + } + + #[test] + fn an_unplugged_fallback_says_so_rather_than_moving_anything() { + let f = Facts { + link: Some(false), + fallback_present: false, + ..facts() + }; + assert_eq!(decide(&f), Action::Blocked(Problem::FallbackAbsent)); + } + + #[test] + fn the_feature_being_off_decides_nothing_at_all() { + for link in [Some(true), Some(false), None] { + let f = Facts { + enabled: false, + link, + debt: Some(HEADSET), + debt_present: true, + ..facts() + }; + assert_eq!(decide(&f), Action::Nothing); + } + } + + #[test] + fn a_vanished_dongle_is_not_a_powered_down_headset() { + let f = Facts { + link: None, + debt: Some(HEADSET), + debt_present: true, + ..facts() + }; + assert_eq!(decide(&f), Action::Nothing); + } + + // ---- the split --------------------------------------------------------- + + /// The bug the split exists for. Restoring one endpoint into every role + /// took the communications default the user had pointed at the chat + /// channel and pointed it at the game channel, so the voices came out of + /// the wrong one. + #[test] + fn a_headset_coming_back_gets_calls_on_the_chat_channel() { + let f = Facts { + current: Some(SPEAKERS), + debt: Some(HEADSET), + debt_present: true, + ..split_facts() + }; + assert_eq!( + decide(&f), + Action::Split { + game: HEADSET.into(), + chat: CHAT.into() + }, + "the split must beat a plain restore, which is what breaks it" + ); + } + + /// It is its own feature, not a rider on the move-when-off switch: somebody + /// who never wanted their sound moved anywhere may still want their calls + /// on the chat channel. + #[test] + fn the_split_works_with_the_move_when_off_switch_turned_off() { + let f = Facts { + enabled: false, + ..split_facts() + }; + assert_eq!( + decide(&f), + Action::Split { + game: HEADSET.into(), + chat: CHAT.into() + } + ); + } + + #[test] + fn the_split_does_nothing_while_the_headset_is_off() { + for link in [Some(false), None] { + let f = Facts { + enabled: false, + link, + ..split_facts() + }; + assert_eq!(decide(&f), Action::Nothing, "link {link:?}"); + } + } + + /// Half a split is not a split: one role set and the other left wherever it + /// happened to be is a setting that reports success and does the thing the + /// user was complaining about. + #[test] + fn one_channel_without_the_other_is_refused_and_said_out_loud() { + for (game, chat) in [(Some(HEADSET), None), (None, Some(CHAT)), (None, None)] { + let f = Facts { + game, + chat, + ..split_facts() + }; + assert_eq!( + decide(&f), + Action::Blocked(Problem::NoChannelsChosen), + "game {game:?} chat {chat:?}" + ); + } + } + + /// The ordinary case on the first look: a wireless link comes up seconds + /// before its audio endpoints do. Treating that as a failure would report + /// one on every single power-on. + #[test] + fn channels_that_have_not_appeared_yet_are_waited_for() { + for (game_present, chat_present) in [(false, true), (true, false), (false, false)] { + let base = Facts { + game_present, + chat_present, + ..split_facts() + }; + for attempts in 0..RESTORE_ATTEMPTS - 1 { + assert_eq!( + decide(&Facts { attempts, ..base }), + Action::Retry, + "attempt {attempts} with game {game_present} chat {chat_present}" + ); + } + assert_eq!( + decide(&Facts { + attempts: RESTORE_ATTEMPTS - 1, + ..base + }), + Action::Blocked(Problem::ChannelsAbsent), + "waiting has to end somewhere" + ); + } + } + + /// Every problem has to fit the settings row it is drawn in. That box holds + /// one line and is vertically centred, so an over-long message wraps + /// upward into the title rather than downward into empty space. + #[test] + fn every_problem_fits_the_settings_row() { + for p in [ + Problem::NoDeviceChosen, + Problem::FallbackAbsent, + Problem::RestoreGone, + Problem::SwitchFailed, + Problem::NoChannelsChosen, + Problem::ChannelsAbsent, + ] { + let n = p.short().chars().count(); + assert!(n <= 40, "{:?} is {n} characters: {}", p, p.short()); + assert!(!p.detail().is_empty()); + } + } + + /// The record outlives the process, so the name written into it has to read + /// back as the same problem. A key that two variants shared, or one that + /// nothing parsed, would put a complaint on the wrong settings row or lose + /// it entirely. + #[test] + fn every_problem_survives_a_trip_through_its_key() { + let all = [ + Problem::NoDeviceChosen, + Problem::FallbackAbsent, + Problem::RestoreGone, + Problem::SwitchFailed, + Problem::NoChannelsChosen, + Problem::ChannelsAbsent, + ]; + for p in all { + assert_eq!(Problem::from_key(p.key()), Some(p)); + } + let keys: std::collections::BTreeSet<&str> = all.iter().map(|p| p.key()).collect(); + assert_eq!(keys.len(), all.len(), "keys must be distinct"); + assert_eq!(Problem::from_key("something an older build wrote"), None); + } +} diff --git a/crates/headset-tray/src/settings.rs b/crates/headset-tray/src/settings.rs index 8d22853..8d0e114 100644 --- a/crates/headset-tray/src/settings.rs +++ b/crates/headset-tray/src/settings.rs @@ -23,6 +23,8 @@ use windows::Win32::System::Registry::{ RRF_RT_REG_DWORD, RRF_RT_REG_SZ, }; +use crate::output::{Problem, Slot}; + /// Where Windows looks for per-user startup programs. const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; /// The value name under `RUN_KEY`. Also the app's identity in Task Manager. @@ -31,6 +33,10 @@ pub const APP_NAME: &str = "HeadsetTray"; const APP_KEY: &str = r"Software\HeadsetTray"; const SYNAPSE_WARNING_VALUE: &str = "ShowSynapseWarning"; const APPEARANCE_VALUE: &str = "Appearance"; +const OUTPUT_SWITCH_VALUE: &str = "SwitchOutputWhenOff"; +const SPLIT_VALUE: &str = "SplitGameAndChat"; +const RESTORE_OUTPUT_VALUE: &str = "RestoreOutputId"; +const OUTPUT_ISSUE_VALUE: &str = "LastOutputIssue"; /// Where Windows keeps the user's light-or-dark preference for applications. const PERSONALIZE_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; @@ -236,6 +242,121 @@ pub fn set_show_synapse_warning(enabled: bool) -> bool { set_dword(APP_KEY, SYNAPSE_WARNING_VALUE, u32::from(enabled)) } +/// Whether to move Windows' sound elsewhere while the headset is powered down. +/// +/// Defaults to **off**, unlike the Synapse warning. Changing which device the +/// whole machine plays through is not something to start doing to somebody who +/// never asked for it. +pub fn switch_output_when_off() -> bool { + read_dword(HKEY_CURRENT_USER, APP_KEY, OUTPUT_SWITCH_VALUE) == Some(1) +} + +pub fn set_switch_output_when_off(enabled: bool) -> bool { + set_dword(APP_KEY, OUTPUT_SWITCH_VALUE, u32::from(enabled)) +} + +/// Whether to point the headset's two channels at different Windows roles when +/// it comes back on: the game channel at ordinary playback, the chat channel at +/// calls. +/// +/// Defaults to **off**, for the same reason the switch above it does. Moving +/// somebody's calls to a different endpoint than their music is a choice, not a +/// correction, and plenty of people use one channel for everything. +pub fn split_game_and_chat() -> bool { + read_dword(HKEY_CURRENT_USER, APP_KEY, SPLIT_VALUE) == Some(1) +} + +pub fn set_split_game_and_chat(enabled: bool) -> bool { + set_dword(APP_KEY, SPLIT_VALUE, u32::from(enabled)) +} + +/// The registry value names holding one slot's `(endpoint id, name)`. +/// +/// The fallback's two keep their original names so an existing installation +/// does not silently lose the device it was already switching to. +fn slot_values(slot: Slot) -> (&'static str, &'static str) { + match slot { + Slot::Fallback => ("FallbackOutputId", "FallbackOutputName"), + Slot::Game => ("GameOutputId", "GameOutputName"), + Slot::Chat => ("ChatOutputId", "ChatOutputName"), + } +} + +/// A chosen device, as `(endpoint id, name at the time it was chosen)`. +/// +/// Both are stored because they answer different questions. The id is what the +/// switch acts on and is stable across reboots and renames; the name is only so +/// the settings panel can say what was chosen without enumerating devices, and +/// can still name it when that device is currently absent. +pub fn output_choice(slot: Slot) -> Option<(String, String)> { + let (id_value, name_value) = slot_values(slot); + let id = read_string(HKEY_CURRENT_USER, APP_KEY, id_value)?; + if id.is_empty() { + return None; + } + let name = read_string(HKEY_CURRENT_USER, APP_KEY, name_value).unwrap_or_else(|| id.clone()); + Some((id, name)) +} + +pub fn set_output_choice(slot: Slot, id: &str, name: &str) -> bool { + let (id_value, name_value) = slot_values(slot); + set_string(APP_KEY, id_value, id) && set_string(APP_KEY, name_value, name) +} + +/// The endpoint to put back when the headset returns, or `None` when nothing +/// has been switched away from. +/// +/// Persisted rather than held in memory because presence *is* the state: this +/// value existing means "we moved the sound and owe the user a move back". A +/// tray that is closed, updated, or killed while the headset is off would +/// otherwise forget its debt and strand the sound on the speakers. +pub fn restore_output() -> Option { + read_string(HKEY_CURRENT_USER, APP_KEY, RESTORE_OUTPUT_VALUE).filter(|s| !s.is_empty()) +} + +/// Records what to restore, or clears the record once it has been restored. +pub fn set_restore_output(id: Option<&str>) -> bool { + match id { + Some(id) => set_string(APP_KEY, RESTORE_OUTPUT_VALUE, id), + // Absent is the "nothing owed" state, so a missing value is success. + None => delete_value(APP_KEY, RESTORE_OUTPUT_VALUE) || restore_output().is_none(), + } +} + +/// The last thing that stopped the output switch or the split working, if +/// anything. +/// +/// Persisted for the same reason the restore record is: the tray is not +/// running when the user reads it. Somebody who powers their headset off, +/// hears nothing move, and opens the panel an hour later needs the reason to +/// still be there. Cleared the moment a switch succeeds, so a stale message +/// cannot outlive the condition it describes. +/// +/// Stored as [`Problem::key`] rather than as the sentence shown to the user, so +/// the panel can tell which of the two settings rows the complaint belongs on +/// — and so re-wording a message does not move it. Anything unrecognised, which +/// is what an older build's record looks like, reads as no complaint. +pub fn output_problem() -> Option { + let key = read_string(HKEY_CURRENT_USER, APP_KEY, OUTPUT_ISSUE_VALUE)?; + Problem::from_key(&key) +} + +/// Records a problem, or clears the record once things work again. +/// +/// Returns whether this changed what was stored. Callers use that to decide +/// whether to interrupt the user with a balloon: the same problem recurring on +/// every power-off is one notification, not one per cycle. +pub fn set_output_problem(problem: Option) -> bool { + if output_problem() == problem { + return false; + } + match problem { + Some(p) => set_string(APP_KEY, OUTPUT_ISSUE_VALUE, p.key()), + // Absent is the "nothing wrong" state, so a missing value is success. + None => delete_value(APP_KEY, OUTPUT_ISSUE_VALUE) || output_problem().is_none(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -279,6 +400,75 @@ mod tests { assert_eq!(read_dword(HKEY_CURRENT_USER, SCRATCH, "Flag"), None); } + /// Round-trips the real setting, restoring whatever the machine had. The + /// property under test is that *absence* is the "nothing owed" state, since + /// the whole restore mechanism is built on presence meaning a debt. + #[test] + fn clearing_the_restore_record_leaves_nothing_behind() { + let previous = restore_output(); + + assert!(set_restore_output(Some("{0.0.0.00000000}.{test}"))); + assert_eq!(restore_output().as_deref(), Some("{0.0.0.00000000}.{test}")); + assert!(set_restore_output(None)); + assert_eq!(restore_output(), None, "a cleared debt must not linger"); + // An empty string is a value, but not a debt: reading it back as one + // would make the tray try to restore an endpoint that cannot exist. + assert!(set_string(APP_KEY, RESTORE_OUTPUT_VALUE, "")); + assert_eq!(restore_output(), None); + assert!(set_restore_output(None)); + + if let Some(p) = previous { + assert!(set_restore_output(Some(&p))); + } + } + + /// The issue record answers two questions with one value: what went wrong, + /// and whether it is *news*. The second is what keeps a problem that + /// recurs on every power-off from producing a balloon on every power-off. + #[test] + fn recording_the_same_issue_twice_is_not_news() { + let previous = output_problem(); + + assert!( + set_output_problem(Some(Problem::SwitchFailed)), + "a new problem is news" + ); + assert_eq!(output_problem(), Some(Problem::SwitchFailed)); + assert!( + !set_output_problem(Some(Problem::SwitchFailed)), + "the same problem again is not" + ); + assert!( + set_output_problem(Some(Problem::ChannelsAbsent)), + "a different one is" + ); + assert!(set_output_problem(None), "clearing a problem is news"); + assert_eq!(output_problem(), None); + assert!( + !set_output_problem(None), + "clearing nothing must not announce anything" + ); + + let _ = set_output_problem(previous); + } + + /// The value used to hold the sentence shown to the user. An installation + /// upgrading across that change must not read the old text back as a + /// problem it cannot place on any row. + #[test] + fn a_record_written_by_an_older_build_reads_as_nothing() { + let previous = output_problem(); + + assert!(set_string( + APP_KEY, + OUTPUT_ISSUE_VALUE, + "Windows refused the change." + )); + assert_eq!(output_problem(), None); + + let _ = set_output_problem(previous); + } + #[test] fn string_round_trips_through_the_registry() { const SCRATCH: &str = r"Software\HeadsetTray\TestScratch"; diff --git a/crates/headset-tray/src/ui/layout.rs b/crates/headset-tray/src/ui/layout.rs index 71e97b9..7b32982 100644 --- a/crates/headset-tray/src/ui/layout.rs +++ b/crates/headset-tray/src/ui/layout.rs @@ -6,6 +6,7 @@ use headset_protocol::{NoiseControl, NoiseMode, ANC_LEVEL_RANGE}; +use crate::output::{Problem, Slot}; use crate::state::HeadsetState; use crate::ui::theme::*; @@ -110,6 +111,18 @@ pub enum HitTarget { Refresh, ToggleStartup, ToggleWarning, + /// Turns the power-off output switch on or off. + ToggleOutputSwitch, + /// Turns the game/chat split on or off. + ToggleSplit, + /// Opens the device picker, for one of the three choices. + OpenOutputPicker(Slot), + /// One device in the picker, by its position in the drawn list. Positional + /// rather than carrying the id because [`HitTarget`] is `Copy` and a `String` + /// is not; [`Panel::output_ids`] resolves it against the list that was + /// actually drawn, so a device appearing or vanishing between the paint and + /// the click cannot silently select a different one. + OutputChoice(usize), /// The three segments of the noise-mode row, in drawn order. NoiseOff, NoiseAnc, @@ -126,6 +139,10 @@ pub enum HitTarget { pub enum View { Main, Settings, + /// The output-device picker, reached from Settings. Carries which of the + /// three choices is being made, because the list it draws is the same one + /// either way and only the heading and the tick differ. + Output(Slot), } /// Which parameter the single slider is currently driving. @@ -156,7 +173,9 @@ impl SliderParam { pub fn end_labels(self) -> [&'static str; 3] { match self { SliderParam::Sidetone => ["OFF", "", "MAX"], - SliderParam::GameChat => ["CHAT", "BALANCED", "GAME"], + // Low values are game, high values are chat: verified by listening, + // not by the captures, which never established the direction. + SliderParam::GameChat => ["GAME", "BALANCED", "CHAT"], } } pub fn other(self) -> SliderParam { @@ -201,8 +220,8 @@ pub fn format_value(param: SliderParam, value: Option) -> String { } SliderParam::GameChat => match v.cmp(&10) { std::cmp::Ordering::Equal => "Balanced".to_string(), - std::cmp::Ordering::Greater => format!("Game +{}", v - 10), - std::cmp::Ordering::Less => format!("Chat +{}", 10 - v), + std::cmp::Ordering::Greater => format!("Chat +{}", v - 10), + std::cmp::Ordering::Less => format!("Game +{}", 10 - v), }, } } @@ -238,6 +257,10 @@ pub struct Panel { pub track: Option, /// ANC level track, present only while the mode is ANC. pub level_track: Option, + /// Endpoint ids in the order they were drawn, so a + /// [`HitTarget::OutputChoice`] resolves against this paint rather than a + /// fresh enumeration. Empty in every view but [`View::Output`]. + pub output_ids: Vec, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -390,6 +413,7 @@ pub fn build(state: &HeadsetState, view: View, param: SliderParam, preview: Opti let mut track = None; let mut level_track = None; + let mut output_ids = Vec::new(); match view { View::Main => main_body( &mut b, @@ -401,6 +425,7 @@ pub fn build(state: &HeadsetState, view: View, param: SliderParam, preview: Opti &mut level_track, ), View::Settings => settings_body(&mut b, state, &mut y), + View::Output(slot) => output_body(&mut b, slot, &mut y, &mut output_ids), } // Footer @@ -429,6 +454,7 @@ pub fn build(state: &HeadsetState, view: View, param: SliderParam, preview: Opti height, track, level_track, + output_ids, } } @@ -471,7 +497,10 @@ fn header(b: &mut Builder, state: &HeadsetState, view: View, y: &mut f32) { // Gear / Back button, top right. let btn = Rect::new(MARGIN + CONTENT_W - 30.0, *y - 2.0, 30.0, 30.0); - let active = view == View::Settings; + // Every view but the main one is somewhere you came from somewhere else, so + // the header button is Back in all of them. `on_panel_press` decides where + // back actually goes. + let active = view != View::Main; b.p.push(Primitive::RoundRect { rect: btn, radius: BUTTON_RADIUS, @@ -974,50 +1003,65 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { ); *y += 22.0; - let rows: [(&str, &str, HitTarget, bool); 2] = [ - ( - "Start on Windows startup", - "Launch the tray app when you sign in.", - HitTarget::ToggleStartup, - crate::settings_run_on_startup(), - ), - ( - "Display Synapse warning", - "Warn when Synapse may override settings.", - HitTarget::ToggleWarning, - crate::settings_show_warning(), - ), - ]; + // Problems are recorded one at a time, and each belongs to the feature that + // earned it. The switch and the split are separate settings that can be on + // independently, so a row is only ever shown its own complaint -- and only + // while it is on, because a message about something switched off is noise. + let switch_on = crate::settings_switch_output(); + let split_on = crate::settings_split_game_and_chat(); + let problem = crate::settings_output_problem(); + let switch_problem = problem.filter(|p| !p.is_about_split() && switch_on); + let split_problem = problem.filter(|p| p.is_about_split() && split_on); + + toggle_row( + b, + y, + "Start on Windows startup", + "Launch the tray app when you sign in.", + HitTarget::ToggleStartup, + crate::settings_run_on_startup(), + None, + ); + toggle_row( + b, + y, + "Display Synapse warning", + "Warn when Synapse may override settings.", + HitTarget::ToggleWarning, + crate::settings_show_warning(), + None, + ); + toggle_row( + b, + y, + "Switch output when off", + "Move Windows sound elsewhere, and back.", + HitTarget::ToggleOutputSwitch, + switch_on, + switch_problem, + ); + // Always shown, and always clickable, even with the switch turned off: a + // user who has not chosen a device yet needs somewhere to choose one, and + // making the row appear only after the toggle is on hides the very thing + // that makes the toggle do anything. + device_row(b, y, Slot::Fallback); - for (title, desc, target, on) in rows { - let card = Rect::new(MARGIN, *y, CONTENT_W, 66.0); - b.card(card, bg_card(), border_card()); - b.text( - Rect::new(card.x + 16.0, card.y + 12.0, card.w - 76.0, 18.0), - title, - FS_BODY + 1.0, - W_SEMIBOLD, - text_primary(), - Align::Left, - ); - // Sized to hold the longer description on one line. Text is vertically - // centred in its box, so a wrap does not push downward -- it grows both - // ways and collides with the title above. - b.text( - Rect::new(card.x + 16.0, card.y + 34.0, card.w - 62.0, 20.0), - desc, - FS_DESCRIPTION, - W_REGULAR, - text_secondary(), - Align::Left, - ); - toggle( - b, - Rect::new(card.right() - 58.0, card.center_y() - 11.0, 42.0, 22.0), - on, - ); - b.hits.push((card, target)); - *y = card.bottom() + 12.0; + toggle_row( + b, + y, + "Split game and chat", + "Calls use the chat channel.", + HitTarget::ToggleSplit, + split_on, + split_problem, + ); + // These two are hidden while the split is off, unlike "Play through". The + // toggle immediately above them is what reveals them, so nothing is out of + // reach -- and three device rows stacked up, two of them inert, is a + // settings page that looks like it is asking for more than it is. + if split_on { + device_row(b, y, Slot::Game); + device_row(b, y, Slot::Chat); } // ---- appearance -------------------------------------------------------- @@ -1080,6 +1124,219 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { *y = back.bottom() + GAP * 0.5; } +/// One settings row: a title, a line of description, and a switch. +/// +/// `problem` replaces the description when the feature this row governs failed. +/// The failure happens with nobody watching -- the user's evidence is that +/// their sound did not move -- so the row has to be able to answer "why not" +/// whenever they get round to asking. +fn toggle_row( + b: &mut Builder, + y: &mut f32, + title: &str, + desc: &str, + target: HitTarget, + on: bool, + problem: Option, +) { + let card = Rect::new(MARGIN, *y, CONTENT_W, 66.0); + b.card(card, bg_card(), border_card()); + // Titles are kept short enough to stay on one line. The box is vertically + // centred, so a wrapped title grows upward into the row above it and + // downward into its own description -- the same trap the description box's + // width is sized against. + b.text( + Rect::new(card.x + 16.0, card.y + 12.0, card.w - 76.0, 18.0), + title, + FS_BODY + 1.0, + W_SEMIBOLD, + text_primary(), + Align::Left, + ); + // Sized to hold the longer description on one line. + // + // A failure borrows the muted-microphone red rather than introducing a + // colour: theme.rs sampled its palette from the mockups and says so, and + // inventing an amber here would be inventing a design decision nobody + // made. That red already means "this is not doing what you think". + b.text( + Rect::new(card.x + 16.0, card.y + 34.0, card.w - 62.0, 20.0), + problem.map(|p| p.short()).unwrap_or(desc), + FS_DESCRIPTION, + W_REGULAR, + if problem.is_some() { + state_muted() + } else { + text_secondary() + }, + Align::Left, + ); + toggle( + b, + Rect::new(card.right() - 58.0, card.center_y() - 11.0, 42.0, 22.0), + on, + ); + b.hits.push((card, target)); + *y = card.bottom() + 12.0; +} + +/// One settings row naming the device chosen for `slot`, opening the picker. +fn device_row(b: &mut Builder, y: &mut f32, slot: Slot) { + let card = Rect::new(MARGIN, *y, CONTENT_W, 62.0); + b.card(card, bg_card(), border_card()); + b.text( + Rect::new(card.x + 16.0, card.y + 10.0, card.w - 48.0, 18.0), + slot_title(slot), + FS_BODY, + W_SEMIBOLD, + text_primary(), + Align::Left, + ); + let chosen = crate::settings_output_choice(slot); + b.text( + Rect::new(card.x + 16.0, card.y + 30.0, card.w - 48.0, 20.0), + &output_choice_subtitle(slot, chosen.as_ref().map(|(n, p)| (n.as_str(), *p))), + FS_DESCRIPTION, + W_REGULAR, + // An absent device is stated in the ordinary secondary colour rather + // than an alarm colour: it is a fact about the machine right now, not + // an error the user made. + text_secondary(), + Align::Left, + ); + chevron_icon(b, card.right() - 22.0, card.center_y(), text_muted()); + b.hits.push((card, HitTarget::OpenOutputPicker(slot))); + *y = card.bottom() + 12.0; +} + +/// What a slot's settings row is called. +fn slot_title(slot: Slot) -> &'static str { + match slot { + Slot::Fallback => "Play through", + Slot::Game => "Game channel", + Slot::Chat => "Chat channel", + } +} + +/// The picker's heading, and the one place a slot says what it is *for*. +/// +/// It goes here rather than in the row title because the title shares its line +/// with a chevron and the device name sits directly under it; "which Windows +/// role does this take" has nowhere to go in that row, and the picker is where +/// somebody is actually deciding. +fn slot_caption(slot: Slot) -> &'static str { + match slot { + Slot::Fallback => "PLAY THROUGH WHEN POWERED OFF", + Slot::Game => "GAME CHANNEL — ORDINARY SOUND", + Slot::Chat => "CHAT CHANNEL — CALLS", + } +} + +/// What a device row says beneath its title. +/// +/// `chosen` is the stored device's name and whether it is present on the +/// machine right now. A stored device that is currently absent is named rather +/// than hidden: "Speakers (Realtek Audio)" being unplugged is worth seeing, and +/// falling back to the prompt would suggest the setting was never made. The +/// prompt names the slot, because "Choose a device" three times over does not +/// say which of the three is still unanswered. +pub fn output_choice_subtitle(slot: Slot, chosen: Option<(&str, bool)>) -> String { + match chosen { + Some((name, true)) => name.to_string(), + Some((name, false)) => format!("{name} — not connected"), + None => match slot { + Slot::Fallback => "Choose a device".to_string(), + Slot::Game => "Choose the game channel".to_string(), + Slot::Chat => "Choose the chat channel".to_string(), + }, + } +} + +/// The output picker: every active playback device, with the chosen one marked. +/// +/// A list rather than a native dropdown because the panel is a layered window +/// that hides as soon as it loses activation — opening a menu owned by another +/// window would close the very panel the menu belongs to. +fn output_body(b: &mut Builder, slot: Slot, y: &mut f32, ids: &mut Vec) { + b.caption( + Rect::new(MARGIN, *y, CONTENT_W, 14.0), + slot_caption(slot), + text_secondary(), + Align::Left, + ); + *y += 22.0; + + let devices = crate::output_devices(); + let chosen_id = crate::settings_output_choice_id(slot); + + if devices.is_empty() { + let card = Rect::new(MARGIN, *y, CONTENT_W, 44.0); + b.card(card, bg_card(), border_card()); + b.text( + Rect::new(card.x + 16.0, card.y, card.w - 32.0, card.h), + "No playback devices found", + FS_BODY, + W_REGULAR, + text_secondary(), + Align::Left, + ); + *y = card.bottom() + 12.0; + } + + // Every device, never a truncated list: silently dropping the one the user + // was looking for is worse than a tall panel on a machine with many. + for (i, (id, name)) in devices.iter().enumerate() { + let selected = chosen_id.as_deref() == Some(id.as_str()); + let card = Rect::new(MARGIN, *y, CONTENT_W, 44.0); + b.p.push(Primitive::RoundRect { + rect: card, + radius: CARD_RADIUS, + fill: Some(if selected { + accent().with_alpha(0x22) + } else { + bg_card() + }), + stroke: Some(if selected { accent() } else { border_card() }), + stroke_w: 1.0, + }); + b.text( + Rect::new(card.x + 16.0, card.y, card.w - 52.0, card.h), + name, + FS_BODY, + if selected { W_SEMIBOLD } else { W_REGULAR }, + text_primary(), + Align::Left, + ); + if selected { + check_icon(b, card.right() - 24.0, card.center_y(), accent()); + } + b.hits.push((card, HitTarget::OutputChoice(i))); + ids.push(id.clone()); + *y = card.bottom() + 8.0; + } + + *y += 4.0; + let back = Rect::new(MARGIN, *y, 88.0, 34.0); + b.p.push(Primitive::RoundRect { + rect: back, + radius: BUTTON_RADIUS, + fill: Some(bg_card()), + stroke: Some(border_card()), + stroke_w: 1.0, + }); + back_icon(b, back.x + 18.0, back.center_y(), text_primary()); + b.text( + Rect::new(back.x + 30.0, back.y, back.w - 34.0, back.h), + "Back", + FS_BODY, + W_SEMIBOLD, + text_primary(), + Align::Left, + ); + b.hits.push((back, HitTarget::Back)); + *y = back.bottom() + GAP * 0.5; +} + fn toggle(b: &mut Builder, r: Rect, on: bool) { b.p.push(Primitive::RoundRect { rect: r, @@ -1287,6 +1544,27 @@ fn refresh_icon(b: &mut Builder, cx: f32, cy: f32) { }); } +/// The "opens another screen" chevron, pointing the way the panel will move. +fn chevron_icon(b: &mut Builder, cx: f32, cy: f32, c: Color) { + b.p.push(Primitive::Path { + points: vec![(cx - 2.5, cy - 4.5), (cx + 2.5, cy), (cx - 2.5, cy + 4.5)], + closed: false, + fill: None, + stroke: Some(c), + stroke_w: 1.6, + }); +} + +fn check_icon(b: &mut Builder, cx: f32, cy: f32, c: Color) { + b.p.push(Primitive::Path { + points: vec![(cx - 5.0, cy), (cx - 1.5, cy + 3.5), (cx + 5.0, cy - 4.0)], + closed: false, + fill: None, + stroke: Some(c), + stroke_w: 1.8, + }); +} + fn back_icon(b: &mut Builder, cx: f32, cy: f32, c: Color) { b.p.push(Primitive::Path { points: vec![(cx + 3.0, cy - 4.5), (cx - 2.0, cy), (cx + 3.0, cy + 4.5)], @@ -1380,8 +1658,8 @@ mod tests { fn value_text_matches_the_mockups_exactly() { use SliderParam::*; assert_eq!(format_value(GameChat, Some(10)), "Balanced"); - assert_eq!(format_value(GameChat, Some(17)), "Game +7"); - assert_eq!(format_value(GameChat, Some(3)), "Chat +7"); + assert_eq!(format_value(GameChat, Some(17)), "Chat +7"); + assert_eq!(format_value(GameChat, Some(3)), "Game +7"); assert_eq!(format_value(Sidetone, Some(0)), "Off"); assert_eq!(format_value(Sidetone, Some(14)), "14"); } @@ -1776,13 +2054,156 @@ mod tests { assert!(p.track.is_none(), "settings has no slider"); } + #[test] + fn settings_offers_the_output_switch_and_a_way_to_choose_a_device() { + let p = build(&connected(), View::Settings, SliderParam::Sidetone, None); + for want in [ + HitTarget::ToggleOutputSwitch, + HitTarget::ToggleSplit, + HitTarget::OpenOutputPicker(Slot::Fallback), + ] { + assert!(p.hits.iter().any(|(_, t)| *t == want), "{want:?} missing"); + } + } + + #[test] + fn the_picker_row_is_reachable_even_with_the_switch_turned_off() { + // The row is what makes the toggle mean anything, so hiding it until + // the toggle is on would leave a switch with nothing to switch to. + let p = build(&connected(), View::Settings, SliderParam::Sidetone, None); + let row = p + .hits + .iter() + .find(|(_, t)| *t == HitTarget::OpenOutputPicker(Slot::Fallback)) + .expect("the picker row must always be present") + .0; + assert!(row.w > 0.0 && row.h > 0.0); + } + + /// The channel rows follow the split toggle, and the toggle is what reveals + /// them -- so whichever way the machine running this has it set, the two + /// have to agree. A pair of rows shown with the split off would be inert + /// controls; a split turned on with no rows would be a setting that cannot + /// be finished. + #[test] + fn the_channel_rows_are_shown_exactly_when_the_split_is_on() { + let p = build(&connected(), View::Settings, SliderParam::Sidetone, None); + let shown = |slot| { + p.hits + .iter() + .any(|(_, t)| *t == HitTarget::OpenOutputPicker(slot)) + }; + let split_on = crate::settings_split_game_and_chat(); + assert_eq!(shown(Slot::Game), split_on); + assert_eq!(shown(Slot::Chat), split_on); + } + + /// The three slots are chosen from one picker drawing one list, so the only + /// thing telling a user which choice they are making is the wording. Two + /// slots sharing a title or a heading would make that guesswork. + #[test] + fn the_three_slots_are_told_apart_wherever_they_are_named() { + let slots = [Slot::Fallback, Slot::Game, Slot::Chat]; + let titles: std::collections::BTreeSet<&str> = + slots.iter().map(|s| slot_title(*s)).collect(); + let captions: std::collections::BTreeSet<&str> = + slots.iter().map(|s| slot_caption(*s)).collect(); + assert_eq!(titles.len(), slots.len(), "{titles:?}"); + assert_eq!(captions.len(), slots.len(), "{captions:?}"); + // The heading is where a channel says which Windows role it takes, so + // it has to at least name the channel. + assert!(slot_caption(Slot::Game).contains("GAME")); + assert!(slot_caption(Slot::Chat).contains("CHAT")); + } + + #[test] + fn the_output_subtitle_distinguishes_unset_from_unplugged() { + // Three different facts, and collapsing any two of them misleads: no + // choice made, a choice that is live, and a choice that is currently + // absent from the machine. + assert_eq!( + output_choice_subtitle(Slot::Fallback, None), + "Choose a device" + ); + assert_eq!( + output_choice_subtitle(Slot::Fallback, Some(("Speakers (Realtek(R) Audio)", true))), + "Speakers (Realtek(R) Audio)" + ); + let gone = + output_choice_subtitle(Slot::Fallback, Some(("Speakers (Realtek(R) Audio)", false))); + assert!(gone.starts_with("Speakers (Realtek(R) Audio)"), "{gone}"); + assert!(gone.contains("not connected"), "{gone}"); + + // An unanswered channel says which channel it is waiting on: three + // rows all reading "Choose a device" would not. + let prompts: std::collections::BTreeSet = [Slot::Fallback, Slot::Game, Slot::Chat] + .iter() + .map(|s| output_choice_subtitle(*s, None)) + .collect(); + assert_eq!(prompts.len(), 3, "{prompts:?}"); + } + + #[test] + fn the_picker_view_lists_ids_in_the_order_it_drew_them() { + // Off Windows the device list is empty, so this pins the invariant that + // holds either way: one id recorded per drawn choice, in the same order. + // A mismatch is how a click selects a device the user did not see. + let p = build( + &connected(), + View::Output(Slot::Fallback), + SliderParam::Sidetone, + None, + ); + let choices: Vec = p + .hits + .iter() + .filter_map(|(_, t)| match t { + HitTarget::OutputChoice(i) => Some(*i), + _ => None, + }) + .collect(); + assert_eq!(choices.len(), p.output_ids.len()); + assert!( + choices.iter().enumerate().all(|(n, i)| n == *i), + "choice indices must be positional: {choices:?}" + ); + } + + #[test] + fn the_picker_view_can_always_be_left() { + // A view with a list that may be empty still needs a way out. + let p = build( + &connected(), + View::Output(Slot::Fallback), + SliderParam::Sidetone, + None, + ); + assert!(p.hits.iter().any(|(_, t)| *t == HitTarget::Back)); + assert!(p.track.is_none(), "the picker has no slider"); + } + + #[test] + fn only_the_main_view_offers_the_gear() { + // Settings and the picker are both places you arrive at, so both show + // Back. Offering Gear inside Settings would be a button to where you are. + let main = build(&connected(), View::Main, SliderParam::Sidetone, None); + assert!(main.hits.iter().any(|(_, t)| *t == HitTarget::Gear)); + for view in [View::Settings, View::Output(Slot::Fallback)] { + let p = build(&connected(), view, SliderParam::Sidetone, None); + assert!( + !p.hits.iter().any(|(_, t)| *t == HitTarget::Gear), + "{view:?} must not offer the gear" + ); + } + } + #[test] fn preview_overrides_the_device_value_while_dragging() { let p = build(&connected(), View::Main, SliderParam::GameChat, Some(17)); let has_text = p .primitives .iter() - .any(|prim| matches!(prim, Primitive::Text { text, .. } if text == "Game +7")); + .any(|prim| matches!(prim, Primitive::Text { text, .. } if text == "Chat +7")); assert!( has_text, "the dragged value should be shown, not the stored one" diff --git a/crates/headset-tray/src/win32/audio.rs b/crates/headset-tray/src/win32/audio.rs new file mode 100644 index 0000000..382532e --- /dev/null +++ b/crates/headset-tray/src/win32/audio.rs @@ -0,0 +1,302 @@ +//! Which Windows output device the sound goes to. +//! +//! Two halves with very different standing, and the difference matters: +//! +//! - **Listing** the outputs and reading which one is default is ordinary, +//! documented Core Audio (`IMMDeviceEnumerator`). Nothing here is guessed. +//! - **Setting** the default has no documented API at all. Windows exposes no +//! supported way to do it from code — the Sound settings page and `mmsys.cpl` +//! are the only sanctioned routes, and both need a human. So this reaches the +//! same undocumented `IPolicyConfig` interface every audio switcher uses. +//! +//! That second half is a deliberate exception, recorded in +//! `docs/undocumented-apis.md` rather than left for a reader to discover. It is +//! not the kind of guess `CONTRIBUTING.md` forbids — those rules govern HID +//! identifiers sent to the headset, and nothing in this module touches the +//! device. But it is unsupported, so every call site treats failure as normal: +//! a switch that does not happen leaves the user exactly where they were. + +#![allow(unsafe_code)] + +use std::ffi::c_void; + +use windows::core::{IUnknown, Interface, GUID, HRESULT, PCWSTR}; +use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; +use windows::Win32::Media::Audio::{ + eCommunications, eConsole, eMultimedia, eRender, ERole, IMMDevice, IMMDeviceEnumerator, + MMDeviceEnumerator, DEVICE_STATE_ACTIVE, +}; +use windows::Win32::System::Com::{CoCreateInstance, CoTaskMemFree, CLSCTX_ALL, STGM_READ}; + +/// One selectable Windows output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutputDevice { + /// The endpoint id, of the form `{0.0.0.00000000}.{guid}`. + /// + /// This is what the setting stores, not the friendly name: the name is + /// whatever the driver currently calls it and changes under the user's feet + /// (a re-installed driver renames "Speakers (Realtek Audio)"), whereas the + /// id survives reboots, replugs, and renames. + pub id: String, + /// The name as the Sound settings page shows it. + pub name: String, +} + +/// Every active render endpoint, sorted by name so the picker's order is stable +/// between openings rather than following enumeration order. +/// +/// Active only: a disabled or unplugged endpoint cannot be made the default, so +/// offering it would be offering something that silently fails. +pub fn render_outputs() -> Vec { + unsafe { + let enumerator: IMMDeviceEnumerator = + match CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) { + Ok(e) => e, + Err(e) => { + tracing::warn!("could not enumerate audio endpoints: {e}"); + return Vec::new(); + } + }; + let Ok(collection) = enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE) else { + return Vec::new(); + }; + let count = collection.GetCount().unwrap_or(0); + let mut out = Vec::with_capacity(count as usize); + for i in 0..count { + let Ok(device) = collection.Item(i) else { + continue; + }; + // No id means nothing can be stored or restored for this endpoint, + // so it is skipped rather than listed as unselectable. + let Some(id) = device_id(&device) else { + continue; + }; + let name = friendly_name(&device).unwrap_or_else(|| id.clone()); + out.push(OutputDevice { id, name }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out + } +} + +/// The endpoint currently receiving ordinary playback. +/// +/// Read against `eConsole`. Windows keeps three defaults and they can disagree; +/// this asks the one that answers "where does sound go", which is the value the +/// switch has to remember in order to put it back. +pub fn default_output_id() -> Option { + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL).ok()?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole).ok()?; + device_id(&device) + } +} + +/// Whether an endpoint id is currently present and active. +/// +/// Used to tell "you chose speakers that are unplugged" from "you chose nothing", +/// which are different things to say to a user. +pub fn is_present(id: &str) -> bool { + render_outputs().iter().any(|d| d.id == id) +} + +/// Makes `id` the default output for all three roles. +/// +/// All three deliberately: Windows keeps separate defaults for console, +/// multimedia, and communications, and moving only some of them is how you end +/// up with the game on the speakers and the voice chat still in a powered-off +/// headset. This is what picking a device in the Sound settings page does. +/// +/// Returns whether every role took. A partial failure is reported as failure — +/// the caller must not record a successful switch it cannot fully reverse. +pub fn set_default_output(id: &str) -> bool { + with_policy(|p| set_roles(p, id, &[eConsole, eMultimedia, eCommunications])) +} + +/// Gives ordinary playback to `game` and calls to `chat`. +/// +/// The same two writes the Sound settings page makes when you pick a "Default +/// Device" and a separate "Default Communication Device" — which is the whole +/// point of a headset that presents two endpoints, and the thing +/// [`set_default_output`] destroys when it puts one endpoint into all three +/// roles. +/// +/// Both are attempted even if the first fails, so a machine that refuses one +/// role still gets the other, and the failure is still reported as one. +pub fn set_split_output(game: &str, chat: &str) -> bool { + with_policy(|p| { + let game_ok = set_roles(p, game, &[eConsole, eMultimedia]); + let chat_ok = set_roles(p, chat, &[eCommunications]); + game_ok && chat_ok + }) +} + +/// Runs `f` against the policy object, or reports failure if there is none. +fn with_policy(f: impl FnOnce(&PolicyConfig) -> bool) -> bool { + match PolicyConfig::open() { + Some(policy) => f(&policy), + None => { + tracing::warn!("the default-output policy interface is unavailable on this system"); + false + } + } +} + +/// Points `roles` at `id`, reporting whether every one of them took. +fn set_roles(policy: &PolicyConfig, id: &str, roles: &[ERole]) -> bool { + let wide: Vec = id.encode_utf16().chain(std::iter::once(0)).collect(); + let mut ok = true; + for role in roles { + let hr = policy.set_default(&wide, *role); + if hr.is_err() { + tracing::warn!( + "setting the default output for role {} failed: {hr:?}", + role.0 + ); + ok = false; + } + } + ok +} + +unsafe fn device_id(device: &IMMDevice) -> Option { + let raw = device.GetId().ok()?; + if raw.is_null() { + return None; + } + let s = raw.to_string().ok(); + // The id is allocated by the callee with CoTaskMemAlloc; the caller frees it. + CoTaskMemFree(Some(raw.0 as *const c_void)); + s +} + +unsafe fn friendly_name(device: &IMMDevice) -> Option { + let store = device.OpenPropertyStore(STGM_READ).ok()?; + let value = store.GetValue(&PKEY_Device_FriendlyName).ok()?; + // The one place this module depends on the `windows` crate's PROPVARIANT + // ergonomics rather than on a Win32 shape. If a future bump changes it, + // this is the single line to adapt. + let name = value.to_string(); + (!name.trim().is_empty()).then_some(name) +} + +// ---------------------------------------------------------- IPolicyConfig --- +// The undocumented half. See this module's header for why it exists at all. + +/// `CPolicyConfigClient`, the coclass that implements the interface below. +const CLSID_POLICY_CONFIG_CLIENT: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9); +/// `IPolicyConfig`, as shipped since Windows 7 and still present on Windows 11. +const IID_POLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8); + +/// The vtable, declared only as far as the one method this project calls. +/// +/// The ten slots before it are real methods (device format, processing period, +/// share mode, and property get/set) that nothing here calls. They are left +/// deliberately unnamed and untyped: their position is what matters, because a +/// vtable is positional and `set_default_endpoint` must land at index 13. Naming +/// them would claim knowledge of signatures this project has no use for and no +/// way to check. +#[repr(C)] +struct PolicyConfigVtbl { + query_interface: + unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + _unused: [*const c_void; 10], + set_default_endpoint: unsafe extern "system" fn(*mut c_void, PCWSTR, ERole) -> HRESULT, +} + +/// An owned reference to the policy-config object. Releases on drop. +struct PolicyConfig(*mut c_void); + +impl PolicyConfig { + fn open() -> Option { + unsafe { + let unknown: IUnknown = + CoCreateInstance(&CLSID_POLICY_CONFIG_CLIENT, None, CLSCTX_ALL).ok()?; + let mut raw: *mut c_void = std::ptr::null_mut(); + if unknown.query(&IID_POLICY_CONFIG, &mut raw).is_err() || raw.is_null() { + return None; + } + Some(PolicyConfig(raw)) + } + } + + unsafe fn vtbl(&self) -> &PolicyConfigVtbl { + &**(self.0 as *const *const PolicyConfigVtbl) + } + + /// `id` must be NUL-terminated UTF-16. + fn set_default(&self, id: &[u16], role: ERole) -> HRESULT { + unsafe { (self.vtbl().set_default_endpoint)(self.0, PCWSTR(id.as_ptr()), role) } + } +} + +impl Drop for PolicyConfig { + fn drop(&mut self) { + unsafe { + (self.vtbl().release)(self.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The vtable's shape is the whole contract with an interface nobody + /// documents: `set_default_endpoint` works only if it sits at index 13, and + /// a stray field or a wrong-sized filler would call some other method + /// entirely — with arguments meant for this one. + #[test] + fn set_default_endpoint_sits_at_vtable_index_13() { + let slot = std::mem::size_of::<*const c_void>(); + let offset = std::mem::offset_of!(PolicyConfigVtbl, set_default_endpoint); + assert_eq!(offset, slot * 13, "the vtable layout must not drift"); + assert_eq!(std::mem::size_of::(), slot * 14); + } + + /// Proves the undocumented interface actually works on this machine — + /// CLSID, IID, vtable index, and calling convention together — without + /// moving anything: it sets the console default to the endpoint that is + /// *already* the console default. + /// + /// One role, not all three, precisely so it stays a no-op. Setting all + /// three would overwrite a communications default that legitimately differs + /// from the console one, which is a real change to the machine and not this + /// test's business. + /// + /// `#[ignore]`d because it touches the live audio configuration, following + /// the convention the hardware tests use. Run it when the interface is in + /// question, which is the moment a Windows update is suspected of having + /// moved it: + /// + /// ```text + /// cargo test -p headset-tray --target x86_64-pc-windows-gnu -- --ignored policy + /// ``` + #[test] + #[ignore] + fn the_policy_interface_accepts_a_no_op_default() { + use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + } + + let current = default_output_id().expect("a default output must exist"); + let policy = PolicyConfig::open().expect("CPolicyConfigClient must be creatable"); + let wide: Vec = current.encode_utf16().chain(std::iter::once(0)).collect(); + let hr = policy.set_default(&wide, eConsole); + assert!(hr.is_ok(), "SetDefaultEndpoint returned {hr:?}"); + } + + /// Enumeration is read-only and needs no hardware, but it does need COM to + /// have been initialised on this thread. The tray does that at startup; the + /// test harness does not, so this only pins that the call is total — it + /// returns a list or an empty one, and never panics or hangs. + #[test] + fn enumeration_is_total_even_without_com() { + let _ = render_outputs(); + let _ = default_output_id(); + } +} diff --git a/crates/headset-tray/src/win32/mod.rs b/crates/headset-tray/src/win32/mod.rs index 7d0efe2..cdfb6cd 100644 --- a/crates/headset-tray/src/win32/mod.rs +++ b/crates/headset-tray/src/win32/mod.rs @@ -33,21 +33,22 @@ use windows::Win32::System::Diagnostics::ToolHelp::{ }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::Shell::{ - Shell_NotifyIconW, NIF_GUID, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, - NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4, + Shell_NotifyIconW, NIF_GUID, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING, NIM_ADD, + NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4, }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreateIconIndirect, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyIcon, - DestroyMenu, DestroyWindow, DispatchMessageW, GetCursorPos, GetMessageW, LoadCursorW, - PostMessageW, PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow, - TrackPopupMenu, TranslateMessage, HICON, ICONINFO, IDC_ARROW, MF_SEPARATOR, MF_STRING, MSG, - TPM_BOTTOMALIGN, TPM_RIGHTALIGN, WINDOW_EX_STYLE, WM_ACTIVATE, WM_APP, WM_CLOSE, WM_COMMAND, - WM_CONTEXTMENU, WM_DESTROY, WM_DPICHANGED, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, - WM_SETTINGCHANGE, WNDCLASSW, WS_OVERLAPPED, + DestroyMenu, DestroyWindow, DispatchMessageW, GetCursorPos, GetMessageW, KillTimer, + LoadCursorW, PostMessageW, PostQuitMessage, RegisterClassW, RegisterWindowMessageW, + SetForegroundWindow, SetTimer, TrackPopupMenu, TranslateMessage, HICON, ICONINFO, IDC_ARROW, + MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, TPM_RIGHTALIGN, WINDOW_EX_STYLE, WM_ACTIVATE, + WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_DPICHANGED, WM_LBUTTONDOWN, + WM_LBUTTONUP, WM_MOUSEMOVE, WM_SETTINGCHANGE, WM_TIMER, WNDCLASSW, WS_OVERLAPPED, }; // Not part of any public API: these are the tray's own window plumbing, and // several are `unsafe` in ways only this module's call sites can uphold. +pub(crate) mod audio; pub(crate) mod dpi; pub(crate) mod panel; pub(crate) mod place; @@ -91,6 +92,18 @@ const ID_GAMECHAT_BASE: usize = 200; const SIDETONE_MAX: u8 = 15; const GAMECHAT_MAX: u8 = 20; +/// Timer that debounces the output switch. +const ID_OUTPUT_TIMER: usize = 1; + +/// How long the link has to hold its new state before the sound is moved. +/// +/// The link parameter carries no reason, so a deliberate power-off, an +/// auto-sleep, and a walk out of range are the same event to us. Two seconds is +/// what stops a momentary dropout from bouncing the whole machine's audio to +/// the speakers and straight back — which is far more disruptive than reacting +/// two seconds late to a real power-off. +const OUTPUT_SWITCH_DEBOUNCE_MS: u32 = 2000; + struct Ctx { icon: HICON, commands: Sender, @@ -125,6 +138,18 @@ struct Ctx { /// slider. pending_noise: Option, panel_visible: bool, + /// The link state the output switch has already reacted to. `None` until + /// the first read lands, which is why a fresh start does not count as a + /// transition and does not move anybody's sound. + link: Option, + /// How many times the endpoint owed a move back has been looked for since + /// the last link change. Held in memory, unlike the record itself: the + /// debt has to survive a restart, but a count of how long we have been + /// waiting on this particular power-on does not. + restore_attempts: u8, + /// Endpoint ids in the order the picker last drew them, so a click resolves + /// against that paint rather than a re-enumeration that may have shifted. + output_ids: Vec, } thread_local! { @@ -342,6 +367,9 @@ pub fn run_ui_with( pending_slider: None, pending_noise: None, panel_visible: false, + link: None, + restore_attempts: 0, + output_ids: Vec::new(), }) }); @@ -356,13 +384,9 @@ pub fn run_ui_with( } } -/// Copies text into the fixed 128-wchar tooltip buffer, truncating rather than -/// overflowing. Windows silently drops an unterminated tip, so the last slot is -/// always left as the NUL. +/// Copies text into the fixed 128-wchar tooltip buffer. fn write_tip(nid: &mut NOTIFYICONDATAW, text: &str) { - let wide: Vec = text.encode_utf16().take(nid.szTip.len() - 1).collect(); - nid.szTip = [0; 128]; - nid.szTip[..wide.len()].copy_from_slice(&wide); + write_fixed(&mut nid.szTip, text); } /// Resolves the palette from the user's choice, Windows' preference, and high @@ -515,6 +539,7 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) // The device has spoken; stop showing what was asked for. ctx.pending_noise = None; ctx.pending_slider = None; + note_link_change(hwnd, ctx); refresh_tray(ctx); if ctx.panel_visible { redraw_panel(ctx); @@ -523,6 +548,13 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) }); LRESULT(0) } + // The debounce elapsed and the link has held its new state. Only the + // owner window arms this timer; the panel shares the procedure. + WM_TIMER if wp.0 == ID_OUTPUT_TIMER && panel::tag(hwnd) != panel::TAG_PANEL => { + let _ = KillTimer(hwnd, ID_OUTPUT_TIMER); + with_ctx(reconcile_output); + LRESULT(0) + } WM_LBUTTONDOWN if panel::tag(hwnd) == panel::TAG_PANEL => { let (x, y) = panel::point_from_lparam(lp); with_ctx(|ctx| on_panel_press(ctx, x, y)); @@ -610,6 +642,210 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) } } +/// Notices the headset coming or going, and starts the debounce. +/// +/// Restarting the timer on every edge is the debounce: a link that flaps twice +/// inside the window arms it twice and fires once, against whatever state it +/// finally settled on. +fn note_link_change(hwnd: HWND, ctx: &mut Ctx) { + let now = ctx.state.lock().ok().and_then(|s| s.connected); + if now == ctx.link { + return; + } + ctx.link = now; + // Each transition gets its own budget of looks for the endpoint owed. A + // previous power-on that gave up must not leave the next one with none. + ctx.restore_attempts = 0; + // A vanished dongle reads as `None`, not as a powered-down headset, and is + // deliberately not acted on: its endpoints go with it and Windows moves the + // sound by itself. + if now.is_some() { + let _ = unsafe { SetTimer(hwnd, ID_OUTPUT_TIMER, OUTPUT_SWITCH_DEBOUNCE_MS, None) }; + } +} + +/// Moves the sound, or moves it back, according to where the link settled. +/// +/// The rule itself is in `crate::output`, which is handed facts and answers +/// with an action; this function gathers those facts and carries the action +/// out. Everything here is best-effort by design: setting the default output +/// uses an interface Windows does not document (see `win32::audio`), so a +/// failure is a thing that can simply happen — and when it does, the user is +/// left exactly where they were rather than half-moved, and told. +fn reconcile_output(ctx: &mut Ctx) { + use crate::output::{Action, Problem}; + + match plan_output(ctx.link, ctx.restore_attempts) { + // Deliberately not a retraction. "Nothing to do" is the answer for a + // powered-on headset with nothing owed, which is the state the machine + // is in moments after a power-off that could not find a device to move + // to — clearing here would wipe that complaint off the settings row and + // then raise it as news all over again on the next power-off. A + // complaint is retracted by a switch that works, or by the user + // changing the setting it is about. + Action::Nothing => {} + Action::MoveAway { to, owing } => { + if audio::set_default_output(&to) { + // Recorded only after the move actually took. A debt for a move + // that never happened would drag the sound somewhere it never + // left. + let _ = crate::settings::set_restore_output(Some(&owing)); + tracing::info!("headset powered down; sound moved to {to}"); + report_output_issue(ctx, None); + } else { + report_output_issue(ctx, Some(Problem::SwitchFailed)); + } + } + Action::MoveBack { to } => { + if audio::set_default_output(&to) { + let _ = crate::settings::set_restore_output(None); + tracing::info!("headset back; sound restored"); + report_output_issue(ctx, None); + } else { + report_output_issue(ctx, Some(Problem::SwitchFailed)); + } + } + Action::Split { game, chat } => { + if audio::set_split_output(&game, &chat) { + // Whatever was owed is settled: the sound has been put on the + // headset deliberately, and leaving the record standing would + // drag it back off at the next opportunity. + let _ = crate::settings::set_restore_output(None); + tracing::info!("headset back; game on {game}, calls on {chat}"); + report_output_issue(ctx, None); + } else { + report_output_issue(ctx, Some(Problem::SwitchFailed)); + } + } + Action::Retry => { + // The endpoint may be moments from appearing — a wireless link + // comes up before its audio endpoints do. Look again rather than + // concluding anything. + ctx.restore_attempts = ctx.restore_attempts.saturating_add(1); + tracing::debug!( + "the endpoint to restore is not present yet (look {})", + ctx.restore_attempts + ); + let _ = + unsafe { SetTimer(ctx.nid.hWnd, ID_OUTPUT_TIMER, crate::output::RETRY_MS, None) }; + } + Action::GiveUp => { + // The endpoint is gone for good — a reinstalled driver or a + // re-enumerated dongle is enough to retire an endpoint id. Letting + // the record go is what stops it blocking every later switch. + let _ = crate::settings::set_restore_output(None); + tracing::warn!("gave up restoring the previous output; the endpoint is gone"); + report_output_issue(ctx, Some(Problem::RestoreGone)); + } + Action::Blocked(p) => report_output_issue(ctx, Some(p)), + } +} + +/// Gathers what the machine currently says and asks `output::decide` about it. +/// +/// Reads only — every write is `reconcile_output`'s. Separating the two is what +/// lets the decision be inspected on a real machine (`--explain-output`) +/// without moving anybody's sound to find out what it would have done. +pub fn plan_output(link: Option, attempts: u8) -> crate::output::Action { + use crate::output::{decide, Facts, Slot}; + + let fallback = crate::settings::output_choice(Slot::Fallback); + let game = crate::settings::output_choice(Slot::Game); + let chat = crate::settings::output_choice(Slot::Chat); + let fallback_id = fallback.as_ref().map(|(id, _)| id.as_str()); + let game_id = game.as_ref().map(|(id, _)| id.as_str()); + let chat_id = chat.as_ref().map(|(id, _)| id.as_str()); + let debt = crate::settings::restore_output(); + let current = audio::default_output_id(); + + // Enumerated once and asked repeatedly: `audio::is_present` is a full Core + // Audio enumeration each time, and every question is about one snapshot. + let present = audio::render_outputs(); + let has = |id: Option<&str>| id.is_some_and(|i| present.iter().any(|d| d.id == i)); + + decide(&Facts { + enabled: crate::settings::switch_output_when_off(), + split: crate::settings::split_game_and_chat(), + link, + fallback: fallback_id, + fallback_present: has(fallback_id), + game: game_id, + game_present: has(game_id), + chat: chat_id, + chat_present: has(chat_id), + current: current.as_deref(), + debt: debt.as_deref(), + debt_present: has(debt.as_deref()), + attempts, + }) +} + +/// Whether the user is visibly halfway through setting the split up. +/// +/// The settings handlers act on the current state the moment something changes, +/// so that a switch turned on with the headset already powered does something +/// rather than appearing broken. That is the wrong thing to do between the two +/// halves of one choice: picking the game channel and immediately being told +/// off for not having picked the chat channel is a complaint about the step the +/// user is in the middle of taking. Only the powered-on case is held back — +/// that is the only one the split has any say over. +fn mid_split_setup(link: Option) -> bool { + use crate::output::Slot; + + link == Some(true) + && crate::settings::split_game_and_chat() + && (crate::settings::output_choice(Slot::Game).is_none() + || crate::settings::output_choice(Slot::Chat).is_none()) +} + +/// Records a problem (or its absence) and tells the user the first time. +/// +/// Persisting it is what lets the settings panel explain itself hours later, +/// when nobody was watching the balloon. `set_output_problem` reports whether the +/// stored value actually changed, so a condition that recurs on every power-off +/// interrupts once rather than every time. +fn report_output_issue(ctx: &Ctx, problem: Option) { + let changed = crate::settings::set_output_problem(problem); + if let Some(p) = problem { + tracing::warn!("output switch: {}", p.detail()); + if changed { + notify_balloon(ctx, "Headset audio switching", p.detail()); + } + } +} + +/// Shows a balloon from the tray icon. +/// +/// The panel is the wrong place for news: it is only on screen when the user +/// opened it, and the whole failure being reported is one they notice by +/// *not* seeing anything happen. Windows may suppress this — focus assist, +/// or notifications turned off for the app — which is why the same text is +/// also persisted for the settings row. +fn notify_balloon(ctx: &Ctx, title: &str, body: &str) { + // A copy carrying only NIF_INFO: the identity fields (hWnd/uID, or the + // GUID when the icon was added with one) come along in the copy, and + // re-sending NIF_ICON | NIF_TIP here would rewrite them for no reason. + let mut nid = ctx.nid; + nid.uFlags = (ctx.nid.uFlags & NIF_GUID) | NIF_INFO; + write_fixed(&mut nid.szInfoTitle, title); + write_fixed(&mut nid.szInfo, body); + nid.dwInfoFlags = NIIF_WARNING; + unsafe { + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); + } +} + +/// Copies text into one of the notification struct's fixed wide buffers, +/// truncating rather than overflowing and always leaving the final NUL. +/// +/// Windows silently drops an unterminated string, so a body that exactly +/// filled the buffer would produce no balloon at all rather than a clipped one. +fn write_fixed(buf: &mut [u16], text: &str) { + let wide: Vec = text.encode_utf16().take(buf.len() - 1).collect(); + buf.fill(0); + buf[..wide.len()].copy_from_slice(&wide); +} + /// Renders the current state and pushes it into the layered window. fn redraw_panel(ctx: &mut Ctx) { let Some(renderer) = ctx.renderer.as_ref() else { @@ -632,6 +868,7 @@ fn redraw_panel(ctx: &mut Ctx) { ctx.hits = panel.hits.clone(); ctx.track = panel.track; ctx.level_track = panel.level_track; + ctx.output_ids = panel.output_ids.clone(); let first_show = !ctx.panel_visible; unsafe { @@ -708,7 +945,12 @@ fn on_panel_press(ctx: &mut Ctx, x: f32, y: f32) { redraw_panel(ctx); } HitTarget::Back => { - ctx.view = View::Main; + // One step back, not all the way out: the picker is reached from + // Settings, so leaving it should land where it was opened from. + ctx.view = match ctx.view { + View::Output(_) => View::Settings, + _ => View::Main, + }; redraw_panel(ctx); } HitTarget::Refresh => { @@ -772,6 +1014,82 @@ fn on_panel_press(ctx: &mut Ctx, x: f32, y: f32) { redraw_panel(ctx); } } + HitTarget::ToggleOutputSwitch => { + let now = crate::settings::switch_output_when_off(); + if crate::settings::set_switch_output_when_off(!now) { + if now { + // Turning it off cancels any move still owed. The sound is + // left where it is — the user may well be listening to it — + // but a switched-off feature must not act on a later + // power-on. The complaint goes with it: a feature that is + // off has nothing to report. + let _ = crate::settings::set_restore_output(None); + let _ = crate::settings::set_output_problem(None); + } else { + // Turning it on acts on the state the headset is in now. + // Waiting for the next transition would mean switching it + // on with the headset already off and watching nothing + // happen, which reads as a setting that does not work. + // + // The old complaint goes first: it was about a setting the + // user has just changed, so it has to be re-earned rather + // than left standing over a fresh attempt. + let _ = crate::settings::set_output_problem(None); + reconcile_output(ctx); + } + redraw_panel(ctx); + } + } + HitTarget::ToggleSplit => { + let now = crate::settings::split_game_and_chat(); + if crate::settings::set_split_game_and_chat(!now) { + // The complaint goes either way: turning the split off retires + // a message about a feature that is no longer running, and + // turning it on means any standing one was about the settings + // as they were, so it has to be re-earned. + let _ = crate::settings::set_output_problem(None); + if !now && !mid_split_setup(ctx.link) { + // Turning it on acts on the state the headset is in now, + // for the same reason the switch above it does: waiting for + // the next power cycle reads as a setting that does nothing. + reconcile_output(ctx); + } + redraw_panel(ctx); + } + } + HitTarget::OpenOutputPicker(slot) => { + ctx.view = View::Output(slot); + redraw_panel(ctx); + } + HitTarget::OutputChoice(i) => { + // Which of the three choices is being made is carried by the view, + // because the picker draws the same list for all three. + let View::Output(slot) = ctx.view else { + return; + }; + // Resolved against the list this paint drew, then re-read from the + // system for its current name: the id is the durable half, and the + // stored name is only ever a label for it. + if let Some(id) = ctx.output_ids.get(i).cloned() { + let name = audio::render_outputs() + .into_iter() + .find(|d| d.id == id) + .map(|d| d.name) + .unwrap_or_else(|| id.clone()); + let _ = crate::settings::set_output_choice(slot, &id, &name); + // Same reasoning as turning the switch on: choosing the device + // is the step that made the setting actionable, so act on where + // the headset is now rather than waiting for it to move. Any + // standing complaint was about the device that was chosen + // before, so it is dropped and has to be re-earned. + let _ = crate::settings::set_output_problem(None); + if !mid_split_setup(ctx.link) { + reconcile_output(ctx); + } + ctx.view = View::Settings; + redraw_panel(ctx); + } + } } } diff --git a/docs/architecture.md b/docs/architecture.md index 46dc23e..dcfa9c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,12 +102,15 @@ flagged with `supported_device: false` plus a stderr warning when it does. `headset-protocol` declares `#![forbid(unsafe_code)]`: it has zero operating-system access and needs none. -`unsafe` is confined to exactly **two** modules in the workspace: +`unsafe` is confined to the workspace's two Win32 areas: - `crates/headset-device/src/windows/ffi.rs` — raw SetupAPI and `hid.dll`, including `HidD_SetOutputReport` for the write path. -- `crates/headset-tray/src/win32/mod.rs` — the notification icon, popup menus, message - loop, process check, and Core Audio. +- `crates/headset-tray/src/win32/` — the notification icon, popup menus, message loop, + process check, and Core Audio. Within it, `audio.rs` is the only place in the workspace + that calls an interface Windows does not document; it is set apart precisely so that + exception is one file rather than a line buried in the message loop. See + [`undocumented-apis.md`](undocumented-apis.md). The second one is a deliberate Phase 2 trade. Phase 1 stated that `headset-tray` would be entirely safe Rust, which assumed it would take a tray-icon crate. Phase 2's footprint @@ -117,6 +120,60 @@ and Core Audio directly instead. A second confined `unsafe` module was judged th side of that trade. Every other module in every crate is safe Rust, and `headset-cli` contains none at all. +## Switching the output device + +The tray can move Windows' sound to another device while the headset is powered down, and +back when it returns. Two things are worth knowing about how it is built: + +- **The trigger is the link parameter, not a button.** No power-button event exists in the + observed protocol, and none is invented. Holding the power button powers the headset + off, and *that* is what parameter `0x20` reports. The link carries no reason, so an + auto-sleep and a walk out of range look identical to a deliberate power-off — which is + why the switch is debounced rather than immediate. +- **The way back is a persisted debt, not a remembered device.** When the sound is moved, + the endpoint it was moved *from* is written to the registry. Its presence is the state: + it means "we owe this user a move back". Holding it in memory would strand somebody on + the speakers if the tray were closed, updated, or killed while the headset was off. +- **A debt that cannot be paid is not a reason to refuse new work.** Endpoint ids are + durable but not permanent: a reinstalled driver or a re-enumerated dongle retires one and + issues another. Alpha 2 read the debt's mere *presence* as "already switched", so a debt + naming a retired endpoint could never be discharged (the endpoint was gone) and never be + replaced (its presence blocked the switch) — the feature died silently and stayed dead + until somebody edited the registry. A debt now counts only while the machine still has + the endpoint it names, and one whose endpoint never reappears is given up on after + `output::RESTORE_ATTEMPTS` looks. + +The decision itself lives in `headset-tray/src/output.rs` and touches nothing: it is handed +facts and answers with an `Action`. That split exists because the alternative is a rule +whose only test is powering a headset off and watching a machine's sound move. +`win32::plan_output` gathers the facts, `win32::reconcile_output` carries the action out, +and `headset-tray.exe --explain-output` prints what would happen for either link state +without doing it. + +Failures are surfaced rather than logged into a subscriber nobody enabled: a balloon from +the tray icon the first time a problem appears, and the same problem persisted — by name, +not by its wording — so the settings row can still explain itself hours later, on the row +of the feature that earned it. + +The same module carries a second, independent rule: **the game/chat split**. Windows keeps +three defaults (console, multimedia, communications) and the headset presents two endpoints, +which is the whole reason a chat channel exists. Restoring the sound can only name the one +endpoint it was moved from, and writing that into all three roles is actively destructive — +it overwrites a communications default the user had pointed at the chat channel. So when +`Slot::Game` and `Slot::Chat` have both been chosen and the split is on, a headset coming +back produces `Action::Split` instead of `Action::MoveBack`: ordinary playback to the game +channel, calls to the chat channel, and the debt discharged, since the sound has been placed +deliberately. It is opt-in and works with the move-when-off switch turned off, because +wanting calls on their own channel and wanting your sound moved when the headset dies are +different wishes. + +Both rules share one retry budget, for the same reason: a wireless link comes up seconds +before its audio endpoints do, so "the endpoint I want is not there" is the ordinary first +answer, not a failure. + +Nothing here reaches the headset. Setting the default output has no documented API at all; +see [`undocumented-apis.md`](undocumented-apis.md). + Microphone mute lives in the tray's Win32 module rather than in `headset-device` because it is a USB Audio Class control, not a vendor HID command. The vendor protocol can only *report* the headset's hardware mute switch; it cannot set mute, and no such command diff --git a/docs/device-research.md b/docs/device-research.md index 512e202..2e92754 100644 --- a/docs/device-research.md +++ b/docs/device-research.md @@ -157,7 +157,7 @@ observed to change with it; see the Unknown bytes policy. | `0x20` | `0x0020` | — | link state | 2 bytes | `00 00` while the headset was off; `01 00` pushed at the instant it was powered on, followed by the host reading every other parameter | | `0x21` | `0x8021` | — | battery level | 0–100 | Read returned `0x34` while the vendor UI showed 52; an event later reported `0x31` after the user observed the level falling | | `0x19` | `0x8019` | `0x8099` | sidetone level | 0–15 | Slider moved end to end; values clamped at `0x00` and `0x0F` while still turning | -| `0x5C` | `0x805C` | `0x80DC` | game/chat balance | 0–20 | Same clamping evidence at `0x00` and `0x14`; centre `0x0A` | +| `0x5C` | `0x805C` | `0x80DC` | game/chat balance | 0–20 | Same clamping evidence at `0x00` and `0x14`; centre `0x0A`. `0x00` is full **game**, `0x14` full **chat** — established by listening, not by the captures, which carry no direction | | `0x55` | `0x8055` | — | mic mute (hardware switch) | 0/1 | Events on the headset's physical mute; no write ever observed, and the vendor UI exposes no mute control | | `0x6A` | `0x806A` | `0x80EA` | onboard slider function | ≥3 states | Toggling which parameter the wheel drives changed this value; the wheel then reported through a different parameter | | `0x12` | `0x8012` | `0x8092` | noise control (mode + ANC level) | 2 bytes; see below | Three capture sessions isolating noise cancellation off/on, mode switching, and level 4→3→2→1→4 | diff --git a/docs/threat-model.md b/docs/threat-model.md index 0953ad3..f16a264 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -9,4 +9,5 @@ | Auto-start persistence | The tray installs itself into a logon-persistence mechanism, which is behaviour malware also uses and which a user must be able to see and revoke | Per-user `HKCU\...\CurrentVersion\Run` only — never `HKLM`, never a service, never a scheduled task, and never elevated. The entry is named `HeadsetTray`, so it appears in Task Manager's Startup tab and can be disabled there. The tray reads that same value to render its checkbox, so the UI cannot claim startup is on after Windows has disabled it. `--uninstall` removes the entry, and so does the Add/Remove Programs entry it registers | | Install location | Writing an executable somewhere a user does not expect, or somewhere shared | `%LOCALAPPDATA%\Programs\HeadsetTray` only. Never `Program Files`, which would need elevation and would put a binary where other users execute it. A unit test asserts the path is neither | | Control-channel targeting | Shape-only ranking selects an unrelated vendor's HID collection | `is_supported_device` scoping on automatic selection; explicit `--candidate` warns on stderr and reports `supported_device: false` | +| System-wide audio routing | The tray changes which device the whole machine plays through, using an interface Windows does not document, and could strand a user's sound somewhere they did not choose | Off by default and inert until a device is explicitly picked. Only ever moves between endpoints that already exist on the machine, and only in response to the headset's link state. The endpoint moved *from* is persisted before the move is considered done, so the way back survives the tray being closed or killed; a failed or partial switch is never recorded as successful. The user can override any of it from the Sound settings page at any time, and turning the setting off cancels any pending move back. See `undocumented-apis.md` | | Supply chain | Compromised signing material or dependency | No `.pfx`/key ever committed; dependency licenses tracked in `THIRD_PARTY_NOTICES.md` | diff --git a/docs/undocumented-apis.md b/docs/undocumented-apis.md new file mode 100644 index 0000000..99bf37a --- /dev/null +++ b/docs/undocumented-apis.md @@ -0,0 +1,61 @@ +# Undocumented Windows APIs + +This project uses exactly one Windows interface that Microsoft does not document, and +this file is the record of it. Nothing else in the workspace depends on undocumented +behaviour, and nothing here touches the headset. + +## `IPolicyConfig`, to set the default audio output + +**Used by:** `crates/headset-tray/src/win32/audio.rs::set_default_output`, which the +"switch output when powered off" setting calls. + +**Why there is no alternative.** Windows exposes no supported API for changing the +default playback device. Core Audio's `IMMDeviceEnumerator` can *read* the current +default (`GetDefaultAudioEndpoint`) and enumerate every endpoint, both documented and +both used here — but there is no matching setter, and there never has been. The Sound +settings page and `mmsys.cpl` change it through a private interface. A feature that moves +your sound off a powered-down headset cannot be built without it. + +**What is used:** + +```text +CLSID 870af99c-171d-4f9e-af0d-e63df40c2bc9 (CPolicyConfigClient) +IID f8679f50-850a-41cf-9c72-430f290290c8 (IPolicyConfig) +method vtable index 13, SetDefaultEndpoint(PCWSTR endpointId, ERole role) +``` + +The vtable is declared only as far as that one method. The ten slots before it are real +methods (device format, processing period, share mode, property get/set) that this +project never calls; they are left deliberately unnamed and untyped, because a vtable is +positional and only their *count* matters. Naming them would claim knowledge of +signatures nothing here uses or could check. A unit test asserts the offset, since a +stray field would silently call a different method with this one's arguments. + +`SetDefaultEndpoint` is called once for each of the three roles Windows keeps separately +— console, multimedia, and communications — because that is what choosing a device in the +Sound settings page does. Moving only some of them is how the game ends up on the +speakers with voice chat still in a headset that is switched off. + +**Risk, and how it is contained.** The interface is unsupported: it can change or vanish +in a Windows update without notice. So every call site treats failure as an ordinary +outcome rather than an error to surface loudly: + +- Failing to acquire the interface, or a refused `SetDefaultEndpoint`, leaves the user + exactly where they were. Nothing is half-moved. +- The record of "we owe this user a move back" is written **only after** a switch + succeeds, so a failed switch cannot cause a later, unearned one. +- A partial success across the three roles is reported as failure, because a switch that + cannot be fully reversed must not be recorded as reversible. +- The feature is off by default. A user who never turns it on never loads the interface. + +**How this relates to the project's rules.** `CONTRIBUTING.md` forbids speculative +identifiers and undocumented guesses. Those rules govern **HID commands sent to the +headset**, where a wrong guess reaches hardware whose firmware nobody here can inspect, +and where the whole clean-room posture is at stake. This is a different category: a +Windows API, called on the local machine, that changes a setting the user can change by +hand in the Sound settings page and can change straight back. It is still an exception, +which is why it is written down here rather than left in a comment. + +**What was not done.** Shelling out to a third-party switcher (`nircmd`, `SoundVolumeView`) +was rejected: it adds a dependency the user has to install and trust, and those tools call +this same interface anyway — it would hide the exception rather than remove it.