From b9c64e009464b40683082b8c64eff5dd589d3bfa Mon Sep 17 00:00:00 2001 From: Thijs Herman Date: Thu, 17 Sep 2026 10:26:06 +0200 Subject: [PATCH 1/5] Hear system audio wherever it plays, and record windowed games Windows system audio moves to process loopback: every app's render streams, whichever output they reach, so a changed default, a per-app routing, a virtual mixer or the communications device can no longer leave clips silent. The endpoint loopback stays for an explicit output pick and as the fallback on builds before Windows 10 2004. A failed audio capture is reopened with a backoff on every platform, and the platform is told when the sound is lost and when it is back instead of once and never again. Game-only capture accepts known windowed games at any size: Minecraft Java and Bedrock built in, more through [capture] windowed_games, with UWP frames judged by the process they host rather than by ApplicationFrameHost. --- Cargo.lock | 1 + Cargo.toml | 2 + crates/app/src/main.rs | 370 ++++++++++++------ crates/app/src/tray.rs | 5 +- crates/capture/Cargo.toml | 1 + crates/capture/src/game.rs | 19 +- crates/capture/src/windows/game_window.rs | 344 +++++++++++++--- crates/capture/src/windows/mod.rs | 9 +- crates/capture/src/windows/wasapi_audio.rs | 180 ++++++++- crates/capture/src/windows/wgc_capture.rs | 26 +- crates/config/src/lib.rs | 3 +- crates/config/src/schema.rs | 45 ++- crates/settings/src/main.rs | 54 ++- ...2-windows-system-audio-process-loopback.md | 84 ++++ docs/adr/0023-windows-windowed-games.md | 65 +++ 15 files changed, 998 insertions(+), 210 deletions(-) create mode 100644 docs/adr/0022-windows-system-audio-process-loopback.md create mode 100644 docs/adr/0023-windows-windowed-games.md diff --git a/Cargo.lock b/Cargo.lock index 7ed6cc1..7270250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4727,6 +4727,7 @@ dependencies = [ "wgpu 29.0.0", "windows 0.62.2", "windows-capture", + "windows-core 0.62.2", "zbus", ] diff --git a/Cargo.toml b/Cargo.toml index e842769..47cdd37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,8 @@ cidre = { version = "=0.16.1", default-features = false, features = [ # windows-capture drives the WGC session/frame pool; the `windows` crate (same 0.62.x # its API surfaces, so the D3D11 types unify) creates the shareable textures + NT handles. windows-capture = "2.0" +# `#[implement]` (the process-loopback completion handler) expands to `::windows_core` paths. +windows-core = "0.62" windows = { version = "0.62", features = [ "Win32_Graphics_Direct3D11", "Win32_Graphics_Dxgi", diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index d460d6e..c6fe4a9 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -147,11 +147,46 @@ mod audio_pipeline { /// How often the mixer thread drains settled audio into the encoder. const AUDIO_DRAIN_INTERVAL: Duration = Duration::from_millis(20); + /// First wait before reopening a failed capture; doubles per failure up to the max. A + /// device that was unplugged, went to sleep or changed identity comes back in seconds. + const AUDIO_RETRY_MIN: Duration = Duration::from_secs(1); + const AUDIO_RETRY_MAX: Duration = Duration::from_secs(30); + /// How often a waiting capture thread checks the stop flag. + const AUDIO_RETRY_POLL: Duration = Duration::from_millis(100); + + /// Sleep `wait` in stop-aware steps; `true` when the stop flag rose meanwhile. + fn wait_unless_stopped(stop: &AtomicBool, wait: Duration) -> bool { + let deadline = Instant::now() + wait; + while Instant::now() < deadline { + if stop.load(Ordering::Relaxed) { + return true; + } + std::thread::sleep(AUDIO_RETRY_POLL); + } + stop.load(Ordering::Relaxed) + } + + /// What the system capture reports to the platform, which surfaces it (tray or toast). + pub(crate) enum SystemAudioEvent { + /// The capture failed and is being retried; carries the error. + Lost(String), + /// A reopened capture delivers audio again. + Restored, + } + + /// The platform's handler for [`SystemAudioEvent`]s. + pub(crate) type SystemAudioHook = Box; + /// Spawn a thread that captures `source` from `device`, applies `gain`, and sums each - /// buffer into the shared `mixer`, aligned by its capture-relative PTS. A capture error is + /// buffer into the shared `mixer`, aligned by its capture-relative PTS. + /// + /// A capture that fails (device unplugged or invalidated, activation refused) is reopened + /// after a backoff rather than ending the thread: the recorder runs for a whole session, + /// and a device that comes back must be heard again. The first failure of an outage is /// logged at a severity matching the source; a failed system capture loses the clips' - /// primary audio, so that one also fires `on_system_failure` (the platform surfaces it: - /// tray or toast). + /// primary audio, so that one also reports [`SystemAudioEvent::Lost`] to `on_system_audio`, + /// and [`SystemAudioEvent::Restored`] once audio flows again. Later failures of the same + /// outage log at debug; each reopened stream announces itself. #[allow(clippy::too_many_arguments)] pub(crate) fn spawn_audio_capture( name: &str, @@ -163,7 +198,7 @@ mod audio_pipeline { also_mixer: Option, stop: &Arc, epoch: Instant, - on_system_failure: Option>, + on_system_audio: Option, ) -> Result> { let stop = stop.clone(); let capture_params = AudioParams { @@ -174,102 +209,144 @@ mod audio_pipeline { std::thread::Builder::new() .name(name.to_owned()) .spawn(move || { - // Per-source prep, reused across buffers so the hot path doesn't realloc: the - // mic is centred to mono (see `center_mono_into`) so a single-sided mic isn't - // stuck in one ear, system audio keeps its stereo image, and the configured - // gain is applied to each. - let mut prep = Vec::new(); - // Arc, not Rc: the macOS backend delivers samples on a dispatch queue, so - // the callback must be Send there (the other platforms don't mind). - let panicked = Arc::new(AtomicBool::new(false)); - // No idle timeout (capture runs until shutdown); the stop flag drives the - // watchdog so the loop quits promptly even if the endpoint suspends. - let panicked_flag = panicked.clone(); - let mut buffers: u64 = 0; - let result = capture_audio( - capture_params, - source, - &device, - None, - Some(stop.clone()), - epoch, - move |pcm, pts| { - // Level telemetry for chasing "why is this clip silent" reports — - // ~once a second at the usual 10 ms buffer cadence, debug only. - buffers += 1; - if buffers % 100 == 1 && tracing::enabled!(tracing::Level::DEBUG) { - let peak = pcm.iter().fold(0.0_f32, |m, s| m.max(s.abs())); - tracing::debug!( - ?source, - buffers, - pts_ms = pts.as_millis() as u64, - peak, - "audio level" - ); - } - // A panic must not unwind across the PipeWire C callback boundary (UB); - // treat it as a stream failure instead (harmless-but-uniform on WASAPI, - // where the loop is plain Rust). - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let prepared = match source { - AudioSource::Microphone => { - center_mono_into(pcm, channels, &mut prep); - apply_gain(&mut prep, gain); - prep.as_slice() - } - // Only copy to scale when the gain isn't (near) unity; the - // common gain == 1.0 case passes the buffer through untouched. - // The predicate matches `apply_gain`'s own no-op threshold. - AudioSource::SinkMonitor - if (gain - 1.0).abs() >= f32::EPSILON => + // Shared with the per-attempt callback, which reports the recovery from + // wherever the backend delivers samples. + let on_system_audio = Arc::new(Mutex::new(on_system_audio)); + let mut backoff = AUDIO_RETRY_MIN; + // Failures since audio last flowed; the first one of an outage is the loud one. + let mut failures: u32 = 0; + loop { + // Per-source prep, reused across buffers so the hot path doesn't realloc: + // the mic is centred to mono (see `center_mono_into`) so a single-sided + // mic isn't stuck in one ear, system audio keeps its stereo image, and + // the configured gain is applied to each. + let mut prep = Vec::new(); + // Arc, not Rc: the macOS backend delivers samples on a dispatch queue, + // so the callback must be Send there (the other platforms don't mind). + let panicked = Arc::new(AtomicBool::new(false)); + let panicked_flag = panicked.clone(); + let delivered = Arc::new(AtomicBool::new(false)); + let delivered_flag = delivered.clone(); + let recovering = failures > 0; + let hook = on_system_audio.clone(); + let mixer = mixer.clone(); + let also_mixer = also_mixer.clone(); + let mut buffers: u64 = 0; + // No idle timeout (capture runs until shutdown); the stop flag drives the + // watchdog so the loop quits promptly even if the endpoint suspends. + let result = capture_audio( + capture_params, + source, + &device, + None, + Some(stop.clone()), + epoch, + move |pcm, pts| { + if !delivered_flag.swap(true, Ordering::Relaxed) && recovering { + tracing::info!(?source, "audio capture recovered"); + if source == AudioSource::SinkMonitor + && let Some(hook) = lock_unpoisoned(&hook).as_mut() { - prep.clear(); - prep.extend_from_slice(pcm); - apply_gain(&mut prep, gain); - prep.as_slice() + hook(SystemAudioEvent::Restored); } - AudioSource::SinkMonitor => pcm, - }; - lock_unpoisoned(&mixer).add(prepared, pts); - // The mic's separate-track path: feed the same centred+gained mic PCM - // into its own mixer, so it becomes a second, mic-only Opus track. - if let Some(also) = &also_mixer { - lock_unpoisoned(also).add(prepared, pts); } - })); - match outcome { - Ok(()) => ControlFlow::Continue(()), - Err(_) => { - tracing::error!("audio callback panicked; stopping this capture"); - panicked_flag.store(true, Ordering::Relaxed); - ControlFlow::Break(()) + // Level telemetry for chasing "why is this clip silent" reports — + // ~once a second at the usual 10 ms buffer cadence, debug only. + buffers += 1; + if buffers % 100 == 1 && tracing::enabled!(tracing::Level::DEBUG) { + let peak = pcm.iter().fold(0.0_f32, |m, s| m.max(s.abs())); + tracing::debug!( + ?source, + buffers, + pts_ms = pts.as_millis() as u64, + peak, + "audio level" + ); } - } - }, - ); - // A panic Break reads as a clean stream end; surface it like a capture error. - let result = match result { - Ok(()) if panicked.load(Ordering::Relaxed) => { - Err(rewynd_capture::CaptureError::Callback( - "the audio callback panicked".to_owned(), - )) + // A panic must not unwind across the PipeWire C callback boundary + // (UB); treat it as a stream failure instead (harmless-but-uniform + // on WASAPI, where the loop is plain Rust). + let outcome = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let prepared = match source { + AudioSource::Microphone => { + center_mono_into(pcm, channels, &mut prep); + apply_gain(&mut prep, gain); + prep.as_slice() + } + // Only copy to scale when the gain isn't (near) unity; + // the common gain == 1.0 case passes the buffer through + // untouched. The predicate matches `apply_gain`'s own + // no-op threshold. + AudioSource::SinkMonitor + if (gain - 1.0).abs() >= f32::EPSILON => + { + prep.clear(); + prep.extend_from_slice(pcm); + apply_gain(&mut prep, gain); + prep.as_slice() + } + AudioSource::SinkMonitor => pcm, + }; + lock_unpoisoned(&mixer).add(prepared, pts); + // The mic's separate-track path: feed the same + // centred+gained mic PCM into its own mixer, so it becomes + // a second, mic-only Opus track. + if let Some(also) = &also_mixer { + lock_unpoisoned(also).add(prepared, pts); + } + })); + match outcome { + Ok(()) => ControlFlow::Continue(()), + Err(_) => { + tracing::error!( + "audio callback panicked; stopping this capture" + ); + panicked_flag.store(true, Ordering::Relaxed); + ControlFlow::Break(()) + } + } + }, + ); + // A panic Break reads as a clean stream end; it is a bug, not a device + // hiccup, so it ends the thread rather than retrying into it. + if panicked.load(Ordering::Relaxed) { + tracing::error!(?source, "audio capture stopped after a callback panic"); + return; } - other => other, - }; - if let Err(e) = result { + let error = match result { + Ok(()) => return, + Err(e) => e, + }; + if stop.load(Ordering::Relaxed) { + return; + } + // A stream that delivered and then died starts a fresh outage. + if delivered.load(Ordering::Relaxed) { + failures = 0; + backoff = AUDIO_RETRY_MIN; + } + failures += 1; // A missing mic is expected; a failed system capture means the clip loses - // its primary audio, so surface that louder. - match source { - AudioSource::Microphone => { - tracing::info!(error = %e, "no microphone capture; clips use system audio only"); + // its primary audio, so surface that louder. Both only once per outage. + match (source, failures) { + (AudioSource::Microphone, 1) => { + tracing::info!(error = %error, retry_in = ?backoff, "no microphone capture yet; clips use system audio only"); } - AudioSource::SinkMonitor => { - tracing::error!(error = %e, "system-audio capture failed; clips will have no system sound"); - if let Some(surface) = on_system_failure { - surface(e.to_string()); + (AudioSource::SinkMonitor, 1) => { + tracing::error!(error = %error, retry_in = ?backoff, "system-audio capture failed; clips will have no system sound"); + if let Some(hook) = lock_unpoisoned(&on_system_audio).as_mut() { + hook(SystemAudioEvent::Lost(error.to_string())); } } + _ => { + tracing::debug!(?source, error = %error, failures, retry_in = ?backoff, "audio capture failed again"); + } } + if wait_unless_stopped(&stop, backoff) { + return; + } + backoff = (backoff * 2).min(AUDIO_RETRY_MAX); } }) .with_context(|| format!("spawning the {name} thread")) @@ -959,7 +1036,9 @@ mod linux { use rewynd_config::{self as config}; - use crate::audio_pipeline::{AUDIO_SETTLE, SharedMixer, run_audio_mixer, spawn_audio_capture}; + use crate::audio_pipeline::{ + AUDIO_SETTLE, SharedMixer, SystemAudioEvent, run_audio_mixer, spawn_audio_capture, + }; use crate::badge; use crate::params::{audio_encode_params, encode_params, session_params}; use crate::tray; @@ -980,6 +1059,8 @@ mod linux { enum RecorderEvent { CaptureFailed(String), SystemAudioFailed(String), + /// A reopened system capture delivers again: the tray's "lost" status is stale. + SystemAudioRestored, /// The GPU encoder was unavailable mid-run; recording continues on the CPU. EncoderFallback(String), } @@ -1319,8 +1400,11 @@ mod linux { epoch, Some(Box::new({ let events = events_tx.clone(); - move |e| { - let _ = events.send(RecorderEvent::SystemAudioFailed(e)); + move |event| { + let _ = events.send(match event { + SystemAudioEvent::Lost(e) => RecorderEvent::SystemAudioFailed(e), + SystemAudioEvent::Restored => RecorderEvent::SystemAudioRestored, + }); } })), )?); @@ -1486,6 +1570,7 @@ mod linux { tokio::select! { event = events.recv() => { let Some(event) = event else { continue }; + let restored = matches!(event, RecorderEvent::SystemAudioRestored); let (title, body) = match event { RecorderEvent::CaptureFailed(e) => ( "Recording stopped".to_owned(), @@ -1493,7 +1578,11 @@ mod linux { ), RecorderEvent::SystemAudioFailed(e) => ( "System audio lost".to_owned(), - format!("Clips will have no system sound: {e}"), + format!("Clips will have no system sound until it is back: {e}"), + ), + RecorderEvent::SystemAudioRestored => ( + "System audio is back".to_owned(), + "Clips have system sound again.".to_owned(), ), // A fall-back to the CPU encoder isn't a failure — recording continues, // just at a higher CPU cost, so keep an unalarming tooltip. @@ -1502,8 +1591,15 @@ mod linux { format!("{e} Recording continues on the CPU, which uses more processor power."), ), }; + // A recovery toasts, but the tooltip goes back to normal rather than + // keeping "lost" or announcing "back" forever. + let status = if restored { + tray::DEFAULT_STATUS.to_owned() + } else { + title.clone() + }; handle - .update(|tray: &mut tray::RewyndTray| tray.status = title.clone()) + .update(|tray: &mut tray::RewyndTray| tray.status = status) .await; tray::toast(&title, &body).await; } @@ -2062,7 +2158,8 @@ mod windows { use anyhow::{Context, Result, anyhow}; use rewynd_buffer::{AudioRingBuffer, EncodedChunk, RingBuffer}; use rewynd_capture::windows::{ - CapturedD3d11Frame, capture_game_stream, capture_stream, default_render_endpoints, + CapturedD3d11Frame, WindowedGames, capture_game_stream, capture_stream, + default_render_endpoints, process_loopback_supported, }; use rewynd_capture::{AudioDevice, AudioSource, StreamPrefs}; use rewynd_clip::{ClipSaver, SaveError, SharedAudioBuffer, SharedBuffer, lock_unpoisoned}; @@ -2082,7 +2179,9 @@ mod windows { DispatchMessageW, MSG, PM_REMOVE, PeekMessageW, TranslateMessage, WM_HOTKEY, }; - use crate::audio_pipeline::{AUDIO_SETTLE, SharedMixer, run_audio_mixer, spawn_audio_capture}; + use crate::audio_pipeline::{ + AUDIO_SETTLE, SharedMixer, SystemAudioEvent, run_audio_mixer, spawn_audio_capture, + }; use crate::overlay; use crate::params::{audio_encode_params, session_params}; @@ -2242,6 +2341,7 @@ mod windows { // per-game clip folders stay current, and each new session starts with cleared // rings so a clip never spans an between-games gap. let capture_desktop = config.capture_desktop(); + let windowed_games = WindowedGames::new(config.windowed_games()); let recording = Arc::new(AtomicBool::new(capture_desktop)); // Publish the recorder's live status (chosen backend + game/desktop/idle state) for the @@ -2277,17 +2377,20 @@ mod windows { // final drain + Opus flush. let captures_done = Arc::new(AtomicBool::new(false)); - // Voice apps follow Windows' separate communications default, so record that endpoint - // too when it differs and the user hasn't picked one themselves. + // Voice apps follow Windows' separate communications default. Process loopback hears + // them wherever they play; only the endpoint-loopback fallback needs that endpoint + // recorded too, and only when the user hasn't picked an output themselves. let output_device = config.output_device().map(str::to_owned); + let process_loopback = output_device.is_none() && process_loopback_supported(); let mut comms_device = None; if let Some(defaults) = default_render_endpoints() { tracing::info!( console = defaults.console_name, comms = defaults.comms_name, + process_loopback, "default playback endpoints" ); - if output_device.is_none() { + if output_device.is_none() && !process_loopback { comms_device = defaults.separate_comms; } } @@ -2303,11 +2406,24 @@ mod windows { None, &stop, epoch, - Some(Box::new(|e: String| { - toast( - "System audio lost", - &format!("Clips will have no system sound: {e}"), - ); + Some(Box::new({ + // Once per process each: a flapping device must not spam toasts. + let mut lost_toasted = false; + let mut restored_toasted = false; + move |event: SystemAudioEvent| match event { + SystemAudioEvent::Lost(e) if !lost_toasted => { + lost_toasted = true; + toast( + "System audio lost", + &format!("Clips will have no system sound until it is back: {e}"), + ); + } + SystemAudioEvent::Restored if !restored_toasted => { + restored_toasted = true; + toast("System audio is back", "Clips have system sound again."); + } + _ => {} + } })), )?; // Optional extra: erroring here would detach the system capture spawned above. @@ -2417,6 +2533,7 @@ mod windows { &capture_buffer, &capture_stop, capture_desktop, + windowed_games, on_game, capture_choice, &capture_status, @@ -2616,6 +2733,7 @@ mod windows { buffer: &SharedBuffer, stop: &Arc, desktop: bool, + windowed_games: WindowedGames, on_game: Option, choice: config::EncoderChoice, status: &crate::status::StatusPublisher, @@ -2688,7 +2806,14 @@ mod windows { if desktop { capture_stream(None, epoch, prefs, Some(stop.clone()), on_frame)?; } else { - capture_game_stream(epoch, prefs, Some(stop.clone()), on_frame, on_game)?; + capture_game_stream( + epoch, + prefs, + Some(stop.clone()), + windowed_games, + on_frame, + on_game, + )?; } drop(enc); @@ -2987,7 +3112,9 @@ mod macos { use rewynd_config::{self as config}; use rewynd_encode::{AudioMixer, EncodeParams, VideoToolboxEncoder}; - use crate::audio_pipeline::{AUDIO_SETTLE, SharedMixer, run_audio_mixer, spawn_audio_capture}; + use crate::audio_pipeline::{ + AUDIO_SETTLE, SharedMixer, SystemAudioEvent, run_audio_mixer, spawn_audio_capture, + }; use crate::badge_macos; use crate::chime; use crate::params::{audio_encode_params, session_params}; @@ -3182,6 +3309,7 @@ mod macos { // audio toast fires once per process: session-scoped captures would otherwise // re-toast a persistent failure on every game. let audio_lost_toasted = Arc::new(AtomicBool::new(false)); + let audio_restored_toasted = Arc::new(AtomicBool::new(false)); let spawn_audio = { let mixer = mixer.clone(); let mic_mixer = mic_mixer.clone(); @@ -3201,15 +3329,25 @@ mod macos { session_stop, epoch, Some(Box::new({ - let toasted = audio_lost_toasted.clone(); - move |e: String| { - if toasted.swap(true, Ordering::Relaxed) { - tracing::warn!(error = %e, "system-audio capture failed again"); - } else { - toast( - "System audio lost", - &format!("Clips will have no system sound: {e}"), - ); + let lost_toasted = audio_lost_toasted.clone(); + let restored_toasted = audio_restored_toasted.clone(); + move |event: SystemAudioEvent| match event { + SystemAudioEvent::Lost(e) => { + if lost_toasted.swap(true, Ordering::Relaxed) { + tracing::warn!(error = %e, "system-audio capture failed again"); + } else { + toast( + "System audio lost", + &format!( + "Clips will have no system sound until it is back: {e}" + ), + ); + } + } + SystemAudioEvent::Restored => { + if !restored_toasted.swap(true, Ordering::Relaxed) { + toast("System audio is back", "Clips have system sound again."); + } } } })), diff --git a/crates/app/src/tray.rs b/crates/app/src/tray.rs index f9a9221..2ed5c8a 100644 --- a/crates/app/src/tray.rs +++ b/crates/app/src/tray.rs @@ -38,6 +38,9 @@ static ICON: LazyLock> = LazyLock::new(|| { .collect() }); +/// The tooltip title while nothing is wrong. +pub const DEFAULT_STATUS: &str = "rewynd is recording"; + pub struct RewyndTray { tx: UnboundedSender, /// One-line pipeline status shown as the tooltip title; the recorder updates it on failures. @@ -135,7 +138,7 @@ pub async fn spawn( let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let tray = RewyndTray { tx, - status: "rewynd is recording".to_owned(), + status: DEFAULT_STATUS.to_owned(), mic_enabled, }; let handle = tray.spawn().await?; diff --git a/crates/capture/Cargo.toml b/crates/capture/Cargo.toml index 4f68609..ac0c311 100644 --- a/crates/capture/Cargo.toml +++ b/crates/capture/Cargo.toml @@ -58,6 +58,7 @@ futures-util.workspace = true windows-capture.workspace = true # Window style/state queries (IsIconic, GWL_STYLE) behind the game heuristic. windows = { workspace = true, features = ["Win32_UI_WindowsAndMessaging"] } +windows-core.workspace = true [target.'cfg(target_os = "windows")'.dev-dependencies] # The probe opens the shared handle on a second D3D11 device (D3D11CreateDevice diff --git a/crates/capture/src/game.rs b/crates/capture/src/game.rs index aeff59b..b322992 100644 --- a/crates/capture/src/game.rs +++ b/crates/capture/src/game.rs @@ -97,7 +97,11 @@ pub fn steam_app_name(appid: u32) -> Option { /// already the best stable key we have. fn clean_app_id(app_id: &str) -> String { let base = app_id.trim(); - let base = base.strip_suffix(".exe").unwrap_or(base); + // A Windows process name keeps its whole stem: the dots in `Minecraft.Windows.exe` + // are part of the name, not a reverse-DNS id. + if let Some(stem) = base.strip_suffix(".exe") { + return stem.trim().to_owned(); + } // Reverse-DNS desktop ids: keep the final segment, which names the app. let base = base.rsplit('.').next().unwrap_or(base); base.trim().to_owned() @@ -158,6 +162,19 @@ mod tests { ); } + #[test] + fn display_name_keeps_a_dotted_exe_stem_whole() { + let no_steam = |_: u32| None; + assert_eq!( + info("Minecraft.Windows.exe", "Minecraft").display_name_via(no_steam), + "Minecraft.Windows" + ); + assert_eq!( + info("Battle.net.exe", "").display_name_via(no_steam), + "Battle.net" + ); + } + #[test] fn display_name_prefers_the_steam_title() { let lookup = |appid: u32| (appid == 1245620).then(|| "ELDEN RING".to_owned()); diff --git a/crates/capture/src/windows/game_window.rs b/crates/capture/src/windows/game_window.rs index af24b2a..73fbe8a 100644 --- a/crates/capture/src/windows/game_window.rs +++ b/crates/capture/src/windows/game_window.rs @@ -3,20 +3,24 @@ //! The heuristic is deliberately conservative — capture too little rather than too //! much (the whole point of game-only capture is not recording the desktop): the //! foreground window counts as a game only when it covers its entire monitor, which -//! is how both exclusive-fullscreen and borderless-fullscreen games present. -//! Windowed-mode games don't match; the desktop-capture opt-in covers those. +//! is how both exclusive-fullscreen and borderless-fullscreen games present — or when +//! it is a known windowed game ([`WindowedGames`]: Minecraft built in, more from the +//! config), which qualifies at any size while it is visible. Other windowed-mode games +//! don't match; the desktop-capture opt-in covers those. //! -//! Known non-game apps and decorated maximized windows are rejected too. +//! Known non-game apps and decorated maximized windows are rejected too. A UWP app is +//! judged by the process hosted inside ApplicationFrameHost, not by the host. //! [`Latch`] applies the same [`WindowState`] to the *captured* window, releasing one -//! that stops being fullscreen. Losing focus is not a state at all. +//! that stops qualifying. Losing focus is not a state at all. use std::time::{Duration, Instant}; use windows::Win32::Foundation::{HWND, RECT}; use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, HMONITOR, MONITORINFO}; use windows::Win32::UI::WindowsAndMessaging::{ - GWL_STYLE, GetWindowLongPtrW, IsIconic, IsWindowVisible, WS_CAPTION, WS_MAXIMIZE, + FindWindowExW, GWL_STYLE, GetWindowLongPtrW, IsIconic, IsWindowVisible, WS_CAPTION, WS_MAXIMIZE, }; +use windows::core::{PCWSTR, w}; use windows_capture::window::Window; /// Shell processes that legitimately own monitor-sized windows, plus rewynd itself. @@ -117,6 +121,85 @@ fn process_exclusion(name: &str) -> Option { None } +/// UWP apps present through this host; the app itself owns the `CoreWindow` child. +const UWP_HOST: &str = "applicationframehost.exe"; + +/// JVM launchers: Minecraft Java is one of these with a "Minecraft" title. +const JAVA_PROCESSES: &[&str] = &["javaw.exe", "java.exe"]; + +/// Games commonly played in a window, matched by (lowercase) process name and title. The +/// `Some` is the name the clip folder gets, since the process name would not say it. +fn builtin_windowed_game(process: &str, title: &str) -> Option<&'static str> { + let title = title.trim_start().to_lowercase(); + if JAVA_PROCESSES.contains(&process) && title.starts_with("minecraft") { + return Some("Minecraft"); + } + if process == "minecraft.windows.exe" { + return Some("Minecraft"); + } + None +} + +/// Which windows count as a game at any size: the built-in rules plus the user's +/// `[capture] windowed_games` entries. An entry ending in `.exe` names a process; any other +/// entry is matched against the window title, as a case-insensitive substring. +#[derive(Debug, Clone, Default)] +pub struct WindowedGames { + entries: Vec, +} + +/// How a window matched [`WindowedGames`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WindowedMatch { + /// A built-in rule, carrying the game's name. + Builtin(&'static str), + /// A configured `.exe` entry: the user named this process, so it beats the exclusion lists. + ConfiguredProcess, + /// A configured title fragment. Titles are ambiguous ("Balatro - YouTube" in a browser, + /// "Balatro on Steam" in the store), so the exclusion lists still apply. + ConfiguredTitle, +} + +impl WindowedMatch { + fn overrides_exclusions(self) -> bool { + self == Self::ConfiguredProcess + } +} + +impl WindowedGames { + #[must_use] + pub fn new(entries: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + entries: entries + .into_iter() + .map(|e| e.as_ref().trim().to_lowercase()) + .filter(|e| !e.is_empty()) + .collect(), + } + } + + fn matches(&self, process: &str, title: &str) -> Option { + let process = process.to_ascii_lowercase(); + if let Some(name) = builtin_windowed_game(&process, title) { + return Some(WindowedMatch::Builtin(name)); + } + let title = title.to_lowercase(); + let (exe_entries, title_entries): (Vec<_>, Vec<_>) = + self.entries.iter().partition(|e| e.ends_with(".exe")); + if exe_entries.iter().any(|entry| process == **entry) { + return Some(WindowedMatch::ConfiguredProcess); + } + title_entries + .iter() + .any(|entry| title.contains(entry.as_str())) + .then_some(WindowedMatch::ConfiguredTitle) + } +} + /// A borderless window may hang a pixel over, so "covers" is `<=`/`>=`, not equality. fn rect_covers(rect: RECT, bounds: RECT) -> bool { rect.left <= bounds.left @@ -131,10 +214,11 @@ fn is_fullscreen_style(style: u32) -> bool { !((style & WS_CAPTION.0) == WS_CAPTION.0 && (style & WS_MAXIMIZE.0) != 0) } -/// Only [`WindowState::Fullscreen`] qualifies as a game; the rest say why not. +/// Only [`WindowState::Capturable`] qualifies as a game; the rest say why not. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum WindowState { - Fullscreen, + /// Fullscreen, or a known windowed game at any size. + Capturable, Minimized, /// Covering its monitor, but still drawing a title bar. Decorated, @@ -149,7 +233,7 @@ pub(crate) const RELEASE_GRACE: Duration = Duration::from_secs(1); /// minimized cannot hold the recorder while another runs. pub(crate) const MINIMIZED_GRACE: Duration = Duration::from_secs(30); -/// Releases the captured window once it has not been fullscreen for its earned grace. +/// Releases the captured window once it has not qualified for its earned grace. #[derive(Debug, Default)] pub(crate) struct Latch { away_since: Option, @@ -159,7 +243,7 @@ pub(crate) struct Latch { impl Latch { /// `true` while the session should keep running. pub(crate) fn observe(&mut self, state: WindowState, now: Instant) -> bool { - if state == WindowState::Fullscreen { + if state == WindowState::Capturable { self.away_since = None; self.grace = Duration::ZERO; return true; @@ -180,6 +264,8 @@ impl Latch { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Verdict { Game, + /// A [`WindowedGames`] match, visible and not minimized. + WindowedGame, Shell, NonGame, Minimized, @@ -189,12 +275,13 @@ enum Verdict { impl Verdict { fn is_game(self) -> bool { - self == Self::Game + matches!(self, Self::Game | Self::WindowedGame) } fn label(self) -> &'static str { match self { Self::Game => "YES", + Self::WindowedGame => "YES (known windowed game, recorded at any size)", Self::Shell => "NO (shell process)", Self::NonGame => "NO (known non-game app: browser/media player/chat/launcher)", Self::Minimized => "NO (minimized)", @@ -206,30 +293,87 @@ impl Verdict { } } +/// The foreground window, found to be a running game. +pub(crate) struct DetectedGame { + pub(crate) window: Window, + /// Matched as a windowed game: the latch keeps it at any size. + pub(crate) windowed: bool, + /// The process name (the hosted app's for a UWP frame); empty when unreadable. + pub(crate) process: String, + /// A built-in rule's game name, which beats the process name for labelling. + pub(crate) name: Option<&'static str>, +} + /// The foreground window when it looks like a running game. -pub(crate) fn fullscreen_game_window() -> Option { +pub(crate) fn game_window(windowed_games: &WindowedGames) -> Option { let window = Window::foreground().ok()?; if !window.is_valid() { return None; } - classify(&window).is_game().then_some(window) + let process = process_name(&window); + let (verdict, matched) = classify(&window, &process, windowed_games); + verdict.is_game().then(|| DetectedGame { + window, + windowed: matched.is_some(), + process, + name: match matched { + Some(WindowedMatch::Builtin(name)) => Some(name), + _ => None, + }, + }) +} + +/// The window's process; for a UWP frame, the app hosted in it. Empty when unreadable: +/// anti-cheat games refuse OpenProcess, so that must NOT disqualify. +fn process_name(window: &Window) -> String { + let Ok(name) = window.process_name() else { + return String::new(); + }; + if name.eq_ignore_ascii_case(UWP_HOST) + && let Some(hosted) = hosted_process(window) + { + return hosted; + } + name +} + +fn hosted_process(window: &Window) -> Option { + // SAFETY: FFI; a stale parent HWND yields an error, not a crash. + let child = unsafe { + FindWindowExW( + Some(HWND(window.as_raw_hwnd())), + None, + w!("Windows.UI.Core.CoreWindow"), + PCWSTR::null(), + ) + } + .ok()?; + Window::from_raw_hwnd(child.0).process_name().ok() } -fn classify(window: &Window) -> Verdict { - // Anti-cheat games refuse OpenProcess, so a failed name query must NOT disqualify. - if let Ok(process) = window.process_name() { - match process_exclusion(&process) { - Some(Exclusion::Shell) => return Verdict::Shell, - Some(Exclusion::NonGame) => return Verdict::NonGame, +fn classify( + window: &Window, + process: &str, + windowed_games: &WindowedGames, +) -> (Verdict, Option) { + // Compared only, never logged: titles carry documents, URLs and chat context. + let title = window.title().unwrap_or_default(); + let matched = windowed_games.matches(process, &title); + if !matched.is_some_and(WindowedMatch::overrides_exclusions) { + match process_exclusion(process) { + Some(Exclusion::Shell) => return (Verdict::Shell, None), + Some(Exclusion::NonGame) => return (Verdict::NonGame, None), None => {} } } - match window_state(window) { - WindowState::Fullscreen => Verdict::Game, + let verdict = match window_state(window, matched.is_some()) { + WindowState::Capturable if matched.is_some() => Verdict::WindowedGame, + WindowState::Capturable => Verdict::Game, WindowState::Minimized => Verdict::Minimized, WindowState::Decorated => Verdict::Decorated, WindowState::Lost => Verdict::Windowed, - } + }; + (verdict, matched) } /// 0 when the bits can't be read, which never disqualifies. @@ -240,7 +384,9 @@ fn window_style(window: &Window) -> u32 { } /// Shared with the latch, so a window is never kept under a rule that would not latch it. -pub(crate) fn window_state(window: &Window) -> WindowState { +/// `windowed` is the detector's [`WindowedGames`] verdict: such a window qualifies at any +/// size and with any decoration. +pub(crate) fn window_state(window: &Window, windowed: bool) -> WindowState { let hwnd = HWND(window.as_raw_hwnd()); // SAFETY: FFI; both tolerate a destroyed HWND (they report false). let visible = unsafe { IsWindowVisible(hwnd) }.as_bool(); @@ -251,10 +397,17 @@ pub(crate) fn window_state(window: &Window) -> WindowState { minimized, covers_its_monitor(window), window_style(window), + windowed, ) } -fn window_state_from(visible: bool, minimized: bool, covers: bool, style: u32) -> WindowState { +fn window_state_from( + visible: bool, + minimized: bool, + covers: bool, + style: u32, + windowed: bool, +) -> WindowState { if !visible { return WindowState::Lost; } @@ -262,11 +415,14 @@ fn window_state_from(visible: bool, minimized: bool, covers: bool, style: u32) - if minimized { return WindowState::Minimized; } + if windowed { + return WindowState::Capturable; + } if !covers { return WindowState::Lost; } if is_fullscreen_style(style) { - WindowState::Fullscreen + WindowState::Capturable } else { WindowState::Decorated } @@ -303,18 +459,22 @@ pub fn describe_foreground() -> String { if !window.is_valid() { return "foreground window is not a valid capture target (invisible/tool/child)".to_owned(); } - let process = match window.process_name() { - Ok(p) => p, - // The anti-cheat case: unreadable process = still a game candidate. - Err(e) => format!(""), - }; + let process = process_name(&window); let rect = window .rect() .map(|r| format!("{},{} → {},{}", r.left, r.top, r.right, r.bottom)) .unwrap_or_else(|e| format!("")); let style = window_style(&window); - let state = window_state(&window); - let verdict = classify(&window).label(); + // Built-in windowed rules only: the probe has no config. + let (verdict, matched) = classify(&window, &process, &WindowedGames::default()); + let state = window_state(&window, matched.is_some()); + // The anti-cheat case: unreadable process = still a game candidate. + let process = if process.is_empty() { + "" + } else { + process.as_str() + }; + let verdict = verdict.label(); format!("process={process} style=0x{style:08x} rect={rect} state={state:?} → game: {verdict}") } @@ -428,10 +588,10 @@ mod tests { fn latch_rides_out_a_brief_loss() { let t0 = Instant::now(); let mut latch = Latch::default(); - assert!(latch.observe(WindowState::Fullscreen, t0)); + assert!(latch.observe(WindowState::Capturable, t0)); assert!(latch.observe(WindowState::Lost, t0 + Duration::from_millis(200))); assert!(latch.observe(WindowState::Lost, t0 + Duration::from_millis(800))); - assert!(latch.observe(WindowState::Fullscreen, t0 + Duration::from_millis(1000))); + assert!(latch.observe(WindowState::Capturable, t0 + Duration::from_millis(1000))); assert!(latch.observe(WindowState::Lost, t0 + Duration::from_millis(1200))); assert!(latch.observe(WindowState::Lost, t0 + Duration::from_millis(2100))); assert!(!latch.observe(WindowState::Lost, t0 + Duration::from_millis(2200))); @@ -441,9 +601,9 @@ mod tests { fn latch_keeps_a_briefly_minimized_window() { let t0 = Instant::now(); let mut latch = Latch::default(); - assert!(latch.observe(WindowState::Fullscreen, t0)); + assert!(latch.observe(WindowState::Capturable, t0)); assert!(latch.observe(WindowState::Minimized, t0 + Duration::from_secs(2))); - assert!(latch.observe(WindowState::Fullscreen, t0 + Duration::from_secs(10))); + assert!(latch.observe(WindowState::Capturable, t0 + Duration::from_secs(10))); assert!(latch.observe( WindowState::Minimized, t0 + Duration::from_secs(10) + MINIMIZED_GRACE - Duration::from_secs(1) @@ -455,7 +615,7 @@ mod tests { let t0 = Instant::now(); let away = t0 + Duration::from_secs(1); let mut latch = Latch::default(); - assert!(latch.observe(WindowState::Fullscreen, t0)); + assert!(latch.observe(WindowState::Capturable, t0)); assert!(latch.observe(WindowState::Minimized, away)); assert!(latch.observe( WindowState::Minimized, @@ -479,19 +639,19 @@ mod tests { fn latch_lets_a_window_restoring_from_minimized_reach_fullscreen() { let t0 = Instant::now(); let mut latch = Latch::default(); - assert!(latch.observe(WindowState::Fullscreen, t0)); + assert!(latch.observe(WindowState::Capturable, t0)); assert!(latch.observe(WindowState::Minimized, t0 + Duration::from_secs(1))); assert!(latch.observe(WindowState::Minimized, t0 + Duration::from_secs(10))); // Restoring clears the minimized flag before the window covers its monitor. assert!(latch.observe(WindowState::Lost, t0 + Duration::from_millis(10_200))); - assert!(latch.observe(WindowState::Fullscreen, t0 + Duration::from_millis(10_600))); + assert!(latch.observe(WindowState::Capturable, t0 + Duration::from_millis(10_600))); } #[test] fn latch_releases_a_window_that_grew_a_title_bar() { let t0 = Instant::now(); let mut latch = Latch::default(); - assert!(latch.observe(WindowState::Fullscreen, t0)); + assert!(latch.observe(WindowState::Capturable, t0)); assert!(latch.observe(WindowState::Decorated, t0 + Duration::from_millis(200))); assert!(!latch.observe( WindowState::Decorated, @@ -502,11 +662,11 @@ mod tests { #[test] fn window_state_reads_visibility_first_then_minimized() { assert_eq!( - window_state_from(false, true, true, WS_POPUP.0), + window_state_from(false, true, true, WS_POPUP.0, false), WindowState::Lost ); assert_eq!( - window_state_from(true, true, false, WS_POPUP.0), + window_state_from(true, true, false, WS_POPUP.0, false), WindowState::Minimized ); } @@ -514,15 +674,21 @@ mod tests { #[test] fn window_state_separates_covering_from_decorated_and_windowed() { assert_eq!( - window_state_from(true, false, true, WS_POPUP.0), - WindowState::Fullscreen + window_state_from(true, false, true, WS_POPUP.0, false), + WindowState::Capturable ); assert_eq!( - window_state_from(true, false, true, WS_OVERLAPPEDWINDOW.0 | WS_MAXIMIZE.0), + window_state_from( + true, + false, + true, + WS_OVERLAPPEDWINDOW.0 | WS_MAXIMIZE.0, + false + ), WindowState::Decorated ); assert_eq!( - window_state_from(true, false, false, WS_POPUP.0), + window_state_from(true, false, false, WS_POPUP.0, false), WindowState::Lost ); } @@ -551,15 +717,99 @@ mod tests { } } + #[test] + fn builtin_rules_know_minecraft_java_and_bedrock() { + let games = WindowedGames::default(); + assert_eq!( + games.matches("javaw.exe", "Minecraft* 1.21.4 - Singleplayer"), + Some(WindowedMatch::Builtin("Minecraft")) + ); + assert_eq!( + games.matches("Java.exe", " minecraft 1.8.9"), + Some(WindowedMatch::Builtin("Minecraft")) + ); + assert_eq!( + games.matches("Minecraft.Windows.exe", ""), + Some(WindowedMatch::Builtin("Minecraft")) + ); + // A JVM that is not Minecraft (an IDE, a launcher) stays a windowed app. + assert_eq!(games.matches("javaw.exe", "IntelliJ IDEA"), None); + assert_eq!(games.matches("eldenring.exe", "ELDEN RING"), None); + } + + #[test] + fn configured_entries_match_processes_by_exe_and_titles_by_substring() { + let games = WindowedGames::new([" RobloxPlayerBeta.exe ", "balatro", ""]); + assert_eq!( + games.matches("robloxplayerbeta.exe", "Roblox"), + Some(WindowedMatch::ConfiguredProcess) + ); + assert_eq!( + games.matches("balatro.exe", "Balatro"), + Some(WindowedMatch::ConfiguredTitle) + ); + // An exe entry never matches a title, and a title entry never a process name. + assert_eq!( + games.matches("chrome.exe", "RobloxPlayerBeta.exe - Downloads"), + None + ); + assert_eq!(games.matches("balatro", "Something else"), None); + assert_eq!( + WindowedGames::new(["", " "]).matches("javaw.exe", "IntelliJ"), + None + ); + } + + #[test] + fn only_a_named_process_overrides_the_exclusion_lists() { + // "balatro" in a browser tab or the Steam store must stay excluded; naming the + // process is the user's explicit word. + assert!(!WindowedMatch::ConfiguredTitle.overrides_exclusions()); + assert!(!WindowedMatch::Builtin("Minecraft").overrides_exclusions()); + assert!(WindowedMatch::ConfiguredProcess.overrides_exclusions()); + } + + #[test] + fn a_windowed_game_qualifies_at_any_size_unless_hidden_or_minimized() { + let decorated = WS_OVERLAPPEDWINDOW.0 | WS_VISIBLE.0; + assert_eq!( + window_state_from(true, false, false, decorated, true), + WindowState::Capturable + ); + assert_eq!( + window_state_from(true, false, true, decorated | WS_MAXIMIZE.0, true), + WindowState::Capturable + ); + assert_eq!( + window_state_from(true, true, false, decorated, true), + WindowState::Minimized + ); + assert_eq!( + window_state_from(false, false, false, decorated, true), + WindowState::Lost + ); + // The same window without the rule is just a windowed app. + assert_eq!( + window_state_from(true, false, false, decorated, false), + WindowState::Lost + ); + } + + #[test] + fn a_windowed_game_verdict_reads_as_yes() { + assert!(Verdict::WindowedGame.is_game()); + assert!(Verdict::WindowedGame.label().starts_with("YES")); + } + #[test] fn detector_never_matches_this_test_process() { // The foreground window while tests run is a terminal/IDE at best — never a // fullscreen game. Mostly asserts the FFI path doesn't crash or hang. - let detected = fullscreen_game_window(); + let detected = game_window(&WindowedGames::default()); if let Some(w) = detected { // A fullscreen video or similar could legitimately match on a dev box; // just prove the accessor path works. - let _ = w.title(); + let _ = w.window.title(); } } } diff --git a/crates/capture/src/windows/mod.rs b/crates/capture/src/windows/mod.rs index 3969f7b..9e8e349 100644 --- a/crates/capture/src/windows/mod.rs +++ b/crates/capture/src/windows/mod.rs @@ -6,14 +6,17 @@ //! per-frame copy + NT-handle duplication. //! - [`game_window`]: the foreground-game heuristic behind game-only capture, and //! the latch that releases a captured window once it leaves fullscreen. -//! - [`wasapi_audio`]: loopback (system mix) and microphone capture as f32 PCM. +//! - [`wasapi_audio`]: system audio (process loopback, or an endpoint's loopback) and +//! microphone capture as f32 PCM. mod game_window; pub mod wasapi_audio; pub mod wgc_capture; -pub use game_window::describe_foreground; -pub use wasapi_audio::{RenderDefaults, capture_audio, default_render_endpoints}; +pub use game_window::{WindowedGames, describe_foreground}; +pub use wasapi_audio::{ + RenderDefaults, capture_audio, default_render_endpoints, process_loopback_supported, +}; pub use wgc_capture::{ CapturedD3d11Frame, GameCallback, capture_game_stream, capture_stream, display_geometry, }; diff --git a/crates/capture/src/windows/wasapi_audio.rs b/crates/capture/src/windows/wasapi_audio.rs index 61a545e..ae94b8f 100644 --- a/crates/capture/src/windows/wasapi_audio.rs +++ b/crates/capture/src/windows/wasapi_audio.rs @@ -1,33 +1,55 @@ -//! System-audio capture over WASAPI: the default render endpoint's loopback (the -//! system mix — what you hear) or the default microphone, as interleaved f32 PCM. +//! System-audio capture over WASAPI as interleaved f32 PCM: every application's playback +//! through the process-loopback virtual device, an output endpoint's loopback, or a +//! microphone. //! -//! Shared-mode streams are opened with `AUTOCONVERTPCM | SRC_DEFAULT_QUALITY`, so the -//! audio engine converts whatever the device's mix format is into the [`AudioParams`] -//! format we ask for — rate and channel count stay parameters, exactly like the -//! PipeWire negotiation on Linux. The capture client is polled on the calling thread -//! (WASAPI buffers ~200 ms internally; a 10 ms poll never starves it), which keeps the -//! blocking per-buffer-callback shape of [`crate::linux::capture_audio`]: same -//! arguments, same `ControlFlow` contract, same epoch-relative PTS. +//! Process loopback (`VAD\Process_Loopback`, Windows 10 2004+) is the default for system +//! audio. It taps each process's render streams *before* they reach an endpoint, so it +//! hears a game or voice app whichever output it plays to: the communications default, +//! a per-app routing in the volume mixer, a virtual mixer's device, an output that became +//! the default after the recorder started. An endpoint loopback hears only its own +//! endpoint's mix, and Windows never moves it when the default changes, which is how +//! clips ended up without system sound. The endpoint path stays for an explicit output +//! pick and as the fallback when the activation is unavailable. +//! +//! Endpoint streams are opened with `AUTOCONVERTPCM | SRC_DEFAULT_QUALITY`, so the audio +//! engine converts whatever the device's mix format is into the [`AudioParams`] format we +//! ask for — rate and channel count stay parameters, exactly like the PipeWire negotiation +//! on Linux; the process-loopback device has no mix format and delivers what was asked +//! for. The capture client is polled on the calling thread (WASAPI buffers ~200 ms +//! internally; a 10 ms poll never starves it), which keeps the blocking per-buffer-callback +//! shape of [`crate::linux::capture_audio`]: same arguments, same `ControlFlow` contract, +//! same epoch-relative PTS. +use std::mem::ManuallyDrop; use std::ops::ControlFlow; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{OnceLock, mpsc}; use std::time::{Duration, Instant}; use windows::Win32::Foundation::PROPERTYKEY; use windows::Win32::Media::Audio::{ AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY, AUDCLNT_BUFFERFLAGS_SILENT, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, AUDCLNT_STREAMFLAGS_LOOPBACK, - AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, IAudioCaptureClient, IAudioClient, - IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, eCapture, eCommunications, eConsole, + AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, AUDIOCLIENT_ACTIVATION_PARAMS, + AUDIOCLIENT_ACTIVATION_PARAMS_0, AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, + AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS, ActivateAudioInterfaceAsync, + IActivateAudioInterfaceAsyncOperation, IActivateAudioInterfaceCompletionHandler, + IActivateAudioInterfaceCompletionHandler_Impl, IAudioCaptureClient, IAudioClient, + IMMDeviceEnumerator, MMDeviceEnumerator, PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, + VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, WAVEFORMATEX, eCapture, eCommunications, eConsole, eRender, }; use windows::Win32::Media::Audio::{DEVICE_STATE_ACTIVE, IMMDevice, IMMDeviceCollection}; +use windows::Win32::System::Com::StructuredStorage::{ + PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0, +}; use windows::Win32::System::Com::{ - CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, + BLOB, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, STGM_READ, }; -use windows::core::GUID; +use windows::Win32::System::Variant::VT_BLOB; +use windows::core::{GUID, HRESULT, IUnknown, Interface, Ref, implement}; use crate::{AudioDevice, AudioParams, AudioSource, CaptureError}; @@ -51,6 +73,10 @@ const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; /// re-syncs promptly instead of back-dating the resumed audio. const REANCHOR_DRIFT: Duration = Duration::from_millis(100); +/// Bound on waiting for `ActivateAudioInterfaceAsync` to complete. It finishes in +/// milliseconds; a longer wait means the audio service is wedged, not slow. +const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(5); + /// Balances `CoInitializeEx` on drop, so every exit path uninitializes COM exactly once. struct ComGuard; @@ -136,6 +162,108 @@ pub fn default_render_endpoints() -> Option { }) } +/// `ActivateAudioInterfaceAsync` reports completion on an audio-service worker thread; the +/// activating thread blocks on the channel instead of pumping messages (it is MTA). +#[implement(IActivateAudioInterfaceCompletionHandler)] +struct ActivationDone(mpsc::Sender<()>); + +impl IActivateAudioInterfaceCompletionHandler_Impl for ActivationDone_Impl { + fn ActivateCompleted( + &self, + _operation: Ref<'_, IActivateAudioInterfaceAsyncOperation>, + ) -> windows::core::Result<()> { + // A closed receiver means the activator gave up waiting; nothing left to tell. + let _ = self.0.send(()); + Ok(()) + } +} + +/// A capture client on the process-loopback virtual device: the render streams of every +/// process outside `exclude_pid`'s tree, whichever endpoint each plays to. Fails on builds +/// before Windows 10 2004, where the device does not exist. +fn process_loopback_client(exclude_pid: u32) -> Result { + let mut params = AUDIOCLIENT_ACTIVATION_PARAMS { + ActivationType: AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK, + Anonymous: AUDIOCLIENT_ACTIVATION_PARAMS_0 { + ProcessLoopbackParams: AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS { + TargetProcessId: exclude_pid, + ProcessLoopbackMode: PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE, + }, + }, + }; + // A VT_BLOB PROPVARIANT pointing at `params`. Never dropped as a PROPVARIANT: the windows + // crate clears one on drop, and clearing a blob frees its data pointer, which is this + // stack frame. + let activation = ManuallyDrop::new(PROPVARIANT { + Anonymous: PROPVARIANT_0 { + Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 { + vt: VT_BLOB, + wReserved1: 0, + wReserved2: 0, + wReserved3: 0, + Anonymous: PROPVARIANT_0_0_0 { + blob: BLOB { + cbSize: size_of::() as u32, + pBlobData: std::ptr::from_mut(&mut params).cast(), + }, + }, + }), + }, + }); + let (done, completed) = mpsc::channel(); + let handler: IActivateAudioInterfaceCompletionHandler = ActivationDone(done).into(); + // SAFETY: FFI; `params` and `activation` outlive the call, which copies them, and the + // operation holds its own reference to the handler. + let operation = unsafe { + ActivateAudioInterfaceAsync( + VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, + &IAudioClient::IID, + Some(&*activation), + &handler, + ) + } + .map_err(|e| CaptureError::Wasapi(format!("activate process loopback: {e}")))?; + completed.recv_timeout(ACTIVATION_TIMEOUT).map_err(|_| { + CaptureError::Wasapi("process loopback activation did not complete".to_owned()) + })?; + let mut result = HRESULT(0); + let mut client: Option = None; + // SAFETY: FFI; both out-params are valid for the call. + unsafe { operation.GetActivateResult(&mut result, &mut client) } + .map_err(|e| CaptureError::Wasapi(format!("process loopback result: {e}")))?; + result + .ok() + .map_err(|e| CaptureError::Wasapi(format!("process loopback: {e}")))?; + let client: IAudioClient = client + .ok_or_else(|| CaptureError::Wasapi("process loopback activated nothing".to_owned()))? + .cast() + .map_err(|e| CaptureError::Wasapi(format!("process loopback client: {e}")))?; + Ok(client) +} + +/// Whether this Windows can capture system audio through process loopback, the path the +/// default output selection takes. `false` means the endpoint-loopback fallback is what +/// will run, so the caller may want the communications-endpoint extra next to it. +/// +/// Decided once per process by a trial activation and then fixed, so the capture takes +/// the same path the caller planned around: a supported box whose activation fails later +/// gets a retry of the same path, never a silent switch to an endpoint without the extra. +#[must_use] +pub fn process_loopback_supported() -> bool { + *PROCESS_LOOPBACK_SUPPORTED.get_or_init(|| { + let Ok(_com) = ComGuard::init() else { + return false; + }; + process_loopback_client(std::process::id()) + .inspect_err(|e| { + tracing::warn!(error = %e, "process loopback unavailable; system audio will use the default output's loopback"); + }) + .is_ok() + }) +} + +static PROCESS_LOOPBACK_SUPPORTED: OnceLock = OnceLock::new(); + /// Resolve the capture endpoint: the flow's default, or the active endpoint the selector picks /// out — its endpoint ID, else its friendly name (exact first, then substring; case-insensitive). /// An unmatched selector errors unless it is a [`AudioDevice::Preferred`] render endpoint, which @@ -239,10 +367,19 @@ pub fn capture_audio( AudioSource::SinkMonitor => eRender, AudioSource::Microphone => eCapture, }; - let device = endpoint(&enumerator, flow, device)?; - // SAFETY: FFI. - let client: IAudioClient = unsafe { device.Activate(CLSCTX_ALL, None) } - .map_err(|e| CaptureError::Wasapi(format!("activate audio client: {e}")))?; + let process_loopback = source == AudioSource::SinkMonitor + && *device == AudioDevice::Default + && process_loopback_supported(); + let client: IAudioClient = if process_loopback { + let client = process_loopback_client(std::process::id())?; + tracing::info!("system audio: process loopback (every app, whichever output it plays to)"); + client + } else { + let device = endpoint(&enumerator, flow, device)?; + // SAFETY: FFI. + unsafe { device.Activate(CLSCTX_ALL, None) } + .map_err(|e| CaptureError::Wasapi(format!("activate audio client: {e}")))? + }; let block_align = params.channels as u16 * (size_of::() as u16); let format = WAVEFORMATEX { @@ -254,8 +391,13 @@ pub fn capture_audio( wBitsPerSample: (size_of::() as u16) * 8, cbSize: 0, }; - let mut stream_flags = - AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + let mut stream_flags = if process_loopback { + // The process-loopback device has no mix format: it delivers the format asked for. + 0 + } else { + // The engine converts the endpoint's mix format into ours. + AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY + }; if source == AudioSource::SinkMonitor { stream_flags |= AUDCLNT_STREAMFLAGS_LOOPBACK; } diff --git a/crates/capture/src/windows/wgc_capture.rs b/crates/capture/src/windows/wgc_capture.rs index 7dd0927..33a4f45 100644 --- a/crates/capture/src/windows/wgc_capture.rs +++ b/crates/capture/src/windows/wgc_capture.rs @@ -48,6 +48,7 @@ use windows_capture::settings::{ MinimumUpdateIntervalSettings, SecondaryWindowSettings, Settings, }; +use super::game_window::WindowedGames; use crate::{CaptureError, StreamPrefs}; /// How many shareable slot textures the copies rotate through. Deep enough that a @@ -454,9 +455,9 @@ pub fn display_geometry(monitor_index: Option) -> Option<(u32, u32)> { pub type GameCallback = Box) + Send + Sync>; /// Capture the active *game*, continuously: poll the foreground window until one -/// looks like a running game (fullscreen/borderless — see -/// [`super::game_window::fullscreen_game_window`]), capture it until it closes or -/// stops being fullscreen, then go back to watching for the next one. Losing focus +/// looks like a running game (fullscreen/borderless, or one of `windowed_games` at any +/// size — see [`super::game_window::game_window`]), capture it until it closes or +/// stops qualifying, then go back to watching for the next one. Losing focus /// never ends a session; being minimized ends one only after a long grace. Desktop /// content between games is never captured. `on_game` reports each session's game /// (and its end) so the caller can gate audio and label clip folders. @@ -468,6 +469,7 @@ pub fn capture_game_stream( epoch: Instant, prefs: StreamPrefs, stop: Option>, + windowed_games: WindowedGames, on_frame: F, on_game: Option, ) -> Result<(), CaptureError> @@ -492,21 +494,24 @@ where return Ok(()); } - let Some(window) = super::game_window::fullscreen_game_window() else { + let Some(game) = super::game_window::game_window(&windowed_games) else { std::thread::sleep(GAME_POLL); continue; }; // Process name only — window titles carry documents/URLs/chat context, and // leaking those into logs would undercut the point of game-only capture. tracing::info!( - process = window.process_name().unwrap_or_default(), - "fullscreen game detected; capturing it" + process = game.process, + windowed = game.windowed, + "game detected; capturing it" ); + let window = game.window; + let windowed = game.windowed; if let Some(on_game) = &on_game { // An anti-cheat-shielded process refuses the name query; the empty app id // falls back to the window title for naming. let info = crate::game::GameInfo { - app_id: window.process_name().unwrap_or_default(), + app_id: game.name.map_or(game.process, str::to_owned), title: window.title().unwrap_or_default(), pid: window.process_id().ok().filter(|&pid| pid > 0), }; @@ -535,13 +540,10 @@ where // or a minimized game from holding the recorder. let mut latch = super::game_window::Latch::default(); let keep_alive = move || { - let state = super::game_window::window_state(&window); + let state = super::game_window::window_state(&window, windowed); let keep = latch.observe(state, Instant::now()); if !keep { - tracing::info!( - ?state, - "captured window is no longer fullscreen; releasing it" - ); + tracing::info!(?state, "captured window no longer qualifies; releasing it"); } keep }; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d7707f2..8e6179e 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -68,7 +68,8 @@ pub use resolution::{MAX_AUTO_PIXELS, ResolutionMode}; pub use schema::{ AudioSettings, Config, DEFAULT_HOTKEY_TRIGGER, DEFAULT_TEMPLATE, DEFAULT_UPLOAD_API_URL, DEFAULT_UPLOAD_SHARE_URL, EncoderPreference, MAX_BUFFER_SECONDS, UploadSettings, VideoSettings, - YouTubeSettings, ensure_default_file, load, load_file, non_empty_or, update_stored, + WINDOWED_GAMES_SUPPORTED, YouTubeSettings, ensure_default_file, load, load_file, non_empty_or, + update_stored, }; pub use status::{ RECORDER_STATUS_VERSION, RecorderState, RecorderStatus, clear_recorder_status, diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 16bc2a6..24f8bb2 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -298,8 +298,12 @@ impl Default for HotkeyConfig { } } +/// Whether this platform honours `[capture] windowed_games` (Windows captures the game window +/// itself; the others gate a monitor stream on a fullscreen focus). +pub const WINDOWED_GAMES_SUPPORTED: bool = cfg!(target_os = "windows"); + /// Capture options. -#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] struct CaptureConfig { /// Re-show the ScreenCast monitor picker each launch (ignore the saved restore token), @@ -311,6 +315,10 @@ struct CaptureConfig { /// Linux keeps the portal's monitor stream but only fills the buffer while a /// fullscreen game is focused. desktop: bool, + /// Games recorded at any window size (Windows), where game-only capture otherwise wants + /// a fullscreen window: process names (`roblox.exe`) or window-title fragments. Minecraft + /// needs no entry. + windowed_games: Vec, } /// Desktop-session startup behaviour. @@ -672,6 +680,19 @@ impl Config { self.capture.desktop } + /// Games to record at any window size: process names or title fragments (trimmed; empty + /// entries dropped). Only honoured where [`WINDOWED_GAMES_SUPPORTED`]. + #[must_use] + pub fn windowed_games(&self) -> Vec { + self.capture + .windowed_games + .iter() + .map(|e| e.trim()) + .filter(|e| !e.is_empty()) + .map(str::to_owned) + .collect() + } + /// The parsed encoder selection (`auto` / `cpu` / a pinned GPU). #[must_use] pub fn encoder_preference(&self) -> EncoderPreference { @@ -909,6 +930,11 @@ impl Config { self.capture.desktop = desktop; } + /// Set the games recorded at any window size (see [`Self::windowed_games`]). + pub fn set_windowed_games(&mut self, games: Vec) { + self.capture.windowed_games = games; + } + /// Switch uploads on/off (takes effect only once a key is set — see [`upload`]). pub fn set_upload_enabled(&mut self, enabled: bool) { self.upload.enabled = enabled; @@ -1207,6 +1233,9 @@ always_prompt = false # Record the whole desktop instead of only the active fullscreen game. # Off keeps private windows out of clips. desktop = false +# Games to record at any window size (Windows): program names or parts of a +# window title. Minecraft is built in. +windowed_games = [] [startup] # Start rewynd automatically when you log in. @@ -1266,6 +1295,20 @@ pub fn ensure_default_file() { mod tests { use super::*; + #[test] + fn windowed_games_are_trimmed_and_empty_entries_dropped() { + let c = Config::from_toml_str( + "[capture]\nwindowed_games = [\" RobloxPlayerBeta.exe \", \"\", \"balatro\"]\n", + ) + .expect("parses"); + assert_eq!(c.windowed_games(), ["RobloxPlayerBeta.exe", "balatro"]); + assert!(Config::default().windowed_games().is_empty()); + + let mut c2 = Config::default(); + c2.set_windowed_games(vec!["osu!.exe".to_owned()]); + assert_eq!(c2.windowed_games(), ["osu!.exe"]); + } + #[test] fn encoder_preference_parses_all_forms() { assert_eq!(EncoderPreference::parse(""), EncoderPreference::Auto); diff --git a/crates/settings/src/main.rs b/crates/settings/src/main.rs index 3972ce5..8f8c86e 100644 --- a/crates/settings/src/main.rs +++ b/crates/settings/src/main.rs @@ -740,6 +740,8 @@ struct App { /// Whether the capture card shows its advanced options (the per-start monitor prompt). UI-only. #[cfg(target_os = "linux")] capture_advanced_open: bool, + /// The windowed-games field as typed; the config holds the parsed list. UI-only. + windowed_games_text: String, login: LoginState, /// Mirror of the YouTube OAuth client id override (empty = the compiled-in default). yt_client_id: String, @@ -843,6 +845,7 @@ enum Message { #[cfg(target_os = "linux")] CaptureAdvancedToggled, CaptureDesktop(bool), + WindowedGames(String), GameFolders(bool), StartOnBoot(bool), AutoInstallUpdates(bool), @@ -941,6 +944,7 @@ impl App { audio_advanced_open: false, #[cfg(target_os = "linux")] capture_advanced_open: false, + windowed_games_text: config.windowed_games().join(", "), yt_client_id: config.youtube_client_id().to_owned(), yt_client_secret: config.youtube_client_secret().to_owned(), // A stored OAuth-client override stays visible instead of hiding behind the @@ -1243,6 +1247,19 @@ impl App { self.config.set_capture_desktop(on); self.touch(); } + Message::WindowedGames(text) => { + // The raw text stays as typed (a trailing comma mid-edit must survive a + // redraw); the config gets the parsed list. + self.config.set_windowed_games( + text.split(',') + .map(str::trim) + .filter(|e| !e.is_empty()) + .map(str::to_owned) + .collect(), + ); + self.windowed_games_text = text; + self.touch(); + } Message::GameFolders(on) => { self.config.set_game_folders(on); self.touch(); @@ -2055,20 +2072,39 @@ impl App { // ScreenCast-portal detail (Windows records the active game by default) that only applies // while desktop capture is on. let capture_desktop = self.config.capture_desktop(); - let output_capture = output_capture - .push( + let output_capture = output_capture.push( + column![ + checkbox(capture_desktop) + .label("Record the whole desktop, not just the active game") + .on_toggle(Message::CaptureDesktop) + .style(arena_check), + hint( + "Off records only the game you're playing (fullscreen or \ + borderless), keeping other windows out of your clips.", + ), + ] + .spacing(6), + ); + // Only where game-only capture targets the window itself, and moot while the whole + // desktop is recorded anyway. + let output_capture = if config::WINDOWED_GAMES_SUPPORTED && !capture_desktop { + output_capture.push( column![ - checkbox(capture_desktop) - .label("Record the whole desktop, not just the active game") - .on_toggle(Message::CaptureDesktop) - .style(arena_check), + field_label("Also record these games when windowed"), + text_input("roblox.exe, Balatro", &self.windowed_games_text) + .on_input(Message::WindowedGames) + .style(arena_input), hint( - "Off records only the game you're playing (fullscreen or \ - borderless), keeping other windows out of your clips.", + "Minecraft is built in. Add a program name (roblox.exe) or part of a \ + window title, comma-separated: those are recorded at any window size.", ), ] - .spacing(6), + .spacing(8), ) + } else { + output_capture + }; + let output_capture = output_capture .push( column![ checkbox(self.config.game_folders()) diff --git a/docs/adr/0022-windows-system-audio-process-loopback.md b/docs/adr/0022-windows-system-audio-process-loopback.md new file mode 100644 index 0000000..3b8480b --- /dev/null +++ b/docs/adr/0022-windows-system-audio-process-loopback.md @@ -0,0 +1,84 @@ +# ADR 0022: Windows system audio through process loopback + +## Status + +Accepted (issue #215). + +## Context + +Windows system audio was a WASAPI loopback on the *console default* render endpoint, +resolved once at startup (ADR 0002's WASAPI choice; #210 added a second loopback on the +communications default and a picker for an explicit output). A set of users still got +clips without system sound, some even after picking their output by hand, while the +same builds worked elsewhere. No logs exist from those machines: the installed recorder +has no console and writes no log file. + +An endpoint loopback hears exactly one endpoint's mix, and only what reaches it. The +code allowed three ways for that to be silent without an error: + +- **Routing.** Audio that plays anywhere else never reaches the captured endpoint: the + default changed after the recorder started (a headset powering on, Bluetooth, HDMI + audio waking), a per-app output in the volume mixer, the game's own device setting, + a virtual mixer (SteelSeries Sonar, Nahimic, VoiceMeeter) sitting in front of the + hardware, a Bluetooth headset switching to its hands-free profile when the mic opens. + WASAPI streams do not follow default-device changes; only apps that re-open on + `IMMNotificationClient::OnDefaultDeviceChanged` do. +- **Stream death.** Any WASAPI error (`AUDCLNT_E_DEVICE_INVALIDATED` on unplug or + default change, `E_ACCESSDENIED` under the microphone privacy policy) ended the capture + thread for the rest of the session. The toast that reported it is hidden by Windows' + automatic Do Not Disturb while a game is fullscreen. +- **Engine-side conversion.** `AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM` has been seen to + deliver all-zero samples with a successful `Initialize` (cpal #1200, Windows 11 24H2 + communications-class endpoints; loopback on ARM64 delivering no packets at all). + +## Decision + +**System audio defaults to process loopback.** `ActivateAudioInterfaceAsync` on the +`VAD\Process_Loopback` virtual device with `PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE` +on rewynd's own pid captures every process's render streams *before* they reach an +endpoint, whichever endpoint each plays to. That removes the routing class entirely and +has no device to be invalidated; the format is the one asked for (48 kHz stereo f32), +converted per stream by the engine without `AUTOCONVERTPCM`. Windows 10 2004 or later; +on older builds the activation fails and the capture falls back to the console +default's endpoint loopback, with the communications-endpoint extra of #210 next to it +(`process_loopback_supported()` decides that in the recorder). An explicit output pick +keeps the endpoint loopback on that device: the user asked for one output. + +The stream is polled, not event-driven, the same loop as the endpoint path; the +process-loopback client delivers no packets while nothing renders, which the mixer +zero-fills as before. + +**A failed capture is reopened, not abandoned.** The shared audio pipeline retries a +capture that errors, with a backoff from one to thirty seconds, until shutdown. The +first failure of an outage logs at the source's severity and reports "lost" to the +platform; the first audio a reopened stream delivers reports "restored", so a tray or +toast that said the sound was gone gets corrected. Later failures of the same outage log +at debug; each reopened stream announces itself at info. This covers every platform's +capture, since a background recorder that runs from login has to outlive a device +coming and going. + +The support decision is made once per process by a trial activation and then fixed +(`process_loopback_supported()`), so the recorder's plan (whether to add the +communications capture) and the capture thread's path always agree: on a supported box +a later activation failure is retried on the same path, never a silent switch to an +endpoint loopback without the extra. + +**The activation blob is never dropped as a `PROPVARIANT`.** The `windows` crate clears +a `PROPVARIANT` on drop, and clearing a `VT_BLOB` frees its data pointer, which here is +the activation parameters on the stack: a heap corruption that took an afternoon to find. +The blob is held in `ManuallyDrop`. + +## Consequences + +- Process loopback also captures audio the user is not hearing: an app rendering to a + muted or disconnected output is in the clip. For a game clip that is the better + failure than silence. +- The communications-endpoint capture only runs on the fallback path, so the doubled-mix + concern of #213 cannot arise where process loopback works. +- Clip levels follow per-app volume, not the master volume: process loopback taps the + streams before the endpoint's volume. Endpoint loopback was already before the master + volume on most devices. +- The retry loop applies to the microphone too: a mic plugged in after startup is picked + up at the next attempt instead of never. +- Still owed: a log file for installed users, so the next "no system sound" report + carries evidence instead of a guess. Tracked separately. diff --git a/docs/adr/0023-windows-windowed-games.md b/docs/adr/0023-windows-windowed-games.md new file mode 100644 index 0000000..82dcb61 --- /dev/null +++ b/docs/adr/0023-windows-windowed-games.md @@ -0,0 +1,65 @@ +# ADR 0023: Windows game-only capture records known windowed games + +## Status + +Accepted (issue #214). + +## Context + +Windows game-only capture (ADR 0012, ADR 0021) accepts the foreground window as the +game only when it covers its whole monitor without a title bar: how exclusive and +borderless fullscreen games present. Games that are commonly played in a window, +Minecraft first among them, never qualified, and a maximized Minecraft is rejected +outright as a decorated window. Minecraft Bedrock could not qualify even fullscreen: +a UWP app's top-level window belongs to `ApplicationFrameHost.exe`, which is on the +shell list. + +The fullscreen rule exists because a windowed app cannot be told from a windowed game +by geometry or style. Any relaxation must therefore name games. + +## Decision + +**Known windowed games qualify at any size.** `WindowedGames` holds two kinds of +rule: + +- *Built in*: Minecraft Java (a `javaw.exe`/`java.exe` whose title starts with + "Minecraft", so an IDE on the same JVM does not match) and Minecraft Bedrock + (`Minecraft.Windows.exe`). A built-in rule carries the game's name, which labels the + clip folder and the tray instead of `javaw`. +- *Configured*: `[capture] windowed_games`, a list in the settings app shown when + desktop capture is off. An entry ending in `.exe` matches the process name; anything + else is a case-insensitive substring of the window title. A named process beats the + shell and non-game lists: it is the user's word. A title fragment does not, because + titles are ambiguous: "balatro" also matches a browser tab on the game's wiki and the + Steam store page, and latching onto those is the very thing game-only capture exists to + avoid. + +A matched window is `Capturable` while visible and not minimized, whatever its size or +decoration; the latch of ADR 0021 keeps it under that same rule, so shrinking the window +does not release it while minimizing still does after its grace. Everything else keeps +the fullscreen rule. + +**A UWP frame is judged by the app it hosts.** When the foreground process is +`ApplicationFrameHost.exe`, the `Windows.UI.Core.CoreWindow` child's process stands in +for it, for the exclusion lists, the rules and the game name. A frame with no such child +(a suspended app) stays excluded as the host. + +**WGC captures the window as it is.** The window's size is whatever it is and may +change; the shareable-slot pool already recreates on a size change, windows-capture +recreates its frame pool, and the NV12 pass letterboxes any aspect into the encode size +(ADR 0019). A decorated window's title bar is part of the capture. + +**Process names keep their dotted stems.** `Minecraft.Windows.exe` names the game +`Minecraft.Windows`, not `Windows`: only reverse-DNS ids without `.exe` take their last +segment. + +## Consequences + +- A windowed game is recorded with whatever overlaps it, since WGC window capture draws + the window's own surface: other windows on top are not included, the title bar is. +- The lists stay short on purpose. A game not covered plays fullscreen or gets an entry; + the settings hint says how. +- Linux and macOS ignore the list: they gate a monitor stream on a fullscreen focus and + have no window to capture. The settings field is hidden there. +- The probe (`game_probe`) reports a windowed match with its own verdict, and shows the + hosted process for UWP frames. From 946149778dd7f967c569ab4bc8aafa84a10a2637 Mon Sep 17 00:00:00 2001 From: Thijs Herman Date: Thu, 17 Sep 2026 10:45:08 +0200 Subject: [PATCH 2/5] Update rustls to clear RUSTSEC-2026-0285 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7270250..6c377ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4984,9 +4984,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -5684,7 +5684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", From 9b5497e2b3ac3176c94c4f3bb00024067847f314 Mon Sep 17 00:00:00 2001 From: Thijs Herman Date: Thu, 17 Sep 2026 11:04:03 +0200 Subject: [PATCH 3/5] Toast every audio outage, and keep outranking tray statuses on recovery The lost/back toasts fired once per process, so a second outage went by unnoticed; they now come as one pair per outage. On Linux a recovery clears only its own tooltip, so a "Recording stopped" or CPU-encoder notice that arrived in between stays visible. Process names lose their .EXE in any case. --- crates/app/src/main.rs | 46 ++++++++++++++++++++------------------ crates/capture/src/game.rs | 15 +++++++++++-- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index c6fe4a9..616ae72 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1056,6 +1056,9 @@ mod linux { /// Pipeline failures surfaced to the user via the tray (tooltip + toast); the process /// keeps running so already-buffered footage stays saveable. + /// The tray tooltip while system audio is being retried; a recovery clears exactly this. + const AUDIO_LOST_STATUS: &str = "System audio lost"; + enum RecorderEvent { CaptureFailed(String), SystemAudioFailed(String), @@ -1577,7 +1580,7 @@ mod linux { format!("The screen capture failed: {e}. Already-buffered footage can still be saved."), ), RecorderEvent::SystemAudioFailed(e) => ( - "System audio lost".to_owned(), + AUDIO_LOST_STATUS.to_owned(), format!("Clips will have no system sound until it is back: {e}"), ), RecorderEvent::SystemAudioRestored => ( @@ -1591,15 +1594,17 @@ mod linux { format!("{e} Recording continues on the CPU, which uses more processor power."), ), }; - // A recovery toasts, but the tooltip goes back to normal rather than - // keeping "lost" or announcing "back" forever. - let status = if restored { - tray::DEFAULT_STATUS.to_owned() - } else { - title.clone() - }; + // A recovery toasts, and clears only its own tooltip: a "Recording + // stopped" or CPU-encoder notice that arrived in between outranks it. + let status = title.clone(); handle - .update(|tray: &mut tray::RewyndTray| tray.status = status) + .update(move |tray: &mut tray::RewyndTray| { + if !restored { + tray.status = status; + } else if tray.status == AUDIO_LOST_STATUS { + tray.status = tray::DEFAULT_STATUS.to_owned(); + } + }) .await; tray::toast(&title, &body).await; } @@ -2407,19 +2412,18 @@ mod windows { &stop, epoch, Some(Box::new({ - // Once per process each: a flapping device must not spam toasts. - let mut lost_toasted = false; - let mut restored_toasted = false; + // One pair of toasts per outage: "lost" arms "back", which disarms "lost". + let mut lost_shown = false; move |event: SystemAudioEvent| match event { - SystemAudioEvent::Lost(e) if !lost_toasted => { - lost_toasted = true; + SystemAudioEvent::Lost(e) if !lost_shown => { + lost_shown = true; toast( "System audio lost", &format!("Clips will have no system sound until it is back: {e}"), ); } - SystemAudioEvent::Restored if !restored_toasted => { - restored_toasted = true; + SystemAudioEvent::Restored if lost_shown => { + lost_shown = false; toast("System audio is back", "Clips have system sound again."); } _ => {} @@ -3308,8 +3312,7 @@ mod macos { // them per game session (an idle recorder must hold no SCK streams). The lost- // audio toast fires once per process: session-scoped captures would otherwise // re-toast a persistent failure on every game. - let audio_lost_toasted = Arc::new(AtomicBool::new(false)); - let audio_restored_toasted = Arc::new(AtomicBool::new(false)); + let audio_lost_shown = Arc::new(AtomicBool::new(false)); let spawn_audio = { let mixer = mixer.clone(); let mic_mixer = mic_mixer.clone(); @@ -3329,11 +3332,10 @@ mod macos { session_stop, epoch, Some(Box::new({ - let lost_toasted = audio_lost_toasted.clone(); - let restored_toasted = audio_restored_toasted.clone(); + let lost_shown = audio_lost_shown.clone(); move |event: SystemAudioEvent| match event { SystemAudioEvent::Lost(e) => { - if lost_toasted.swap(true, Ordering::Relaxed) { + if lost_shown.swap(true, Ordering::Relaxed) { tracing::warn!(error = %e, "system-audio capture failed again"); } else { toast( @@ -3345,7 +3347,7 @@ mod macos { } } SystemAudioEvent::Restored => { - if !restored_toasted.swap(true, Ordering::Relaxed) { + if lost_shown.swap(false, Ordering::Relaxed) { toast("System audio is back", "Clips have system sound again."); } } diff --git a/crates/capture/src/game.rs b/crates/capture/src/game.rs index b322992..be6f1fe 100644 --- a/crates/capture/src/game.rs +++ b/crates/capture/src/game.rs @@ -98,8 +98,14 @@ pub fn steam_app_name(appid: u32) -> Option { fn clean_app_id(app_id: &str) -> String { let base = app_id.trim(); // A Windows process name keeps its whole stem: the dots in `Minecraft.Windows.exe` - // are part of the name, not a reverse-DNS id. - if let Some(stem) = base.strip_suffix(".exe") { + // are part of the name, not a reverse-DNS id. The suffix is matched in any case, + // since executables are named `GAME.EXE` often enough. + if let Some((stem, suffix)) = base + .len() + .checked_sub(4) + .and_then(|at| base.is_char_boundary(at).then(|| base.split_at(at))) + && suffix.eq_ignore_ascii_case(".exe") + { return stem.trim().to_owned(); } // Reverse-DNS desktop ids: keep the final segment, which names the app. @@ -173,6 +179,11 @@ mod tests { info("Battle.net.exe", "").display_name_via(no_steam), "Battle.net" ); + assert_eq!( + info("ELDENRING.EXE", "").display_name_via(no_steam), + "ELDENRING" + ); + assert_eq!(info(".exe", "Title").display_name_via(no_steam), "Title"); } #[test] From 32548adfc2e72994d41dab647e0ebb753c37dbde Mon Sep 17 00:00:00 2001 From: Thijs Herman Date: Thu, 17 Sep 2026 12:22:10 +0200 Subject: [PATCH 4/5] Write a bounded log file so installed recorders leave evidence The installed recorder has no console, so every "clips have no sound" report arrived without a single line to go on. It now also logs to a file under the platform's data dir, capped at 2 MB per file and three files in total, rotated between writes. Each audio capture logs one status line a minute with its peak level, and a retrying capture's repeated failures stay visible at info. --- Cargo.lock | 13 ++ Cargo.toml | 2 +- README.md | 13 ++ crates/app/src/main.rs | 84 +++++++- crates/config/src/lib.rs | 2 + crates/config/src/logs.rs | 197 ++++++++++++++++++ ...2-windows-system-audio-process-loopback.md | 5 +- 7 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 crates/config/src/logs.rs diff --git a/Cargo.lock b/Cargo.lock index 6c377ea..67ec784 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3099,6 +3099,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -6074,10 +6083,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index 47cdd37..0088092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,7 @@ opus = "0.4" # BSD-2-Clause; the default `source` feature compiles Cisco's openh264 from vendored source. openh264 = "0.9" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } pollster = "1" # Config file (docs/adr/0005): TOML deserialized via serde. Both MIT/Apache. serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 6f58ad1..be967b8 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,19 @@ The app checks for updates on launch; one click updates both binaries in place. - Optional uploads to [ganked.tv](https://ganked.tv) or YouTube, only when you ask. Clips never leave your machine on their own. +## Logs + +The recorder writes what it is doing to a small set of log files next to its data, capped at +2 MB each and three files in total, so they never grow beyond 6 MB: + +- Windows: `%LOCALAPPDATA% ewyndogs ewynd-recorder.log` +- Linux: `$XDG_DATA_HOME/rewynd/logs/rewynd-recorder.log` (usually `~/.local/share/rewynd/logs/`) +- macOS: `~/Library/Application Support/rewynd/logs/rewynd-recorder.log` + +Attach that file to a bug report about clips with no sound, a game that is not picked up, +or a recorder that stops: it says which audio path ran, whether audio was flowing (a +`peak` line a minute), and what the capture saw. + ## Workspace layout | Crate | Role | diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 616ae72..5b0765b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -113,6 +113,56 @@ fn main() -> anyhow::Result<()> { result } +/// Recorder logging: the console as before, plus a size-capped set of log files under the +/// platform's data dir, so an installed recorder (no console) leaves evidence behind for +/// "my clips have no sound" reports. +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +mod logging { + use std::sync::Mutex; + + use rewynd_config::{RotatingLog, log_dir}; + use tracing_subscriber::filter::LevelFilter; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::{EnvFilter, Layer}; + + /// Per file, with [`KEEP_FILES`] files in total: the set never exceeds 6 MB, which is + /// days of the recorder's info-level chatter. + const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; + const KEEP_FILES: usize = 3; + const FILE_NAME: &str = "rewynd-recorder"; + + /// The console follows `RUST_LOG` (default info); the file stays at info, without + /// colour codes. A file that cannot be opened costs only the file. + pub(crate) fn init() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let file = log_dir().and_then(|dir| { + match RotatingLog::open(&dir, FILE_NAME, MAX_FILE_BYTES, KEEP_FILES) { + Ok(log) => Some(log), + Err(e) => { + eprintln!("rewynd: not logging to {}: {e}", dir.display()); + None + } + } + }); + let path = file.as_ref().map(|log| log.path().to_path_buf()); + let file_layer = file.map(|log| { + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(Mutex::new(log)) + .with_filter(LevelFilter::INFO) + }); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer()) + .with(file_layer) + .init(); + if let Some(path) = path { + tracing::info!(path = %path.display(), "logging to a file as well"); + } + } +} + /// The audio half of the pipeline, shared by the platform recorders: capture threads /// summing into the mixer, and the mixer thread draining into the Opus encoder + ring. /// Only `capture_audio` itself is platform-specific (PipeWire vs WASAPI vs SCK); the @@ -153,6 +203,9 @@ mod audio_pipeline { const AUDIO_RETRY_MAX: Duration = Duration::from_secs(30); /// How often a waiting capture thread checks the stop flag. const AUDIO_RETRY_POLL: Duration = Duration::from_millis(100); + /// How often a running capture logs its buffer count and peak level: the line a + /// "clips have no sound" report needs, cheap enough to keep at info. + const AUDIO_STATUS_INTERVAL: Duration = Duration::from_secs(60); /// Sleep `wait` in stop-aware steps; `true` when the stop flag rose meanwhile. fn wait_unless_stopped(stop: &AtomicBool, wait: Duration) -> bool { @@ -232,6 +285,8 @@ mod audio_pipeline { let mixer = mixer.clone(); let also_mixer = also_mixer.clone(); let mut buffers: u64 = 0; + let mut peak_since_report = 0.0_f32; + let mut last_report = Instant::now(); // No idle timeout (capture runs until shutdown); the stop flag drives the // watchdog so the loop quits promptly even if the endpoint suspends. let result = capture_audio( @@ -250,18 +305,23 @@ mod audio_pipeline { hook(SystemAudioEvent::Restored); } } - // Level telemetry for chasing "why is this clip silent" reports — - // ~once a second at the usual 10 ms buffer cadence, debug only. + // Level telemetry for chasing "why is this clip silent" reports: + // one line a minute with the peak seen since the last one, so a + // silent stream reads as `peak=0` in the log file. buffers += 1; - if buffers % 100 == 1 && tracing::enabled!(tracing::Level::DEBUG) { - let peak = pcm.iter().fold(0.0_f32, |m, s| m.max(s.abs())); - tracing::debug!( + peak_since_report = pcm + .iter() + .fold(peak_since_report, |m, s| m.max(s.abs())); + if last_report.elapsed() >= AUDIO_STATUS_INTERVAL { + tracing::info!( ?source, buffers, pts_ms = pts.as_millis() as u64, - peak, - "audio level" + peak = peak_since_report, + "audio capture status" ); + peak_since_report = 0.0; + last_report = Instant::now(); } // A panic must not unwind across the PipeWire C callback boundary // (UB); treat it as a stream failure instead (harmless-but-uniform @@ -339,8 +399,10 @@ mod audio_pipeline { hook(SystemAudioEvent::Lost(error.to_string())); } } + // Info, not debug: the backoff bounds it to a couple of lines a + // minute, and a persistent failure must stay visible in the log file. _ => { - tracing::debug!(?source, error = %error, failures, retry_in = ?backoff, "audio capture failed again"); + tracing::info!(?source, error = %error, failures, retry_in = ?backoff, "audio capture failed again"); } } if wait_unless_stopped(&stop, backoff) { @@ -1116,7 +1178,7 @@ mod linux { type PortalHandle = rewynd_capture::linux::PortalSession; pub fn run() -> Result<()> { - tracing_subscriber::fmt::init(); + crate::logging::init(); // Settings come from the config file (written on first run) layered under the built-in // defaults and over by `REWYND_*` env overrides (see `rewynd_config`). @@ -2197,7 +2259,7 @@ mod windows { const HOTKEY_POLL: Duration = Duration::from_millis(30); pub fn run() -> Result<()> { - tracing_subscriber::fmt::init(); + crate::logging::init(); // Per-monitor DPI awareness, set before any threads or windows exist: without // it, window/monitor rects arrive DPI-virtualized on scaled displays and the @@ -3135,7 +3197,7 @@ mod macos { } pub fn run() -> Result<()> { - tracing_subscriber::fmt::init(); + crate::logging::init(); config::ensure_default_file(); let config = config::load(); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 8e6179e..1146a95 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -26,6 +26,7 @@ mod desktop; mod devices; mod encoders; mod lock; +mod logs; mod paths; mod process; pub mod resolution; @@ -57,6 +58,7 @@ pub use desktop::{autostart_path, desktop_entry, desktop_exec_value}; #[cfg(any(target_os = "linux", target_os = "macos"))] pub use desktop::{install_icons, install_launcher_entry}; pub use lock::{InstanceLock, acquire_recorder_lock, acquire_settings_lock, settings_running}; +pub use logs::{RotatingLog, log_dir}; pub use paths::{ APP_ID, config_path, default_output_dir, recorder_pid_path, settings_activation_path, settings_lock_path, sibling_binary, diff --git a/crates/config/src/logs.rs b/crates/config/src/logs.rs new file mode 100644 index 0000000..6debad7 --- /dev/null +++ b/crates/config/src/logs.rs @@ -0,0 +1,197 @@ +//! A size-capped log file set for the installed recorder, which has no console. +//! +//! [`RotatingLog`] appends to `.log` and, once a write would take that file past +//! its cap, shifts it to `.1.log` (and that to `.2.log`, …), dropping the oldest, +//! so the whole set is bounded by `max_bytes × keep` no matter how long the recorder +//! runs or how noisy a bad day gets. Rotation happens between writes, never inside one, +//! so a log line is never split across files. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// Where the recorder's log files go: the platform's local data dir +/// (`%LOCALAPPDATA%\rewynd\logs`, `~/Library/Application Support/rewynd/logs`, +/// `$XDG_DATA_HOME/rewynd/logs`). `None` when the platform has no such dir. +#[must_use] +pub fn log_dir() -> Option { + dirs::data_local_dir().map(|dir| dir.join("rewynd").join("logs")) +} + +/// An append-only log file with a byte cap and a bounded number of rotated predecessors. +#[derive(Debug)] +pub struct RotatingLog { + path: PathBuf, + file: File, + written: u64, + max_bytes: u64, + keep: usize, +} + +impl RotatingLog { + /// Open `dir/.log` for appending, creating the directory. `max_bytes` caps each + /// file; `keep` is the number of files kept in total, the live one included (so `1` + /// means the live file is simply truncated when full). + pub fn open(dir: &Path, name: &str, max_bytes: u64, keep: usize) -> io::Result { + fs::create_dir_all(dir)?; + let path = dir.join(format!("{name}.log")); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + let written = file.metadata()?.len(); + Ok(Self { + path, + file, + written, + max_bytes: max_bytes.max(1), + keep: keep.max(1), + }) + } + + /// The live file's path. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// `..log` next to the live file. + fn rotated(&self, index: usize) -> PathBuf { + let stem = self + .path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + self.path.with_file_name(format!("{stem}.{index}.log")) + } + + /// Shift every file up one index (the oldest falls off) and start a fresh live file. + fn rotate(&mut self) -> io::Result<()> { + self.file.flush()?; + for index in (1..self.keep).rev() { + let from = if index == 1 { + self.path.clone() + } else { + self.rotated(index - 1) + }; + // A missing predecessor (a young set) is simply skipped. + if from.exists() { + fs::rename(&from, self.rotated(index))?; + } + } + self.file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&self.path)?; + self.written = 0; + Ok(()) + } +} + +impl Write for RotatingLog { + fn write(&mut self, buf: &[u8]) -> io::Result { + // A single write larger than the cap still lands whole, in a fresh file. + if self.written > 0 && self.written + buf.len() as u64 > self.max_bytes { + self.rotate()?; + } + let n = self.file.write(buf)?; + self.written += n as u64; + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sizes(dir: &Path, name: &str, keep: usize) -> Vec> { + let mut out = vec![ + fs::metadata(dir.join(format!("{name}.log"))) + .ok() + .map(|m| m.len()), + ]; + for index in 1..=keep { + out.push( + fs::metadata(dir.join(format!("{name}.{index}.log"))) + .ok() + .map(|m| m.len()), + ); + } + out + } + + #[test] + fn rotates_at_the_cap_and_keeps_a_bounded_set() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut log = RotatingLog::open(dir.path(), "t", 100, 3).expect("open"); + // 40-byte lines: two fit, the third rotates. Twelve lines = six rotations. + for i in 0..12 { + writeln!(log, "line {i:02} {}", "x".repeat(31)).expect("write"); + } + log.flush().expect("flush"); + let sizes = sizes(dir.path(), "t", 4); + // Live + .1 + .2 exist, each within the cap; .3 never appears, .4 neither. + assert!(sizes[0].is_some_and(|n| n > 0 && n <= 100), "{sizes:?}"); + assert!(sizes[1].is_some_and(|n| n > 0 && n <= 100), "{sizes:?}"); + assert!(sizes[2].is_some_and(|n| n > 0 && n <= 100), "{sizes:?}"); + assert_eq!(sizes[3], None, "{sizes:?}"); + assert_eq!(sizes[4], None, "{sizes:?}"); + // Newest lines live in the live file, older ones behind it. + let live = fs::read_to_string(dir.path().join("t.log")).expect("read"); + let older = fs::read_to_string(dir.path().join("t.1.log")).expect("read"); + assert!(live.contains("line 11"), "{live}"); + assert!( + older.contains("line 09") || older.contains("line 08"), + "{older}" + ); + } + + #[test] + fn keep_one_truncates_the_live_file_in_place() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut log = RotatingLog::open(dir.path(), "solo", 50, 1).expect("open"); + for _ in 0..10 { + writeln!(log, "{}", "y".repeat(19)).expect("write"); + } + let sizes = sizes(dir.path(), "solo", 2); + assert!(sizes[0].is_some_and(|n| n <= 50), "{sizes:?}"); + assert_eq!(&sizes[1..], [None, None], "{sizes:?}"); + } + + #[test] + fn reopening_appends_and_counts_what_is_already_there() { + let dir = tempfile::tempdir().expect("tempdir"); + { + let mut log = RotatingLog::open(dir.path(), "again", 60, 2).expect("open"); + write!(log, "{}", "a".repeat(40)).expect("write"); + } + let mut log = RotatingLog::open(dir.path(), "again", 60, 2).expect("reopen"); + assert_eq!(log.written, 40); + // 40 + 30 > 60: the reopened log rotates rather than overshooting its cap. + write!(log, "{}", "b".repeat(30)).expect("write"); + let sizes = sizes(dir.path(), "again", 1); + assert_eq!(sizes, [Some(30), Some(40)]); + } + + #[test] + fn an_oversized_write_lands_whole() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut log = RotatingLog::open(dir.path(), "big", 10, 2).expect("open"); + write!(log, "{}", "c".repeat(25)).expect("write"); + write!(log, "d").expect("write"); + assert_eq!(sizes(dir.path(), "big", 1), [Some(1), Some(25)]); + } + + #[test] + fn log_dir_sits_under_the_platform_data_dir() { + if let Some(dir) = log_dir() { + assert!( + dir.ends_with(Path::new("rewynd").join("logs")), + "{}", + dir.display() + ); + } + } +} diff --git a/docs/adr/0022-windows-system-audio-process-loopback.md b/docs/adr/0022-windows-system-audio-process-loopback.md index 3b8480b..b16eda9 100644 --- a/docs/adr/0022-windows-system-audio-process-loopback.md +++ b/docs/adr/0022-windows-system-audio-process-loopback.md @@ -80,5 +80,6 @@ The blob is held in `ManuallyDrop`. volume on most devices. - The retry loop applies to the microphone too: a mic plugged in after startup is picked up at the next attempt instead of never. -- Still owed: a log file for installed users, so the next "no system sound" report - carries evidence instead of a guess. Tracked separately. +- The installed recorder now writes a log file as well (`RotatingLog`: three files of at + most 2 MB under the platform's data dir), with a per-minute peak-level line per capture, + so the next "no system sound" report carries evidence instead of a guess. From 6d1bd6ab66009d1111d1cd21d4fc789807e3a48d Mon Sep 17 00:00:00 2001 From: Thijs Herman Date: Thu, 17 Sep 2026 12:22:36 +0200 Subject: [PATCH 5/5] Fix the Windows log path in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index be967b8..0025a1c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The app checks for updates on launch; one click updates both binaries in place. The recorder writes what it is doing to a small set of log files next to its data, capped at 2 MB each and three files in total, so they never grow beyond 6 MB: -- Windows: `%LOCALAPPDATA% ewyndogs ewynd-recorder.log` +- Windows: `%LOCALAPPDATA%\rewynd\logs\rewynd-recorder.log` - Linux: `$XDG_DATA_HOME/rewynd/logs/rewynd-recorder.log` (usually `~/.local/share/rewynd/logs/`) - macOS: `~/Library/Application Support/rewynd/logs/rewynd-recorder.log`