From 3c4dfe3fc51ff4bf48bf295288d0d215f38b704a Mon Sep 17 00:00:00 2001 From: fraa2a Date: Thu, 8 Oct 2026 03:18:49 +0200 Subject: [PATCH] fix: harden media lifetimes, catalogs, IPC and updater validation --- .github/workflows/core-tests.yml | 51 + app/desktop-ui/src-tauri/src/clip_catalog.rs | 158 ++- app/desktop-ui/src-tauri/src/collections.rs | 86 +- app/desktop-ui/src-tauri/src/engine_rpc.rs | 68 +- app/desktop-ui/src-tauri/src/game_catalog.rs | 66 +- app/desktop-ui/src-tauri/src/paths.rs | 4 +- .../src-tauri/src/settings_store.rs | 52 +- app/desktop-ui/ui/src/app.tsx | 48 +- .../ui/src/home/collection-detail.tsx | 11 +- app/desktop-ui/ui/src/lib/api.ts | 12 +- app/desktop-ui/ui/src/lib/multitrack.ts | 16 + app/desktop-ui/ui/src/shell/titlebar.tsx | 21 +- app/recorder/src/main.cpp | 69 +- app/recorder/src/settings_window.cpp | 5 + app/updater/src-tauri/src/apply.rs | 82 +- app/updater/src-tauri/src/archive.rs | 61 + app/updater/src-tauri/src/download.rs | 36 +- app/updater/src-tauri/src/engine_rpc.rs | 75 +- app/updater/src-tauri/src/http.rs | 53 +- app/updater/src-tauri/src/main.rs | 14 +- app/updater/src-tauri/src/manifest.rs | 50 +- app/updater/src-tauri/src/paths.rs | 10 +- app/updater/src-tauri/src/versions.rs | 1 + docs/DECISIONS.md | 10 + docs/audit/2026-10-08.md | 95 ++ docs/audit/file-inventory.md | 247 ++++ docs/handover/ACTIVE_HANDOVER.md | 16 + libs/audio/audio.cpp | 59 +- libs/capture/capture.cpp | 45 +- libs/capture/capture.h | 3 +- libs/encoding/encoding.cpp | 182 +-- libs/encoding/encoding.h | 17 +- libs/encoding/pcm_input.h | 42 + libs/recording/recording.cpp | 71 +- libs/recording/recording.h | 1 + libs/replay-buffer/replay_buffer.cpp | 24 +- libs/storage/storage.cpp | 24 +- plugins/stream-deck/src/ipc-client.ts | 72 +- .../bin/ipc-client.js | 73 +- report.md | 4 + tests/phase1/CMakeLists.txt | 4 + tests/phase1/pcm_input_test.cpp | 23 + tests/phase2/CMakeLists.txt | 13 +- tests/phase2/encoding_test.cpp | 62 + tests/phase2/media_test.cpp | 34 + tests/phase2/verify_timeline.py | 12 + tests/phase2/win32-shim/windows.h | 2 + tests/rust/.gitignore | 1 + tests/rust/Cargo.lock | 1173 +++++++++++++++++ tests/rust/Cargo.toml | 23 + tests/rust/src/lib.rs | 62 + tests/rust/src/tests.rs | 214 +++ tests/stream-deck/ipc-client.test.mjs | 71 + tests/u6/storage_mutation_test.cpp | 5 + 54 files changed, 3247 insertions(+), 486 deletions(-) create mode 100644 .github/workflows/core-tests.yml create mode 100644 app/updater/src-tauri/src/archive.rs create mode 100644 docs/audit/2026-10-08.md create mode 100644 docs/audit/file-inventory.md create mode 100644 libs/encoding/pcm_input.h create mode 100644 tests/phase1/pcm_input_test.cpp create mode 100644 tests/phase2/encoding_test.cpp create mode 100644 tests/rust/.gitignore create mode 100644 tests/rust/Cargo.lock create mode 100644 tests/rust/Cargo.toml create mode 100644 tests/rust/src/lib.rs create mode 100644 tests/rust/src/tests.rs create mode 100644 tests/stream-deck/ipc-client.test.mjs diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml new file mode 100644 index 0000000..a5ebf2e --- /dev/null +++ b/.github/workflows/core-tests.yml @@ -0,0 +1,51 @@ +name: Core regression tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: dtolnay/rust-toolchain@stable + - name: Install native test dependencies + run: sudo apt-get update && sudo apt-get install -y cmake nlohmann-json3-dev libsqlite3-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev pkg-config ffmpeg + - name: Check repository text + run: python3 scripts/check-text.py + - name: Run portable native regressions + run: | + for suite in phase1 phase2 u6; do + cmake -S "tests/$suite" -B "build-tests/$suite" -DCMAKE_CXX_FLAGS='-Werror' + cmake --build "build-tests/$suite" --parallel 2 + ctest --test-dir "build-tests/$suite" --output-on-failure + done + - name: Run Rust catalog and updater regressions + run: cargo test --locked --manifest-path tests/rust/Cargo.toml + - name: Check Windows updater modules + run: | + rustup target add x86_64-pc-windows-msvc + cargo check --locked --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml + - name: Build and test Stream Deck IPC + working-directory: plugins/stream-deck + run: | + npm ci --ignore-scripts --no-audit + npm run build + node --test ../../tests/stream-deck/ipc-client.test.mjs + - name: Check and build frontends + run: | + for app in desktop-ui updater; do + cd "app/$app" + npm ci --ignore-scripts --no-audit + npx tsc --noEmit + npx vite build + cd ../.. + done diff --git a/app/desktop-ui/src-tauri/src/clip_catalog.rs b/app/desktop-ui/src-tauri/src/clip_catalog.rs index 4af1cd4..108787c 100644 --- a/app/desktop-ui/src-tauri/src/clip_catalog.rs +++ b/app/desktop-ui/src-tauri/src/clip_catalog.rs @@ -1,5 +1,5 @@ use crate::{game_catalog, settings_store}; -use rusqlite::{params, Connection, OpenFlags}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension, TransactionBehavior}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap}; use std::fs; @@ -129,7 +129,9 @@ fn clip_hashtags(conn: &Connection) -> HashMap> { let Ok(mut stmt) = conn.prepare("SELECT clip_id, tag FROM clip_hashtags") else { return map; }; - let Ok(rows) = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))) else { + let Ok(rows) = stmt.query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) else { return map; }; for (id, tag) in rows.flatten() { @@ -176,20 +178,32 @@ fn map_clip_row( // (see game_catalog::refresh_stale) has run. let art = artwork.resolve(discord_app_id.as_deref(), game_process_name.as_deref()); let video_path = folder.join(&video_file); - let thumbnail_path = thumbnail_file - .as_ref() - .map(|file| folder.join(".thumbs").join(file).to_string_lossy().to_string()); + let thumbnail_path = thumbnail_file.as_ref().map(|file| { + folder + .join(".thumbs") + .join(file) + .to_string_lossy() + .to_string() + }); Ok(Clip { id, source: source.as_str().to_string(), video_file, - title: row.get::<_, String>(8).unwrap_or_else(|_| "Untitled".to_string()), + title: row + .get::<_, String>(8) + .unwrap_or_else(|_| "Untitled".to_string()), thumbnail_file, created_at_utc: row.get(3)?, duration_seconds: row.get(4)?, game_process_name, - game_display_name: game_display_name.or_else(|| if art.display_name.is_empty() { None } else { Some(art.display_name.clone()) }), + game_display_name: game_display_name.or_else(|| { + if art.display_name.is_empty() { + None + } else { + Some(art.display_name.clone()) + } + }), game_executable_path, discord_app_id: discord_app_id.or(art.discord_app_id.clone()), game_icon_url: art.icon_url, @@ -217,40 +231,72 @@ fn read_source(source: ClipSource, filter: &ClipFilter) -> Vec { return Vec::new(); }; - let Ok(rows) = stmt.query_map([], |row| map_clip_row(row, source, &folder, &tags, &artwork)) else { + let Ok(rows) = stmt.query_map([], |row| { + map_clip_row(row, source, &folder, &tags, &artwork) + }) else { return Vec::new(); }; - rows.flatten().filter(|clip| matches_filter(clip, filter)).collect() + rows.flatten() + .filter(|clip| matches_filter(clip, filter)) + .collect() } -// Single-clip lookup (used by collections: prune + materialize the clips a -// collection references). Returns None when the catalog is missing, the row -// is gone, or the file no longer exists. -pub fn clip_by_id(source: ClipSource, id: i64) -> Option { - let conn = open(source, true)?; +pub fn clips_by_ids(source: ClipSource, ids: &[i64]) -> Result, String> { + let path = db_path(source); + path.metadata() + .map_err(|err| format!("catalog unavailable: {err}"))?; + let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|err| err.to_string())?; + conn.busy_timeout(std::time::Duration::from_millis(4000)) + .map_err(|err| err.to_string())?; let folder = media_folder(source); let tags = clip_hashtags(&conn); let artwork = game_catalog::ArtworkCache::load(); let sql = format!("{} WHERE id = ?1", clip_select_sql(&conn)); - let clip = conn - .query_row(&sql, params![id], |row| map_clip_row(row, source, &folder, &tags, &artwork)) - .ok()?; - if !clip.video_path.is_empty() && std::path::Path::new(&clip.video_path).is_file() { - Some(clip) - } else { - None + let mut stmt = conn.prepare(&sql).map_err(|err| err.to_string())?; + let mut clips = HashMap::new(); + for &id in ids { + let clip = stmt + .query_row(params![id], |row| { + map_clip_row(row, source, &folder, &tags, &artwork) + }) + .optional() + .map_err(|err| err.to_string())?; + if let Some(clip) = clip { + match std::fs::metadata(&clip.video_path) { + Ok(meta) if meta.is_file() => { + clips.insert(id, clip); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.to_string()), + } + } } + Ok(clips) +} + +pub fn clip_by_id(source: ClipSource, id: i64) -> Option { + clips_by_ids(source, &[id]).ok()?.remove(&id) } fn matches_filter(clip: &Clip, filter: &ClipFilter) -> bool { - if filter.game.as_deref().is_some_and(|game| clip.game_display_name.as_deref() != Some(game)) { + if filter + .game + .as_deref() + .is_some_and(|game| clip.game_display_name.as_deref() != Some(game)) + { return false; } if filter.favorite == Some(true) && !clip.favorite { return false; } - if filter.hashtag.as_ref().is_some_and(|tag| !clip.hashtags.contains(tag)) { + if filter + .hashtag + .as_ref() + .is_some_and(|tag| !clip.hashtags.contains(tag)) + { return false; } if let Some(search) = &filter.search { @@ -296,8 +342,12 @@ pub fn distinct_hashtags() -> Vec { } fn video_file_for(conn: &Connection, id: i64) -> Result { - conn.query_row("SELECT video_file FROM clips WHERE id = ?1", params![id], |row| row.get(0)) - .map_err(|_| "clip not found".to_string()) + conn.query_row( + "SELECT video_file FROM clips WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .map_err(|_| "clip not found".to_string()) } pub fn set_duration(source: ClipSource, id: i64, duration: f64) -> Result<(), String> { @@ -307,8 +357,11 @@ pub fn set_duration(source: ClipSource, id: i64, duration: f64) -> Result<(), St let Some(conn) = open(source, false) else { return Err("catalog unavailable".to_string()); }; - conn.execute("UPDATE clips SET duration_seconds = ?1 WHERE id = ?2", params![duration, id]) - .map_err(|err| err.to_string())?; + conn.execute( + "UPDATE clips SET duration_seconds = ?1 WHERE id = ?2", + params![duration, id], + ) + .map_err(|err| err.to_string())?; if conn.changes() == 0 { return Err("clip not found".to_string()); } @@ -331,8 +384,11 @@ pub fn set_favorite(source: ClipSource, id: i64, favorite: bool) -> Result<(), S let Some(conn) = open(source, false) else { return Err("catalog unavailable".to_string()); }; - conn.execute("UPDATE clips SET favorite = ?1 WHERE id = ?2", params![favorite as i64, id]) - .map_err(|err| err.to_string())?; + conn.execute( + "UPDATE clips SET favorite = ?1 WHERE id = ?2", + params![favorite as i64, id], + ) + .map_err(|err| err.to_string())?; if conn.changes() == 0 { return Err("clip not found".to_string()); } @@ -344,8 +400,11 @@ pub fn set_title(source: ClipSource, id: i64, title: &str) -> Result<(), String> let Some(conn) = open(source, false) else { return Err("catalog unavailable".to_string()); }; - conn.execute("UPDATE clips SET title = ?1 WHERE id = ?2", params![value, id]) - .map_err(|err| err.to_string())?; + conn.execute( + "UPDATE clips SET title = ?1 WHERE id = ?2", + params![value, id], + ) + .map_err(|err| err.to_string())?; if conn.changes() == 0 { return Err("clip not found".to_string()); } @@ -412,7 +471,8 @@ pub fn list_bookmarks(source: ClipSource, id: i64) -> Result, S }) }) .map_err(|err| err.to_string())?; - rows.collect::, _>>().map_err(|err| err.to_string()) + rows.collect::, _>>() + .map_err(|err| err.to_string()) } pub fn add_bookmark( @@ -425,26 +485,29 @@ pub fn add_bookmark( if !time_seconds.is_finite() || time_seconds < 0.0 { return Err("invalid bookmark time".to_string()); } - let Some(conn) = open(source, false) else { + let Some(mut conn) = open(source, false) else { return Err("catalog unavailable".to_string()); }; - if !clip_exists(&conn, id) { + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| err.to_string())?; + if !clip_exists(&tx, id) { return Err("clip not found".to_string()); } - let next_seq: i64 = conn + let next_seq: i64 = tx .query_row( "SELECT COALESCE(MAX(seq), 0) + 1 FROM clip_bookmarks WHERE clip_id = ?1", params![id], |row| row.get(0), ) .map_err(|err| err.to_string())?; - conn.execute( + tx.execute( "INSERT INTO clip_bookmarks (clip_id, seq, time_seconds, label, color) VALUES (?1, ?2, ?3, ?4, ?5)", params![id, next_seq, time_seconds, label, color], ) .map_err(|err| err.to_string())?; - Ok(()) + tx.commit().map_err(|err| err.to_string()) } pub fn update_bookmark( @@ -498,7 +561,9 @@ pub fn remove_clip(source: ClipSource, id: i64) -> Result<(), String> { // One transaction: a failure between the DELETEs must not strand hashtag // or bookmark rows pointing at a deleted clip. - let tx = conn.unchecked_transaction().map_err(|err| err.to_string())?; + let tx = conn + .unchecked_transaction() + .map_err(|err| err.to_string())?; tx.execute("DELETE FROM clip_bookmarks WHERE clip_id = ?1", params![id]) .map_err(|err| err.to_string())?; tx.execute("DELETE FROM clip_hashtags WHERE clip_id = ?1", params![id]) @@ -558,7 +623,15 @@ pub fn rename_clip(source: ClipSource, id: i64, new_stem: &str) -> Result<(), St if let Err(err) = conn.execute( "UPDATE clips SET video_file = ?1, thumbnail_file = ?2 WHERE id = ?3", - params![new_video, if thumb_renamed { Some(&new_thumb) } else { None }, id], + params![ + new_video, + if thumb_renamed { + Some(&new_thumb) + } else { + None + }, + id + ], ) { // Roll the filesystem back so the DB never points at files that no // longer exist under their recorded names. @@ -594,8 +667,11 @@ pub fn save_thumbnail_capture(source: ClipSource, id: i64, png: &[u8]) -> Result let folder = media_folder(source).join(".thumbs"); fs::create_dir_all(&folder).map_err(|err| err.to_string())?; fs::write(folder.join(&thumb_file), png).map_err(|err| err.to_string())?; - conn.execute("UPDATE clips SET thumbnail_file = ?1 WHERE id = ?2", params![thumb_file, id]) - .map_err(|err| err.to_string())?; + conn.execute( + "UPDATE clips SET thumbnail_file = ?1 WHERE id = ?2", + params![thumb_file, id], + ) + .map_err(|err| err.to_string())?; if conn.changes() == 0 { return Err("clip not found".to_string()); } diff --git a/app/desktop-ui/src-tauri/src/collections.rs b/app/desktop-ui/src-tauri/src/collections.rs index 8a48c60..2c6f265 100644 --- a/app/desktop-ui/src-tauri/src/collections.rs +++ b/app/desktop-ui/src-tauri/src/collections.rs @@ -29,7 +29,8 @@ fn open() -> Result { std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?; let conn = Connection::open(dir.join("collections.db")).map_err(|err| err.to_string())?; let _ = conn.busy_timeout(std::time::Duration::from_millis(4000)); - conn.execute_batch("PRAGMA foreign_keys = ON;").map_err(|err| err.to_string())?; + conn.execute_batch("PRAGMA foreign_keys = ON;") + .map_err(|err| err.to_string())?; conn.execute_batch(DDL).map_err(|err| err.to_string())?; Ok(conn) } @@ -88,7 +89,8 @@ pub fn list_collections() -> Result, String> { }) }) .map_err(|err| err.to_string())?; - rows.collect::, _>>().map_err(|err| err.to_string()) + rows.collect::, _>>() + .map_err(|err| err.to_string()) } pub fn create_collection(name: &str, color: &str) -> Result { @@ -109,8 +111,11 @@ pub fn rename_collection(id: i64, name: &str) -> Result<(), String> { return Err("empty collection name".to_string()); } let conn = open()?; - conn.execute("UPDATE collections SET name = ?1 WHERE id = ?2", params![name.trim(), id]) - .map_err(|err| err.to_string())?; + conn.execute( + "UPDATE collections SET name = ?1 WHERE id = ?2", + params![name.trim(), id], + ) + .map_err(|err| err.to_string())?; if conn.changes() == 0 { return Err("collection not found".to_string()); } @@ -119,11 +124,6 @@ pub fn rename_collection(id: i64, name: &str) -> Result<(), String> { pub fn delete_collection(id: i64) -> Result<(), String> { let conn = open()?; - // collection_clips rows go via the FK ON DELETE CASCADE now that the - // pragma is enabled in open(); delete clips first anyway so a failed - // cascade (older DB, pragma didn't stick) cannot leave orphans. - conn.execute("DELETE FROM collection_clips WHERE collection_id = ?1", params![id]) - .map_err(|err| err.to_string())?; conn.execute("DELETE FROM collections WHERE id = ?1", params![id]) .map_err(|err| err.to_string())?; if conn.changes() == 0 { @@ -132,10 +132,14 @@ pub fn delete_collection(id: i64) -> Result<(), String> { Ok(()) } -pub fn add_clip_to_collection(collection_id: i64, source: ClipSource, clip_id: i64) -> Result<(), String> { +pub fn add_clip_to_collection( + collection_id: i64, + source: ClipSource, + clip_id: i64, +) -> Result<(), String> { let conn = open()?; // Only accept clips that exist in the catalog right now. - if clip_catalog::clip_by_id(source, clip_id).is_none() { + if !clip_catalog::clips_by_ids(source, &[clip_id])?.contains_key(&clip_id) { return Err("clip not found".to_string()); } let exists: bool = conn @@ -157,7 +161,11 @@ pub fn add_clip_to_collection(collection_id: i64, source: ClipSource, clip_id: i Ok(()) } -pub fn remove_clip_from_collection(collection_id: i64, source: ClipSource, clip_id: i64) -> Result<(), String> { +pub fn remove_clip_from_collection( + collection_id: i64, + source: ClipSource, + clip_id: i64, +) -> Result<(), String> { let conn = open()?; conn.execute( "DELETE FROM collection_clips WHERE collection_id = ?1 AND source = ?2 AND clip_id = ?3", @@ -184,24 +192,54 @@ pub fn collection_clips(collection_id: i64) -> Result, S }) .map_err(|err| err.to_string())?; + let entries = rows + .collect::, _>>() + .map_err(|err| err.to_string())?; + let replay_ids: Vec<_> = entries + .iter() + .filter(|(source, _)| source == "replay") + .map(|(_, id)| *id) + .collect(); + let manual_ids: Vec<_> = entries + .iter() + .filter(|(source, _)| source == "manual") + .map(|(_, id)| *id) + .collect(); + let mut replay = if replay_ids.is_empty() { + std::collections::HashMap::new() + } else { + clip_catalog::clips_by_ids(ClipSource::Replay, &replay_ids)? + }; + let mut manual = if manual_ids.is_empty() { + std::collections::HashMap::new() + } else { + clip_catalog::clips_by_ids(ClipSource::Manual, &manual_ids)? + }; let mut clips = Vec::new(); let mut stale = Vec::new(); - for entry in rows { - let (source_str, clip_id) = entry.map_err(|err| err.to_string())?; - let Some(source) = ClipSource::parse(&source_str) else { - stale.push((collection_id, source_str, clip_id)); - continue; + for (source, id) in entries { + let clip = match source.as_str() { + "replay" => replay.remove(&id), + "manual" => manual.remove(&id), + _ => None, }; - match clip_catalog::clip_by_id(source, clip_id) { - Some(clip) => clips.push(clip), - None => stale.push((collection_id, source_str, clip_id)), + if let Some(clip) = clip { + clips.push(clip); + } else { + stale.push((source, id)); } } - for (cid, source, clip_id) in stale { - let _ = conn.execute( + drop(stmt); + // Prune only after both catalogs were read without errors. + let tx = conn + .unchecked_transaction() + .map_err(|err| err.to_string())?; + for (source, id) in stale { + tx.execute( "DELETE FROM collection_clips WHERE collection_id = ?1 AND source = ?2 AND clip_id = ?3", - params![cid, source, clip_id], - ); + params![collection_id, source, id], + ).map_err(|err| err.to_string())?; } + tx.commit().map_err(|err| err.to_string())?; Ok(clips) } diff --git a/app/desktop-ui/src-tauri/src/engine_rpc.rs b/app/desktop-ui/src-tauri/src/engine_rpc.rs index fed5351..f742f16 100644 --- a/app/desktop-ui/src-tauri/src/engine_rpc.rs +++ b/app/desktop-ui/src-tauri/src/engine_rpc.rs @@ -1,10 +1,13 @@ use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; -use std::net::TcpStream; +use std::net::{SocketAddr, TcpStream}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -const HOST: &str = "127.0.0.1:45991"; +const HOST: SocketAddr = SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::LOCALHOST, + 45991, +)); static NEXT_ID: AtomicU64 = AtomicU64::new(1); fn rpc(method: &str, params: Option) -> Result { @@ -16,16 +19,63 @@ fn rpc(method: &str, params: Option) -> Result { "params": params, }); - let mut stream = TcpStream::connect(HOST).map_err(|err| err.to_string())?; - let _ = stream.set_read_timeout(Some(Duration::from_secs(4))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(4))); + let mut stream = + TcpStream::connect_timeout(&HOST, Duration::from_secs(4)).map_err(|err| err.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(4))) + .map_err(|err| err.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(4))) + .map_err(|err| err.to_string())?; writeln!(stream, "{payload}").map_err(|err| err.to_string())?; - let mut line = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut line).map_err(|err| err.to_string())?; + read_response(BufReader::new(stream), id) +} + +fn read_response(reader: impl BufRead, id: u64) -> Result { + let mut line = Vec::new(); + reader + .take(64 * 1024 + 1) + .read_until(b'\n', &mut line) + .map_err(|err| err.to_string())?; + if line.len() > 64 * 1024 || line.last() != Some(&b'\n') { + return Err("invalid or oversized engine response".into()); + } + let value: Value = serde_json::from_slice(&line).map_err(|err| err.to_string())?; + if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + || value.get("id").and_then(Value::as_u64) != Some(id) + || value.get("result").is_some() == value.get("error").is_some() + { + return Err("invalid engine response envelope".into()); + } + Ok(value) +} - serde_json::from_str::(line.trim()).map_err(|err| err.to_string()) +#[cfg(test)] +mod tests { + use super::read_response; + use std::io::Cursor; + #[test] + fn response_envelope_and_size_are_bounded() { + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}\n"), + 1 + ) + .is_ok()); + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}\n"), + 1 + ) + .is_err()); + assert!(read_response(Cursor::new(b"{\"id\":1,\"result\":null}\n"), 1).is_err()); + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null,\"error\":null}\n"), + 1 + ) + .is_err()); + assert!(read_response(Cursor::new(vec![b'x'; 65537]), 1).is_err()); + assert!(read_response(Cursor::new(b"{}"), 1).is_err()); + } } pub fn mutate_clip(method: &str, params: Value) -> Value { diff --git a/app/desktop-ui/src-tauri/src/game_catalog.rs b/app/desktop-ui/src-tauri/src/game_catalog.rs index d614ccc..6449fc5 100644 --- a/app/desktop-ui/src-tauri/src/game_catalog.rs +++ b/app/desktop-ui/src-tauri/src/game_catalog.rs @@ -1,7 +1,7 @@ use crate::paths; use rusqlite::{params, Connection, OpenFlags}; -use serde_json::Value; use serde::Serialize; +use serde_json::Value; use std::collections::BTreeMap; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -55,8 +55,8 @@ fn open(readonly: bool) -> Option { Some(conn) } -fn ensure_schema(conn: &Connection) { - let _ = conn.execute_batch( +fn ensure_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( "CREATE TABLE IF NOT EXISTS game_catalog ( process_name_lower TEXT PRIMARY KEY, display_name TEXT NOT NULL DEFAULT '', @@ -64,22 +64,26 @@ fn ensure_schema(conn: &Connection) { icon_url TEXT, cover_url TEXT ); - // entry_by_app_id and refresh_stale filter on discord_app_id; without - // this index both are full table scans on the clip-grid hot path. - CREATE INDEX IF NOT EXISTS idx_game_catalog_app_id - ON game_catalog(discord_app_id);", - ); + ", + )?; for (name, ty) in [ ("display_name", "TEXT NOT NULL DEFAULT ''"), ("discord_app_id", "TEXT"), ("icon_url", "TEXT"), ("cover_url", "TEXT"), ("exe_icon_png", "BLOB"), + ("last_updated", "INTEGER NOT NULL DEFAULT 0"), ] { if !has_column(conn, "game_catalog", name) { - let _ = conn.execute(&format!("ALTER TABLE game_catalog ADD COLUMN {name} {ty}"), []); + conn.execute( + &format!("ALTER TABLE game_catalog ADD COLUMN {name} {ty}"), + [], + )?; } } + conn.execute_batch( + "CREATE INDEX IF NOT EXISTS idx_game_catalog_app_id ON game_catalog(discord_app_id);", + ) } fn has_column(conn: &Connection, table: &str, column: &str) -> bool { @@ -191,13 +195,18 @@ pub fn cached_exe_icon(process_name: &str) -> Option> { pub fn store_exe_icon(process_name: &str, png: &[u8]) { let key = process_name.to_lowercase(); let Some(conn) = open(false) else { return }; - ensure_schema(&conn); - let _ = conn.execute( + if let Err(err) = ensure_schema(&conn) { + eprintln!("game catalog schema: {err}"); + return; + } + if let Err(err) = conn.execute( "INSERT INTO game_catalog (process_name_lower, exe_icon_png) VALUES (?1, ?2) ON CONFLICT(process_name_lower) DO UPDATE SET exe_icon_png=excluded.exe_icon_png", params![&key, png], - ); + ) { + eprintln!("game icon cache write: {err}"); + } } // Batched form of the cache-only lookup: one DB read serves a whole page of @@ -220,7 +229,10 @@ impl ArtworkCache { .map(|id| (id.to_string(), entry.clone())) }) .collect(); - Self { by_process, by_app_id } + Self { + by_process, + by_app_id, + } } pub fn resolve(&self, app_id: Option<&str>, process_name: Option<&str>) -> CatalogEntry { @@ -324,8 +336,11 @@ fn fetch_and_cache_discord_app(app_id: &str, process_name: Option<&str>) -> Opti .unwrap_or_else(|| format!("discord:{app_id}")); let last_updated = now_unix(); if let Some(conn) = open(false) { - ensure_schema(&conn); - let _ = conn.execute( + if let Err(err) = ensure_schema(&conn) { + eprintln!("game catalog schema: {err}"); + return None; + } + if let Err(err) = conn.execute( "INSERT INTO game_catalog (process_name_lower, display_name, discord_app_id, icon_url, cover_url, last_updated) VALUES (?1, ?2, ?3, ?4, ?5, ?6) @@ -336,7 +351,7 @@ fn fetch_and_cache_discord_app(app_id: &str, process_name: Option<&str>) -> Opti cover_url=excluded.cover_url, last_updated=excluded.last_updated", params![&key, &name, app_id, &icon_url, &cover_url, last_updated], - ); + ) { eprintln!("game artwork cache write: {err}"); return None; } } Some(CatalogEntry { @@ -354,21 +369,30 @@ fn fetch_and_cache_discord_app(app_id: &str, process_name: Option<&str>) -> Opti // cache-only reads (resolve_artwork_cached) stay reasonably fresh without ever // blocking a display path on the network. Call from a background thread. pub fn refresh_stale(max_age: Duration) { - let Some(conn) = open(true) else { return }; - ensure_schema(&conn); + let Some(conn) = open(false) else { return }; + if let Err(err) = ensure_schema(&conn) { + eprintln!("game catalog schema: {err}"); + return; + } let cutoff = now_unix() - max_age.as_secs() as i64; let sql = format!( "SELECT {} FROM game_catalog WHERE discord_app_id IS NOT NULL AND last_updated < ?1", select_expr(&conn), ); - let Ok(mut stmt) = conn.prepare(&sql) else { return }; - let Ok(rows) = stmt.query_map(params![cutoff], row_to_entry) else { return }; + let Ok(mut stmt) = conn.prepare(&sql) else { + return; + }; + let Ok(rows) = stmt.query_map(params![cutoff], row_to_entry) else { + return; + }; let stale: Vec = rows.flatten().collect(); drop(stmt); drop(conn); for entry in stale { - let Some(app_id) = entry.discord_app_id.as_deref() else { continue }; + let Some(app_id) = entry.discord_app_id.as_deref() else { + continue; + }; let process_name = if entry.process_name_lower.starts_with("discord:") { None } else { diff --git a/app/desktop-ui/src-tauri/src/paths.rs b/app/desktop-ui/src-tauri/src/paths.rs index 7fb61d9..d59275b 100644 --- a/app/desktop-ui/src-tauri/src/paths.rs +++ b/app/desktop-ui/src-tauri/src/paths.rs @@ -10,7 +10,9 @@ pub struct OutputDirs { pub fn local_app_data() -> PathBuf { env::var_os("LOCALAPPDATA") .map(PathBuf::from) - .or_else(|| env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join("AppData").join("Local"))) + .or_else(|| { + env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join("AppData").join("Local")) + }) .unwrap_or_default() } diff --git a/app/desktop-ui/src-tauri/src/settings_store.rs b/app/desktop-ui/src-tauri/src/settings_store.rs index 32f0753..9cdc4c1 100644 --- a/app/desktop-ui/src-tauri/src/settings_store.rs +++ b/app/desktop-ui/src-tauri/src/settings_store.rs @@ -20,7 +20,9 @@ pub fn read_config() -> Option { let _ = conn.busy_timeout(std::time::Duration::from_millis(4000)); let mut stmt = conn.prepare("SELECT key, value FROM settings").ok()?; let rows = stmt - .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) .ok()?; let mut config = Map::new(); @@ -61,7 +63,11 @@ pub fn write_config(config: &Value) -> rusqlite::Result<()> { stmt.execute(params![key, value.to_string()])?; } } - tx.commit() + tx.commit()?; + if let Ok(mut cache) = DIRS_CACHE.lock() { + *cache = None; + } + Ok(()) } pub fn read_runtime_status() -> Value { @@ -77,26 +83,36 @@ pub fn read_runtime_status() -> Value { // each time showed up with large libraries, so cache the result keyed by the // settings.db mtime+size - a stat() instead of a DB round-trip, and still // correct when the engine (or anything else) rewrites the file. -static DIRS_CACHE: Mutex, u64, paths::OutputDirs)>> = Mutex::new(None); +struct DirCache { + path: PathBuf, + stamps: [(Option, u64); 2], + dirs: paths::OutputDirs, +} + +static DIRS_CACHE: Mutex> = Mutex::new(None); pub fn output_dirs() -> paths::OutputDirs { - let defaults = paths::default_output_dirs(); - let meta = fs::metadata(settings_db_path()).ok(); - let mtime = meta.as_ref().and_then(|m| m.modified().ok()); - let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); - - if let Ok(guard) = DIRS_CACHE.lock() { - if let Some((c_mtime, c_size, dirs)) = guard.as_ref() { - if *c_mtime == mtime && *c_size == size { - return dirs.clone(); - } + let path = settings_db_path(); + let wal = path.with_file_name("settings.db-wal"); + let stamps = [path.as_path(), wal.as_path()].map(|p| { + let meta = fs::metadata(p).ok(); + ( + meta.as_ref().and_then(|m| m.modified().ok()), + meta.map(|m| m.len()).unwrap_or(0), + ) + }); + let mut cache = DIRS_CACHE.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(hit) = cache.as_ref() { + if hit.path == path && hit.stamps == stamps { + return hit.dirs.clone(); } } - - let dirs = compute_output_dirs(defaults); - if let Ok(mut guard) = DIRS_CACHE.lock() { - *guard = Some((mtime, size, dirs.clone())); - } + let dirs = compute_output_dirs(paths::default_output_dirs()); + *cache = Some(DirCache { + path, + stamps, + dirs: dirs.clone(), + }); dirs } diff --git a/app/desktop-ui/ui/src/app.tsx b/app/desktop-ui/ui/src/app.tsx index 4e45411..896e317 100644 --- a/app/desktop-ui/ui/src/app.tsx +++ b/app/desktop-ui/ui/src/app.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { collectionsApi, type Clip, @@ -41,6 +41,8 @@ export function App() { const [hashtags, setHashtags] = useState([]); const [filter, setFilter] = useState({}); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const reloadId = useRef(0); const [menu, setMenu] = useState(null); const [tagDialog, setTagDialog] = useState(null); @@ -53,20 +55,28 @@ export function App() { const [collectionPicker, setCollectionPicker] = useState(null); const reload = useCallback(async (silent = false) => { + const requestId = ++reloadId.current; if (!silent) setLoading(true); - // In collection view the grid isn't shown: skip the clips+games scans - // (each is a full catalog pass); hashtags stay for the detail filter UI. - const [c, g, h, cols] = await Promise.all([ - collectionView ? Promise.resolve([] as Clip[]) : fetchClips(filter), - collectionView ? Promise.resolve([] as string[]) : fetchGames(), - fetchHashtags(), - collectionView ? collectionsApi.list() : Promise.resolve(null as CollectionSummary[] | null), - ]); - setClips(c); - setGames(g); - setHashtags(h); - if (cols) setCollections(cols); - setLoading(false); + try { + // In collection view the grid isn't shown: skip the clips+games scans + // (each is a full catalog pass); hashtags stay for the detail filter UI. + const [c, g, h, cols] = await Promise.all([ + collectionView ? Promise.resolve([] as Clip[]) : fetchClips(filter), + collectionView ? Promise.resolve([] as string[]) : fetchGames(), + fetchHashtags(), + collectionView ? collectionsApi.list() : Promise.resolve(null as CollectionSummary[] | null), + ]); + if (requestId !== reloadId.current) return; + setLoadError(null); + setClips(c); + setGames(g); + setHashtags(h); + if (cols) setCollections(cols); + } catch (error) { + if (requestId === reloadId.current) setLoadError(String(error)); + } finally { + if (requestId === reloadId.current) setLoading(false); + } }, [filter, collectionView]); const updateClip = useCallback((next: Clip) => { @@ -82,7 +92,8 @@ export function App() { }, []); useEffect(() => { - reload(); + void reload(); + return () => { reloadId.current++; }; }, [reload]); useEffect(() => { @@ -152,7 +163,11 @@ export function App() { const doDelete = async () => { if (!confirmDel) return; - await clipApi.delete(confirmDel); + const result = await clipApi.delete(confirmDel); + if (!result.ok) { + setLoadError(result.error ?? "Could not delete clip"); + return; + } setConfirmDel(null); setDetailIndex(null); await reload(true); @@ -180,6 +195,7 @@ export function App() { onOpenCollections={() => setCollectionView({ kind: "list" })} />
+ {loadError && } {collectionView ? ( collectionView.kind === "list" ? ( (null); + const loadId = useRef(0); const refetch = useCallback(async (silent = false) => { + const id = ++loadId.current; if (!silent) setLoading(true); try { - setClips(await collectionsApi.clips(collection.id)); + const items = await collectionsApi.clips(collection.id); + if (id !== loadId.current) return; + setClips(items); setError(null); } catch (err) { - setError(String(err)); + if (id === loadId.current) setError(String(err)); } finally { - setLoading(false); + if (id === loadId.current) setLoading(false); } }, [collection.id]); useEffect(() => { refetch(); + return () => { ++loadId.current; }; }, [refetch]); const updateClip = (next: Clip) => { diff --git a/app/desktop-ui/ui/src/lib/api.ts b/app/desktop-ui/ui/src/lib/api.ts index d5f85e1..8e67263 100644 --- a/app/desktop-ui/ui/src/lib/api.ts +++ b/app/desktop-ui/ui/src/lib/api.ts @@ -156,7 +156,7 @@ export function subscribeClips(onChange: () => void): () => void { } else { unlisten = fn; } - }); + }).catch((err) => console.error("Clip subscription failed", err)); return () => { cancelled = true; unlisten?.(); @@ -241,7 +241,15 @@ export function exeIconUrl(executablePath: string, processName: string): Promise let request = exeIconRequests.get(key); if (!request) { request = invoke("exe_icon", { path: executablePath, process: processName }) - .catch(() => null); + .catch(() => null) + .then((icon) => { + if (icon === null) exeIconRequests.delete(key); + return icon; + }); + if (exeIconRequests.size >= 128) { + const oldest = exeIconRequests.keys().next().value; + if (oldest !== undefined) exeIconRequests.delete(oldest); + } exeIconRequests.set(key, request); } return request; diff --git a/app/desktop-ui/ui/src/lib/multitrack.ts b/app/desktop-ui/ui/src/lib/multitrack.ts index 0e8663d..0245c53 100644 --- a/app/desktop-ui/ui/src/lib/multitrack.ts +++ b/app/desktop-ui/ui/src/lib/multitrack.ts @@ -71,6 +71,8 @@ export function useMultiTrackAudio( } let cancelled = false; + mutedRef.current = video.muted; + volumeRef.current = video.volume; const onLoadedMetadata = () => { if (cancelled) return; @@ -101,6 +103,7 @@ export function useMultiTrackAudio( shadow.volume = volumeRef.current; shadow.preload = "auto"; shadow.playsInline = true; + shadow.playbackRate = video.playbackRate; const trackIndex = i; shadow.onloadedmetadata = () => { if (cancelled) return; @@ -114,6 +117,7 @@ export function useMultiTrackAudio( } } shadow.currentTime = video.currentTime; + shadow.playbackRate = video.playbackRate; if (!video.paused) shadow.play().catch(() => {}); }; shadow.src = src; @@ -146,7 +150,17 @@ export function useMultiTrackAudio( } }; + const syncVolume = () => { + applyMuted(video.muted); + applyVolume(video.volume); + }; + const syncRate = () => { + for (const { el } of shadowsRef.current) el.playbackRate = video.playbackRate; + }; + video.addEventListener("volumechange", syncVolume); + video.addEventListener("ratechange", syncRate); video.addEventListener("loadedmetadata", onLoadedMetadata); + if (video.readyState >= HTMLMediaElement.HAVE_METADATA) onLoadedMetadata(); video.addEventListener("play", syncPlay); video.addEventListener("pause", syncPause); video.addEventListener("seeked", syncSeek); @@ -154,6 +168,8 @@ export function useMultiTrackAudio( return () => { cancelled = true; + video.removeEventListener("volumechange", syncVolume); + video.removeEventListener("ratechange", syncRate); video.removeEventListener("loadedmetadata", onLoadedMetadata); video.removeEventListener("play", syncPlay); video.removeEventListener("pause", syncPause); diff --git a/app/desktop-ui/ui/src/shell/titlebar.tsx b/app/desktop-ui/ui/src/shell/titlebar.tsx index 5e9279f..cca8744 100644 --- a/app/desktop-ui/ui/src/shell/titlebar.tsx +++ b/app/desktop-ui/ui/src/shell/titlebar.tsx @@ -32,6 +32,8 @@ export function Titlebar({ view }: Props) { const [open, setOpen] = useState(false); const [showConnectToast, setShowConnectToast] = useState(false); const hasCheckedOnce = useRef(false); + const saving = useRef(false); + const [saveError, setSaveError] = useState(null); // Runtime + engine status are read-only and safe to poll live. useEffect(() => { @@ -162,12 +164,21 @@ export function Titlebar({ view }: Props) { const patternIcon = exeIcon ?? art.icon ?? null; const persist = async (mutate: (draft: Config) => void) => { - const draft = cloneConfig(config); + if (saving.current || !config) return; + saving.current = true; + const previous = config; + const draft = cloneConfig(previous); draft.capture_mode ??= {}; draft.capture ??= {}; mutate(draft); + setSaveError(null); setConfig(draft); - await saveConfig(draft); + const result = await saveConfig(draft); + if (!result.ok) { + setConfig(previous); + setSaveError(result.error ?? "Settings could not be saved"); + } + saving.current = false; }; const setMode = (next: "always" | "game_only") => { @@ -209,6 +220,12 @@ export function Titlebar({ view }: Props) { return ( <> + {saveError && ( +
+ {saveError} + +
+ )} {showConnectToast && (
Couldn't reach the Monolith engine, is it running? diff --git a/app/recorder/src/main.cpp b/app/recorder/src/main.cpp index 81909e9..9b8bd88 100644 --- a/app/recorder/src/main.cpp +++ b/app/recorder/src/main.cpp @@ -439,12 +439,11 @@ static void publish_runtime_status() } std::string error; std::string json = settings::serialize_runtime_status(snapshot); - { - std::lock_guard lk(g_last_runtime_status_mutex); - if (json == g_last_runtime_status_json) return; // unchanged: skip disk write - g_last_runtime_status_json = json; - } - if (!settings::write_runtime_status(app_data_dir(), snapshot, &error)) + std::lock_guard lk(g_last_runtime_status_mutex); + if (json == g_last_runtime_status_json) return; + if (settings::write_runtime_status(app_data_dir(), snapshot, &error)) + g_last_runtime_status_json = std::move(json); + else log_msg("settings", ("runtime-status write failed: " + error).c_str()); } @@ -522,9 +521,10 @@ static DWORD WINAPI pacer_thread_proc(LPVOID) if (htimer) { LARGE_INTEGER due; due.QuadPart = -static_cast(tick_ms) * 10000; // 100ns units, relative - SetWaitableTimer(htimer, &due, 0, nullptr, nullptr, FALSE); HANDLE handles[2] = { g_pacer_stop_event, htimer }; - DWORD w = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + DWORD w = SetWaitableTimer(htimer, &due, 0, nullptr, nullptr, FALSE) + ? WaitForMultipleObjects(2, handles, FALSE, INFINITE) + : WaitForSingleObject(g_pacer_stop_event, static_cast(tick_ms)); if (w == WAIT_OBJECT_0 || w == WAIT_FAILED) break; } else { DWORD w = WaitForSingleObject(g_pacer_stop_event, @@ -629,8 +629,19 @@ static void pacer_start() } g_pacer_stop_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); - g_pacer_running = true; - g_pacer_thread = CreateThread(nullptr, 0, pacer_thread_proc, nullptr, 0, nullptr); + if (g_pacer_stop_event) { + g_pacer_running = true; + g_pacer_thread = CreateThread(nullptr, 0, pacer_thread_proc, nullptr, 0, nullptr); + if (g_pacer_thread) return; + CloseHandle(g_pacer_stop_event); + g_pacer_stop_event = nullptr; + } + g_pacer_running = false; + if (g_pacer_time_period_set) { + timeEndPeriod(1); + g_pacer_time_period_set = false; + } + log_error("capture", "video pacer could not start"); } static void pacer_stop() @@ -1143,7 +1154,7 @@ static void auto_record_stop() std::wstring path; if (g_recording.stop(&path)) { rec_clock_stop(); - if (path.empty()) log_msg("recording", "auto recording stopped: no packets written"); + if (path.empty()) log_msg("recording", "auto recording stopped: no complete output"); else { log_path("recording", "auto recording saved: ", path); // Same closure as manual stop: pending bookmarks flush into the @@ -2417,7 +2428,8 @@ static void media_start(HWND hwnd) // ── WGC display capture ─────────────────────────────────────────────────── if (capture::is_supported()) { - bool ok = g_video.start(hmon, [](capture::FrameInfo const& f) { + const settings::Config video_settings = g_settings; + bool ok = g_video.start(hmon, [video_settings](capture::FrameInfo const& f) { // Log buffer stats every 300 frames (~5s at 60fps). if (f.seq % 300 == 0) { // FrameArrived can fire on different thread-pool threads and @@ -2523,7 +2535,7 @@ static void media_start(HWND hwnd) avg_push_ms, avg_sws_ms, avg_encode_ms, - g_settings.video_fps); + video_settings.video_fps); log_msg("perf-video", perf); last_capture_stats = capture_stats; @@ -2563,7 +2575,7 @@ static void media_start(HWND hwnd) // this remains the source of truth either way. int g_enc_w_resolved = 0, g_enc_h_resolved = 0; resolve_target_size(static_cast(f.width), static_cast(f.height), - g_settings.resolution_preset, + video_settings.resolution_preset, &g_enc_w_resolved, &g_enc_h_resolved); g_enc_w = g_enc_w_resolved; g_enc_h = g_enc_h_resolved; @@ -2571,18 +2583,21 @@ static void media_start(HWND hwnd) // Resolve the concrete FFmpeg encoder from the user's CPU/GPU + // codec choice, given what this machine can actually open. std::string resolved = encoding::resolve_video_encoder( - g_settings.encoder_device, g_settings.encoder_codec, + video_settings.encoder_device, video_settings.encoder_codec, g_enc_w, g_enc_h); encoding::VideoEncoder::Config vcfg; vcfg.width = g_enc_w; vcfg.height = g_enc_h; - vcfg.fps = g_settings.video_fps; + vcfg.fps = video_settings.video_fps; + vcfg.codec = video_settings.encoder_codec == "av1" ? encoding::VideoCodec::AV1 : + (video_settings.encoder_codec == "h265" || video_settings.encoder_codec == "hevc" ? + encoding::VideoCodec::H265 : encoding::VideoCodec::H264); vcfg.quality = 0; // 0 = CBR - vcfg.bitrate = static_cast(g_settings.video_bitrate_kbps) * 1000; + vcfg.bitrate = static_cast(video_settings.video_bitrate_kbps) * 1000; vcfg.scaling_filter = "bilinear"; // fixed; scaler choice removed from UI vcfg.preferred_encoder = resolved; // "" → probe order fallback - vcfg.extra_options = g_settings.extra_ffmpeg_options; + vcfg.extra_options = video_settings.extra_ffmpeg_options; bool enc_ok = g_video_enc.open(vcfg, [](encoding::EncodedPacket pkt) { g_perf_encoder_packets_output.fetch_add(1, std::memory_order_relaxed); @@ -2598,15 +2613,15 @@ static void media_start(HWND hwnd) snprintf(enc_msg, sizeof(enc_msg), "%s %dx%d @%dfps %dkbps CBR%s%s", g_video_enc.encoder_name().c_str(), g_enc_w, g_enc_h, - g_settings.video_fps, - g_settings.video_bitrate_kbps, - g_settings.extra_ffmpeg_options.empty() ? "" : + video_settings.video_fps, + video_settings.video_bitrate_kbps, + video_settings.extra_ffmpeg_options.empty() ? "" : (g_video_enc.extra_options_rejected() ? " extra_options=REJECTED (opened without them)" : " extra_options="), - (!g_settings.extra_ffmpeg_options.empty() && + (!video_settings.extra_ffmpeg_options.empty() && !g_video_enc.extra_options_rejected()) - ? g_settings.extra_ffmpeg_options.c_str() : ""); + ? video_settings.extra_ffmpeg_options.c_str() : ""); log_msg("encoding", enc_msg); } else { log_msg("encoding", "WARNING: video encoder open failed - will retry"); @@ -2629,9 +2644,9 @@ static void media_start(HWND hwnd) char err[176]; snprintf(err, sizeof(err), "No usable video encoder for %dx%d @%dfps (%s/%s). Retrying…", - g_enc_w, g_enc_h, g_settings.video_fps, - g_settings.encoder_device.c_str(), - g_settings.encoder_codec.c_str()); + g_enc_w, g_enc_h, video_settings.video_fps, + video_settings.encoder_device.c_str(), + video_settings.encoder_codec.c_str()); std::lock_guard lk(g_status_mutex); g_runtime_status.active_encoder.clear(); g_runtime_status.video_encoder_error = err; @@ -3267,7 +3282,7 @@ static void dispatch(Cmd cmd, HWND hwnd) std::wstring path; if (g_recording.stop(&path)) { rec_clock_stop(); - if (path.empty()) log_msg("recording", "recording stopped: no packets written"); + if (path.empty()) log_msg("recording", "recording stopped: no complete output"); else { log_path("recording", "recording saved: ", path); // Catalog off the UI thread - decode + DB I/O must not block it. diff --git a/app/recorder/src/settings_window.cpp b/app/recorder/src/settings_window.cpp index 1120309..55df1bb 100644 --- a/app/recorder/src/settings_window.cpp +++ b/app/recorder/src/settings_window.cpp @@ -1,6 +1,7 @@ #include "settings_window.h" #include +#include #include #include #include @@ -11,6 +12,7 @@ namespace { // Handle of the last spawned Settings process; owned by the UI thread // (show() is only called from the tray window's message loop). HANDLE g_settings_process = nullptr; +std::mutex g_process_mutex; struct FocusContext { DWORD pid; @@ -117,6 +119,7 @@ UiLaunch resolve_ui_launch() void show(HWND owner, UINT reload_message) { + std::lock_guard lock(g_process_mutex); if (settings_already_running()) return; UiLaunch launch = resolve_ui_launch(); @@ -168,6 +171,7 @@ void show(HWND owner, UINT reload_message) DuplicateHandle(GetCurrentProcess(), process_info.hProcess, GetCurrentProcess(), &guard, 0, FALSE, DUPLICATE_SAME_ACCESS); + if (g_settings_process) CloseHandle(g_settings_process); g_settings_process = guard; std::thread([owner, reload_message, process]() { @@ -179,6 +183,7 @@ void show(HWND owner, UINT reload_message) void close_running() { + std::lock_guard lock(g_process_mutex); if (!g_settings_process) return; DWORD wait = WaitForSingleObject(g_settings_process, 0); diff --git a/app/updater/src-tauri/src/apply.rs b/app/updater/src-tauri/src/apply.rs index 9c3bb50..94fca4e 100644 --- a/app/updater/src-tauri/src/apply.rs +++ b/app/updater/src-tauri/src/apply.rs @@ -3,35 +3,6 @@ use std::path::Path; use windows::core::PCWSTR; use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING}; -/// Extracts a component zip into dest, rejecting entries that escape dest -/// (zip-slip). -pub fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { - let file = fs::File::open(zip_path).map_err(|e| format!("open zip: {e}"))?; - let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("read zip: {e}"))?; - fs::create_dir_all(dest).map_err(|e| format!("staging dir: {e}"))?; - for i in 0..archive.len() { - let mut entry = archive - .by_index(i) - .map_err(|e| format!("zip entry {i}: {e}"))?; - let Some(rel) = entry.enclosed_name() else { - return Err(format!("unsafe path in zip: {}", entry.name())); - }; - let out = dest.join(rel); - if entry.is_dir() { - fs::create_dir_all(&out).map_err(|e| format!("mkdir {}: {e}", out.display()))?; - continue; - } - if let Some(parent) = out.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; - } - let mut w = - fs::File::create(&out).map_err(|e| format!("extract {}: {e}", out.display()))?; - std::io::copy(&mut entry, &mut w) - .map_err(|e| format!("extract {}: {e}", out.display()))?; - } - Ok(()) -} - fn wide(path: &Path) -> Vec { path.as_os_str() .to_string_lossy() @@ -43,9 +14,7 @@ fn wide(path: &Path) -> Vec { fn to_old(path: &Path) -> Result<(), String> { // Renaming a running image or a loaded DLL is legal on Windows; deleting // is not. Park replaced files as *.old - swept on the next launch. - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); - name.push(".old"); - let old = path.with_file_name(name); + let old = old_path(path); unsafe { MoveFileExW( PCWSTR(wide(path).as_ptr()), @@ -57,19 +26,47 @@ fn to_old(path: &Path) -> Result<(), String> { Ok(()) } +fn old_path(path: &Path) -> std::path::PathBuf { + let mut name = path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + name.push(".old"); + path.with_file_name(name) +} + fn place_file(staged: &Path, target: &Path) -> Result<(), String> { - if target.exists() { - to_old(target)?; - } if let Some(parent) = target.parent() { fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; } + let replaced = target.exists(); + if replaced { + to_old(target)?; + } if fs::rename(staged, target).is_err() { // Same-volume rename should always work (staging lives inside the // app dir); the copy+delete fallback covers exotic setups. - fs::copy(staged, target) - .and_then(|_| fs::remove_file(staged)) - .map_err(|e| format!("place {}: {e}", target.display()))?; + if let Err(error) = fs::copy(staged, target) { + if replaced { + unsafe { + MoveFileExW( + PCWSTR(wide(&old_path(target)).as_ptr()), + PCWSTR(wide(target).as_ptr()), + MOVEFILE_REPLACE_EXISTING, + ) + .map_err(|restore| { + format!( + "place {}: {error}; restore failed: {restore}", + target.display() + ) + })?; + } + } else { + let _ = fs::remove_file(target); + } + return Err(format!("place {}: {error}", target.display())); + } + let _ = fs::remove_file(staged); } Ok(()) } @@ -101,10 +98,7 @@ fn place_tree_rec(root: &Path, src: &Path, dest: &Path) -> Result<(), String> { /// safe mid-flight; the parked image is swept on the next launch. pub fn self_swap(new_exe: &Path) -> Result<(), String> { let current = std::env::current_exe().map_err(|e| format!("self path: {e}"))?; - to_old(¤t)?; - fs::copy(new_exe, ¤t) - .map(|_| ()) - .map_err(|e| format!("self swap: {e}")) + place_file(new_exe, ¤t) } /// Deletes *.old leftovers from previous applies (app root + ui\). A failure @@ -114,7 +108,9 @@ pub fn self_swap(new_exe: &Path) -> Result<(), String> { pub fn sweep_old(app_dir: &Path) { let dirs = [app_dir.to_path_buf(), app_dir.join("ui")]; for dir in dirs { - let Ok(entries) = fs::read_dir(&dir) else { continue }; + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; for entry in entries.flatten() { let p = entry.path(); if p.extension().map(|e| e == "old").unwrap_or(false) { diff --git a/app/updater/src-tauri/src/archive.rs b/app/updater/src-tauri/src/archive.rs new file mode 100644 index 0000000..f26ab7c --- /dev/null +++ b/app/updater/src-tauri/src/archive.rs @@ -0,0 +1,61 @@ +use std::fs; +use std::path::Path; + +/// Extracts a component zip into dest, rejecting entries that escape dest +/// (zip-slip). +pub fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { + let file = fs::File::open(zip_path).map_err(|e| format!("open zip: {e}"))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("read zip: {e}"))?; + if archive.len() > 4096 { + return Err("too many archive entries".into()); + } + let mut total = 0u64; + fs::create_dir_all(dest).map_err(|e| format!("staging dir: {e}"))?; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("zip entry {i}: {e}"))?; + let Some(rel) = entry.enclosed_name() else { + return Err(format!("unsafe path in zip: {}", entry.name())); + }; + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("archive links are not supported".into()); + } + for part in rel.components() { + let name = part.as_os_str().to_string_lossy(); + let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase(); + if name.contains([':', '\\']) + || name.ends_with(['.', ' ']) + || [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", + "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", + "LPT8", "LPT9", + ] + .contains(&stem.as_str()) + { + return Err(format!("unsupported archive path: {}", entry.name())); + } + } + total = total + .checked_add(entry.size()) + .ok_or("archive size overflow")?; + if total > 2 * 1024 * 1024 * 1024 { + return Err("archive exceeds 2 GiB".into()); + } + let out = dest.join(rel); + if entry.is_dir() { + fs::create_dir_all(&out).map_err(|e| format!("mkdir {}: {e}", out.display()))?; + continue; + } + if let Some(parent) = out.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?; + } + let mut w = + fs::File::create(&out).map_err(|e| format!("extract {}: {e}", out.display()))?; + std::io::copy(&mut entry, &mut w).map_err(|e| format!("extract {}: {e}", out.display()))?; + } + Ok(()) +} diff --git a/app/updater/src-tauri/src/download.rs b/app/updater/src-tauri/src/download.rs index 3ae56a7..6363161 100644 --- a/app/updater/src-tauri/src/download.rs +++ b/app/updater/src-tauri/src/download.rs @@ -24,7 +24,15 @@ pub fn download( on_progress: &dyn Fn(u64), on_verify: &dyn Fn(), ) -> Result<(), String> { - let downloaded = crate::http::get_to_file(url, dest, cancel, on_progress) + crate::manifest::ComponentInfo { + version: "0.0.0".into(), + url: url.into(), + size: expected_size, + sha256: expected_sha256.into(), + ed_signature: ed_signature_b64.into(), + } + .validate()?; + let downloaded = crate::http::get_to_file(url, dest, expected_size, cancel, on_progress) .map_err(|e| { if e == "__cancelled" { CANCELLED.to_string() @@ -47,11 +55,9 @@ pub fn download( return Err("checksum mismatch - the download is corrupt".to_string()); } } - if !ed_signature_b64.is_empty() { - if let Err(e) = verify_signature(dest, ed_signature_b64) { - let _ = std::fs::remove_file(dest); - return Err(e); - } + if let Err(e) = verify_signature(dest, ed_signature_b64) { + let _ = std::fs::remove_file(dest); + return Err(e); } Ok(()) } @@ -83,10 +89,24 @@ fn verify_signature(path: &Path, sig_b64: &str) -> Result<(), String> { .map_err(|_| "bad signature encoding".to_string())? .try_into() .map_err(|_: Vec| "bad signature length".to_string())?; - let key = - VerifyingKey::from_bytes(&key_bytes).map_err(|_| "bad public key".to_string())?; + let key = VerifyingKey::from_bytes(&key_bytes).map_err(|_| "bad public key".to_string())?; let sig = Signature::from_bytes(&sig_bytes); let msg = std::fs::read(path).map_err(|e| format!("read for verify: {e}"))?; key.verify_strict(&msg, &sig) .map_err(|_| "signature verification failed".to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_and_forged_signatures_are_rejected() { + let path = std::env::temp_dir().join(format!("monolith-signature-{}", std::process::id())); + std::fs::write(&path, b"untrusted component").unwrap(); + assert!(verify_signature(&path, "").is_err()); + let forged = base64::engine::general_purpose::STANDARD.encode([0u8; 64]); + assert!(verify_signature(&path, &forged).is_err()); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/app/updater/src-tauri/src/engine_rpc.rs b/app/updater/src-tauri/src/engine_rpc.rs index c464b37..9d4d133 100644 --- a/app/updater/src-tauri/src/engine_rpc.rs +++ b/app/updater/src-tauri/src/engine_rpc.rs @@ -1,12 +1,15 @@ use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; -use std::net::TcpStream; +use std::net::{SocketAddr, TcpStream}; use std::time::{Duration, Instant}; // Same newline-delimited JSON-RPC 2.0 control socket the main UI and the // Stream Deck plugin speak (libs/ipc): 127.0.0.1:45991, one request per // connection. The updater is just another client. -const HOST: &str = "127.0.0.1:45991"; +const HOST: SocketAddr = SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::LOCALHOST, + 45991, +)); fn rpc(method: &str) -> Result { let payload = json!({ @@ -14,15 +17,63 @@ fn rpc(method: &str) -> Result { "id": 1, "method": method, }); - let mut stream = TcpStream::connect(HOST).map_err(|err| err.to_string())?; - let _ = stream.set_read_timeout(Some(Duration::from_secs(4))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(4))); + let mut stream = + TcpStream::connect_timeout(&HOST, Duration::from_secs(4)).map_err(|err| err.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(4))) + .map_err(|err| err.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(4))) + .map_err(|err| err.to_string())?; writeln!(stream, "{payload}").map_err(|err| err.to_string())?; - let mut line = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut line).map_err(|err| err.to_string())?; - serde_json::from_str::(line.trim()).map_err(|err| err.to_string()) + read_response(BufReader::new(stream), 1) +} + +fn read_response(reader: impl BufRead, id: u64) -> Result { + let mut line = Vec::new(); + reader + .take(64 * 1024 + 1) + .read_until(b'\n', &mut line) + .map_err(|err| err.to_string())?; + if line.len() > 64 * 1024 || line.last() != Some(&b'\n') { + return Err("invalid or oversized engine response".into()); + } + let value: Value = serde_json::from_slice(&line).map_err(|err| err.to_string())?; + if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") + || value.get("id").and_then(Value::as_u64) != Some(id) + || value.get("result").is_some() == value.get("error").is_some() + { + return Err("invalid engine response envelope".into()); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::read_response; + use std::io::Cursor; + #[test] + fn response_envelope_and_size_are_bounded() { + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}\n"), + 1 + ) + .is_ok()); + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}\n"), + 1 + ) + .is_err()); + assert!(read_response(Cursor::new(b"{\"id\":1,\"result\":null}\n"), 1).is_err()); + assert!(read_response( + Cursor::new(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null,\"error\":null}\n"), + 1 + ) + .is_err()); + assert!(read_response(Cursor::new(vec![b'x'; 65537]), 1).is_err()); + assert!(read_response(Cursor::new(b"{}"), 1).is_err()); + } } pub fn engine_running() -> bool { @@ -32,11 +83,7 @@ pub fn engine_running() -> bool { pub fn recording() -> bool { rpc("get_status") .ok() - .and_then(|r| { - r.get("result")? - .get("recording")? - .as_bool() - }) + .and_then(|r| r.get("result")?.get("recording")?.as_bool()) .unwrap_or(false) } diff --git a/app/updater/src-tauri/src/http.rs b/app/updater/src-tauri/src/http.rs index 4c435d0..f82b5b7 100644 --- a/app/updater/src-tauri/src/http.rs +++ b/app/updater/src-tauri/src/http.rs @@ -14,9 +14,8 @@ use windows::core::PCWSTR; use windows::Win32::Networking::WinHttp::{ WinHttpCloseHandle, WinHttpConnect, WinHttpCrackUrl, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryHeaders, WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, - WinHttpSetTimeouts, URL_COMPONENTS, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, - WINHTTP_FLAG_SECURE, WINHTTP_INTERNET_SCHEME_HTTPS, WINHTTP_QUERY_FLAG_NUMBER, - WINHTTP_QUERY_STATUS_CODE, + WinHttpSetTimeouts, URL_COMPONENTS, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_FLAG_SECURE, + WINHTTP_INTERNET_SCHEME_HTTPS, WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_QUERY_STATUS_CODE, }; const USER_AGENT: &str = "Monolith-Updater"; @@ -44,6 +43,9 @@ struct Parts { } fn crack_url(url: &str) -> Result { + if url.contains('\0') || !url.starts_with("https://") { + return Err("URL must use HTTPS and contain no NUL characters".into()); + } let wide_url: Vec = url.encode_utf16().collect(); let mut comps = URL_COMPONENTS::default(); comps.dwStructSize = std::mem::size_of::() as u32; @@ -53,25 +55,34 @@ fn crack_url(url: &str) -> Result { comps.dwUrlPathLength = 0xFFFF_FFFF; comps.dwExtraInfoLength = 0xFFFF_FFFF; unsafe { - WinHttpCrackUrl(&wide_url, 0, &mut comps) - .map_err(|_| format!("invalid URL: {url}"))?; + WinHttpCrackUrl(&wide_url, 0, &mut comps).map_err(|_| format!("invalid URL: {url}"))?; } - let host = unsafe { - std::slice::from_raw_parts(comps.lpszHostName.0, comps.dwHostNameLength as usize) + if comps.nScheme != WINHTTP_INTERNET_SCHEME_HTTPS + || comps.dwHostNameLength == 0 + || comps.lpszHostName.0.is_null() + { + return Err("invalid HTTPS host".into()); } - .to_vec(); - let mut path = unsafe { - std::slice::from_raw_parts(comps.lpszUrlPath.0, comps.dwUrlPathLength as usize) + let mut host = unsafe { + std::slice::from_raw_parts(comps.lpszHostName.0, comps.dwHostNameLength as usize) } .to_vec(); + host.push(0); + let mut path = if comps.dwUrlPathLength == 0 { + vec![b'/' as u16] + } else { + if comps.lpszUrlPath.0.is_null() { + return Err("invalid URL path".into()); + } + unsafe { std::slice::from_raw_parts(comps.lpszUrlPath.0, comps.dwUrlPathLength as usize) } + .to_vec() + }; if !comps.lpszExtraInfo.0.is_null() && comps.dwExtraInfoLength > 0 { path.extend_from_slice(unsafe { - std::slice::from_raw_parts( - comps.lpszExtraInfo.0, - comps.dwExtraInfoLength as usize, - ) + std::slice::from_raw_parts(comps.lpszExtraInfo.0, comps.dwExtraInfoLength as usize) }); } + path.push(0); Ok(Parts { host, path, @@ -104,8 +115,7 @@ where .map_err(|e| format!("WinHttpSetTimeouts failed: {e}"))?; let port = if parts.port != 0 { parts.port } else { 443 }; - let connect_raw = - WinHttpConnect(session.0, PCWSTR(parts.host.as_ptr()), port, 0); + let connect_raw = WinHttpConnect(session.0, PCWSTR(parts.host.as_ptr()), port, 0); if connect_raw.is_null() { return Err("WinHttpConnect failed".to_string()); } @@ -174,6 +184,9 @@ where pub fn get_to_string(url: &str) -> Result { let mut body = Vec::new(); get(url, |chunk| { + if chunk.len() > 1024 * 1024 - body.len() { + return Err("manifest exceeds 1 MiB".into()); + } body.extend_from_slice(chunk); Ok(()) })?; @@ -186,6 +199,7 @@ pub fn get_to_string(url: &str) -> Result { pub fn get_to_file( url: &str, dest: &Path, + max_bytes: u64, cancel: &AtomicBool, on_progress: &dyn Fn(u64), ) -> Result { @@ -198,11 +212,16 @@ pub fn get_to_file( if cancel.load(Ordering::Relaxed) { return Err("__cancelled".to_string()); } - file.write_all(chunk).map_err(|e| format!("staging write failed: {e}"))?; + if chunk.len() as u64 > max_bytes.saturating_sub(total) { + return Err("download exceeds declared component size".into()); + } + file.write_all(chunk) + .map_err(|e| format!("staging write failed: {e}"))?; total += chunk.len() as u64; on_progress(total); Ok(()) }); + drop(file); match result { Ok(()) => Ok(total), Err(e) => { diff --git a/app/updater/src-tauri/src/main.rs b/app/updater/src-tauri/src/main.rs index 0afa77f..57da512 100644 --- a/app/updater/src-tauri/src/main.rs +++ b/app/updater/src-tauri/src/main.rs @@ -16,6 +16,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod apply; +mod archive; mod download; mod engine_rpc; mod http; @@ -228,7 +229,12 @@ fn run_pipeline(app: &AppHandle) { let app_dir = paths::app_dir(); let staging = paths::staging_dir(); - let _ = std::fs::remove_dir_all(&staging); + if staging.exists() { + if let Err(e) = std::fs::remove_dir_all(&staging) { + fail(app, &format!("cannot clear staging dir: {e}")); + return; + } + } if let Err(e) = std::fs::create_dir_all(&staging) { fail(app, &format!("cannot create staging dir: {e}")); return; @@ -338,7 +344,7 @@ fn run_pipeline(app: &AppHandle) { } } let src = staging.join("ui"); - if let Err(e) = apply::extract_zip(&staging.join("ui.zip"), &src) + if let Err(e) = archive::extract_zip(&staging.join("ui.zip"), &src) .and_then(|_| apply::place_tree(&src, &app_dir.join("ui"))) { fail(app, &format!("applying interface: {e}")); @@ -355,7 +361,7 @@ fn run_pipeline(app: &AppHandle) { } } let src = staging.join("engine"); - if let Err(e) = apply::extract_zip(&staging.join("engine.zip"), &src) + if let Err(e) = archive::extract_zip(&staging.join("engine.zip"), &src) .and_then(|_| apply::place_tree(&src, &app_dir)) { fail(app, &format!("applying engine: {e}")); @@ -397,7 +403,7 @@ fn run_pipeline(app: &AppHandle) { if has("updater") { let src = staging.join("updater"); - if let Err(e) = apply::extract_zip(&staging.join("updater.zip"), &src) + if let Err(e) = archive::extract_zip(&staging.join("updater.zip"), &src) .and_then(|_| apply::self_swap(&src.join("Updater.exe"))) { fail(app, &format!("applying updater: {e}")); diff --git a/app/updater/src-tauri/src/manifest.rs b/app/updater/src-tauri/src/manifest.rs index 8570a21..6bbd1cb 100644 --- a/app/updater/src-tauri/src/manifest.rs +++ b/app/updater/src-tauri/src/manifest.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; pub const DEFAULT_MANIFEST_URL: &str = "https://github.com/fraa2a/Monolith/releases/latest/download/update-manifest.json"; +pub const MAX_COMPONENT_BYTES: u64 = 512 * 1024 * 1024; + #[derive(Deserialize, Clone)] pub struct Manifest { pub schema: u32, @@ -34,6 +36,47 @@ pub struct ComponentInfo { pub ed_signature: String, } +impl ComponentInfo { + pub fn validate(&self) -> Result<(), String> { + semver::Version::parse(&self.version).map_err(|_| "invalid component version")?; + if !self.url.starts_with("https://") || self.url.contains('\0') { + return Err("component URL must use HTTPS".into()); + } + if self.size == 0 || self.size > MAX_COMPONENT_BYTES { + return Err("invalid component size".into()); + } + if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("missing or invalid component checksum".into()); + } + use base64::Engine; + let signature = base64::engine::general_purpose::STANDARD + .decode(self.ed_signature.trim()) + .map_err(|_| "invalid component signature")?; + if signature.len() != 64 { + return Err("missing or invalid component signature".into()); + } + Ok(()) + } +} + +impl Manifest { + pub fn validate(&self) -> Result<(), String> { + if self.schema != 1 { + return Err(format!("unsupported manifest schema {}", self.schema)); + } + if self.components.is_empty() { + return Err("manifest contains no components".into()); + } + for (key, component) in &self.components { + if !["engine", "ui", "updater"].contains(&key.as_str()) { + return Err(format!("unknown component: {key}")); + } + component.validate().map_err(|e| format!("{key}: {e}"))?; + } + Ok(()) + } +} + pub fn manifest_url() -> String { std::env::var("MONOLITH_UPDATE_MANIFEST") .ok() @@ -47,11 +90,6 @@ pub fn fetch() -> Result { serde_json::from_str(&body).map_err(|e| format!("invalid manifest JSON: {e}"))?; let m: Manifest = serde_json::from_value(value).map_err(|e| format!("invalid manifest: {e}"))?; - if m.schema != 1 { - return Err(format!("unsupported manifest schema {}", m.schema)); - } - if m.components.is_empty() { - return Err("manifest contains no components".to_string()); - } + m.validate()?; Ok(m) } diff --git a/app/updater/src-tauri/src/paths.rs b/app/updater/src-tauri/src/paths.rs index a0949c6..088d8da 100644 --- a/app/updater/src-tauri/src/paths.rs +++ b/app/updater/src-tauri/src/paths.rs @@ -27,10 +27,12 @@ pub fn app_dir() -> PathBuf { } let mut cur: Option<&std::path::Path> = Some(exe.as_path()); while let Some(dir) = cur { - if dir.join("app").join("desktop-ui").is_dir() - && dir.join("app").join("updater").is_dir() - { - return dir.join("build").join("app").join("recorder").join("Release"); + if dir.join("app").join("desktop-ui").is_dir() && dir.join("app").join("updater").is_dir() { + return dir + .join("build") + .join("app") + .join("recorder") + .join("Release"); } cur = dir.parent(); } diff --git a/app/updater/src-tauri/src/versions.rs b/app/updater/src-tauri/src/versions.rs index 9bb07a8..0594c23 100644 --- a/app/updater/src-tauri/src/versions.rs +++ b/app/updater/src-tauri/src/versions.rs @@ -86,6 +86,7 @@ fn file_version(path: &Path) -> Option { ) .as_bool() || block.is_null() + || len < std::mem::size_of::() as u32 { return None; } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 182860a..265ebe1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -309,3 +309,13 @@ Date: 2026-10-08 The native recorder and vcpkg dependencies retain dynamic MSVC linkage. CMake copies the redistributable runtime beside the engine. Rust UI and updater binaries use a static CRT, matching their independent single-executable component payloads. The verifier scans the root and sidecars, requires imports beside each binary and never treats VC runtime DLLs installed on a CI host as Windows prerequisites. All component staging directories are checked before packaging. Dynamically loaded driver/WebView2 dependencies and actual loader behavior require a clean Windows 11 smoke test. + +## ADR-0020: Audit bounds and error propagation + +Date: 2026-10-08 + +Collection hydration distinguishes unavailable catalogs from absent clips, loads IDs in batches, and prunes only after all required catalogs are readable. Bookmark sequence allocation uses an immediate transaction. Existing direct UI metadata writes remain a documented single-writer violation. + +Both Rust engine clients require bounded JSON-RPC response envelopes tied to the request ID. Updater components require validated size, SHA256 and Ed25519 metadata; unsigned components have no bypass. Archive extraction is a separate module with Windows path/link/resource limits. Per-file replacement restoration does not provide component-wide rollback. + +Manual recording uses one presentation origin across all streams to preserve composition and intertrack offsets. Failed packet/finalization output is not returned for cataloging. Synchronous recording I/O and pause/resume continuity remain follow-up work. diff --git a/docs/audit/2026-10-08.md b/docs/audit/2026-10-08.md new file mode 100644 index 0000000..cbfe3a6 --- /dev/null +++ b/docs/audit/2026-10-08.md @@ -0,0 +1,95 @@ +# Monolith repository audit - 2026-10-08 + +This report accompanies three English pull requests. The baseline is `db3056f5` on `main`. The packaging branch is the base of the technical audit; the comment branch is based on the technical audit. Review and merge in that order so the diffs stay separate. + +## Scope and evidence + +The review covers the recorder, native libraries, both Tauri hosts and frontends, Stream Deck plugin, updater, build/release/installer configuration, tests and repository documentation. [The file inventory](file-inventory.md) accounts for every tracked file and new deliverable, including generated schemas, lockfiles and binary assets. Generated files and assets receive configuration/metadata review rather than independent source-code verification. Static review, portable regression tests and cross-compilation checks have different limits. No claim of defect-free code, measured production speedup, clean Windows installation or hardware-capture runtime validation is made. + +Historical findings in `report.md` and `research_codebase/` remain historical records. This report does not mark their entire backlog resolved. + +## PR 1: code correctness, security, performance and maintainability + +| ID | Problem and change | Evidence | +| --- | --- | --- | +| A01 | Capture callbacks retained a raw implementation pointer across teardown. Each session now has shared state; callback revocation and a mutex drain replace the fixed sleep. Partial startup is cleaned up. | Windows source review; runtime stress test still required. | +| A02 | Audio activation could complete after its event handle was closed; failed startup left resources behind. Completion owns the handle and failures stop/join/release the client. Packet callback exceptions release the WASAPI buffer. | Windows source review; hardware execution pending. | +| A03 | Packed 24-bit PCM was passed as 32-bit samples. A shared input converter sign-extends and left-aligns it; unsigned 8-bit silence is 128. Invalid frame alignment and channel counts are rejected. | PCM conversion regression test. | +| A04 | A delayed mixer tick could allocate a large output block, and source FIFOs could retain excessive audio. Tick output is capped at 100 ms, FIFO retention at one second, and drain counts at the available frames. Nonfinite/negative gains become zero. | Production mixer regression with oversized input and finite, bounded output. | +| A05 | A throwing mixer sink could terminate the process, and a stopped worker could leave a joinable thread. Worker failures clear the running flag; close joins even after failure; thread creation is handled. | Throwing-sink regression followed by reopen. | +| A06 | Invalid video dimensions/stride could overrun conversion buffers. Encoder input now rejects invalid bounds. A requested H.265/AV1 codec cannot silently fall back to H.264. | Production encoder test for wrong-family preference, valid encode and invalid stride. | +| A07 | Manual recording paths used fixed buffers and second resolution. Filesystem joins, recursive directories, milliseconds and a process-local sequence prevent long-path truncation and same-process collisions. Failed header creation frees its format context. | Nested-directory, long-path and repeated-start tests. Windows long-path policy is separate. | +| A08 | Independent initial PTS/DTS anchors altered B-frame composition and could drop video frames while decoding still succeeded. Manual recording now uses a common presentation origin for all streams and preserves composition offsets. Pause tracking uses DTS. | All decoded video hashes and both audio packet/offset comparisons against the source fixture. Pause/resume continuity remains an open validation item. | +| A09 | Packet/trailer/close failures were ignored, allowing incomplete recordings to be cataloged. Failed output is suppressed from the returned saved path and logged; recording state still stops. | Injected mux write failure does not return a saved path. | +| A10 | Replay destruction released callback state before the disk worker joined; throwing completion handlers could leave saving set. Disk teardown runs first and completion failures release saving state. | Throwing completion followed by another successful replay save; lifetime ordering reviewed. | +| A11 | Timer/handle startup failures and concurrent settings reads in capture had weak handling. Startup cleans up handles, timer failure uses a bounded wait, and callback settings are snapshotted. Runtime-status cache advances only after a successful write. | Windows source review; pacer runtime pending. | +| A12 | Reopening the settings sidecar could leak its process handle; handle access was not serialized. Guarded replacement closes the prior handle. | Windows source review. | +| A13 | Catalog mutation accepted dot/dot-dot and trailing dot/space aliases; an abandoned Windows mutex was treated as failure. Invalid leaf names are rejected in mutations and reconciliation; abandoned ownership is accepted and released. | Storage mutation/recovery regression with alias rejection. Full reparse/file-identity safety is open. | +| A14 | Collection hydration opened/reloaded catalogs repeatedly and treated a missing/corrupt database like a deleted clip, pruning valid membership. IDs are loaded in batches with one connection/prepared query/artwork map per catalog, and errors propagate before pruning. | Missing/corrupt catalog preserves membership; actual missing media is pruned transactionally. No production latency benchmark. | +| A15 | Bookmark sequence allocation raced between connections. An immediate transaction serializes allocation and insertion. Collection deletion uses enabled foreign-key cascade in one statement. | Eight concurrent bookmark writers produce unique sequences. | +| A16 | Older game catalogs could fail index creation before missing columns were migrated; refresh writes used a readonly connection. Migration runs before index creation, refresh opens writable, and artwork persistence errors are surfaced. | Legacy one-column catalog migration and icon persistence. | +| A17 | Output-directory cache ignored settings changes in a WAL file and could reinsert stale values after save. Cache invalidation is serialized and fingerprints both database and WAL timestamps/sizes. | Local saves and external WAL updates change the returned folders. | +| A18 | RPC reads were unbounded and responses were not tied strictly to requests. Both clients bound response bytes, set timeouts, assign atomic IDs, and validate JSON-RPC version/id/result/error envelopes. | Production-module response tests and Windows-target type check. Timeouts are per socket operation, not a total request deadline. | +| A19 | Stale library/collection requests could overwrite newer state, failed deletion closed its dialog, and settings toggles ignored save errors. Load generations discard old responses; failures keep the dialog/error visible; titlebar saves are guarded and revert on failure. | TypeScript checks and production frontend builds; interactive runtime validation pending. | +| A20 | Hidden multitrack audio did not inherit muted/volume/playback rate or attach when metadata was already loaded. Initial state and change events now synchronize all tracks, with listener cleanup. | TypeScript checks and frontend build; WebView2 playback test pending. | +| A21 | Executable-icon promises accumulated indefinitely and cached failed lookup forever. The cache is bounded to 128 entries and retries null results; failed clip-event subscription is logged. | TypeScript checks and frontend build. | +| A22 | Stream Deck connections accumulated retry timers/socket state and split UTF-8 responses incorrectly. One socket, a per-socket decoder, bounded input/pending maps and explicit disconnect/destroy cleanup prevent those cases. Generated JS matches source. | Real loopback socket regression for split UTF-8, reconnection, limits and shutdown. | +| A23 | Updater could accept missing signatures or malformed integrity metadata. Schema/component/version/HTTPS/size/hash/signature shape are required before network I/O; Ed25519 verification cannot be bypassed. | Missing/forged signature, invalid manifest and size tests. | +| A24 | WinHTTP URL slices were not NUL-terminated, queries could be lost, and downloads/manifest reads were unbounded. Host/path/query are copied, HTTPS is enforced and response sizes capped; files close before failed downloads are removed. | Windows-target type check plus source inspection; real WinHTTP execution pending. | +| A25 | Archive validation covered traversal but not Windows aliases, links or archive limits. Extraction is isolated into a testable module and rejects those entries, with 4096-entry/2-GiB declared size limits. Failed file replacement restores its parked predecessor. | Unsafe-path ZIP fixtures and valid nested extraction; Windows replacement check is compile-only. Component-wide rollback remains open. | + +New CI runs the native, Rust, Stream Deck and frontend checks on pull requests, including stacked branches. Test harnesses compile production modules rather than duplicate their implementation. The native Windows portions still rely on the Windows build and subsequent runtime testing. + +## PR 2: essential comments + +The separate comment-only branch removes decorative separators, obsolete migration/task narratives, descriptions already apparent from names, and unsubstantiated claims of safety or correctness. Retained comments state nonobvious units, clock/ordering rules, callback/resource ownership, caller constraints and format limitations in brief English. License notices and tool directives remain. Generated plugin JavaScript is kept aligned with its TypeScript source. + +Validation compares the surviving source lines against the audit parent, ignoring only full-line comments and blank lines, and reruns TypeScript/plugin builds. This is a source-preservation check, not a general-purpose language parser. + +## PR 3: startup DLL failure and other release fixes + +The app-local dependency closure must include DLLs imported by DLLs, not only those imported directly by the EXE. The native recorder retains dynamic MSVC linkage and CMake stages the redistributable runtime next to it. Rust UI/updater use static CRT linkage so independent single-executable component packages have matching runtime contracts. + +The recursive verifier checks imports relative to each executable's own directory and refuses to count Visual Studio/VC runtime DLLs installed on a CI runner as operating-system prerequisites. It runs on the release folder and engine/UI/updater component staging before packaging. Eight verifier regressions include missing nested dependencies and sibling-directory masking. + +Additional release findings: the pinned x264 GitLab archive failed its SHA512 check, so an overlay fetches the same upstream commit through Git; overlay registration uses `vcpkg-configuration.json`. The updater Cargo version now agrees with its Tauri 1.0.1 version. Desktop fullscreen has the explicit maximize/unmaximize capabilities it calls. The repository text policy rejects the two prohibited long-dash code points, including in CI. + +This corrects the packaging contract for the reported startup failure. It does not prove the specific installed build launches on a clean Windows 11 machine. Drivers, dynamically loaded libraries and WebView2 require that smoke test. + +## Validation performed + +| Check | Local result and scope | +| --- | --- | +| Native phase1 | 3 tests: replay ring, real socket IPC, PCM input; strict warnings. | +| Native phase2 | 16 MP4/MKV lossless/re-encode fractional/VFR/B-frame fixtures, frame/PTS/audio comparisons, manual recording, replay/disk cases; strict warnings. Separate production encoder/mixer regression passes. | +| Storage U6 | Mutation/recovery/invalid-journal/path-alias cases pass under Linux Win32 shims. | +| Rust | 6 tests pass, including missing/forged signatures and concurrent catalog writes. | +| Windows Rust modules | `cargo check --locked --no-default-features --target x86_64-pc-windows-msvc --manifest-path tests/rust/Cargo.toml` passes. This is neither linking nor running the Tauri apps. | +| Frontends | Both `tsc --noEmit` and Vite production builds pass. | +| Stream Deck | TypeScript build and one loopback integration regression pass. | +| Packaging | 8 PowerShell verifier regression cases pass. | +| Repository | `git diff --check` and `scripts/check-text.py` pass. | +| Windows Actions | Build started on the packaging PR. Final CI status must be read from the PR checks; initial iterations exposed and corrected the x264 archive and vcpkg overlay failures. | + +Native tests use small synthetic media and Linux compatibility shims. They do not exercise actual WGC, WASAPI, D3D11, hardware encoders, Windows file sharing or installer execution. Fixture-pressure AAC reset warnings are distinct from the checked continuous timeline. Performance changes bound memory/work and remove repeated I/O; FPS, CPU/GPU load and user-visible latency have not been benchmarked. + +## Remaining findings and recommended follow-up + +| ID | Priority | Finding and required follow-up | +| --- | --- | --- | +| O01 | High | `apply.rs::place_tree` is not component-transactional: failure after several successful file swaps leaves a mixed version. Add durable component rollback/recovery and Windows fault-injection tests before claiming atomic update. | +| O02 | High | `updater/main.rs` records installed versions before the final self-swap, and relaunch errors are not fully surfaced. Persist truthful version metadata only after all applies, and report restart failure. | +| O03 | High | Recorder control/finalization and packet mux I/O still run synchronously in paths reachable from the tray or capture/encoding pipeline. Move them to owned bounded workers, with explicit shutdown/drain and overload policy. Historical R7/R8 and writer backpressure are not resolved. | +| O04 | High | Tauri commands still directly write title/favorite/tag/bookmark/duration/thumbnail data despite the engine-single-writer design rule. Route every catalog mutation through engine RPC without adding offline writer fallbacks. Delete/rename already follow that contract. | +| O05 | High | Storage journaling does not establish stable file identity, reparse-point safety or filesystem/SQLite atomicity. Add a comprehensive Windows crash/fault matrix and malicious-path tests. | +| O06 | Medium | Asset scope adds configured directories without revoking old ones. Track and revoke obsolete permissions, including failures, when output folders change. | +| O07 | Medium | Settings popup cancels a pending debounced save on close, concurrent full-config saves can overwrite each other, and stale save completions can show the wrong state. Serialize revisioned writes and flush or explicitly discard pending edits. | +| O08 | Medium | Updater signature verification reads a component of up to 512 MiB into memory in addition to staging. Profile peak memory or adopt a release-compatible signing scheme that supports bounded verification. Do not change the signed protocol without migration. | +| O09 | Medium | IPC timeout limits are per operation; slow peers can extend total work. Add a total deadline and consider local authentication/authorization; loopback binding alone is not authentication. | +| O10 | Medium | Manual pause/resume currently removes gaps per stream and waits for a video keyframe. Test cross-track skew, B-frames, repeated pauses and tail behavior before declaring continuity. | +| O11 | Medium | Logger rotates on opening only and flushes every record under a mutex. Long sessions can exceed the size limit and synchronous disk writes can stall callers. Add bounded rotation and measure before changing flush durability. | +| O12 | Medium | UI collection membership picker hydrates each entire collection, some mutation dialogs omit failure feedback, and fullscreen does not remember prior maximized state. Add targeted membership lookup and interaction tests. | +| O13 | Low | Large recorder/settings frontend files retain multiple responsibilities; checked-in generated schemas/JS and old scaffold directories complicate navigation. Extract cohesive modules only with focused regression coverage; do not rename public APIs just to shorten names. | +| O14 | Release gate | Clean Windows 11 install/start, WebView2 prerequisites, loaded-DLL replacement and capture stop/restart stress remain untested. Test the actual installer payload on a machine without developer runtimes and preserve logs. | + +The three PRs are intended for review. No merge, release tag, installer publication or production deployment is included. diff --git a/docs/audit/file-inventory.md b/docs/audit/file-inventory.md new file mode 100644 index 0000000..aeb85b8 --- /dev/null +++ b/docs/audit/file-inventory.md @@ -0,0 +1,247 @@ +# File-by-file audit inventory + +Snapshot after technical changes and before the comment-only branch. 239 files are accounted for. SHA256 prefixes identify reviewed content; they change with subsequent comments. Inventory inclusion is not a claim that every path or failure mode has been executed. Binary assets, generated schemas and dependency locks receive metadata review. The inventory file itself is excluded to avoid a self-referential hash. + +See [audit findings, validation and unresolved work](2026-10-08.md). + +| File | Review type | Bytes | SHA256 prefix | Observation | +| --- | --- | ---: | --- | --- | +| `.cargo/config.toml` | Configuration review | 81 | `53329a3ce3b8` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `.github/workflows/core-tests.yml` | Configuration review | 1842 | `5b4f706ea6a8` | Portable native/Rust/plugin/frontend regressions on all PR branches. | +| `.github/workflows/version-tag.yml` | Configuration review | 14250 | `28f8a39ab2c6` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `.github/workflows/windows-ci.yml` | Configuration review | 6462 | `b922c37fa08c` | Runtime/text verifier plus complete Windows build on all PR branches. | +| `.gitignore` | Configuration review | 1175 | `a01da3edebe1` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `AGENTS.md` | Documentation | 907 | `7db4d1094db6` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `CLAUDE.md` | Documentation | 5889 | `186f370617a9` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `CMakeLists.txt` | Configuration review | 2077 | `69c9cf59f18f` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `LICENSE` | Configuration review | 35148 | `1c14cc78a086` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/assets/exeicon.ico` | Binary metadata | 36959 | `9b6a8ad2ef0f` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/assets/exeicon.png` | Binary metadata | 3240110 | `010b821deb5e` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/assets/icon.ico` | Binary metadata | 6758 | `86a350fea7a5` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/assets/icon.png` | Binary metadata | 263787 | `366936932c73` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/desktop-ui/.gitignore` | Configuration review | 245 | `ac0cac065088` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/CMakeLists.txt` | Configuration review | 2588 | `9449856f9aee` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/PRODUCT.md` | Documentation | 2475 | `9690174b036a` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `app/desktop-ui/README.md` | Documentation | 3477 | `3edd2d82dc55` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `app/desktop-ui/package-lock.json` | Dependency metadata | 78331 | `1c588c6ea009` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `app/desktop-ui/package.json` | Configuration review | 663 | `8e46a5a148d9` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/src-tauri/Cargo.lock` | Dependency metadata | 129289 | `4a0bf62b9410` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `app/desktop-ui/src-tauri/Cargo.toml` | Configuration review | 731 | `de1624dba27d` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/src-tauri/build.rs` | Source review | 40 | `6aeeab41bc35` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/src-tauri/capabilities/default.json` | Configuration review | 463 | `0d91e0486d5c` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/src-tauri/gen/schemas/acl-manifests.json` | Generated metadata | 65934 | `b8665c2471bd` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/desktop-ui/src-tauri/gen/schemas/capabilities.json` | Generated metadata | 308 | `22b043a59cea` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/desktop-ui/src-tauri/gen/schemas/desktop-schema.json` | Generated metadata | 116049 | `f68a9c570ecf` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/desktop-ui/src-tauri/gen/schemas/linux-schema.json` | Generated metadata | 116049 | `f68a9c570ecf` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/desktop-ui/src-tauri/gen/schemas/windows-schema.json` | Generated metadata | 116049 | `f68a9c570ecf` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/desktop-ui/src-tauri/icons/icon.png` | Binary metadata | 263787 | `366936932c73` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/desktop-ui/src-tauri/src/asset_scope.rs` | Source review | 721 | `fb5bd9ddc252` | Directory permission changes inspected; obsolete roots are retained, O06. | +| `app/desktop-ui/src-tauri/src/clip_catalog.rs` | Source review | 22189 | `cede95166fdd` | A14-A15: batched hydration, failures and transaction sequence allocation; direct writes remain O04. | +| `app/desktop-ui/src-tauri/src/collections.rs` | Source review | 8356 | `bc168cabc087` | A14-A15: preserve membership on catalog failure and transactional pruning/cascade. | +| `app/desktop-ui/src-tauri/src/commands.rs` | Source review | 20049 | `d602ed7721b3` | Delete/rename routed to engine; remaining direct metadata/thumbnail writes violate O04. | +| `app/desktop-ui/src-tauri/src/engine_rpc.rs` | Source review | 4760 | `5ca8b4102d28` | A18: bounded response, request IDs, envelope checks, per-operation timeouts; O09 remains. | +| `app/desktop-ui/src-tauri/src/exe_icon.rs` | Source review | 5604 | `80432c69002c` | Win32 icon/GDI handle cleanup and conversion inspected; actual extraction untested. | +| `app/desktop-ui/src-tauri/src/game_catalog.rs` | Source review | 13772 | `80317b1202c7` | A16: migration/index order, writable refresh and persistence errors. | +| `app/desktop-ui/src-tauri/src/main.rs` | Source review | 4690 | `faff379e17de` | Native command registration, event watcher and background catalog startup inspected; UI host runtime requires Windows. | +| `app/desktop-ui/src-tauri/src/paths.rs` | Source review | 835 | `ed715cc2127b` | Default/dev/override path resolution inspected; environment/installed-layout runtime untested. | +| `app/desktop-ui/src-tauri/src/settings_store.rs` | Source review | 4256 | `2f306fd2559d` | A17: serialized directory cache invalidation including WAL changes. | +| `app/desktop-ui/src-tauri/tauri.conf.json` | Configuration review | 780 | `67dc8f7c5e1b` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/tsconfig.json` | Configuration review | 301 | `c3d2136219f5` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/ui/index.html` | Configuration review | 637 | `aaebe08b00df` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/ui/src/app.tsx` | Source review | 11526 | `0808e1d4322b` | A19: load generations and visible deletion failures; frontend build passes. | +| `app/desktop-ui/ui/src/home/clip-card.tsx` | Source review | 10907 | `4a52652ad21b` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/collection-detail.tsx` | Source review | 10864 | `53e355e9b914` | A19: stale response/unmount guard; some action feedback remains O12. | +| `app/desktop-ui/ui/src/home/collection-picker.tsx` | Source review | 6168 | `4c334160788e` | Membership lookup loads whole collections, O12; loading/action state inspected. | +| `app/desktop-ui/ui/src/home/collections-view.tsx` | Source review | 7551 | `acc31f4d7231` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/confirm-dialog.tsx` | Source review | 1253 | `50d68fc9814f` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/context-menu.tsx` | Source review | 2319 | `616ca42c1e24` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/detail-view.tsx` | Source review | 27093 | `916dd2611daa` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/filter-menu.tsx` | Source review | 3423 | `6efa33ded081` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/filters.tsx` | Source review | 1486 | `6819c2bf02a9` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/home/fullscreen.tsx` | Source review | 8305 | `ecc24ec3acaa` | Native capabilities corrected in packaging PR; prior maximized state restoration remains O12. | +| `app/desktop-ui/ui/src/home/hashtag-dialog.tsx` | Source review | 3661 | `42d8ed3b26bb` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/lib/api.ts` | Source review | 10045 | `aa8872eda63c` | A21: bounded/retryable icon requests and visible subscription errors; IPC names inspected. | +| `app/desktop-ui/ui/src/lib/format.ts` | Source review | 3764 | `5210dd2ee6b6` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/lib/multitrack.ts` | Source review | 6378 | `09118860d11f` | A20: initial state/events/cleanup; actual WebView2 multitrack playback gate. | +| `app/desktop-ui/ui/src/lib/player.ts` | Source review | 725 | `af0b0e289df1` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/lib/settings-api.ts` | Source review | 2754 | `7e4fca860455` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/lib/window.ts` | Source review | 582 | `3167c98ae86c` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/main.tsx` | Source review | 145 | `b35076b88cca` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/settings/audio-settings.tsx` | Source review | 9579 | `a95c6d862554` | Track/source baseline and UI-to-config routing inspected; hardware execution gate. | +| `app/desktop-ui/ui/src/settings/controls.tsx` | Source review | 8176 | `1e15fea7f1b6` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/settings/settings-popup.tsx` | Source review | 26010 | `7aac5c84cd90` | Typed frontend build; debounced-close/concurrent save and completion-state issues remain O07. | +| `app/desktop-ui/ui/src/shell/icons.tsx` | Source review | 8014 | `371f22d80b14` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/shell/sidebar.tsx` | Source review | 1690 | `885158a4e44d` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/desktop-ui/ui/src/shell/titlebar.tsx` | Source review | 14560 | `8739564b9bea` | A19: guarded saves and rollback/error feedback; whole-config concurrency remains O07. | +| `app/desktop-ui/ui/styles.css` | Configuration review | 53496 | `df381d8a2813` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/desktop-ui/vite.config.ts` | Source review | 478 | `8e3962f4204a` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/engine/README.md` | Documentation | 231 | `dd91ae9a32e0` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `app/recorder/CMakeLists.txt` | Configuration review | 2467 | `9e27109cff73` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/recorder/app/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/recorder/hotkeys/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/recorder/include/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/recorder/monolith.rc` | Configuration review | 1315 | `67832b2d5b18` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/recorder/recording/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/recorder/resources/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/recorder/src/feedback_sounds.h` | Source review | 4039 | `43eee00bf6eb` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/main.cpp` | Source review | 149309 | `1f249266d20e` | A11: pacer/capture settings/status writes; tray control/bookmarks/catalog boundaries inspected; owned workers and writer backpressure remain O03. | +| `app/recorder/src/replay_disk_settings.h` | Source review | 851 | `2ce813fb1844` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/resource.h` | Source review | 106 | `fe5859ceed1b` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/settings_config.cpp` | Source review | 32027 | `66511cb12ecf` | Defaults, SQLite config and input validation inspected; UI save concurrency remains O07. | +| `app/recorder/src/settings_config.h` | Source review | 7918 | `648663a40775` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/settings_window.cpp` | Source review | 6856 | `5698353ed86e` | A12: serialized sidecar handle replacement; process lifetime requires Windows execution. | +| `app/recorder/src/settings_window.h` | Source review | 267 | `68d29976a04a` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/updater.cpp` | Source review | 3930 | `c939ad50c846` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/updater.h` | Source review | 1409 | `7e3891d026c0` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/recorder/src/version.h.in` | Configuration review | 588 | `fbe1e2014d94` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/recorder/tray/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `app/updater/.gitignore` | Configuration review | 150 | `d25b24d3691b` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/CMakeLists.txt` | Configuration review | 2485 | `a6d60f9b73cd` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/package-lock.json` | Dependency metadata | 79107 | `f4d8ed5c8d32` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `app/updater/package.json` | Configuration review | 666 | `f138f7b3f7c9` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/src-tauri/Cargo.lock` | Dependency metadata | 127724 | `18463253a34c` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `app/updater/src-tauri/Cargo.toml` | Configuration review | 799 | `37d074ee5a2e` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/src-tauri/build.rs` | Source review | 40 | `6aeeab41bc35` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/updater/src-tauri/capabilities/default.json` | Configuration review | 323 | `0f532b070a4c` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/src-tauri/gen/schemas/acl-manifests.json` | Generated metadata | 69499 | `4d93885b4645` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/updater/src-tauri/gen/schemas/capabilities.json` | Generated metadata | 251 | `da5f373eddbc` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/updater/src-tauri/gen/schemas/desktop-schema.json` | Generated metadata | 129129 | `623c82e1cf0b` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/updater/src-tauri/gen/schemas/windows-schema.json` | Generated metadata | 129129 | `623c82e1cf0b` | Generated Tauri schema; source capability/config is authoritative; not independently edited. | +| `app/updater/src-tauri/icons/icon.png` | Binary metadata | 263787 | `366936932c73` | Tracked asset, size/type/reference inventory; no source or image-content assurance. | +| `app/updater/src-tauri/src/apply.rs` | Source review | 4331 | `dd1f6e414f71` | A25: per-file predecessor restoration; component rollback and Windows fault matrix remain O01. | +| `app/updater/src-tauri/src/archive.rs` | Source review | 2496 | `fe574bc97240` | A25: traversal/aliases/links/limits with production ZIP extraction regression. | +| `app/updater/src-tauri/src/download.rs` | Source review | 3985 | `24156f81c285` | A23: mandatory integrity/signatures and forged-signature regression; memory remains O08. | +| `app/updater/src-tauri/src/engine_rpc.rs` | Source review | 3783 | `a4966e54abed` | A18: bounded response, request IDs, envelope checks, per-operation timeouts; O09 remains. | +| `app/updater/src-tauri/src/http.rs` | Source review | 7912 | `c94ac6448f6b` | A24: WinHTTP string termination, query, HTTPS and size bounds; Windows-target check only. | +| `app/updater/src-tauri/src/main.rs` | Source review | 15856 | `e92ff99a2fe6` | Download/apply/cancel/restart/version flow inspected; O01/O02 remain, Windows Tauri runtime gate. | +| `app/updater/src-tauri/src/manifest.rs` | Source review | 3000 | `047a6d85a6f2` | A23: schema/key/version/size/HTTPS/hash/signature validation with invalid-input tests. | +| `app/updater/src-tauri/src/paths.rs` | Source review | 1430 | `71b25316f94c` | Default/dev/override path resolution inspected; environment/installed-layout runtime untested. | +| `app/updater/src-tauri/src/state.rs` | Source review | 3686 | `19e151cc74b7` | Serialized updater state/enums and public frontend shape inspected. | +| `app/updater/src-tauri/src/versions.rs` | Source review | 3678 | `221c5c65f478` | VERSIONINFO size/signature inspected and Windows-target checked; persistence flow remains O02. | +| `app/updater/src-tauri/tauri.conf.json` | Configuration review | 625 | `ccda47035cc2` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/tsconfig.json` | Configuration review | 301 | `c3d2136219f5` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/ui/index.html` | Configuration review | 575 | `4f622288b4ca` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/ui/src/main.tsx` | Source review | 157 | `c5213a964838` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `app/updater/ui/src/updater.tsx` | Source review | 15531 | `4fa003a038a3` | Updater phases, download progress and native command/event flow inspected; restart truthfulness depends on O02. | +| `app/updater/ui/styles.css` | Configuration review | 11272 | `fc83ea77b541` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `app/updater/vite.config.ts` | Source review | 478 | `8260f4b94697` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `build.bat` | Configuration review | 1214 | `4f803e309313` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `config/default-config.json` | Configuration review | 1723 | `4c1a7d38fd71` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `config/default_config.h.in` | Configuration review | 343 | `379fab68a27d` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `docs/ARCHITECTURE.md` | Documentation | 5112 | `e4b52a663b6a` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `docs/DECISIONS.md` | Documentation | 15980 | `74be1a60967b` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `docs/DEVELOPMENT_RULES.md` | Documentation | 1498 | `db94e7965221` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `docs/RELEASING.md` | Documentation | 6037 | `b94e0a8cf5ad` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `docs/audit/2026-10-08.md` | Documentation | 16691 | `82057887b493` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `docs/handover/ACTIVE_HANDOVER.md` | Documentation | 36695 | `dd4a6ebc4504` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `global.json` | Configuration review | 80 | `1fdeb1b73953` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `installer/monolith.iss` | Configuration review | 3919 | `2d1a8e8f0c2f` | Installer payload/prerequisite/launch configuration inspected; clean install remains O14. | +| `libs/audio/CMakeLists.txt` | Configuration review | 488 | `531d48fc2e3a` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/audio/audio.cpp` | Source review | 33382 | `e535e149e9fe` | A02: asynchronous activation lifetime, error cleanup and packet-buffer release; Windows runtime gate. | +| `libs/audio/audio.h` | Source review | 6210 | `2a69c1e89b32` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/capture/CMakeLists.txt` | Configuration review | 455 | `33d942d9dbac` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/capture/capture.cpp` | Source review | 21931 | `aa1b9cc940f5` | A01: session ownership, callback revocation, partial startup; Windows runtime gate. | +| `libs/capture/capture.h` | Source review | 2924 | `13eaa48601cb` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/config/README.md` | Documentation | 306 | `2ccd33018471` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `libs/disk-segments/CMakeLists.txt` | Configuration review | 602 | `7533c8b2572b` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/disk-segments/disk_budget.h` | Source review | 601 | `7a5d3918e480` | Budget arithmetic and config boundary fixtures inspected. | +| `libs/disk-segments/disk_segments.cpp` | Source review | 16938 | `2dfc43965615` | Segment/key/pin/save/budget transitions covered by phase2; Windows sharing semantics remain untested. | +| `libs/disk-segments/disk_segments.h` | Source review | 3041 | `486f596848c9` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/encoding/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `libs/encoding/CMakeLists.txt` | Configuration review | 720 | `6b844fe4c9cc` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/encoding/encoding.cpp` | Source review | 42009 | `0ad4c6ac54ab` | A03-A06: PCM conversion, bounds, codec family, bounded mixer and worker teardown. | +| `libs/encoding/encoding.h` | Source review | 10311 | `4ef977fa4ae5` | Stream defaults, codec selection and caller contracts checked. | +| `libs/encoding/mux_common.cpp` | Source review | 6415 | `b3b47136a268` | Stream indexes, timebases, AVIO ownership inspected; synchronous I/O remains O03. | +| `libs/encoding/mux_common.h` | Source review | 3593 | `54fc50328bfe` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/encoding/pcm_input.h` | Source review | 1531 | `82807585d990` | A03: shared packed-PCM conversion; production helper regression. | +| `libs/encoding/thumbnail.cpp` | Source review | 6290 | `a8db2bacf99d` | FFmpeg RAII, dimensions, scaling and file writes inspected; decoder runtime not independently stressed. | +| `libs/encoding/trim.cpp` | Source review | 26046 | `c35410aacb23` | Lossless/re-encode/concat timeline and error handling covered by media fixtures; atomic replacement is limited. | +| `libs/encoding/trim.h` | Source review | 2217 | `66f0535fcd97` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/gamelist/CMakeLists.txt` | Configuration review | 696 | `6a437c33e202` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/gamelist/gamelist.cpp` | Source review | 17450 | `149127aa45d8` | Database-gated selection, scan cadence, Windows process/window handling inspected; live detection untested. | +| `libs/gamelist/gamelist.h` | Source review | 2924 | `51a10e492ef7` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/ipc/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `libs/ipc/CMakeLists.txt` | Configuration review | 445 | `8dcd53d1f067` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/ipc/ipc_limits.h` | Source review | 988 | `3cf6f4aa5d08` | Shared protocol limits inspected against client/test bounds. | +| `libs/ipc/ipc_server.cpp` | Source review | 18701 | `a113a6357f64` | Framing, limits, connection shutdown and real socket regression; authentication/deadlines remain O09. | +| `libs/ipc/ipc_server.h` | Source review | 4344 | `28af0a2763c9` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/logging/CMakeLists.txt` | Configuration review | 373 | `ce2e45ee854e` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/logging/logging.cpp` | Source review | 3354 | `c713b056d266` | Locking, error visibility and rotation inspected; open-only rotation/synchronous flush remain O11. | +| `libs/logging/logging.h` | Source review | 1200 | `e1fce496df37` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/platform-win/CMakeLists.txt` | Configuration review | 398 | `196d898006d6` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/platform-win/platform_win.cpp` | Source review | 2857 | `957fc63e16b9` | UTF conversion, process/window handle use inspected; path-size and Windows execution limits remain. | +| `libs/platform-win/platform_win.h` | Source review | 1553 | `87a00380e837` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/recording/CMakeLists.txt` | Configuration review | 574 | `cc76c7e6d5af` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/recording/recording.cpp` | Source review | 9207 | `1b5fbc88bb15` | A07-A09: paths, B-frame timestamps and failed output; O03/O10 remain. | +| `libs/recording/recording.h` | Source review | 1082 | `ad79285258c2` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/replay-buffer/.gitkeep` | Scaffold | 1 | `01ba4719c80b` | Empty placeholder; retained pending cohesive module cleanup, O13. | +| `libs/replay-buffer/CMakeLists.txt` | Configuration review | 624 | `2a53e279ab75` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/replay-buffer/packet_ring.h` | Source review | 5918 | `59b2f5687831` | Retention, key selection, timestamps and skew covered by phase1/phase2. | +| `libs/replay-buffer/replay_buffer.cpp` | Source review | 12509 | `2f7551804d16` | A10: worker/callback lifetime and completion failure; full backpressure remains O03. | +| `libs/replay-buffer/replay_buffer.h` | Source review | 2736 | `153e73ebee8a` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `libs/storage/CMakeLists.txt` | Configuration review | 610 | `c931581ffa9f` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `libs/storage/storage.cpp` | Source review | 46820 | `5dc12a91d8fd` | A13: mutation names and abandoned mutex; journal fixtures pass; O05 remains. | +| `libs/storage/storage.h` | Source review | 8223 | `3afb54526991` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/.gitignore` | Configuration review | 36 | `d932426cfacc` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `plugins/stream-deck/README.md` | Documentation | 1328 | `d2674fb6e455` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `plugins/stream-deck/manifest-notes.md` | Documentation | 1463 | `bb90f7b84660` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `plugins/stream-deck/manifest.json` | Configuration review | 1698 | `afe74b70adc4` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `plugins/stream-deck/package-lock.json` | Dependency metadata | 3609 | `c7567ccf9859` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `plugins/stream-deck/package.json` | Configuration review | 464 | `e033d71423da` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `plugins/stream-deck/scripts/package-plugin.ps1` | Configuration review | 6306 | `080ae6ad9a3b` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `plugins/stream-deck/src/actions/pause-resume.ts` | Source review | 828 | `6b09a19a00e4` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/src/actions/recording-toggle.ts` | Source review | 1043 | `0b16ee0f933b` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/src/actions/save-replay.ts` | Source review | 640 | `e6208220f7c1` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/src/ipc-client.ts` | Source review | 4600 | `ed3c7c4e7487` | A22: bounded framing/requests and single-socket teardown; real loopback integration passes. | +| `plugins/stream-deck/src/plugin.ts` | Source review | 1445 | `5cd3fd835e0e` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/actions/pause-resume.js` | Source review | 3849 | `1020742bfeed` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/actions/recording-toggle.js` | Source review | 4096 | `0c789ed02da3` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/actions/save-replay.js` | Source review | 3641 | `85c5f6b8589b` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js` | Source review | 4167 | `d0f2ed456482` | Generated counterpart checked against TypeScript build; A22. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/plugin.js` | Source review | 1479 | `88b6d479f8ec` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `plugins/stream-deck/top.fraa2a.monolith.sdPlugin/imgs/README.md` | Documentation | 852 | `6d17f48fd24a` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `plugins/stream-deck/tsconfig.json` | Configuration review | 299 | `72812f6b4ff6` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/README.md` | Documentation | 671 | `69c1f777ebda` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `ports/x264/LICENSE.vcpkg` | Configuration review | 1073 | `1ee376fc340e` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/allow-clang-cl.patch` | Configuration review | 863 | `9411a951a9f6` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/configure.patch` | Configuration review | 1328 | `2c7032f904de` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/parallel-install.patch` | Configuration review | 359 | `d8a4dd8d2642` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/portfile.cmake` | Configuration review | 4514 | `761c1edce09d` | Pinned x264 Git transport overlay; upstream build/patches retained. | +| `ports/x264/uwp-cflags.patch` | Configuration review | 577 | `4f90d00aac77` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/vcpkg.json` | Configuration review | 1034 | `311d617109f0` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `ports/x264/version.diff.in` | Configuration review | 449 | `5902c75a38f4` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `report.md` | Documentation | 64423 | `d6023eaac2fe` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `research_codebase/findings_codebase.md` | Documentation | 1739 | `168d8908617f` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `research_codebase/research_plan.md` | Documentation | 1164 | `3b2ab4d5a31d` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `research_codebase/research_report.md` | Documentation | 1864 | `314f47b7e55c` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `scripts/README.md` | Documentation | 869 | `8866574dd0e8` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `scripts/check-text.py` | Configuration review | 820 | `2074fe998117` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `scripts/generate-appcast.ps1` | Configuration review | 6129 | `5dbff57583e3` | Legacy installer metadata/signature flow inspected; no release published. | +| `scripts/generate-update-manifest.ps1` | Configuration review | 6066 | `d028f1e2f1a2` | Component staging/runtime verification and signing metadata inspected. | +| `scripts/verify-runtime-dlls.ps1` | Configuration review | 3084 | `d73bafbfe42c` | Recursive per-directory dependency closure and eight verifier tests. | +| `tests/packaging/verify-runtime-dlls.tests.ps1` | Test support | 2668 | `05ceb975934b` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/phase1/CMakeLists.txt` | Test support | 1193 | `0ad01309775c` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/phase1/README.md` | Documentation | 2036 | `1e187d922517` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `tests/phase1/ipc_server_test.cpp` | Source review | 6304 | `1c3fd3cd94dc` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase1/pcm_input_test.cpp` | Source review | 1012 | `26440f87aaeb` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase1/replay_packet_ring_test.cpp` | Source review | 4291 | `3b7d57ed6081` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase1/win32-shim/windows.h` | Source review | 455 | `b5370ba28757` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `tests/phase1/win32-shim/winsock2.h` | Source review | 962 | `c7dd69e95407` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `tests/phase1/win32-shim/ws2tcpip.h` | Source review | 35 | `482846ae880f` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `tests/phase2/CMakeLists.txt` | Test support | 2096 | `35591467821b` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/phase2/README.md` | Documentation | 4508 | `c67162b4b719` | Requirements/history/architecture cross-check; historical claims are not re-certified by this audit. | +| `tests/phase2/encoding_test.cpp` | Source review | 2631 | `6b6dae23a07c` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase2/media_test.cpp` | Source review | 23486 | `53405d39622c` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase2/verify_timeline.py` | Test support | 4405 | `605f50dec108` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/phase2/win32-shim/windows.h` | Source review | 1996 | `ffc1984a7a75` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `tests/rust/.gitignore` | Test support | 9 | `306fd52e74fc` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/rust/Cargo.lock` | Dependency metadata | 30080 | `a461455cbe65` | Lockfile/version consistency and locked-build use checked; no exhaustive dependency CVE audit. | +| `tests/rust/Cargo.toml` | Test support | 754 | `80ede3bfcf0e` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/rust/src/lib.rs` | Source review | 1798 | `d9636b1a7938` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/rust/src/tests.rs` | Source review | 7980 | `b3953a2adb5a` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/stream-deck/ipc-client.test.mjs` | Test support | 2851 | `79911695d86e` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/u6/CMakeLists.txt` | Test support | 601 | `a7444450817e` | Fixture/build/shim checked against production modules; platform substitutions are explicit. | +| `tests/u6/storage_mutation_test.cpp` | Source review | 5263 | `e33d780a4397` | Production regression/fixture inspected and executed where listed in the validation table; shims do not certify Windows behavior. | +| `tests/u6/win32-shim/windows.h` | Source review | 2063 | `4f095a1fddba` | Static logic, lifetime/input/action flow and source/build references inspected; applicable module findings and platform limits are in the report. | +| `vcpkg-configuration.json` | Configuration review | 41 | `8282438f3650` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | +| `vcpkg.json` | Configuration review | 384 | `c959fc3111a7` | Build/package/config/resource/style syntax, references and defaults inspected; no runtime proof implied. | diff --git a/docs/handover/ACTIVE_HANDOVER.md b/docs/handover/ACTIVE_HANDOVER.md index b969c5e..676ee2f 100644 --- a/docs/handover/ACTIVE_HANDOVER.md +++ b/docs/handover/ACTIVE_HANDOVER.md @@ -3,6 +3,14 @@ Updated: 2026-10-08 +## Continuation checkpoint 2026-10-08: status verification + +- Published packaging PR: https://github.com/fraa2a/Monolith/pull/3. Remote head verified as `8ba6db2`; Windows CI run `37710318411` failed during Configure because `overlay-ports` was in `vcpkg.json` instead of `vcpkg-configuration.json`. The earlier x264 archive/hash issue prompted the pinned Git overlay. +- Local packaging follow-up `15c498a` moves overlay configuration into its correct file and tracks it in git. Local branch also contains the updater version/capability follow-up. Publishing was rejected by automatic authorization review, which treated the latest user turn as a status request. Do not bypass that rejection; obtain explicit authorization before another remote write. +- Audit work remains uncommitted on `fix/code-audit`. Re-ran the portable Rust production-module harness: 5/5 tests passed, Windows updater module cross-check passed, and unused imports were removed. This is not a full Tauri host build or Windows execution. +- Both frontends passed TypeScript checks and Vite production builds. Stream Deck build and the socket regression passed (UTF-8 splits, disconnect cleanup, bounded responses/pending requests, destroy). +- Full native Windows build and clean-machine installation remain unverified. Audit and comment PRs and the final file-by-file report have not yet been published. Do not report them as complete. + ## Session 2026-10-08: Windows runtime packaging and text policy - Copy MSVC redistributable libraries into the native runtime output via CMake. @@ -445,3 +453,11 @@ replacement are still open. Delete/rename UI calls now require the running recorder and route over existing JSON-RPC; no UI SQLite fallback exists. Storage uses a validated operation journal and no-overwrite moves, delete quarantine, startup recovery, and a catalog-keyed Windows mutex. Focused Linux storage shim coverage exercised normal mutation, prepared delete/rename recovery, committed delete cleanup, ambiguous state rejection, and invalid journal rejection; phase2 tests and offline Cargo check also passed. Residual work: this does not prove filesystem/SQLite atomicity, stable file identity, reparse safety, Windows runtime/cross-process behavior, or a comprehensive fault matrix. R7/R8 remain pending. + +## Repository audit - 2026-10-08 + +The user authorized autonomous publication of all three English PRs after checks. Earlier status-only approval blocks are superseded. Packaging PR #3 is published; native runtime staging, Rust static CRT, recursive runtime verifier, pinned x264 Git overlay, updater version and fullscreen permissions are included. Windows CI is compiling dependencies. No merge/release is authorized by this task. + +Technical audit changes and local regression evidence are in `docs/audit/2026-10-08.md`; `file-inventory.md` inventories every tracked/new file. PCM/mixer/recording/media, Rust catalogs/RPC/integrity, storage, frontends, plugin and packaging verifier checks passed. Windows native/Tauri runtime and clean installer launch are not certified. O01-O14 explicitly preserve unresolved architectural/runtime findings. Historical report findings must not all be marked closed. + +Comment cleanup follows on a separate branch based on the technical PR. Review stack: packaging, technical audit, comment cleanup. diff --git a/libs/audio/audio.cpp b/libs/audio/audio.cpp index 46968ba..7a1a020 100644 --- a/libs/audio/audio.cpp +++ b/libs/audio/audio.cpp @@ -157,6 +157,7 @@ class ActivateCompletionHandler final , public IAgileObject { public: explicit ActivateCompletionHandler(HANDLE done) : done_(done) {} + ~ActivateCompletionHandler() { if (done_) CloseHandle(done_); } ULONG STDMETHODCALLTYPE AddRef() override { @@ -228,10 +229,11 @@ static void capture_thread(ImplT* impl) HRESULT hr = impl->cap_client->GetNextPacketSize(&packet_size); if (FAILED(hr)) { OutputDebugStringA("[audio] GetNextPacketSize failed - stopping\n"); + impl->running.store(false, std::memory_order_release); break; } - while (packet_size > 0) { + while (packet_size > 0 && impl->running.load(std::memory_order_acquire)) { BYTE* data = nullptr; UINT32 frames = 0; DWORD flags = 0; @@ -257,10 +259,19 @@ static void capture_thread(ImplT* impl) pkt.data = silent ? nullptr : reinterpret_cast(data); pkt.data_bytes = bytes; - if (impl->cb) impl->cb(pkt); + try { if (impl->cb) impl->cb(pkt); } + catch (...) { + impl->cap_client->ReleaseBuffer(frames); + impl->running.store(false, std::memory_order_release); + break; + } impl->cap_client->ReleaseBuffer(frames); - impl->cap_client->GetNextPacketSize(&packet_size); + hr = impl->cap_client->GetNextPacketSize(&packet_size); + if (FAILED(hr)) { + impl->running.store(false, std::memory_order_release); + break; + } } // Fallback pacing when no event handle is available (should not happen @@ -691,15 +702,17 @@ bool WasapiCapture::start(Mode mode, PacketCallback cb) bool WasapiCapture::start_device(Mode mode, const std::wstring& device_id_value, PacketCallback cb) { if (impl_->running) return false; + stop(); impl_->cb = std::move(cb); + auto fail = [this]() { stop(); return false; }; HRESULT hr; hr = CoCreateInstance( __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), impl_->enumerator.put_void()); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); // Loopback: render endpoint + AUDCLNT_STREAMFLAGS_LOOPBACK. // Microphone: capture endpoint, no extra flags. @@ -707,19 +720,19 @@ bool WasapiCapture::start_device(Mode mode, const std::wstring& device_id_value, EDataFlow flow = (mode == Mode::Loopback) ? eRender : eCapture; hr = impl_->enumerator->GetDefaultAudioEndpoint( flow, eConsole, impl_->device.put()); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); } else { hr = impl_->enumerator->GetDevice(device_id_value.c_str(), impl_->device.put()); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); } hr = impl_->device->Activate( __uuidof(IAudioClient), CLSCTX_ALL, nullptr, impl_->client.put_void()); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); hr = impl_->client->GetMixFormat(&impl_->mix_fmt); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); DWORD flags = (mode == Mode::Loopback) ? AUDCLNT_STREAMFLAGS_LOOPBACK : 0; flags |= AUDCLNT_STREAMFLAGS_EVENTCALLBACK; @@ -730,29 +743,31 @@ bool WasapiCapture::start_device(Mode mode, const std::wstring& device_id_value, 0, impl_->mix_fmt, nullptr); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); impl_->event = CreateEventW(nullptr, FALSE, FALSE, nullptr); - if (!impl_->event) return false; + if (!impl_->event) return fail(); hr = impl_->client->SetEventHandle(impl_->event); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); hr = impl_->client->GetService( __uuidof(IAudioCaptureClient), impl_->cap_client.put_void()); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); hr = impl_->client->Start(); - if (FAILED(hr)) return false; + if (FAILED(hr)) return fail(); impl_->running = true; - impl_->thread = std::thread([impl = impl_]() { capture_thread(impl); }); + try { impl_->thread = std::thread([impl = impl_]() { capture_thread(impl); }); } + catch (...) { stop(); return false; } return true; } bool WasapiCapture::start_process_loopback(uint32_t process_id, PacketCallback cb) { if (process_id == 0 || impl_->running) return false; + stop(); impl_->cb = std::move(cb); HANDLE done = CreateEventW(nullptr, FALSE, FALSE, nullptr); @@ -786,7 +801,6 @@ bool WasapiCapture::start_process_loopback(uint32_t process_id, PacketCallback c static_cast(hr)); audio_log("audio.app", buf); handler->Release(); - CloseHandle(done); return false; } @@ -796,22 +810,20 @@ bool WasapiCapture::start_process_loopback(uint32_t process_id, PacketCallback c snprintf(buf, sizeof(buf), "process loopback: activation %s (hr=0x%08lX)", wait_result != WAIT_OBJECT_0 ? "timed out" : "failed", - static_cast(handler->activate_hr())); + static_cast(wait_result == WAIT_OBJECT_0 ? handler->activate_hr() : E_PENDING)); audio_log("audio.app", buf); handler->Release(); - CloseHandle(done); return false; } winrt::com_ptr activated = handler->activated(); handler->Release(); - CloseHandle(done); if (!activated) { audio_log("audio.app", "process loopback: activation returned no interface"); return false; } - impl_->client = activated.as(); + impl_->client = activated.try_as(); if (!impl_->client) { audio_log("audio.app", "process loopback: IAudioClient query failed"); return false; @@ -822,7 +834,7 @@ bool WasapiCapture::start_process_loopback(uint32_t process_id, PacketCallback c // WASAPI event callbacks to wake only when process audio is available. auto* wf = static_cast( CoTaskMemAlloc(sizeof(WAVEFORMATEXTENSIBLE))); - if (!wf) return false; + if (!wf) { stop(); return false; } ZeroMemory(wf, sizeof(*wf)); wf->Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; wf->Format.nChannels = 2; @@ -895,16 +907,17 @@ bool WasapiCapture::start_process_loopback(uint32_t process_id, PacketCallback c } impl_->running = true; - impl_->thread = std::thread([impl = impl_]() { capture_thread(impl); }); + try { impl_->thread = std::thread([impl = impl_]() { capture_thread(impl); }); } + catch (...) { stop(); return false; } return true; } void WasapiCapture::stop() { - const bool was_running = impl_->running.exchange(false, std::memory_order_acq_rel); + impl_->running.store(false, std::memory_order_release); if (impl_->thread.joinable()) impl_->thread.join(); - if (was_running && impl_->client) impl_->client->Stop(); + if (impl_->client) impl_->client->Stop(); if (impl_->mix_fmt) { CoTaskMemFree(impl_->mix_fmt); diff --git a/libs/capture/capture.cpp b/libs/capture/capture.cpp index 6452917..313c307 100644 --- a/libs/capture/capture.cpp +++ b/libs/capture/capture.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace wgc = winrt::Windows::Graphics::Capture; namespace wgdx = winrt::Windows::Graphics::DirectX; @@ -97,6 +98,8 @@ struct DisplayCapture::Impl { CaptureOptions options; int64_t qpc_freq = 0; FrameCallback cb; + std::mutex callback_mutex; + bool handler_registered = false; }; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -249,8 +252,8 @@ static bool gpu_downscale(DisplayCapture::Impl* impl, ID3D11Texture2D* src_tex, // ── DisplayCapture ──────────────────────────────────────────────────────────── -DisplayCapture::DisplayCapture() : impl_(new Impl()) {} -DisplayCapture::~DisplayCapture() { stop(); delete impl_; } +DisplayCapture::DisplayCapture() : impl_(std::make_shared()) {} +DisplayCapture::~DisplayCapture() { stop(); } bool DisplayCapture::running() const { return impl_->active.load(std::memory_order_relaxed); } bool DisplayCapture::border_suppressed() const { return impl_->border_suppressed.load(std::memory_order_relaxed); } @@ -275,6 +278,8 @@ bool DisplayCapture::start(HMONITOR hmon, FrameCallback cb, CaptureOptions optio { if (impl_->active) return false; if (!is_supported()) return false; + stop(); + impl_ = std::make_shared(); impl_->cb = std::move(cb); impl_->options = options; @@ -321,8 +326,10 @@ bool DisplayCapture::start(HMONITOR hmon, FrameCallback cb, CaptureOptions optio impl_->active = true; impl_->frame_token = impl_->pool.FrameArrived( - [this](wgc::Direct3D11CaptureFramePool const& pool, auto const&) + [state = impl_](wgc::Direct3D11CaptureFramePool const& pool, auto const&) { + auto* impl_ = state.get(); + std::lock_guard lock(impl_->callback_mutex); if (!impl_->active.load(std::memory_order_acquire)) return; auto frame = pool.TryGetNextFrame(); @@ -430,18 +437,26 @@ bool DisplayCapture::start(HMONITOR hmon, FrameCallback cb, CaptureOptions optio impl_->frames_readback.fetch_add(1, std::memory_order_relaxed); fi.bgra_data = static_cast(mapped.pData); fi.bgra_stride = mapped.RowPitch; - if (impl_->cb) impl_->cb(fi); + try { if (impl_->cb) impl_->cb(fi); } + catch (...) { + impl_->d3d_ctx->Unmap(impl_->staging_tex.get(), 0); + frame.Close(); + return; + } impl_->d3d_ctx->Unmap(impl_->staging_tex.get(), 0); } } } // Fall through: call cb even if readback failed (bgra_data = nullptr). - if (fi.bgra_data == nullptr && impl_->cb) impl_->cb(fi); + if (fi.bgra_data == nullptr && impl_->cb) { + try { impl_->cb(fi); } catch (...) {} + } frame.Close(); }); + impl_->handler_registered = true; impl_->session = impl_->pool.CreateCaptureSession(item); impl_->border_suppressed = false; @@ -463,9 +478,7 @@ bool DisplayCapture::start(HMONITOR hmon, FrameCallback cb, CaptureOptions optio impl_->session.StartCapture(); } catch (...) { - impl_->active = false; - impl_->cb = nullptr; - impl_->d3d_device = nullptr; + stop(); return false; } @@ -474,17 +487,13 @@ bool DisplayCapture::start(HMONITOR hmon, FrameCallback cb, CaptureOptions optio void DisplayCapture::stop() { - if (!impl_->active) return; - - // Signal the callback to abort immediately on next invocation. impl_->active.store(false, std::memory_order_release); - - // Give any in-flight callback time to see active=false and return. - // Spike-quality sync: 30ms >> max callback duration at 60fps (16ms). - Sleep(30); - - // Revoke the handler - no new invocations after this returns. - impl_->pool.FrameArrived(impl_->frame_token); + if (impl_->handler_registered && impl_->pool) { + impl_->pool.FrameArrived(impl_->frame_token); + impl_->handler_registered = false; + } + // Queued callbacks retain their session state; drain GPU access before release. + std::lock_guard lock(impl_->callback_mutex); if (impl_->session) { impl_->session.Close(); impl_->session = nullptr; } if (impl_->pool) { impl_->pool.Close(); impl_->pool = nullptr; } diff --git a/libs/capture/capture.h b/libs/capture/capture.h index 6584cd0..4a967c3 100644 --- a/libs/capture/capture.h +++ b/libs/capture/capture.h @@ -8,6 +8,7 @@ #include #include #include +#include struct ID3D11Texture2D; @@ -79,7 +80,7 @@ class DisplayCapture { private: struct Impl; - Impl* impl_; + std::shared_ptr impl_; friend bool ensure_video_processor(Impl*, uint32_t, uint32_t, uint32_t, uint32_t); friend bool gpu_downscale(Impl*, ID3D11Texture2D*, uint32_t, uint32_t, uint32_t, uint32_t); diff --git a/libs/encoding/encoding.cpp b/libs/encoding/encoding.cpp index 11e218a..2913df0 100644 --- a/libs/encoding/encoding.cpp +++ b/libs/encoding/encoding.cpp @@ -1,4 +1,5 @@ #include "encoding.h" +#include "pcm_input.h" extern "C" { #include @@ -13,6 +14,7 @@ extern "C" { } #include +#include #include #include #include @@ -206,7 +208,7 @@ VideoEncoderPerfStats VideoEncoder::perf_stats() const bool VideoEncoder::open(Config const& cfg, PacketSink sink) { - if (cfg.width <= 0 || cfg.height <= 0 || cfg.fps <= 0) + if (cfg.width <= 0 || cfg.width > 16384 || cfg.height <= 0 || cfg.height > 16384 || cfg.fps <= 0 || cfg.fps > 1000) return false; if (cfg.bitrate <= 0 && cfg.quality <= 0) return false; @@ -254,7 +256,8 @@ bool VideoEncoder::open(Config const& cfg, PacketSink sink) auto try_open = [&](const std::string& name, bool use_extras) -> bool { const AVCodec* codec = avcodec_find_encoder_by_name(name.c_str()); - if (!codec) return false; + if (!codec || codec->id != static_cast(cfg.codec == VideoCodec::H265 ? AV_CODEC_ID_HEVC : + (cfg.codec == VideoCodec::AV1 ? AV_CODEC_ID_AV1 : AV_CODEC_ID_H264))) return false; impl_->ctx = avcodec_alloc_context3(codec); if (!impl_->ctx) return false; @@ -459,7 +462,7 @@ void VideoEncoder::push_bgra(const uint8_t* bgra, int stride, int width, int hei { std::lock_guard lk(impl_->mutex); if (!impl_->ctx || !bgra) return; - if (width <= 0 || height <= 0) return; + if (width <= 0 || width > INT_MAX / 4 || height <= 0 || stride < width * 4) return; // (Re-)create the scaler when the source size changes (display mode // switches, or output resolution differs from capture resolution). @@ -698,9 +701,10 @@ static AVSampleFormat wasapi_to_av_fmt(int bit_depth, bool is_float) { if (is_float) return (bit_depth == 64) ? AV_SAMPLE_FMT_DBL : AV_SAMPLE_FMT_FLT; switch (bit_depth) { + case 8: return AV_SAMPLE_FMT_U8; case 16: return AV_SAMPLE_FMT_S16; case 32: return AV_SAMPLE_FMT_S32; - default: return AV_SAMPLE_FMT_FLT; // best guess + default: return AV_SAMPLE_FMT_NONE; } } @@ -712,16 +716,11 @@ void AudioEncoder::push_pcm(const uint8_t* data, int bytes, if (!impl_->ctx || bytes <= 0) return; if (sample_rate <= 0 || channels <= 0 || bit_depth <= 0) return; - AVSampleFormat src_fmt = wasapi_to_av_fmt(bit_depth, is_float); - int src_frames = bytes / (bit_depth / 8 * channels); - if (src_frames <= 0) return; - - std::vector silence; - const uint8_t* src_data = data; - if (!src_data) { - silence.resize(static_cast(bytes), 0); - src_data = silence.data(); - } + detail::PcmInput input; + if (!input.prepare(data, bytes, channels, bit_depth, is_float)) return; + AVSampleFormat src_fmt = wasapi_to_av_fmt(input.bit_depth, is_float); + const int src_frames = input.frames; + const uint8_t* src_data = input.data; // (Re-)init swr if the input format changed. bool need_swr = !impl_->swr @@ -916,75 +915,87 @@ int TrackMixer::source_count() const bool TrackMixer::open(int out_sample_rate, int out_channels, Sink sink) { - if (out_sample_rate <= 0 || out_channels <= 0) return false; + if (out_sample_rate <= 0 || out_sample_rate > 384000 || out_channels <= 0 || out_channels > 64) return false; close(); impl_->out_rate = out_sample_rate; impl_->out_ch = out_channels; impl_->sink = std::move(sink); impl_->running.store(true, std::memory_order_release); - impl_->thread = std::thread([this] { - using clock = std::chrono::steady_clock; - const auto start = clock::now(); - int64_t emitted_frames = 0; // frames already sent since `start` - const int ch = impl_->out_ch; - std::vector acc; // accumulator (interleaved) - std::vector tmp; // per-source read buffer (interleaved) - - while (impl_->running.load(std::memory_order_acquire)) { - std::this_thread::sleep_for(std::chrono::milliseconds(kMixIntervalMs)); - - // Clock-locked CFR: the number of frames emitted is a function of the - // real time elapsed since `start`, not of loop iterations. Tracking a - // running total against a fixed origin avoids the cumulative drift of - // truncating per-tick deltas, keeping audio aligned over long sessions. - int64_t elapsed_us = - std::chrono::duration_cast( - clock::now() - start).count(); - int64_t target = (elapsed_us * impl_->out_rate) / 1'000'000; - int frames = static_cast(target - emitted_frames); - if (frames <= 0) continue; - emitted_frames = target; - - acc.assign(static_cast(frames) * ch, 0.0f); - { - std::lock_guard lk(impl_->mutex); - tmp.resize(static_cast(frames) * ch); - for (auto& [id, sp] : impl_->sources) { - if (!sp->fifo) continue; - int avail = av_audio_fifo_size(sp->fifo); - int take = std::min(frames, avail); - if (take <= 0) continue; - void* dst[1] = { tmp.data() }; - int got = av_audio_fifo_read(sp->fifo, dst, take); - if (got <= 0) continue; - size_t n = static_cast(got) * ch; - const float g = sp->gain; - if (g == 1.0f) { - for (size_t i = 0; i < n; ++i) acc[i] += tmp[i]; - } else { - for (size_t i = 0; i < n; ++i) acc[i] += tmp[i] * g; + try { + impl_->thread = std::thread([this] { + try { + using clock = std::chrono::steady_clock; + const auto start = clock::now(); + int64_t emitted_frames = 0; // frames already sent since `start` + const int ch = impl_->out_ch; + std::vector acc; // accumulator (interleaved) + std::vector tmp; // per-source read buffer (interleaved) + + while (impl_->running.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(kMixIntervalMs)); + + // Clock-locked CFR: the number of frames emitted is a function of the + // real time elapsed since `start`, not of loop iterations. Tracking a + // running total against a fixed origin avoids the cumulative drift of + // truncating per-tick deltas, keeping audio aligned over long sessions. + int64_t elapsed_us = + std::chrono::duration_cast( + clock::now() - start).count(); + int64_t target = (elapsed_us * impl_->out_rate) / 1'000'000; + const int64_t pending = target - emitted_frames; + if (pending <= 0) continue; + const int frames = static_cast(std::min(pending, std::max(1, impl_->out_rate / 10))); + emitted_frames += frames; + + acc.assign(static_cast(frames) * ch, 0.0f); + { + std::lock_guard lk(impl_->mutex); + tmp.resize(static_cast(frames) * ch); + for (auto& [id, sp] : impl_->sources) { + if (!sp->fifo) continue; + int avail = av_audio_fifo_size(sp->fifo); + int take = std::min(frames, avail); + if (take <= 0) continue; + void* dst[1] = { tmp.data() }; + int got = av_audio_fifo_read(sp->fifo, dst, take); + if (got <= 0) continue; + size_t n = static_cast(got) * ch; + const float g = sp->gain; + if (g == 1.0f) { + for (size_t i = 0; i < n; ++i) acc[i] += tmp[i]; + } else { + for (size_t i = 0; i < n; ++i) acc[i] += tmp[i] * g; + } } } - } - // Clip to [-1, 1] to avoid wrap/overflow on summed sources. - for (float& v : acc) { - if (v > 1.0f) v = 1.0f; - else if (v < -1.0f) v = -1.0f; - } + // Clip to [-1, 1] to avoid wrap/overflow on summed sources. + for (float& v : acc) { + if (v > 1.0f) v = 1.0f; + else if (v < -1.0f) v = -1.0f; + } - // Always emit, even when no source contributed (acc is then silence). - // A continuous output keeps every configured track present in the - // file and the downstream encoder timeline advancing at real time. - if (impl_->sink) { - impl_->sink( - reinterpret_cast(acc.data()), - static_cast(acc.size() * sizeof(float)), - impl_->out_rate, ch, 32, /*is_float=*/true); + // Always emit, even when no source contributed (acc is then silence). + // A continuous output keeps every configured track present in the + // file and the downstream encoder timeline advancing at real time. + if (impl_->sink) { + impl_->sink( + reinterpret_cast(acc.data()), + static_cast(acc.size() * sizeof(float)), + impl_->out_rate, ch, 32, /*is_float=*/true); + } } - } - }); + } catch (...) { + impl_->running.store(false, std::memory_order_release); + std::fprintf(stderr, "[audio] mixer worker failed\n"); + } + }); + } catch (...) { + impl_->running.store(false, std::memory_order_release); + impl_->sink = nullptr; + return false; + } return true; } @@ -996,7 +1007,7 @@ int TrackMixer::add_source(float gain) auto src = std::make_unique(); src->fifo = av_audio_fifo_alloc(AV_SAMPLE_FMT_FLT, impl_->out_ch, impl_->out_rate); if (!src->fifo) return -1; - src->gain = (gain < 0.0f) ? 0.0f : gain; + src->gain = (std::isfinite(gain) && gain >= 0.0f) ? gain : 0.0f; impl_->sources.emplace(id, std::move(src)); return id; } @@ -1006,7 +1017,7 @@ void TrackMixer::set_source_gain(int source_id, float gain) std::lock_guard lk(impl_->mutex); auto it = impl_->sources.find(source_id); if (it == impl_->sources.end()) return; - it->second->gain = (gain < 0.0f) ? 0.0f : gain; + it->second->gain = (std::isfinite(gain) && gain >= 0.0f) ? gain : 0.0f; } void TrackMixer::remove_source(int source_id) @@ -1024,13 +1035,11 @@ void TrackMixer::push(int source_id, const uint8_t* data, int bytes, int sample_rate, int channels, int bit_depth, bool is_float) { if (bytes <= 0 || sample_rate <= 0 || channels <= 0 || bit_depth <= 0) return; - AVSampleFormat src_fmt = wasapi_to_av_fmt(bit_depth, is_float); - int src_frames = bytes / (bit_depth / 8 * channels); - if (src_frames <= 0) return; - - std::vector silence; - const uint8_t* src_data = data; - if (!src_data) { silence.assign(static_cast(bytes), 0); src_data = silence.data(); } + detail::PcmInput input; + if (!input.prepare(data, bytes, channels, bit_depth, is_float)) return; + const AVSampleFormat src_fmt = wasapi_to_av_fmt(input.bit_depth, is_float); + const int src_frames = input.frames; + const uint8_t* src_data = input.data; std::lock_guard lk(impl_->mutex); auto it = impl_->sources.find(source_id); @@ -1063,17 +1072,18 @@ void TrackMixer::push(int source_id, const uint8_t* data, int bytes, // Drop oldest data if a source overruns (downstream stalled / paused). if (av_audio_fifo_size(s.fifo) + converted > kMaxFifoFrames) { int drop = av_audio_fifo_size(s.fifo) + converted - kMaxFifoFrames; - av_audio_fifo_drain(s.fifo, drop); + av_audio_fifo_drain(s.fifo, std::min(drop, av_audio_fifo_size(s.fifo))); } - av_audio_fifo_write(s.fifo, reinterpret_cast(s.conv), converted); + const int retained = std::min(converted, kMaxFifoFrames); + void* samples[1] = {s.conv[0] + static_cast(converted - retained) * impl_->out_ch * sizeof(float)}; + av_audio_fifo_write(s.fifo, samples, retained); } } void TrackMixer::close() { - if (impl_->running.exchange(false, std::memory_order_acq_rel)) { - if (impl_->thread.joinable()) impl_->thread.join(); - } + impl_->running.store(false, std::memory_order_release); + if (impl_->thread.joinable()) impl_->thread.join(); std::lock_guard lk(impl_->mutex); for (auto& [id, sp] : impl_->sources) { if (sp->swr) swr_free(&sp->swr); diff --git a/libs/encoding/encoding.h b/libs/encoding/encoding.h index 12e448b..a162096 100644 --- a/libs/encoding/encoding.h +++ b/libs/encoding/encoding.h @@ -64,17 +64,17 @@ enum class VideoCodec { // Codec metadata needed by muxers to write stream headers. struct VideoStreamParams { VideoCodec codec = VideoCodec::H264; - int width, height; - int fps_num, fps_den; - int tb_num, tb_den; + int width = 0, height = 0; + int fps_num = 0, fps_den = 0; + int tb_num = 0, tb_den = 0; std::vector extradata; // global headers (SPS/PPS/VPS) }; struct AudioStreamParams { int stream_index = 1; // 1..6; 0 is reserved for video - int sample_rate; - int channels; - int tb_num, tb_den; + int sample_rate = 0; + int channels = 0; + int tb_num = 0, tb_den = 0; std::vector extradata; // AAC AudioSpecificConfig }; @@ -112,8 +112,9 @@ class VideoEncoder { VideoEncoder& operator=(const VideoEncoder&) = delete; struct Config { - int width; // output (encoded) width - int height; // output (encoded) height + int width = 0; // output (encoded) width + int height = 0; // output (encoded) height + VideoCodec codec = VideoCodec::H264; int fps = 60; // Rate control is always CBR at this target bitrate (bits per second). // quality is retained only for probe/back-compat and is unused by the diff --git a/libs/encoding/pcm_input.h b/libs/encoding/pcm_input.h new file mode 100644 index 0000000..f1e8417 --- /dev/null +++ b/libs/encoding/pcm_input.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include + +namespace encoding::detail { +struct PcmInput { + const uint8_t* data = nullptr; + int frames = 0; + int bit_depth = 0; + std::vector silence; + std::vector expanded; + + bool prepare(const uint8_t* input, int bytes, int channels, int bits, bool floating) + { + if (bytes <= 0 || channels <= 0 || channels > 64) return false; + if (floating ? (bits != 32 && bits != 64) + : (bits != 8 && bits != 16 && bits != 24 && bits != 32)) return false; + const int frame_bytes = (bits / 8) * channels; + if (bytes % frame_bytes != 0) return false; + frames = bytes / frame_bytes; + bit_depth = bits; + data = input; + if (!data) { + silence.assign(static_cast(bytes), bits == 8 ? 128 : 0); + data = silence.data(); + } + if (bits == 24) { + expanded.resize(static_cast(frames) * channels); + for (size_t i = 0; i < expanded.size(); ++i) { + // Packed little-endian PCM becomes left-aligned signed S32. + const uint32_t sample = (uint32_t(data[i * 3]) << 8) | + (uint32_t(data[i * 3 + 1]) << 16) | (uint32_t(data[i * 3 + 2]) << 24); + std::memcpy(&expanded[i], &sample, sizeof(sample)); + } + data = reinterpret_cast(expanded.data()); + bit_depth = 32; + } + return frames > 0; + } +}; +} diff --git a/libs/recording/recording.cpp b/libs/recording/recording.cpp index 540f86f..3f24eb8 100644 --- a/libs/recording/recording.cpp +++ b/libs/recording/recording.cpp @@ -18,7 +18,10 @@ extern "C" { #include #include +#include +#include #include +#include #include #include #include @@ -50,9 +53,13 @@ struct ManualRecorder::Impl { std::array audio_streams{}; bool header_written = false; bool wrote_packet = false; + bool output_failed = false; bool started_at_keyframe = false; bool resume_needs_keyframe = false; std::array timing{}; + int64_t origin_pts = 0; + int origin_num = 1; + int origin_den = 1; }; static std::wstring generate_recording_path( @@ -61,14 +68,18 @@ static std::wstring generate_recording_path( { SYSTEMTIME st; GetLocalTime(&st); - wchar_t path[MAX_PATH]; - swprintf_s(path, MAX_PATH, - L"%s\\%04d%02d%02d_%02d%02d%02d_recording.%s", - dir.c_str(), - st.wYear, st.wMonth, st.wDay, - st.wHour, st.wMinute, st.wSecond, - mux::file_extension(container)); - return path; + static std::atomic sequence{0}; + for (;;) { + wchar_t name[96]; + swprintf_s(name, 96, + L"%04u%02u%02u_%02u%02u%02u%03u_%llu_recording.%s", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, + st.wMilliseconds, static_cast(sequence.fetch_add(1)), + mux::file_extension(container)); + const auto path = std::filesystem::path(dir) / name; + std::error_code error; + if (!std::filesystem::exists(path, error)) return path.wstring(); + } } template @@ -77,7 +88,9 @@ static bool open_output(ImplT* impl) if (impl->header_written) return true; if (!impl->vsp_set || impl->path.empty()) return false; - CreateDirectoryW(impl->output_dir.c_str(), nullptr); + std::error_code error; + std::filesystem::create_directories(impl->output_dir, error); + if (error) return false; std::string path_utf = mux::wcs_to_utf8(impl->path); if (path_utf.empty()) return false; @@ -90,19 +103,25 @@ static bool open_output(ImplT* impl) impl->video_stream = streams.video; impl->audio_streams = streams.audio; - if (!mux::open_file_and_write_header(impl->fmt, path_utf, impl->container)) + if (!mux::open_file_and_write_header(impl->fmt, path_utf, impl->container)) { + avformat_free_context(impl->fmt); + impl->fmt = nullptr; + impl->video_stream = nullptr; + impl->audio_streams = {}; return false; + } impl->header_written = true; return true; } template -static void close_output(ImplT* impl) +static bool close_output(ImplT* impl) { + bool ok = !impl->output_failed; if (impl->fmt) { - if (impl->header_written) av_write_trailer(impl->fmt); - if (impl->fmt->pb) avio_closep(&impl->fmt->pb); + if (impl->header_written && av_write_trailer(impl->fmt) < 0) ok = false; + if (impl->fmt->pb && avio_closep(&impl->fmt->pb) < 0) ok = false; avformat_free_context(impl->fmt); } impl->fmt = nullptr; @@ -113,6 +132,8 @@ static void close_output(ImplT* impl) impl->started_at_keyframe = false; impl->resume_needs_keyframe = false; impl->timing = {}; + impl->output_failed = false; + return ok; } template @@ -129,11 +150,13 @@ static bool write_packet(ImplT* impl, const encoding::EncodedPacket& ep) if (!dst_stream) return false; StreamTiming& timing = impl->timing[ep.stream_index]; - timing.anchor.observe(ep.pts, ep.dts); + const auto origin = av_rescale_q(impl->origin_pts, AVRational{impl->origin_num, impl->origin_den}, + AVRational{ep.tb_num, ep.tb_den}); + timing.anchor.observe(origin, origin); if (timing.pause_start_set) { - if (ep.pts > timing.pause_start_pts) - timing.paused_pts += ep.pts - timing.pause_start_pts; + if (ep.dts > timing.pause_start_pts) + timing.paused_pts += ep.dts - timing.pause_start_pts; timing.pause_start_set = false; } @@ -209,8 +232,10 @@ bool ManualRecorder::stop(std::wstring* output_path) { std::lock_guard lk(impl_->mutex); if (impl_->state == RecordingState::Idle) return false; - if (output_path) *output_path = impl_->wrote_packet ? impl_->path : std::wstring{}; - close_output(impl_); + const bool wrote_packet = impl_->wrote_packet; + const bool complete = close_output(impl_); + if (!complete) std::fprintf(stderr, "[recording] output could not be finalized\n"); + if (output_path) *output_path = complete && wrote_packet ? impl_->path : std::wstring{}; impl_->path.clear(); impl_->state = RecordingState::Idle; return true; @@ -226,7 +251,7 @@ void ManualRecorder::push(encoding::EncodedPacket pkt) StreamTiming& timing = impl_->timing[pkt.stream_index]; if (timing.anchor.set && !timing.pause_start_set) { timing.pause_start_set = true; - timing.pause_start_pts = pkt.pts; + timing.pause_start_pts = pkt.dts; } } return; @@ -234,6 +259,9 @@ void ManualRecorder::push(encoding::EncodedPacket pkt) if (!impl_->started_at_keyframe) { if (pkt.stream_index != 0 || !pkt.is_keyframe) return; + impl_->origin_pts = pkt.pts; + impl_->origin_num = pkt.tb_num; + impl_->origin_den = pkt.tb_den; impl_->started_at_keyframe = true; } @@ -242,7 +270,10 @@ void ManualRecorder::push(encoding::EncodedPacket pkt) impl_->resume_needs_keyframe = false; } - write_packet(impl_, pkt); + if (!impl_->output_failed && !write_packet(impl_, pkt)) { + impl_->output_failed = true; + std::fprintf(stderr, "[recording] packet write failed\n"); + } } RecordingState ManualRecorder::state() const diff --git a/libs/recording/recording.h b/libs/recording/recording.h index f4106f8..0f0c948 100644 --- a/libs/recording/recording.h +++ b/libs/recording/recording.h @@ -29,6 +29,7 @@ class ManualRecorder { bool start(std::wstring output_dir, std::string container); bool pause(); bool resume(); + // output_path is empty if no packets were written or finalization failed. bool stop(std::wstring* output_path = nullptr); void push(encoding::EncodedPacket pkt); diff --git a/libs/replay-buffer/replay_buffer.cpp b/libs/replay-buffer/replay_buffer.cpp index 5122902..de48d37 100644 --- a/libs/replay-buffer/replay_buffer.cpp +++ b/libs/replay-buffer/replay_buffer.cpp @@ -58,6 +58,7 @@ ReplayBuffer::~ReplayBuffer() { if (impl_->save_thread.joinable()) impl_->save_thread.join(); + impl_->disk.reset(); delete impl_; } @@ -262,6 +263,7 @@ void ReplayBuffer::save_clip(std::function cb) if (!impl_->saving.compare_exchange_strong(expected, true)) return; + try { // Disk mode: hand off to the segment buffer (it owns its own save // thread); the facade's saving flag mirrors it so stats() stays honest. // Read cfg/disk under the lock - configure() can swap storage modes and @@ -276,7 +278,8 @@ void ReplayBuffer::save_clip(std::function cb) // Run the completion callback before clearing the flag so // a follow-up save cannot join this thread while its // callback (catalog/thumbnail work) is still running. - if (cb) cb(std::move(path)); + try { if (cb) cb(std::move(path)); } + catch (...) { OutputDebugStringA("[replay] save callback failed\n"); } impl_->saving.store(false); }); // The segment buffer dropped the request (its own save still @@ -312,15 +315,20 @@ void ReplayBuffer::save_clip(std::function cb) cb = std::move(cb)]() mutable { std::wstring result; - if (!snapshot.empty() && vsp_set) { - result = write_clip(std::move(snapshot), vsp, audio_params, - cfg.output_dir, cfg.duration_sec, - cfg.container); - } - // Callback first, flag after: see the disk branch above. - if (cb) cb(result); + try { + if (!snapshot.empty() && vsp_set) { + result = write_clip(std::move(snapshot), vsp, audio_params, + cfg.output_dir, cfg.duration_sec, cfg.container); + } + } catch (...) { OutputDebugStringA("[replay] save worker failed\n"); } + try { if (cb) cb(result); } + catch (...) { OutputDebugStringA("[replay] save callback failed\n"); } impl_->saving.store(false); }); + } catch (...) { + impl_->saving.store(false); + OutputDebugStringA("[replay] could not start save\n"); + } } } // namespace replay_buffer diff --git a/libs/storage/storage.cpp b/libs/storage/storage.cpp index 0ade8d3..270488e 100644 --- a/libs/storage/storage.cpp +++ b/libs/storage/storage.cpp @@ -158,11 +158,10 @@ const char *kCreateSchema = std::string now_iso8601_utc() { std::time_t t = std::time(nullptr); std::tm gm{}; - gmtime_s(&gm, &t); - char buf[32]; - std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02dZ", - gm.tm_year + 1900, gm.tm_mon + 1, gm.tm_mday, gm.tm_hour, - gm.tm_min, gm.tm_sec); + if (gmtime_s(&gm, &t) != 0) return {}; + char buf[32]{}; + if (std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &gm) == 0) + return {}; return buf; } @@ -291,7 +290,9 @@ std::unordered_map> g_mutation_map; bool leaf(const std::wstring &s) { - return !s.empty() && fs::path(s).filename() == fs::path(s) && + return !s.empty() && s != L"." && s != L".." && + s.back() != L'.' && s.back() != L' ' && + fs::path(s).filename() == fs::path(s) && s.find_first_of(L"\\/:") == std::wstring::npos && s.find(L'\0') == std::wstring::npos; } @@ -349,7 +350,8 @@ class MutationLock { mutex_ = CreateMutexW(nullptr, FALSE, (L"Local\\MonolithStorage-" + std::to_wstring(h)).c_str()); - if (!mutex_ || WaitForSingleObject(mutex_, INFINITE) != WAIT_OBJECT_0) { + const DWORD result = mutex_ ? WaitForSingleObject(mutex_, INFINITE) : WAIT_FAILED; + if (result != WAIT_OBJECT_0 && result != WAIT_ABANDONED) { if (mutex_) CloseHandle(mutex_); mutex_ = nullptr; @@ -1178,7 +1180,7 @@ bool ClipDb::regenerate_thumbnail(int64_t id, std::string *error) { } sqlite3_finalize(st); } - if (video_base.empty()) { + if (!leaf(video_base) || !optional_leaf(thumb_base)) { if (error) *error = "clip not found"; return false; @@ -1217,7 +1219,7 @@ bool ClipDb::regenerate_thumbnail(int64_t id, std::string *error) { bool ClipDb::rename_clip(int64_t id, const std::wstring &new_stem, std::string *error) { - if (new_stem.empty() || + if (!leaf(new_stem) || new_stem.find_first_of(L"\\/:*?\"<>|.") != std::wstring::npos) { if (error) *error = "invalid clip name"; @@ -1361,6 +1363,10 @@ ReconcileStats ClipDb::reconcile(std::string *error) { std::unordered_set known; for (auto &r : rows) { + if (!leaf(r.video) || !optional_leaf(r.thumb)) { + if (error) *error = "invalid catalog media path"; + continue; + } const std::wstring vpath = impl_->video_path(r.video); if (!fs::exists(vpath, ec)) { if (remove_clip(r.id, /*remove_files=*/true, nullptr)) diff --git a/plugins/stream-deck/src/ipc-client.ts b/plugins/stream-deck/src/ipc-client.ts index ec1b7e0..ff6bddb 100644 --- a/plugins/stream-deck/src/ipc-client.ts +++ b/plugins/stream-deck/src/ipc-client.ts @@ -12,47 +12,54 @@ export interface RecordingStatus { recording_enabled: boolean; } -type Resolver = [(value: unknown) => void, (reason: unknown) => void]; +type Resolver = [(value: unknown) => void, (reason: unknown) => void, ReturnType]; export class IpcClient { private socket: net.Socket | null = null; - private buf = ''; + private connected = false; private pending = new Map(); private nextId = 1; private reconnectTimer: ReturnType | null = null; private destroyed = false; connect(): void { - this._connect(); + this.openSocket(); } - private _connect(): void { - if (this.destroyed) return; + private openSocket(): void { + if (this.destroyed || this.socket) return; const s = new net.Socket(); - s.setTimeout(0); + this.socket = s; + s.setEncoding('utf8'); + let buf = ''; s.connect(IPC_PORT, IPC_HOST, () => { - this.socket = s; + this.connected = true; if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } }); - s.on('data', (chunk: Buffer) => { - this.buf += chunk.toString('utf8'); + s.on('data', (chunk: string) => { + buf += chunk; let nl: number; - while ((nl = this.buf.indexOf('\n')) !== -1) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - this._onLine(line.trim()); + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (Buffer.byteLength(line) > 64 * 1024) { s.destroy(); return; } + this.onLine(line.trim()); } + if (Buffer.byteLength(buf) > 64 * 1024) s.destroy(); }); s.on('close', () => { + if (this.socket !== s) return; this.socket = null; - this._scheduleReconnect(); + this.connected = false; + this.rejectPending(new Error('IPC: disconnected')); + this.scheduleReconnect(); }); s.on('error', () => { @@ -60,22 +67,23 @@ export class IpcClient { }); } - private _scheduleReconnect(): void { + private scheduleReconnect(): void { if (this.destroyed || this.reconnectTimer !== null) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - this._connect(); + this.openSocket(); }, RECONNECT_MS); } - private _onLine(line: string): void { + private onLine(line: string): void { if (!line) return; try { const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: unknown }; - if (typeof msg.id !== 'number') return; + if (!msg || typeof msg.id !== 'number') return; const handlers = this.pending.get(msg.id); if (!handlers) return; this.pending.delete(msg.id); + clearTimeout(handlers[2]); if (msg.error !== undefined) { handlers[1](msg.error); } else { @@ -88,21 +96,23 @@ export class IpcClient { request(method: string): Promise { return new Promise((resolve, reject) => { - if (!this.socket) { + if (!this.connected || !this.socket) { reject(new Error('IPC: not connected')); return; } + if (this.pending.size >= 64) { + reject(new Error('IPC: too many pending requests')); + return; + } const id = this.nextId++; - this.pending.set(id, [resolve, reject]); + const timer = setTimeout(() => { + if (this.pending.delete(id)) reject(new Error(`IPC: timeout waiting for ${method}`)); + }, REQUEST_TIMEOUT_MS); + this.pending.set(id, [resolve, reject, timer]); const payload = JSON.stringify({ jsonrpc: '2.0', id, method }) + '\n'; this.socket.write(payload, 'utf8'); - setTimeout(() => { - if (this.pending.delete(id)) { - reject(new Error(`IPC: timeout waiting for ${method}`)); - } - }, REQUEST_TIMEOUT_MS); }); } @@ -115,7 +125,15 @@ export class IpcClient { } isConnected(): boolean { - return this.socket !== null; + return this.connected; + } + + private rejectPending(error: Error): void { + for (const handlers of this.pending.values()) { + clearTimeout(handlers[2]); + handlers[1](error); + } + this.pending.clear(); } destroy(): void { @@ -126,5 +144,7 @@ export class IpcClient { } this.socket?.destroy(); this.socket = null; + this.connected = false; + this.rejectPending(new Error('IPC: client destroyed')); } } diff --git a/plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js b/plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js index e69668c..c68a279 100644 --- a/plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js +++ b/plugins/stream-deck/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js @@ -5,62 +5,75 @@ const RECONNECT_MS = 2000; const REQUEST_TIMEOUT_MS = 3000; export class IpcClient { socket = null; - buf = ''; + connected = false; pending = new Map(); nextId = 1; reconnectTimer = null; destroyed = false; connect() { - this._connect(); + this.openSocket(); } - _connect() { - if (this.destroyed) + openSocket() { + if (this.destroyed || this.socket) return; const s = new net.Socket(); - s.setTimeout(0); + this.socket = s; + s.setEncoding('utf8'); + let buf = ''; s.connect(IPC_PORT, IPC_HOST, () => { - this.socket = s; + this.connected = true; if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } }); s.on('data', (chunk) => { - this.buf += chunk.toString('utf8'); + buf += chunk; let nl; - while ((nl = this.buf.indexOf('\n')) !== -1) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - this._onLine(line.trim()); + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (Buffer.byteLength(line) > 64 * 1024) { + s.destroy(); + return; + } + this.onLine(line.trim()); } + if (Buffer.byteLength(buf) > 64 * 1024) + s.destroy(); }); s.on('close', () => { + if (this.socket !== s) + return; this.socket = null; - this._scheduleReconnect(); + this.connected = false; + this.rejectPending(new Error('IPC: disconnected')); + this.scheduleReconnect(); }); s.on('error', () => { // handled by 'close' - suppress unhandled error event }); } - _scheduleReconnect() { + scheduleReconnect() { if (this.destroyed || this.reconnectTimer !== null) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - this._connect(); + this.openSocket(); }, RECONNECT_MS); } - _onLine(line) { + onLine(line) { if (!line) return; try { const msg = JSON.parse(line); - if (typeof msg.id !== 'number') + if (!msg || typeof msg.id !== 'number') return; const handlers = this.pending.get(msg.id); if (!handlers) return; this.pending.delete(msg.id); + clearTimeout(handlers[2]); if (msg.error !== undefined) { handlers[1](msg.error); } @@ -74,19 +87,22 @@ export class IpcClient { } request(method) { return new Promise((resolve, reject) => { - if (!this.socket) { + if (!this.connected || !this.socket) { reject(new Error('IPC: not connected')); return; } + if (this.pending.size >= 64) { + reject(new Error('IPC: too many pending requests')); + return; + } const id = this.nextId++; - this.pending.set(id, [resolve, reject]); - const payload = JSON.stringify({ jsonrpc: '2.0', id, method }) + '\n'; - this.socket.write(payload, 'utf8'); - setTimeout(() => { - if (this.pending.delete(id)) { + const timer = setTimeout(() => { + if (this.pending.delete(id)) reject(new Error(`IPC: timeout waiting for ${method}`)); - } }, REQUEST_TIMEOUT_MS); + this.pending.set(id, [resolve, reject, timer]); + const payload = JSON.stringify({ jsonrpc: '2.0', id, method }) + '\n'; + this.socket.write(payload, 'utf8'); }); } async getStatus() { @@ -98,7 +114,14 @@ export class IpcClient { } } isConnected() { - return this.socket !== null; + return this.connected; + } + rejectPending(error) { + for (const handlers of this.pending.values()) { + clearTimeout(handlers[2]); + handlers[1](error); + } + this.pending.clear(); } destroy() { this.destroyed = true; @@ -108,5 +131,7 @@ export class IpcClient { } this.socket?.destroy(); this.socket = null; + this.connected = false; + this.rejectPending(new Error('IPC: client destroyed')); } } diff --git a/report.md b/report.md index 82f21a0..c26b0e7 100644 --- a/report.md +++ b/report.md @@ -874,3 +874,7 @@ not evidence of recovery. R7/R8/trim replacement remain open. U6 routes UI delete/rename exclusively through recorder JSON-RPC; offline engine returns its RPC error. `storage` now journals leaf-only delete/rename operations before no-overwrite moves, uses `.monolith-mutations` quarantine for deletes, commits catalog/tag/bookmark changes with journal stage, and recovers prepared/committed records at open. Ambiguous paths and invalid journal records fail closed. Reconcile removes a missing-video row transactionally. A named per-catalog Windows mutex plus recursive in-process lock guards recovery and mutations. Executed: Linux production-storage shim test (`tests/u6`), phase2 media tests, `cargo check --offline`, and `git diff --check`. This is not a filesystem/SQLite atomicity proof. Later hardening still needs stable file identity, reparse-point policy, Windows runtime validation, cross-process behavior validation, and broader crash/fault coverage. R7/R8 remain pending. + +## 2026-10-08 repository audit checkpoint + +See `docs/audit/2026-10-08.md` for current changes, concrete validation, three-PR scope and unresolved findings. `docs/audit/file-inventory.md` provides the file-by-file inventory. This checkpoint does not re-certify or close all historical findings above. The user authorized autonomous PR publication; no merge or release is part of the task. diff --git a/tests/phase1/CMakeLists.txt b/tests/phase1/CMakeLists.txt index 0a7fc06..ee940c3 100644 --- a/tests/phase1/CMakeLists.txt +++ b/tests/phase1/CMakeLists.txt @@ -19,3 +19,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_test(NAME ipc_server COMMAND ipc_server_test) set_tests_properties(ipc_server PROPERTIES TIMEOUT 120 RUN_SERIAL TRUE) endif() + +add_executable(pcm_input_test pcm_input_test.cpp) +target_include_directories(pcm_input_test PRIVATE ../../libs) +add_test(NAME pcm_input COMMAND pcm_input_test) diff --git a/tests/phase1/pcm_input_test.cpp b/tests/phase1/pcm_input_test.cpp new file mode 100644 index 0000000..b85ada5 --- /dev/null +++ b/tests/phase1/pcm_input_test.cpp @@ -0,0 +1,23 @@ +#include +#include +#include +#include + +void check(bool condition) { if (!condition) throw std::runtime_error("PCM contract failed"); } +int main() { + encoding::detail::PcmInput pcm; + check(!pcm.prepare(nullptr, 6, 2, 1, false)); + check(!pcm.prepare(nullptr, 6, 0, 24, false)); + check(!pcm.prepare(nullptr, 5, 2, 24, false)); + check(!pcm.prepare(nullptr, 6, 2, 24, true)); + check(pcm.prepare(nullptr, 4, 2, 8, false)); + check(pcm.data[0] == 128 && pcm.frames == 2); + std::array samples{0xff, 0xff, 0x7f, 0, 0, 0x80, 0xff, 0xff, 0xff}; + check(pcm.prepare(samples.data(), 9, 1, 24, false)); + check(pcm.bit_depth == 32 && pcm.frames == 3); + check(pcm.expanded[0] == 0x7fffff00); + check(pcm.expanded[1] == INT32_MIN && pcm.expanded[2] == -256); + check(pcm.prepare(nullptr, 6, 2, 24, false)); + check(pcm.expanded[0] == 0 && pcm.expanded[1] == 0); + std::cout << "PCM format and packed sample tests passed\n"; +} diff --git a/tests/phase2/CMakeLists.txt b/tests/phase2/CMakeLists.txt index 5e32311..0869831 100644 --- a/tests/phase2/CMakeLists.txt +++ b/tests/phase2/CMakeLists.txt @@ -4,11 +4,11 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) enable_testing() find_package(PkgConfig REQUIRED) -pkg_check_modules(AV REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil) +pkg_check_modules(AV REQUIRED IMPORTED_TARGET libavformat libavcodec libavutil libswscale libswresample) find_package(Threads REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) add_library(media_under_test STATIC ../../libs/encoding/trim.cpp ../../libs/encoding/mux_common.cpp - ../../libs/disk-segments/disk_segments.cpp ../../libs/replay-buffer/replay_buffer.cpp) + ../../libs/disk-segments/disk_segments.cpp ../../libs/replay-buffer/replay_buffer.cpp ../../libs/recording/recording.cpp) target_include_directories(media_under_test PUBLIC ../../libs PRIVATE win32-shim) target_compile_definitions(media_under_test PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) target_compile_options(media_under_test PRIVATE -Wall -Wextra -Wpedantic -Werror) @@ -16,10 +16,17 @@ target_link_libraries(media_under_test PUBLIC PkgConfig::AV Threads::Threads) add_executable(media_test media_test.cpp) target_link_libraries(media_test PRIVATE media_under_test nlohmann_json::nlohmann_json) target_compile_options(media_test PRIVATE -Wall -Wextra -Wpedantic -Werror) -target_link_options(media_test PRIVATE -Wl,--wrap=av_write_frame) +target_link_options(media_test PRIVATE -Wl,--wrap=av_write_frame -Wl,--wrap=av_interleaved_write_frame) add_test(NAME media COMMAND media_test ${CMAKE_CURRENT_BINARY_DIR}/fixtures) set_tests_properties(media PROPERTIES TIMEOUT 120 FIXTURES_SETUP media_fixtures) find_package(Python3 COMPONENTS Interpreter REQUIRED) add_test(NAME media_timeline COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/verify_timeline.py ${CMAKE_CURRENT_BINARY_DIR}/fixtures) set_tests_properties(media_timeline PROPERTIES DEPENDS media FIXTURES_REQUIRED media_fixtures TIMEOUT 120) + +add_executable(encoding_test encoding_test.cpp ../../libs/encoding/encoding.cpp) +target_include_directories(encoding_test PRIVATE ../../libs) +target_link_libraries(encoding_test PRIVATE PkgConfig::AV Threads::Threads) +target_compile_options(encoding_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME encoding COMMAND encoding_test) +set_tests_properties(encoding PROPERTIES TIMEOUT 30) diff --git a/tests/phase2/encoding_test.cpp b/tests/phase2/encoding_test.cpp new file mode 100644 index 0000000..1d846a7 --- /dev/null +++ b/tests/phase2/encoding_test.cpp @@ -0,0 +1,62 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) throw std::runtime_error(#x); } while (false) +using namespace std::chrono_literals; + +int main() { + encoding::VideoEncoder video; + encoding::VideoEncoder::Config cfg; + CHECK(!video.open(cfg, {})); + cfg.width = 64; cfg.height = 64; cfg.preferred_encoder = "libx264"; + cfg.codec = encoding::VideoCodec::AV1; + if (video.open(cfg, {})) { + CHECK(video.stream_params().codec == encoding::VideoCodec::AV1); + video.close(); + } + cfg.codec = encoding::VideoCodec::H264; + int packets = 0; + CHECK(video.open(cfg, [&](auto) { ++packets; })); + std::vector bgra(64 * 64 * 4); + video.push_bgra(bgra.data(), 1, 64, 64); + CHECK(video.perf_stats().frames_submitted == 0); + video.push_bgra(bgra.data(), 64 * 4, 64, 64); + video.flush(); CHECK(packets > 0); video.close(); + + encoding::TrackMixer mixer; + CHECK(!mixer.open(48000, 65, {})); + std::promise called; + CHECK(mixer.open(48000, 2, [&](auto, auto, auto, auto, auto, auto) { + called.set_value(); throw std::runtime_error("sink failed"); + })); + CHECK(called.get_future().wait_for(2s) == std::future_status::ready); + for (int i = 0; mixer.is_open() && i < 100; ++i) std::this_thread::sleep_for(1ms); + CHECK(!mixer.is_open()); mixer.close(); + + std::atomic callbacks{0}; + std::atomic invalid{false}; + CHECK(mixer.open(48000, 2, [&](const uint8_t* data, int bytes, int rate, int channels, int bits, bool is_float) { + if (bytes <= 0 || bytes > 4800 * 2 * 4 || rate != 48000 || channels != 2 || bits != 32 || !is_float) + invalid = true; + const auto* samples = reinterpret_cast(data); + for (int i = 0; i < bytes / 4; ++i) + if (!std::isfinite(samples[i]) || std::abs(samples[i]) > 1.0f) invalid = true; + ++callbacks; + })); + const auto muted_source = mixer.add_source(std::numeric_limits::quiet_NaN()); + CHECK(muted_source >= 0); + const auto source = mixer.add_source(); CHECK(source >= 0); + mixer.set_source_gain(source, std::numeric_limits::infinity()); + std::vector burst(96000 * 2, 0.5f); + mixer.push(source, reinterpret_cast(burst.data()), static_cast(burst.size() * 4), + 48000, 2, 32, true); + for (int i = 0; callbacks < 3 && i < 500; ++i) std::this_thread::sleep_for(1ms); + mixer.close(); CHECK(callbacks >= 3 && !invalid); +} diff --git a/tests/phase2/media_test.cpp b/tests/phase2/media_test.cpp index 0647bc6..fc2de50 100644 --- a/tests/phase2/media_test.cpp +++ b/tests/phase2/media_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -20,12 +21,19 @@ namespace fs = std::filesystem; std::mutex save_gate_mutex; std::condition_variable save_gate; bool block_save = false, save_entered = false; +bool fail_write = false; +extern "C" int __real_av_interleaved_write_frame(AVFormatContext*, AVPacket*); +extern "C" int __wrap_av_interleaved_write_frame(AVFormatContext* fmt, AVPacket* packet) { + if (fail_write) return AVERROR(EIO); + return __real_av_interleaved_write_frame(fmt, packet); +} extern "C" int __real_av_write_frame(AVFormatContext*, AVPacket*); extern "C" int __wrap_av_write_frame(AVFormatContext* fmt, AVPacket* packet) { { std::unique_lock lk(save_gate_mutex); if (block_save) { save_entered = true; save_gate.notify_all(); save_gate.wait(lk, [] { return !block_save; }); } } + if (fail_write) return AVERROR(EIO); return __real_av_write_frame(fmt, packet); } void command(const std::string& cmd) { CHECK(std::system(cmd.c_str()) == 0); } @@ -151,6 +159,32 @@ int main(int argc, char** argv) { std::promise ram_saved; replay.save_clip([&](auto p) { ram_saved.set_value(p); }); auto ram = ram_saved.get_future().get(); CHECK(!ram.empty()); decode(ram); + replay.save_clip([](auto) { throw std::runtime_error("completion failed"); }); + for (int i = 0; i < 500 && replay.stats().saving; ++i) std::this_thread::sleep_for(10ms); + CHECK(!replay.stats().saving); + std::promise recovered_save; + replay.save_clip([&](auto p) { recovered_save.set_value(p); }); + CHECK(!recovered_save.get_future().get().empty()); + recording::ManualRecorder recorder; + recorder.set_video_params(media.video); recorder.set_audio_params(media.audio); + CHECK(recorder.start((dir/"recordings"/"nested").wstring(), "mp4")); + const auto first_path = recorder.current_path(); + for (const auto& p : media.packets) recorder.push(p); + std::wstring recorded; + CHECK(recorder.stop(&recorded) && !recorded.empty()); decode(recorded); + std::ofstream(dir/"manual-path.txt") << fs::path(recorded).string(); + CHECK(recorder.start((dir/"recordings"/"nested").wstring(), "mp4")); + CHECK(recorder.current_path() != first_path); CHECK(recorder.stop()); + CHECK(recorder.start((dir/"failed-recordings").wstring(), "mp4")); + fail_write = true; + recorder.push(*first_video); + fail_write = false; + recorded = L"stale"; + CHECK(recorder.stop(&recorded) && recorded.empty()); + const std::wstring long_folder(300, L'a'); + CHECK(recorder.start(long_folder, "mkv")); + CHECK(recorder.current_path().size() > 300); CHECK(recorder.stop()); + // Continuous encoder clock: unlike the pressure fixture below, never loop // independently encoded AAC priming/tail packets over a reset boundary. const auto continuous_path = dir/"continuous.mp4"; diff --git a/tests/phase2/verify_timeline.py b/tests/phase2/verify_timeline.py index a6a8667..8797988 100644 --- a/tests/phase2/verify_timeline.py +++ b/tests/phase2/verify_timeline.py @@ -56,3 +56,15 @@ def hashes(path): assert abs(expected - float(packet['pts_time'])) < 0.0021, ('audio offset', output_name, index, expected, packet['pts_time']) pos += 1 print('PASS timeline: 16 trims, lossless frame hashes, fractional/VFR frame PTS/count, audio codec/payload/offsets, actual duration') + +manual = Path((root / 'manual-path.txt').read_text()) +assert hashes(manual) == hashes(root / 'input.mp4'), 'manual recording dropped or changed frames' +for index in (1, 2): + src = probe(root / 'input.mp4', '-select_streams', str(index), '-show_packets', '-show_data_hash', 'sha256')['packets'] + dst = probe(manual, '-select_streams', str(index), '-show_packets', '-show_data_hash', 'sha256')['packets'] + by_hash = {p['data_hash']: p for p in src} + assert dst, 'missing manual audio' + for packet in dst: + original = by_hash[packet['data_hash']] + assert abs(float(original['pts_time']) - float(packet['pts_time'])) < 0.0021, 'manual A/V origin' +print('PASS manual recording: all video frame hashes and both audio track offsets preserved') diff --git a/tests/phase2/win32-shim/windows.h b/tests/phase2/win32-shim/windows.h index 36fbc40..9622fac 100644 --- a/tests/phase2/win32-shim/windows.h +++ b/tests/phase2/win32-shim/windows.h @@ -34,3 +34,5 @@ template int swprintf_s(wchar_t* dst, size_t size, const wchar_t* inline bool CreateDirectoryW(const wchar_t* path, void*) { std::error_code ec; std::filesystem::create_directories(path, ec); return !ec; } + +inline void OutputDebugStringA(const char*) {} diff --git a/tests/rust/.gitignore b/tests/rust/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/tests/rust/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/tests/rust/Cargo.lock b/tests/rust/Cargo.lock new file mode 100644 index 0000000..17026ef --- /dev/null +++ b/tests/rust/Cargo.lock @@ -0,0 +1,1173 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "monolith-core-tests" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-dalek", + "rusqlite", + "semver", + "serde", + "serde_json", + "sha2", + "ureq", + "windows", + "zip", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml new file mode 100644 index 0000000..7e2dd64 --- /dev/null +++ b/tests/rust/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "monolith-core-tests" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"], optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = { version = "2", features = ["json"], optional = true } +base64 = "0.22" +semver = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } +sha2 = "0.10" +ed25519-dalek = "2" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security", "Win32_UI_WindowsAndMessaging", "Win32_System_Threading", "Win32_Storage_FileSystem", "Win32_Networking_WinHttp"] } + +[features] +default = ["catalogs"] +catalogs = ["dep:rusqlite", "dep:ureq"] diff --git a/tests/rust/src/lib.rs b/tests/rust/src/lib.rs new file mode 100644 index 0000000..97cafd2 --- /dev/null +++ b/tests/rust/src/lib.rs @@ -0,0 +1,62 @@ +#![allow(dead_code)] + +#[path = "../../../app/updater/src-tauri/src/archive.rs"] +mod archive; +#[cfg(feature = "catalogs")] +#[path = "../../../app/desktop-ui/src-tauri/src/clip_catalog.rs"] +mod clip_catalog; +#[cfg(feature = "catalogs")] +#[path = "../../../app/desktop-ui/src-tauri/src/collections.rs"] +mod collections; +#[path = "../../../app/updater/src-tauri/src/download.rs"] +mod download; +#[cfg(feature = "catalogs")] +#[path = "../../../app/desktop-ui/src-tauri/src/game_catalog.rs"] +mod game_catalog; +#[path = "../../../app/updater/src-tauri/src/manifest.rs"] +mod manifest; +#[cfg(feature = "catalogs")] +#[path = "../../../app/desktop-ui/src-tauri/src/paths.rs"] +mod paths; +#[cfg(feature = "catalogs")] +#[path = "../../../app/desktop-ui/src-tauri/src/settings_store.rs"] +mod settings_store; + +#[cfg(windows)] +#[path = "../../../app/updater/src-tauri/src/apply.rs"] +mod apply; +#[cfg(windows)] +#[path = "../../../app/updater/src-tauri/src/http.rs"] +mod http; +#[cfg(all(windows, not(feature = "catalogs")))] +#[path = "../../../app/updater/src-tauri/src/versions.rs"] +mod versions; + +#[cfg(not(windows))] +mod http { + use std::{path::Path, sync::atomic::AtomicBool}; + pub fn get_to_string(_: &str) -> Result { + Err("WinHTTP requires Windows".into()) + } + pub fn get_to_file( + _: &str, + _: &Path, + _: u64, + _: &AtomicBool, + _: &dyn Fn(u64), + ) -> Result { + Err("WinHTTP requires Windows".into()) + } +} + +#[cfg(not(feature = "catalogs"))] +#[path = "../../../app/updater/src-tauri/src/paths.rs"] +mod paths; + +#[cfg(all(test, feature = "catalogs"))] +mod tests; + +#[path = "../../../app/desktop-ui/src-tauri/src/engine_rpc.rs"] +mod ui_rpc; +#[path = "../../../app/updater/src-tauri/src/engine_rpc.rs"] +mod updater_rpc; diff --git a/tests/rust/src/tests.rs b/tests/rust/src/tests.rs new file mode 100644 index 0000000..4134a56 --- /dev/null +++ b/tests/rust/src/tests.rs @@ -0,0 +1,214 @@ +use crate::{archive, clip_catalog, collections, game_catalog, manifest, paths, settings_store}; +use base64::Engine; +use rusqlite::Connection; +use serde_json::json; +use std::{fs, io::Write, path::Path}; + +fn component() -> manifest::ComponentInfo { + manifest::ComponentInfo { + version: "1.2.3".into(), + url: "https://example.com/component.zip".into(), + size: 1, + sha256: "ab".repeat(32), + ed_signature: base64::engine::general_purpose::STANDARD.encode([0u8; 64]), + } +} + +#[test] +fn manifest_requires_integrity_and_bounded_components() { + let valid = component(); + assert!(valid.validate().is_ok()); + let mut bad = valid.clone(); + bad.ed_signature.clear(); + assert!(bad.validate().is_err()); + bad = valid.clone(); + bad.sha256 = "gg".repeat(32); + assert!(bad.validate().is_err()); + bad = valid.clone(); + bad.size = 0; + assert!(bad.validate().is_err()); + bad = valid.clone(); + bad.size = manifest::MAX_COMPONENT_BYTES + 1; + assert!(bad.validate().is_err()); + bad = valid.clone(); + bad.url = "http://example.com/a".into(); + assert!(bad.validate().is_err()); + bad = valid.clone(); + bad.url.push('\0'); + assert!(bad.validate().is_err()); + bad = valid; + bad.version = "nonsense".into(); + assert!(bad.validate().is_err()); + let mut value: manifest::Manifest = + serde_json::from_value(json!({"schema": 1, "components": {}})).unwrap(); + assert!(value.validate().is_err()); + value.components.insert("engine".into(), component()); + assert!(value.validate().is_ok()); + value.components.insert("other".into(), component()); + assert!(value.validate().is_err()); + value.components.remove("other"); + value.schema = 2; + assert!(value.validate().is_err()); +} + +fn make_zip(path: &Path, name: &str) { + let mut writer = zip::ZipWriter::new(fs::File::create(path).unwrap()); + writer + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(b"payload").unwrap(); + writer.finish().unwrap(); +} + +#[test] +fn archive_rejects_windows_path_aliases_and_traversal() { + let root = std::env::temp_dir().join(format!("monolith-archive-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let zip = root.join("component.zip"); + for name in [ + "../outside", + "/outside", + "ui/file:stream", + "ui/CON.txt", + "ui/lpt1.log", + "ui/trailing.", + "ui/trailing ", + "ui\\escape", + ] { + make_zip(&zip, name); + assert!( + archive::extract_zip(&zip, &root.join("staging")).is_err(), + "accepted {name}" + ); + } + make_zip(&zip, "ui/Monolith.UI.exe"); + archive::extract_zip(&zip, &root.join("staging")).unwrap(); + assert_eq!( + fs::read(root.join("staging/ui/Monolith.UI.exe")).unwrap(), + b"payload" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn catalogs_preserve_membership_on_errors_and_serialize_bookmarks() { + let root = std::env::temp_dir().join(format!("monolith-catalogs-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + std::env::set_var("LOCALAPPDATA", &root); + let clips = root.join("clips"); + let recs = root.join("recs"); + fs::create_dir_all(&clips).unwrap(); + fs::create_dir_all(&recs).unwrap(); + settings_store::write_config( + &json!({"output": {"clips_directory": clips, "recordings_directory": recs}}), + ) + .unwrap(); + assert_eq!(settings_store::output_dirs().clips, clips); + let alternate = root.join("other"); + settings_store::write_config( + &json!({"output": {"clips_directory": alternate, "recordings_directory": recs}}), + ) + .unwrap(); + assert_eq!(settings_store::output_dirs().clips, alternate); + settings_store::write_config( + &json!({"output": {"clips_directory": clips, "recordings_directory": recs}}), + ) + .unwrap(); + let settings = Connection::open(settings_store::settings_db_path()).unwrap(); + settings + .execute_batch("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0;") + .unwrap(); + let alternate_config = json!({"clips_directory": alternate, "recordings_directory": recs}); + settings + .execute( + "UPDATE settings SET value=?1 WHERE key='output'", + [alternate_config.to_string()], + ) + .unwrap(); + assert_eq!(settings_store::output_dirs().clips, alternate); + settings + .execute( + "UPDATE settings SET value=?1 WHERE key='output'", + [json!({"clips_directory": clips,"recordings_directory": recs}).to_string()], + ) + .unwrap(); + assert_eq!(settings_store::output_dirs().clips, clips); + drop(settings); + let game_db = paths::monolith_data_dir().join("game_catalog.db"); + let game = Connection::open(&game_db).unwrap(); + game.execute_batch("CREATE TABLE game_catalog (process_name_lower TEXT PRIMARY KEY);") + .unwrap(); + game_catalog::store_exe_icon("Game.EXE", b"png"); + assert_eq!( + game_catalog::cached_exe_icon("game.exe"), + Some(b"png".to_vec()) + ); + assert!(game_catalog::entry_by_process("game.exe").is_some()); + let timestamp: i64 = game + .query_row("SELECT last_updated FROM game_catalog", [], |r| r.get(0)) + .unwrap(); + assert_eq!(timestamp, 0); + let db = Connection::open(clips.join("clips.db")).unwrap(); + db.execute_batch( + "CREATE TABLE clips (id INTEGER PRIMARY KEY, video_file TEXT, thumbnail_file TEXT, + created_at_utc TEXT, duration_seconds REAL, game_process_name TEXT, game_display_name TEXT, + favorite INTEGER, title TEXT); + CREATE TABLE clip_hashtags (clip_id INTEGER, tag TEXT); + INSERT INTO clips VALUES (1,'a.mp4',NULL,'2026-10-08T00:00:00Z',5,NULL,NULL,0,'A');", + ) + .unwrap(); + fs::write(clips.join("a.mp4"), b"video").unwrap(); + let collection = collections::create_collection("Games", "").unwrap(); + collections::add_clip_to_collection(collection, clip_catalog::ClipSource::Replay, 1).unwrap(); + assert_eq!(collections::collection_clips(collection).unwrap().len(), 1); + let mut workers = Vec::new(); + for index in 0..8 { + workers.push(std::thread::spawn(move || { + clip_catalog::add_bookmark( + clip_catalog::ClipSource::Replay, + 1, + f64::from(index), + "", + "", + ) + .unwrap(); + })); + } + for worker in workers { + worker.join().unwrap(); + } + let count: i64 = db + .query_row("SELECT COUNT(DISTINCT seq) FROM clip_bookmarks", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(count, 8); + db.execute_batch("ALTER TABLE clips RENAME TO broken;") + .unwrap(); + assert!(collections::collection_clips(collection).is_err()); + let col_db = Connection::open(paths::monolith_data_dir().join("collections.db")).unwrap(); + let count: i64 = col_db + .query_row("SELECT COUNT(*) FROM collection_clips", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + db.execute_batch("ALTER TABLE broken RENAME TO clips;") + .unwrap(); + fs::rename(clips.join("clips.db"), clips.join("clips.db.saved")).unwrap(); + assert!(collections::collection_clips(collection).is_err()); + let count: i64 = col_db + .query_row("SELECT COUNT(*) FROM collection_clips", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + fs::rename(clips.join("clips.db.saved"), clips.join("clips.db")).unwrap(); + fs::remove_file(clips.join("a.mp4")).unwrap(); + assert!(collections::collection_clips(collection) + .unwrap() + .is_empty()); + collections::delete_collection(collection).unwrap(); + drop(col_db); + drop(db); + drop(game); + fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/stream-deck/ipc-client.test.mjs b/tests/stream-deck/ipc-client.test.mjs new file mode 100644 index 0000000..54d20b4 --- /dev/null +++ b/tests/stream-deck/ipc-client.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { once } from 'node:events'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import { IpcClient } from '../../plugins/stream-deck/.build/top.fraa2a.monolith.sdPlugin/bin/ipc-client.js'; + +async function until(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await delay(10); + } + throw new Error('condition timed out'); +} + +test('IPC handles UTF-8 splits, disconnects, response limits and shutdown', { timeout: 15000 }, async () => { + let connections = 0; + let peer; + const peers = new Set(); + const server = net.createServer(socket => { + peer = socket; + peers.add(socket); + socket.on('close', () => peers.delete(socket)); + connections++; + }); + server.listen(45991, '127.0.0.1'); + await once(server, 'listening'); + const client = new IpcClient(); + try { + client.connect(); client.connect(); + await until(() => client.isConnected() && peer); + assert.equal(connections, 1); + const data = once(peer, 'data'); + const reply = client.request('get_status'); + const request = JSON.parse((await data)[0].toString()); + const response = Buffer.from(JSON.stringify({ id: request.id, result: 'caffè' }) + '\n'); + const split = response.indexOf(Buffer.from('è')) + 1; + peer.write(response.subarray(0, split)); + await delay(10); + peer.write(response.subarray(split)); + assert.equal(await reply, 'caffè'); + peer.write('{"id":'); + const pending = client.request('get_status'); + const rejected = assert.rejects(pending, /disconnected/); + peer.destroy(); + await rejected; + await until(() => !client.isConnected()); + client.connect(); + await until(() => client.isConnected() && connections === 2); + const nextData = once(peer, 'data'); + const next = client.request('get_status'); + const nextRequest = JSON.parse((await nextData)[0].toString()); + peer.write(JSON.stringify({ id: nextRequest.id, result: 42 }) + '\n'); + assert.equal(await next, 42); + peer.write('x'.repeat(65537)); + await until(() => !client.isConnected()); + client.connect(); await until(() => client.isConnected() && connections === 3); + const waiting = Array.from({ length: 64 }, () => client.request('get_status').catch(error => error)); + await assert.rejects(client.request('get_status'), /too many pending/); + client.destroy(); + for (const result of await Promise.all(waiting)) assert.match(result.message, /destroyed/); + await assert.rejects(client.request('get_status'), /not connected/); + client.connect(); await delay(20); + assert.equal(connections, 3); + } finally { + client.destroy(); + for (const socket of peers) socket.destroy(); + server.close(); + await once(server, 'close'); + } +}); diff --git a/tests/u6/storage_mutation_test.cpp b/tests/u6/storage_mutation_test.cpp index be4361c..4e018f0 100644 --- a/tests/u6/storage_mutation_test.cpp +++ b/tests/u6/storage_mutation_test.cpp @@ -110,6 +110,11 @@ int main() { check(!db, "ambiguous journal rejected"); sql(path, "DELETE FROM file_mutation_journal WHERE token='1122334455667788';"); + // Directory aliases cannot enter catalog file mutations. + db = storage::ClipDb::open(folder, "replay", &e); + check(db != nullptr, "open after ambiguous recovery"); + check(!db->rename_clip(1, L"..", &e), "parent alias rejected"); + check(!db->rename_clip(1, L"trailing ", &e), "trailing space rejected"); // Invalid journal is rejected rather than interpreted. sql(path, "INSERT INTO file_mutation_journal VALUES " "('bad',1,'delete',3,'../x','q','','','prepared');");