diff --git a/Cargo.lock b/Cargo.lock index 7ed6cc1..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" @@ -4727,6 +4736,7 @@ dependencies = [ "wgpu 29.0.0", "windows 0.62.2", "windows-capture", + "windows-core 0.62.2", "zbus", ] @@ -4983,9 +4993,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", @@ -5683,7 +5693,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", @@ -6073,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 e842769..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"] } @@ -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/README.md b/README.md index 6f58ad1..0025a1c 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%\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` + +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 d460d6e..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 @@ -147,11 +197,49 @@ 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); + /// 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 { + 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 +251,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 +262,153 @@ 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; + 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( + 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: + // 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; + 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 = 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 + // 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; 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 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())); } } + // 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::info!(?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 +1098,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; @@ -977,9 +1118,14 @@ 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), + /// 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), } @@ -1032,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`). @@ -1319,8 +1465,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,14 +1635,19 @@ 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(), format!("The screen capture failed: {e}. Already-buffered footage can still be saved."), ), RecorderEvent::SystemAudioFailed(e) => ( - "System audio lost".to_owned(), - format!("Clips will have no system sound: {e}"), + AUDIO_LOST_STATUS.to_owned(), + 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 +1656,17 @@ mod linux { format!("{e} Recording continues on the CPU, which uses more processor power."), ), }; + // 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 = title.clone()) + .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; } @@ -2062,7 +2225,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 +2246,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}; @@ -2093,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 @@ -2242,6 +2408,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 +2444,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 +2473,23 @@ 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({ + // 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_shown => { + lost_shown = true; + toast( + "System audio lost", + &format!("Clips will have no system sound until it is back: {e}"), + ); + } + SystemAudioEvent::Restored if lost_shown => { + lost_shown = false; + toast("System audio is back", "Clips have system sound again."); + } + _ => {} + } })), )?; // Optional extra: erroring here would detach the system capture spawned above. @@ -2417,6 +2599,7 @@ mod windows { &capture_buffer, &capture_stop, capture_desktop, + windowed_games, on_game, capture_choice, &capture_status, @@ -2616,6 +2799,7 @@ mod windows { buffer: &SharedBuffer, stop: &Arc, desktop: bool, + windowed_games: WindowedGames, on_game: Option, choice: config::EncoderChoice, status: &crate::status::StatusPublisher, @@ -2688,7 +2872,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 +3178,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}; @@ -3004,7 +3197,7 @@ mod macos { } pub fn run() -> Result<()> { - tracing_subscriber::fmt::init(); + crate::logging::init(); config::ensure_default_file(); let config = config::load(); @@ -3181,7 +3374,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_lost_shown = Arc::new(AtomicBool::new(false)); let spawn_audio = { let mixer = mixer.clone(); let mic_mixer = mic_mixer.clone(); @@ -3201,15 +3394,24 @@ 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_shown = audio_lost_shown.clone(); + move |event: SystemAudioEvent| match event { + SystemAudioEvent::Lost(e) => { + if lost_shown.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 lost_shown.swap(false, 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..be6f1fe 100644 --- a/crates/capture/src/game.rs +++ b/crates/capture/src/game.rs @@ -97,7 +97,17 @@ 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. 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. let base = base.rsplit('.').next().unwrap_or(base); base.trim().to_owned() @@ -158,6 +168,24 @@ 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" + ); + assert_eq!( + info("ELDENRING.EXE", "").display_name_via(no_steam), + "ELDENRING" + ); + assert_eq!(info(".exe", "Title").display_name_via(no_steam), "Title"); + } + #[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..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, @@ -68,7 +70,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/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/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..b16eda9 --- /dev/null +++ b/docs/adr/0022-windows-system-audio-process-loopback.md @@ -0,0 +1,85 @@ +# 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. +- 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. 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.