From 5938736b25d8213fb648d5710ec9d0893fe0018f Mon Sep 17 00:00:00 2001 From: micmancg Date: Tue, 4 Aug 2026 23:12:22 -0400 Subject: [PATCH 1/5] fix(tray): swap the reversed game/chat slider ends The panel labelled low values CHAT and high values GAME, so dragging toward GAME moved the mix toward chat. Raw 0x00 is full game and 0x14 full chat, which the captures never established -- they record the range and the clamps, not which end is which -- so it went in backwards and stayed that way until it was heard. active_end_label needs no change: it returns a positional index into the label array, so value 0 still highlights the left label, now GAME. Co-Authored-By: Claude Opus 5 (1M context) --- crates/headset-tray/src/ui/layout.rs | 14 ++++++++------ docs/device-research.md | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/headset-tray/src/ui/layout.rs b/crates/headset-tray/src/ui/layout.rs index 71e97b9..734566a 100644 --- a/crates/headset-tray/src/ui/layout.rs +++ b/crates/headset-tray/src/ui/layout.rs @@ -156,7 +156,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 +203,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), }, } } @@ -1380,8 +1382,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"); } @@ -1782,7 +1784,7 @@ mod tests { 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/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 | From a479dc7e3f74c1469f23a64f8d2accb6f531d05f Mon Sep 17 00:00:00 2001 From: micmancg Date: Wed, 5 Aug 2026 11:17:12 -0400 Subject: [PATCH 2/5] feat(tray): switch the Windows output when the headset powers off Holding the power button is what turns the headset off, and there is no power-button event in the protocol to hang this on -- so the trigger is parameter 0x20, the link state the dongle already pushes. The link carries no reason, so an auto-sleep or walking out of range is indistinguishable from a deliberate power-off; the two-second debounce is what stops a momentary dropout bouncing the whole machine's audio to the speakers and straight back. The way back is a persisted debt rather than a remembered device. The endpoint the sound was moved *from* is written to the registry, and its presence is the state: it means a move back is owed. Holding it in memory would strand somebody on the speakers if the tray were closed, updated, or killed while the headset was off. It is recorded only after a switch actually succeeds, so a failed switch cannot cause a later unearned one. Remembering the endpoint rather than "the headset" also matters because the dongle presents two render endpoints, Game and Chat, and only the user knows which one they were on. Setting the default output has no documented Windows API at all -- IMMDeviceEnumerator reads the default and has never had a setter. This is the workspace's one undocumented interface, confined to its own module and recorded in docs/undocumented-apis.md with the reasoning for why it is a different category from the HID rules in CONTRIBUTING.md: a local Windows setting the user can change back by hand, not a guessed byte sent to firmware nobody here can inspect. The vtable is declared only as far as the one method called, with the ten preceding slots deliberately unnamed and untyped; a unit test pins the offset, because a stray field there would silently call a different method with these arguments. The picker is an in-panel view rather than a native dropdown: the panel is a layered window that hides on losing activation, so a menu owned by another window would close the panel the menu belongs to. Verified on the machine: the undocumented interface accepts a no-op SetDefaultEndpoint against the live audio stack (an ignored test, one role only so nothing moves), and the picker renders real device names. A layout bug was caught by rendering rather than by a test -- the settings title wrapped and collided with its own description, the exact hazard the neighbouring comment warns about. Not verified: the full power-off and power-on round trip, which needs the button pressed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79 --- CHANGELOG.md | 6 + Cargo.toml | 8 + README.md | 19 ++ crates/headset-tray/src/lib.rs | 50 +++++ crates/headset-tray/src/main.rs | 10 + crates/headset-tray/src/settings.rs | 80 ++++++++ crates/headset-tray/src/ui/layout.rs | 268 +++++++++++++++++++++++- crates/headset-tray/src/win32/audio.rs | 272 +++++++++++++++++++++++++ crates/headset-tray/src/win32/mod.rs | 177 +++++++++++++++- docs/architecture.md | 27 ++- docs/threat-model.md | 1 + docs/undocumented-apis.md | 61 ++++++ 12 files changed, 967 insertions(+), 12 deletions(-) create mode 100644 crates/headset-tray/src/win32/audio.rs create mode 100644 docs/undocumented-apis.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 96233df..2d640ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project are documented here. The format follows ### 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/Cargo.toml b/Cargo.toml index db924eb..d53c244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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..f8a8815 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,25 @@ 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. + 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..04bb777 100644 --- a/crates/headset-tray/src/lib.rs +++ b/crates/headset-tray/src/lib.rs @@ -49,6 +49,36 @@ pub fn settings_show_warning() -> bool { settings::show_synapse_warning() } +#[cfg(windows)] +pub fn settings_switch_output() -> bool { + settings::switch_output_when_off() +} + +/// The chosen fallback output 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::fallback_output_subtitle`. +#[cfg(windows)] +pub fn settings_fallback_output() -> Option<(String, bool)> { + let (id, name) = settings::fallback_output()?; + Some((name, win32::audio::is_present(&id))) +} + +#[cfg(windows)] +pub fn settings_fallback_output_id() -> Option { + settings::fallback_output().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 +93,23 @@ 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_fallback_output() -> Option<(String, bool)> { + None +} + +#[cfg(not(windows))] +pub fn settings_fallback_output_id() -> 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..1b6982f 100644 --- a/crates/headset-tray/src/main.rs +++ b/crates/headset-tray/src/main.rs @@ -188,6 +188,16 @@ 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. + cases.push(( + "output-picker", + base.clone(), + View::Output, + 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/settings.rs b/crates/headset-tray/src/settings.rs index 8d22853..1dfbf72 100644 --- a/crates/headset-tray/src/settings.rs +++ b/crates/headset-tray/src/settings.rs @@ -31,6 +31,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 FALLBACK_OUTPUT_ID_VALUE: &str = "FallbackOutputId"; +const FALLBACK_OUTPUT_NAME_VALUE: &str = "FallbackOutputName"; +const RESTORE_OUTPUT_VALUE: &str = "RestoreOutputId"; /// 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 +240,60 @@ 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)) +} + +/// The device to switch to, 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 fallback_output() -> Option<(String, String)> { + let id = read_string(HKEY_CURRENT_USER, APP_KEY, FALLBACK_OUTPUT_ID_VALUE)?; + if id.is_empty() { + return None; + } + let name = read_string(HKEY_CURRENT_USER, APP_KEY, FALLBACK_OUTPUT_NAME_VALUE) + .unwrap_or_else(|| id.clone()); + Some((id, name)) +} + +pub fn set_fallback_output(id: &str, name: &str) -> bool { + set_string(APP_KEY, FALLBACK_OUTPUT_ID_VALUE, id) + && set_string(APP_KEY, FALLBACK_OUTPUT_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(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -279,6 +337,28 @@ 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))); + } + } + #[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 734566a..74c0362 100644 --- a/crates/headset-tray/src/ui/layout.rs +++ b/crates/headset-tray/src/ui/layout.rs @@ -110,6 +110,16 @@ pub enum HitTarget { Refresh, ToggleStartup, ToggleWarning, + /// Turns the power-off output switch on or off. + ToggleOutputSwitch, + /// Opens the device picker. + OpenOutputPicker, + /// 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 +136,8 @@ pub enum HitTarget { pub enum View { Main, Settings, + /// The output-device picker, reached from Settings. + Output, } /// Which parameter the single slider is currently driving. @@ -240,6 +252,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)] @@ -392,6 +408,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, @@ -403,6 +420,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 => output_body(&mut b, &mut y, &mut output_ids), } // Footer @@ -431,6 +449,7 @@ pub fn build(state: &HeadsetState, view: View, param: SliderParam, preview: Opti height, track, level_track, + output_ids, } } @@ -473,7 +492,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, @@ -976,7 +998,7 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { ); *y += 22.0; - let rows: [(&str, &str, HitTarget, bool); 2] = [ + let rows: [(&str, &str, HitTarget, bool); 3] = [ ( "Start on Windows startup", "Launch the tray app when you sign in.", @@ -989,6 +1011,16 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { HitTarget::ToggleWarning, crate::settings_show_warning(), ), + // Kept short enough to stay on one line. The title 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. + ( + "Switch output when off", + "Move Windows sound elsewhere, and back.", + HitTarget::ToggleOutputSwitch, + crate::settings_switch_output(), + ), ]; for (title, desc, target, on) in rows { @@ -1022,6 +1054,37 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { *y = card.bottom() + 12.0; } + // ---- which device to switch to ----------------------------------------- + // 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. + 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), + "Play through", + FS_BODY, + W_SEMIBOLD, + text_primary(), + Align::Left, + ); + let chosen = crate::settings_fallback_output(); + b.text( + Rect::new(card.x + 16.0, card.y + 30.0, card.w - 48.0, 20.0), + &fallback_output_subtitle(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)); + *y = card.bottom() + 12.0; + // ---- appearance -------------------------------------------------------- // Taller than a toggle row: it carries a segmented control rather than a // pill, because three states cannot be expressed by a switch. @@ -1082,6 +1145,105 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { *y = back.bottom() + GAP * 0.5; } +/// What the "Play through" 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 "Choose a device" would suggest the setting was never made. +pub fn fallback_output_subtitle(chosen: Option<(&str, bool)>) -> String { + match chosen { + Some((name, true)) => name.to_string(), + Some((name, false)) => format!("{name} — not connected"), + None => "Choose a device".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, y: &mut f32, ids: &mut Vec) { + b.caption( + Rect::new(MARGIN, *y, CONTENT_W, 14.0), + "PLAY THROUGH WHEN POWERED OFF", + text_secondary(), + Align::Left, + ); + *y += 22.0; + + let devices = crate::output_devices(); + let chosen_id = crate::settings_fallback_output_id(); + + 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, @@ -1289,6 +1451,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)], @@ -1778,6 +1961,87 @@ 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::OpenOutputPicker] { + 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) + .expect("the picker row must always be present") + .0; + assert!(row.w > 0.0 && row.h > 0.0); + } + + #[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!(fallback_output_subtitle(None), "Choose a device"); + assert_eq!( + fallback_output_subtitle(Some(("Speakers (Realtek(R) Audio)", true))), + "Speakers (Realtek(R) Audio)" + ); + let gone = fallback_output_subtitle(Some(("Speakers (Realtek(R) Audio)", false))); + assert!(gone.starts_with("Speakers (Realtek(R) Audio)"), "{gone}"); + assert!(gone.contains("not connected"), "{gone}"); + } + + #[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, 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, 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] { + 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)); diff --git a/crates/headset-tray/src/win32/audio.rs b/crates/headset-tray/src/win32/audio.rs new file mode 100644 index 0000000..3cc8b32 --- /dev/null +++ b/crates/headset-tray/src/win32/audio.rs @@ -0,0 +1,272 @@ +//! 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 { + let Some(policy) = PolicyConfig::open() else { + tracing::warn!("the default-output policy interface is unavailable on this system"); + return false; + }; + let wide: Vec = id.encode_utf16().chain(std::iter::once(0)).collect(); + let mut ok = true; + for role in [eConsole, eMultimedia, eCommunications] { + 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..9ca2186 100644 --- a/crates/headset-tray/src/win32/mod.rs +++ b/crates/headset-tray/src/win32/mod.rs @@ -38,16 +38,17 @@ use windows::Win32::UI::Shell::{ }; 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,13 @@ 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, + /// 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 +362,8 @@ pub fn run_ui_with( pending_slider: None, pending_noise: None, panel_visible: false, + link: None, + output_ids: Vec::new(), }) }); @@ -515,6 +537,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 +546,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 +640,91 @@ 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; + // 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. +/// +/// 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. +fn reconcile_output(ctx: &mut Ctx) { + if !crate::settings::switch_output_when_off() { + return; + } + match ctx.link { + Some(false) => switch_output_away(), + Some(true) => switch_output_back(), + None => {} + } +} + +fn switch_output_away() { + // Already away. Without this, a second drop would record the fallback + // itself as the endpoint we owe the user a return to, and the way back + // would be lost. + if crate::settings::restore_output().is_some() { + return; + } + let Some((fallback_id, fallback_name)) = crate::settings::fallback_output() else { + tracing::debug!("no output chosen to switch to"); + return; + }; + let Some(current) = audio::default_output_id() else { + return; + }; + if current == fallback_id { + // 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; + } + if !audio::is_present(&fallback_id) { + tracing::info!("{fallback_name} is not connected; leaving the sound where it is"); + return; + } + if audio::set_default_output(&fallback_id) { + // 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(¤t)); + tracing::info!("headset powered down; sound moved to {fallback_name}"); + } +} + +fn switch_output_back() { + let Some(previous) = crate::settings::restore_output() else { + return; + }; + if !audio::is_present(&previous) { + // Keep owing it rather than discarding the debt: the endpoint may be + // moments from reappearing, and forgetting here would strand the user + // on the fallback with nothing left to say where they came from. + tracing::info!("the endpoint to restore is not present yet; keeping the record"); + return; + } + if audio::set_default_output(&previous) { + let _ = crate::settings::set_restore_output(None); + tracing::info!("headset back; sound restored"); + } +} + /// 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 +747,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 +824,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 +893,48 @@ 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. + let _ = crate::settings::set_restore_output(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. + reconcile_output(ctx); + } + redraw_panel(ctx); + } + } + HitTarget::OpenOutputPicker => { + ctx.view = View::Output; + redraw_panel(ctx); + } + HitTarget::OutputChoice(i) => { + // 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_fallback_output(&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. + reconcile_output(ctx); + ctx.view = View::Settings; + redraw_panel(ctx); + } + } } } diff --git a/docs/architecture.md b/docs/architecture.md index 46dc23e..59db0e3 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,24 @@ 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. + +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/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. From 3293a3c5670d88a2889f1f2ab7cf8618f19afffe Mon Sep 17 00:00:00 2001 From: micmancg Date: Wed, 5 Aug 2026 11:17:20 -0400 Subject: [PATCH 3/5] docs(contributing): record the two toolchain steps rustup does not do Building this workspace needs MinGW-w64 on PATH and a GNU *default host*, and neither is discoverable from the error it produces. `rustup target add x86_64-pc-windows-gnu` is not enough on its own: build scripts and proc macros compile for the host triple, so a stock MSVC-host rustup dies with `linker 'link.exe' not found` before reaching any of this project's code. That error names Visual Studio, which is the wrong fix for a workspace that pins the GNU target deliberately. CI never hits either problem, which is why neither was written down: GitHub's Windows runners ship MinGW-w64 already, and the workflow passes --target explicitly while running on a host that has both linkers. Found by setting the project up from scratch on a machine that had only rustup installed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79 --- CONTRIBUTING.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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, From 8b1207114cc03042ffbed91bbc26e43d9b1e35e2 Mon Sep 17 00:00:00 2001 From: micmancg Date: Wed, 5 Aug 2026 11:17:41 -0400 Subject: [PATCH 4/5] chore(release): 0.1.0-alpha.2 The workspace manifest is the single source of truth for the version: Inno takes the same value on its command line via build-installer.ps1, and Cargo.lock follows from the build. Also records the game/chat slider fix (5938736) under Fixed, which that commit never added to the changelog. It is the user-visible reason this build exists, so release notes that omitted it would be a poor record. docs/history/ still names 0.1.0-alpha.1 in places and is deliberately left alone -- it is a record of what was known at the time, not documentation that tracks the current version. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UtGiZeMkxkrztrcVeWqy79 --- CHANGELOG.md | 9 ++++++++- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d640ae..4451f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [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 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 d53c244..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 From 6d8a0c4c5d69d7fe56b83f053f75d9901ccf9197 Mon Sep 17 00:00:00 2001 From: micmancg Date: Wed, 19 Aug 2026 10:09:42 -0400 Subject: [PATCH 5/5] feat(tray): keep the output switch working, and split game from chat Two changes to the same feature, which is why they arrive together: the switch had stopped working at all, and what it does when it works was wrong for a headset that presents two channels. The switch died silently. The record of where the sound came from doubles as the "a move back is owed" flag, and its mere presence was read as "already switched". Endpoint ids are durable but not permanent -- a reinstalled driver or a re-enumerated dongle retires one -- so the moment that record named an endpoint the machine no longer had, it could never be discharged (the endpoint was gone) and never be replaced (its presence blocked the switch). Every later power-off and power-on did nothing, for good, until somebody edited the registry. A debt now counts only while the machine still has the endpoint it names, and one that never reappears is given up on rather than kept forever. The rule moved into its own module, handed facts and answering with an Action, because the alternative is a rule whose only test is powering a headset off and watching a machine's audio move. Restoring the sound was actively destructive on this hardware. Windows keeps three defaults -- console, multimedia, communications -- and the headset presents two endpoints, which is the entire reason a chat channel exists. A restore can only name the one endpoint it moved away from, and writing that into all three roles overwrote a communications default the user had pointed at the chat channel, so the voices came out of the game channel. Hence "Split game and chat": pick a Game channel and a Chat channel, and a headset coming back gets ordinary playback on the first and calls on the second. It is opt-in and off by default. A headset with two channels is not a reason to assume somebody wants their calls moved, and the setting is independent of the move-when-off switch because wanting calls on their own channel and wanting your sound moved when the headset dies are different wishes. When it is on it beats a plain restore, deliberately: restoring is the thing that breaks it, and the sound lands on the headset either way, so the debt is discharged rather than left to drag the sound back off. It also overrides "put it back where it came from" -- a power-on always lands on the chosen game channel, even if the user had moved to speakers by hand. That is the trade for roles that are deterministic rather than inferred. Both rules share one retry budget, because a wireless link comes up seconds before its audio endpoints do: "the endpoint I want is not there" is the ordinary first answer, not a failure. The three device choices are one Slot enum rather than three parallel settings and three picker views -- they ask the same question and differ only in what the answer is for -- so there is one store, one picker, and one place a fourth would go. The two channel rows appear only once the split is on, unlike "Play through", because the toggle directly above them is what reveals them and three device rows with two inert is a settings page that looks like it is asking for more than it is. Failures are surfaced rather than logged into a subscriber nobody enabled: a balloon the first time, and the problem persisted so the settings row can still explain itself hours later. Stored by name rather than by its wording, so the panel can put a complaint on the row of the feature that earned it and re-phrasing a message cannot move it. A record written by an older build reads as no complaint. Acting on a settings change immediately is held back in exactly one case: picking the game channel and being told off for not having picked the chat channel is a complaint about the step the user is visibly in the middle of. Also adds --explain-output, which prints what would happen for a powered-off and a powered-on headset, and why, without moving anything. The switch acts on a device event nobody can stage on demand and on facts the user cannot see; before this, telling those candidates apart meant reading the registry by hand. Verified: 125 tests pass, clippy clean at -D warnings, the settings panel renders without the title collision its neighbouring comment warns about, and a release build installs and runs. Not verified: the power-off and power-on round trip with the split on, which needs the button held. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVDi4cWtmLALKXVmy1pxo5 --- CHANGELOG.md | 30 ++ README.md | 18 + crates/headset-tray/src/lib.rs | 44 +- crates/headset-tray/src/main.rs | 123 ++++- crates/headset-tray/src/output.rs | 663 +++++++++++++++++++++++++ crates/headset-tray/src/settings.rs | 130 ++++- crates/headset-tray/src/ui/layout.rs | 357 +++++++++---- crates/headset-tray/src/win32/audio.rs | 42 +- crates/headset-tray/src/win32/mod.rs | 293 ++++++++--- docs/architecture.md | 36 ++ 10 files changed, 1536 insertions(+), 200 deletions(-) create mode 100644 crates/headset-tray/src/output.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4451f33..357d1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 diff --git a/README.md b/README.md index f8a8815..6e11691 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,24 @@ this uses the same undocumented interface every audio switcher uses. If it ever 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 04bb777..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; @@ -54,20 +56,36 @@ pub fn settings_switch_output() -> bool { settings::switch_output_when_off() } -/// The chosen fallback output as `(name, is it plugged in right now)`. +/// 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::fallback_output_subtitle`. +/// `layout::output_choice_subtitle`. #[cfg(windows)] -pub fn settings_fallback_output() -> Option<(String, bool)> { - let (id, name) = settings::fallback_output()?; +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_fallback_output_id() -> Option { - settings::fallback_output().map(|(id, _)| id) +pub fn settings_output_choice_id(slot: Slot) -> Option { + settings::output_choice(slot).map(|(id, _)| id) } /// Every playback device, as `(endpoint id, name)`. @@ -100,12 +118,22 @@ pub fn settings_switch_output() -> bool { } #[cfg(not(windows))] -pub fn settings_fallback_output() -> Option<(String, bool)> { +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_fallback_output_id() -> Option { +pub fn settings_output_choice_id(_slot: Slot) -> Option { None } diff --git a/crates/headset-tray/src/main.rs b/crates/headset-tray/src/main.rs index 1b6982f..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. /// @@ -191,12 +296,18 @@ fn run_render_panel() { // 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. - cases.push(( - "output-picker", - base.clone(), - View::Output, - SliderParam::GameChat, - )); + 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. 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 1dfbf72..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. @@ -32,9 +34,9 @@ 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 FALLBACK_OUTPUT_ID_VALUE: &str = "FallbackOutputId"; -const FALLBACK_OUTPUT_NAME_VALUE: &str = "FallbackOutputName"; +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"; @@ -253,25 +255,52 @@ pub fn set_switch_output_when_off(enabled: bool) -> bool { set_dword(APP_KEY, OUTPUT_SWITCH_VALUE, u32::from(enabled)) } -/// The device to switch to, as `(endpoint id, name at the time it was chosen)`. +/// 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 fallback_output() -> Option<(String, String)> { - let id = read_string(HKEY_CURRENT_USER, APP_KEY, FALLBACK_OUTPUT_ID_VALUE)?; +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, FALLBACK_OUTPUT_NAME_VALUE) - .unwrap_or_else(|| id.clone()); + let name = read_string(HKEY_CURRENT_USER, APP_KEY, name_value).unwrap_or_else(|| id.clone()); Some((id, name)) } -pub fn set_fallback_output(id: &str, name: &str) -> bool { - set_string(APP_KEY, FALLBACK_OUTPUT_ID_VALUE, id) - && set_string(APP_KEY, FALLBACK_OUTPUT_NAME_VALUE, 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 @@ -294,6 +323,40 @@ pub fn set_restore_output(id: Option<&str>) -> bool { } } +/// 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::*; @@ -359,6 +422,53 @@ mod tests { } } + /// 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 74c0362..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::*; @@ -112,8 +113,10 @@ pub enum HitTarget { ToggleWarning, /// Turns the power-off output switch on or off. ToggleOutputSwitch, - /// Opens the device picker. - OpenOutputPicker, + /// 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 @@ -136,8 +139,10 @@ pub enum HitTarget { pub enum View { Main, Settings, - /// The output-device picker, reached from Settings. - Output, + /// 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. @@ -420,7 +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 => output_body(&mut b, &mut y, &mut output_ids), + View::Output(slot) => output_body(&mut b, slot, &mut y, &mut output_ids), } // Footer @@ -998,92 +1003,66 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { ); *y += 22.0; - let rows: [(&str, &str, HitTarget, bool); 3] = [ - ( - "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(), - ), - // Kept short enough to stay on one line. The title 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. - ( - "Switch output when off", - "Move Windows sound elsewhere, and back.", - HitTarget::ToggleOutputSwitch, - crate::settings_switch_output(), - ), - ]; - - 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; - } - - // ---- which device to switch to ----------------------------------------- + // 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. - 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), - "Play through", - FS_BODY, - W_SEMIBOLD, - text_primary(), - Align::Left, - ); - let chosen = crate::settings_fallback_output(); - b.text( - Rect::new(card.x + 16.0, card.y + 30.0, card.w - 48.0, 20.0), - &fallback_output_subtitle(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, + device_row(b, y, Slot::Fallback); + + toggle_row( + b, + y, + "Split game and chat", + "Calls use the chat channel.", + HitTarget::ToggleSplit, + split_on, + split_problem, ); - chevron_icon(b, card.right() - 22.0, card.center_y(), text_muted()); - b.hits.push((card, HitTarget::OpenOutputPicker)); - *y = card.bottom() + 12.0; + // 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 -------------------------------------------------------- // Taller than a toggle row: it carries a segmented control rather than a @@ -1145,17 +1124,131 @@ fn settings_body(b: &mut Builder, _state: &HeadsetState, y: &mut f32) { *y = back.bottom() + GAP * 0.5; } -/// What the "Play through" row says beneath its title. +/// 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 "Choose a device" would suggest the setting was never made. -pub fn fallback_output_subtitle(chosen: Option<(&str, bool)>) -> String { +/// 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 => "Choose a device".to_string(), + 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(), + }, } } @@ -1164,17 +1257,17 @@ pub fn fallback_output_subtitle(chosen: Option<(&str, bool)>) -> String { /// 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, y: &mut f32, ids: &mut Vec) { +fn output_body(b: &mut Builder, slot: Slot, y: &mut f32, ids: &mut Vec) { b.caption( Rect::new(MARGIN, *y, CONTENT_W, 14.0), - "PLAY THROUGH WHEN POWERED OFF", + slot_caption(slot), text_secondary(), Align::Left, ); *y += 22.0; let devices = crate::output_devices(); - let chosen_id = crate::settings_fallback_output_id(); + let chosen_id = crate::settings_output_choice_id(slot); if devices.is_empty() { let card = Rect::new(MARGIN, *y, CONTENT_W, 44.0); @@ -1964,7 +2057,11 @@ mod tests { #[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::OpenOutputPicker] { + for want in [ + HitTarget::ToggleOutputSwitch, + HitTarget::ToggleSplit, + HitTarget::OpenOutputPicker(Slot::Fallback), + ] { assert!(p.hits.iter().any(|(_, t)| *t == want), "{want:?} missing"); } } @@ -1977,25 +2074,73 @@ mod tests { let row = p .hits .iter() - .find(|(_, t)| *t == HitTarget::OpenOutputPicker) + .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!(fallback_output_subtitle(None), "Choose a device"); assert_eq!( - fallback_output_subtitle(Some(("Speakers (Realtek(R) Audio)", true))), + 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 = fallback_output_subtitle(Some(("Speakers (Realtek(R) Audio)", false))); + 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] @@ -2003,7 +2148,12 @@ mod tests { // 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, SliderParam::Sidetone, None); + let p = build( + &connected(), + View::Output(Slot::Fallback), + SliderParam::Sidetone, + None, + ); let choices: Vec = p .hits .iter() @@ -2022,7 +2172,12 @@ mod tests { #[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, SliderParam::Sidetone, None); + 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"); } @@ -2033,7 +2188,7 @@ mod tests { // 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] { + 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), diff --git a/crates/headset-tray/src/win32/audio.rs b/crates/headset-tray/src/win32/audio.rs index 3cc8b32..382532e 100644 --- a/crates/headset-tray/src/win32/audio.rs +++ b/crates/headset-tray/src/win32/audio.rs @@ -111,14 +111,44 @@ pub fn is_present(id: &str) -> bool { /// 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 { - let Some(policy) = PolicyConfig::open() else { - tracing::warn!("the default-output policy interface is unavailable on this system"); - return false; - }; + 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 [eConsole, eMultimedia, eCommunications] { - let hr = policy.set_default(&wide, role); + 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:?}", diff --git a/crates/headset-tray/src/win32/mod.rs b/crates/headset-tray/src/win32/mod.rs index 9ca2186..cdfb6cd 100644 --- a/crates/headset-tray/src/win32/mod.rs +++ b/crates/headset-tray/src/win32/mod.rs @@ -33,8 +33,8 @@ 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, @@ -142,6 +142,11 @@ struct Ctx { /// 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, @@ -363,6 +368,7 @@ pub fn run_ui_with( pending_noise: None, panel_visible: false, link: None, + restore_attempts: 0, output_ids: Vec::new(), }) }); @@ -378,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 @@ -651,6 +653,9 @@ fn note_link_change(hwnd: HWND, ctx: &mut Ctx) { 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. @@ -661,70 +666,186 @@ fn note_link_change(hwnd: HWND, ctx: &mut Ctx) { /// Moves the sound, or moves it back, according to where the link settled. /// -/// 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. +/// 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) { - if !crate::settings::switch_output_when_off() { - return; - } - match ctx.link { - Some(false) => switch_output_away(), - Some(true) => switch_output_back(), - None => {} + 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)), } } -fn switch_output_away() { - // Already away. Without this, a second drop would record the fallback - // itself as the endpoint we owe the user a return to, and the way back - // would be lost. - if crate::settings::restore_output().is_some() { - return; - } - let Some((fallback_id, fallback_name)) = crate::settings::fallback_output() else { - tracing::debug!("no output chosen to switch to"); - return; - }; - let Some(current) = audio::default_output_id() else { - return; - }; - if current == fallback_id { - // 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; - } - if !audio::is_present(&fallback_id) { - tracing::info!("{fallback_name} is not connected; leaving the sound where it is"); - return; - } - if audio::set_default_output(&fallback_id) { - // 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(¤t)); - tracing::info!("headset powered down; sound moved to {fallback_name}"); - } +/// 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, + }) } -fn switch_output_back() { - let Some(previous) = crate::settings::restore_output() else { - return; - }; - if !audio::is_present(&previous) { - // Keep owing it rather than discarding the debt: the endpoint may be - // moments from reappearing, and forgetting here would strand the user - // on the fallback with nothing left to say where they came from. - tracing::info!("the endpoint to restore is not present yet; keeping the record"); - return; +/// 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()); + } } - if audio::set_default_output(&previous) { - let _ = crate::settings::set_restore_output(None); - tracing::info!("headset back; sound restored"); +} + +/// 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 { @@ -827,7 +948,7 @@ fn on_panel_press(ctx: &mut Ctx, x: f32, y: f32) { // 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::Output(_) => View::Settings, _ => View::Main, }; redraw_panel(ctx); @@ -900,23 +1021,52 @@ fn on_panel_press(ctx: &mut Ctx, x: f32, y: f32) { // 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. + // 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::OpenOutputPicker => { - ctx.view = View::Output; + 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. @@ -926,11 +1076,16 @@ fn on_panel_press(ctx: &mut Ctx, x: f32, y: f32) { .find(|d| d.id == id) .map(|d| d.name) .unwrap_or_else(|| id.clone()); - let _ = crate::settings::set_fallback_output(&id, &name); + 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. - reconcile_output(ctx); + // 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 59db0e3..dcfa9c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,6 +134,42 @@ back when it returns. Two things are worth knowing about how it is built: 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).