From 71f217006ffd92e443b5347edca7dabc606e2b96 Mon Sep 17 00:00:00 2001 From: FaulMit <48646918+FaulMit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:32:25 +0200 Subject: [PATCH 1/3] Secure Captail build and release workflows --- .github/workflows/ci.yml | 36 +++++++++++++++++------- .github/workflows/codeql.yml | 53 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 23 +++++++-------- 3 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69b9be..a86ffd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,20 +22,14 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: 9.0.x cache: true - cache-dependency-path: src/Captail/Captail.csproj - - - name: Cache OBS runtime - uses: actions/cache@v6 - with: - path: runtime/obs - key: obs-32.1.2-windows-x64-${{ hashFiles('tools/AcquireObsRuntime.ps1') }} + cache-dependency-path: src/Captail/packages.lock.json - name: Acquire minimal OBS runtime shell: pwsh @@ -43,7 +37,29 @@ jobs: - name: Build Release shell: pwsh - run: dotnet build ./Captail.sln -c Release -p:ContinuousIntegrationBuild=true + run: | + dotnet restore ./src/Captail/Captail.csproj --locked-mode + dotnet build ./Captail.sln -c Release --no-restore ` + -p:ContinuousIntegrationBuild=true + + - name: Run full .NET analyzers + shell: pwsh + run: | + dotnet build ./src/Captail/Captail.csproj -c Debug --no-restore ` + -p:EnableNETAnalyzers=true ` + -p:AnalysisLevel=latest-all ` + -p:AnalysisMode=All ` + -p:WarningsAsErrors=CA1806%3BCA2000%3BCA2216%3BCA5392%3BCA5393 + + - name: Validate GPU capability model + shell: pwsh + run: | + $exe = Resolve-Path './src/Captail/bin/Debug/net9.0-windows10.0.22621.0/win-x64/Captail.exe' + $process = Start-Process -FilePath $exe -ArgumentList '--qa-capability-model' ` + -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Capability model QA failed: $($process.ExitCode)" + } - name: Validate localization dictionaries shell: pwsh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2d1397e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '23 4 * * 1' + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: C# and C++ + runs-on: windows-2022 + timeout-minutes: 45 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: 9.0.x + cache: true + cache-dependency-path: src/Captail/packages.lock.json + + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + languages: csharp,c-cpp + + - name: Acquire verified OBS runtime + shell: pwsh + run: ./tools/AcquireObsRuntime.ps1 + + - name: Build + shell: pwsh + run: | + dotnet restore ./src/Captail/Captail.csproj --locked-mode + dotnet build ./Captail.sln -c Release --no-restore ` + -p:ContinuousIntegrationBuild=true + + - name: Analyze + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a265108..fc2fb94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,22 +47,16 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Check out source - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 with: dotnet-version: 9.0.x cache: true - cache-dependency-path: src/Captail/Captail.csproj - - - name: Cache OBS runtime - uses: actions/cache@v6 - with: - path: runtime/obs - key: obs-32.1.2-windows-x64-${{ hashFiles('tools/AcquireObsRuntime.ps1') }} + cache-dependency-path: src/Captail/packages.lock.json - name: Acquire minimal OBS runtime shell: pwsh @@ -77,6 +71,12 @@ jobs: $installDirectory = Join-Path $env:RUNNER_TEMP "innosetup7" Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $installer + $expectedHash = "5ad54ca3def786f8f4212552e54cc6d8d61329e2d24a1cfee0571d42c2684ff1" + $actualHash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash + if (-not $actualHash.Equals($expectedHash, [StringComparison]::OrdinalIgnoreCase)) { + throw "Inno Setup SHA-256 verification failed: $actualHash" + } + $signature = Get-AuthenticodeSignature -FilePath $installer if ($signature.Status -ne "Valid" -or $signature.SignerCertificate.Subject -notmatch "Pyrsys B\.V\.") { @@ -103,6 +103,7 @@ jobs: - name: Build release packages shell: pwsh run: | + dotnet restore ./src/Captail/Captail.csproj --locked-mode ./tools/BuildRelease.ps1 ` -Version $env:VERSION ` -OutputDirectory $env:RELEASE_DIR ` @@ -178,7 +179,7 @@ jobs: } - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: Captail-${{ inputs.version }}-win-x64 path: | @@ -189,7 +190,7 @@ jobs: retention-days: 30 - name: Attest release assets - uses: actions/attest@v4 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 with: subject-path: | ${{ env.RELEASE_DIR }}/Captail-${{ inputs.version }}-Portable-win-x64.zip From 6f45d36d6237cdfdb7bc8694d7dc68bfaee58218 Mon Sep 17 00:00:00 2001 From: FaulMit <48646918+FaulMit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:30:20 +0200 Subject: [PATCH 2/3] Release Captail 0.1.2 performance and stability update --- CHANGELOG.md | 18 + Captail.sln | 6 - installer/Captail.iss | 25 + native/ObsBridge/CaptailObsBridge.cpp | 18 +- .../ObsCaptureFixture/ObsCaptureFixture.cpp | 63 +- src/Captail/App.xaml.cs | 811 ++++++++++++++---- src/Captail/AssemblyInfo.cs | 6 +- src/Captail/Autostart.cs | 31 +- src/Captail/Captail.csproj | 20 +- src/Captail/Config.cs | 210 ++++- src/Captail/EncoderCapabilities.cs | 6 +- src/Captail/HotkeyManager.cs | 9 +- src/Captail/Interop/CaptureInterop.cs | 17 + src/Captail/Languages/Strings.en.xaml | 2 +- src/Captail/Languages/Strings.ru.xaml | 2 +- src/Captail/Log.cs | 53 +- src/Captail/ObsLogBridge.cs | 8 +- src/Captail/ObsNative.cs | 52 +- src/Captail/ObsReplayEngine.cs | 110 +-- src/Captail/OverlayNotificationWindow.xaml.cs | 14 +- src/Captail/SettingsWindow.xaml.cs | 576 +++++++++---- src/Captail/SingleThreadTaskScheduler.cs | 62 ++ src/Captail/packages.lock.json | 112 +++ tools/AcquireObsRuntime.ps1 | 96 ++- tools/BuildRelease.ps1 | 7 + 25 files changed, 1840 insertions(+), 494 deletions(-) create mode 100644 src/Captail/SingleThreadTaskScheduler.cs create mode 100644 src/Captail/packages.lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a9eb7..b763999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable user-facing changes are documented here. ## [Unreleased] +## [0.1.2] - 2026-07-22 + +### Fixed + +- Settings, audio-source changes, replay toggles, and replay saves no longer block the UI while libobs starts, stops, or writes a replay. +- Failed settings changes now roll back configuration, hotkeys, autostart state, and the recording pipeline instead of leaving Captail partially configured. +- Rapid pipeline operations are serialized to prevent save, restart, watchdog recovery, and shutdown races. +- Configuration writes are atomic and automatically recover from the last valid backup after a damaged file. +- Game Capture now uses the selected game's client size for source resolution and avoids an unnecessary scene-composition pass. +- Installer upgrades request a graceful Captail shutdown and uninstall removes its startup entry. + +### Changed + +- Moved libobs lifetime operations to a dedicated thread and reduced synchronous disk, device, process, and log work on the UI thread. +- Hardened native library loading, OBS runtime acquisition, dependency locking, installer downloads, and GitHub Actions against dependency substitution. +- Added automated capability, codec, recovery, and high-frame-rate Game Capture diagnostics. +- Validated AV1 Game Capture at 2560x1440 and 240 unique frames per second on an NVIDIA GeForce RTX 5070, including concurrent synthetic GPU load. + ## [0.1.1] - 2026-07-21 ### Fixed diff --git a/Captail.sln b/Captail.sln index 94217cf..6ba9ef5 100644 --- a/Captail.sln +++ b/Captail.sln @@ -11,24 +11,18 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 - Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x64.ActiveCfg = Debug|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x64.Build.0 = Debug|Any CPU - {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x86.ActiveCfg = Debug|Any CPU - {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Debug|x86.Build.0 = Debug|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|Any CPU.Build.0 = Release|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x64.ActiveCfg = Release|Any CPU {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x64.Build.0 = Release|Any CPU - {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x86.ActiveCfg = Release|Any CPU - {FB66E8EA-3BE5-4E9C-8533-1C1B10A06151}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/installer/Captail.iss b/installer/Captail.iss index 14bad31..0780c51 100644 --- a/installer/Captail.iss +++ b/installer/Captail.iss @@ -69,3 +69,28 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: [Run] Filename: "{app}\{#MyAppExeName}"; Description: "Launch Captail"; Flags: nowait postinstall skipifsilent + +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; + InstalledExe: String; +begin + Result := ''; + InstalledExe := ExpandConstant('{app}\{#MyAppExeName}'); + if FileExists(InstalledExe) then + begin + if not Exec(InstalledExe, '--shutdown-existing', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode) then + Result := 'Could not ask Captail to stop before updating.' + else if ResultCode <> 0 then + Result := 'Captail is still busy. Wait for replay saving to finish, then retry.'; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + RegDeleteValue(HKCU, + 'Software\Microsoft\Windows\CurrentVersion\Run', 'Captail'); +end; diff --git a/native/ObsBridge/CaptailObsBridge.cpp b/native/ObsBridge/CaptailObsBridge.cpp index 4aa3131..270d360 100644 --- a/native/ObsBridge/CaptailObsBridge.cpp +++ b/native/ObsBridge/CaptailObsBridge.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -7,23 +8,22 @@ using obs_log_handler = void(__cdecl *)(int level, const char *format, va_list a using base_set_log_handler_proc = void(__cdecl *)(obs_log_handler handler, void *context); namespace { -managed_log_callback callback = nullptr; +std::atomic callback{nullptr}; void __cdecl obs_log(int level, const char *format, va_list args, void *) { - if (!callback || !format) + managed_log_callback current = callback.load(std::memory_order_acquire); + if (!current || !format) return; char message[8192]{}; vsnprintf_s(message, sizeof(message), _TRUNCATE, format, args); - callback(level, message); + current(level, message); } base_set_log_handler_proc get_setter() { HMODULE obs = GetModuleHandleW(L"obs.dll"); - if (!obs) - obs = LoadLibraryW(L"obs.dll"); return obs ? reinterpret_cast( GetProcAddress(obs, "base_set_log_handler")) @@ -32,20 +32,20 @@ base_set_log_handler_proc get_setter() } // namespace extern "C" __declspec(dllexport) bool __cdecl -everloop_install_obs_log_handler(managed_log_callback managed_callback) +captail_install_obs_log_handler(managed_log_callback managed_callback) { auto setter = get_setter(); if (!setter) return false; - callback = managed_callback; + callback.store(managed_callback, std::memory_order_release); setter(obs_log, nullptr); return true; } -extern "C" __declspec(dllexport) void __cdecl everloop_remove_obs_log_handler() +extern "C" __declspec(dllexport) void __cdecl captail_remove_obs_log_handler() { auto setter = get_setter(); if (setter) setter(nullptr, nullptr); - callback = nullptr; + callback.store(nullptr, std::memory_order_release); } diff --git a/native/ObsCaptureFixture/ObsCaptureFixture.cpp b/native/ObsCaptureFixture/ObsCaptureFixture.cpp index ba00b66..50520f2 100644 --- a/native/ObsCaptureFixture/ObsCaptureFixture.cpp +++ b/native/ObsCaptureFixture/ObsCaptureFixture.cpp @@ -1,8 +1,8 @@ #include +#include #include #include #include -#include using Microsoft::WRL::ComPtr; @@ -17,8 +17,16 @@ LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lp } } -int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show) +int WINAPI wWinMain( + _In_ HINSTANCE instance, + _In_opt_ HINSTANCE, + _In_ PWSTR, + _In_ int show) { + constexpr int client_width = 2560; + constexpr int client_height = 1440; + constexpr DWORD window_style = WS_POPUP; + WNDCLASSW window_class{}; window_class.hInstance = instance; window_class.lpfnWndProc = window_proc; @@ -27,15 +35,19 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show) if (!RegisterClassW(&window_class)) return 1; + RECT window_rect{0, 0, client_width, client_height}; + if (!AdjustWindowRect(&window_rect, window_style, FALSE)) + return 2; + HWND window = CreateWindowExW( - 0, + WS_EX_TOPMOST, window_class.lpszClassName, L"Captail OBS Capture Fixture", - WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, - CW_USEDEFAULT, - 1280, - 720, + window_style, + 0, + 0, + window_rect.right - window_rect.left, + window_rect.bottom - window_rect.top, nullptr, nullptr, instance, @@ -80,8 +92,20 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show) back_buffer.Get(), nullptr, &render_target))) return 4; + D3D11_TEXTURE2D_DESC stress_desc{}; + back_buffer->GetDesc(&stress_desc); + stress_desc.BindFlags = 0; + stress_desc.MiscFlags = 0; + ComPtr stress_texture; + if (FAILED(device->CreateTexture2D(&stress_desc, nullptr, &stress_texture))) + return 5; + ShowWindow(window, show); uint32_t frame = 0; + constexpr auto frame_interval = std::chrono::nanoseconds(1'000'000'000 / 240); + auto next_frame = std::chrono::steady_clock::now(); + auto measurement_start = next_frame; + uint32_t measurement_frames = 0; MSG message{}; while (message.message != WM_QUIT) { while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { @@ -98,8 +122,29 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show) 1.0f, }; context->ClearRenderTargetView(render_target.Get(), color); + for (int pass = 0; pass < 32; pass++) { + context->CopyResource(stress_texture.Get(), back_buffer.Get()); + context->CopyResource(back_buffer.Get(), stress_texture.Get()); + } swap_chain->Present(0, DXGI_PRESENT_ALLOW_TEARING); - std::this_thread::yield(); + measurement_frames++; + const auto measurement_now = std::chrono::steady_clock::now(); + const auto measurement_elapsed = measurement_now - measurement_start; + if (measurement_elapsed >= std::chrono::seconds(1)) { + const double measured_fps = measurement_frames / + std::chrono::duration(measurement_elapsed).count(); + wchar_t title[96]{}; + swprintf_s(title, L"Captail Capture Fixture - %.1f FPS", measured_fps); + SetWindowTextW(window, title); + measurement_start = measurement_now; + measurement_frames = 0; + } + next_frame += frame_interval; + while (std::chrono::steady_clock::now() < next_frame) + YieldProcessor(); + auto now = std::chrono::steady_clock::now(); + if (now - next_frame > frame_interval) + next_frame = now; } return 0; } diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs index eaae210..dc6e8ef 100644 --- a/src/Captail/App.xaml.cs +++ b/src/Captail/App.xaml.cs @@ -38,10 +38,17 @@ public partial class App : Application private OverlayNotificationWindow? _overlayNotification; private int _saving; private EncoderCapabilities? _capabilities; + private readonly SemaphoreSlim _pipelineGate = new(1, 1); + private readonly SingleThreadTaskScheduler _obsTaskScheduler = + new("Captail OBS"); + private volatile bool _replayRunning; + private string? _captureDescription; + private int _exiting; + private bool _shutdownExistingSucceeded = true; - private bool IsReplayRunning => _obs?.IsActive == true; + private bool IsReplayRunning => _replayRunning; - protected override void OnStartup(StartupEventArgs e) + protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); @@ -71,20 +78,29 @@ protected override void OnStartup(StartupEventArgs e) bool backgroundLaunch = e.Args.Contains( "--background", StringComparer.OrdinalIgnoreCase); + bool shutdownExisting = e.Args.Contains( + "--shutdown-existing", + StringComparer.OrdinalIgnoreCase); if (!AcquireSingleInstance( backgroundLaunch, + shutdownExisting, _uiOnly || faultTest || codecTest || capabilityModelTest || gameCaptureTest)) { Shutdown(); return; } + if (shutdownExisting) + { + Shutdown(_shutdownExistingSucceeded ? 0 : 12); + return; + } _config = Config.Load(); Localization.SetLanguage(_config.Language); Localization.Changed += OnLanguageChanged; #if !DEBUG - if (!_uiOnly && Autostart.IsEnabled()) + if (!_uiOnly && Autostart.HasEntry()) { try { @@ -135,7 +151,7 @@ protected override void OnStartup(StartupEventArgs e) } if (e.Args.Contains("--qa-overlay", StringComparer.OrdinalIgnoreCase)) { - Dispatcher.BeginInvoke( + _ = Dispatcher.BeginInvoke( DispatcherPriority.ApplicationIdle, () => ShowOverlayNotification( "✓", @@ -148,12 +164,15 @@ protected override void OnStartup(StartupEventArgs e) return; } - TerminateLegacyInstances(); CreateTrayIcon(); BindHotkeyAtStartup(); StartHealthMonitor(); + StartActivationServer(); + if (!backgroundLaunch) + OpenSettings(); - if (_config.ReplayEnabled && TryStartPipeline(showError: true)) + if (_config.ReplayEnabled && + await TryStartPipelineAsync(showError: true)) { ShowOverlayNotification( "●", @@ -163,10 +182,6 @@ protected override void OnStartup(StartupEventArgs e) FormatDuration(_config.BufferSeconds)), OverlayTone.Success); } - - StartActivationServer(); - if (!backgroundLaunch) - OpenSettings(); } catch (Exception exception) { @@ -206,6 +221,19 @@ private void RunCapabilityModelTest() var intel = new EncoderCapabilities( "Intel Arc A770", EncoderCatalog.Available(intelIds, "Intel Arc A770")); + var invalidConfig = new Config + { + BufferSeconds = -1, + FrameRate = 999, + Codec = "unknown", + SystemAudioVolume = 500, + Hotkey = "Ctrl+A+B", + }; + invalidConfig.Normalize(); + Config hotkeyOnlyChange = invalidConfig.Clone(); + hotkeyOnlyChange.Hotkey = "Ctrl+Alt+F8"; + Config pipelineChange = invalidConfig.Clone(); + pipelineChange.FrameRate = 30; bool passed = oldNvidia.Supports("h264") && @@ -215,7 +243,14 @@ private void RunCapabilityModelTest() amd.Preferred("av1")?.Family == "amf" && amd.Preferred("h264")?.Family == "amf" && intel.Preferred("av1")?.Family == "qsv" && - intel.Preferred("h264")?.Family == "qsv"; + intel.Preferred("h264")?.Family == "qsv" && + invalidConfig.BufferSeconds == 300 && + invalidConfig.FrameRate == 60 && + invalidConfig.Codec == "h264" && + invalidConfig.SystemAudioVolume == 100 && + invalidConfig.Hotkey == "Ctrl+Shift+F10" && + invalidConfig.PipelineEquals(hotkeyOnlyChange) && + !invalidConfig.PipelineEquals(pipelineChange); Log.Write( $"GPU_CAPABILITY_MODEL_TEST {(passed ? "PASS" : "FAIL")}: " + $"oldNvidiaAv1={oldNvidia.Supports("av1")}, " + @@ -357,15 +392,32 @@ private async void RunGameCaptureTest(string[] args) if (!TryStartPipeline(showError: false)) throw new InvalidOperationException("OBS Game Capture did not start."); - await Task.Delay(TimeSpan.FromSeconds(8)); + DateTime hookDeadline = DateTime.UtcNow.AddSeconds(8); + while (!_obs!.IsGameHooked && DateTime.UtcNow < hookDeadline) + await Task.Delay(100); + if (_obs.IsGameHooked) + await Task.Delay(TimeSpan.FromSeconds(2)); + uint totalBefore = _obs.TotalRenderedFrames; + uint laggedBefore = _obs.LaggedRenderedFrames; + await Task.Delay(TimeSpan.FromSeconds(6)); + uint totalAfter = _obs.TotalRenderedFrames; + uint laggedAfter = _obs.LaggedRenderedFrames; + uint totalDelta = totalAfter - totalBefore; + uint laggedDelta = laggedAfter - laggedBefore; + double steadyLagPercent = totalDelta == 0 + ? 100 + : laggedDelta * 100d / totalDelta; string path = await _obs!.SaveReplayAsync(); bool passed = File.Exists(path) && new FileInfo(path).Length > 0 && _obs.IsGameHooked && - _obs.EncodedFrameCount > 0; + _obs.EncodedFrameCount > 0 && + steadyLagPercent < 10; Log.Write( $"OBS_GAME_TEST {(passed ? "PASS" : "FAIL")}: " + - $"hooked={_obs.IsGameHooked}, frames={_obs.EncodedFrameCount}, path={path}"); + $"hooked={_obs.IsGameHooked}, frames={_obs.EncodedFrameCount}, " + + $"steadyLag={laggedDelta}/{totalDelta} ({steadyLagPercent:0.0}%), " + + $"path={path}"); Shutdown(passed ? 0 : 8); } catch (Exception exception) @@ -396,17 +448,25 @@ private async void RunFaultRecoveryTest() OutputDirectory = root, }; StartHealthMonitor(); - if (!TryStartPipeline(showError: false)) + if (!await TryStartPipelineAsync(showError: false)) throw new InvalidOperationException("The initial OBS pipeline did not start."); - DateTime originalStart = _pipelineStartedUtc; - await Task.Delay(TimeSpan.FromSeconds(3)); - RecoverPipeline("QA: simulated OBS restart."); - await Task.Delay(TimeSpan.FromSeconds(6)); - bool restarted = IsReplayRunning && _pipelineStartedUtc > originalStart; - string path = restarted - ? await _obs!.SaveReplayAsync() - : ""; + bool restarted = true; + for (int attempt = 1; attempt <= 3; attempt++) + { + DateTime originalStart = _pipelineStartedUtc; + await Task.Delay(TimeSpan.FromSeconds(attempt == 1 ? 3 : 1)); + await RecoverPipelineAsync($"QA: simulated OBS restart {attempt}."); + restarted &= IsReplayRunning && _pipelineStartedUtc > originalStart; + } + await Task.Delay(TimeSpan.FromSeconds(4)); + string path = ""; + if (restarted) + { + Task saveOperation = await RunOnObsThreadAsync( + () => _obs!.SaveReplayAsync()); + path = await saveOperation; + } bool passed = restarted && File.Exists(path); Log.Write( $"OBS_FAULT_TEST {(passed ? "PASS" : "FAIL")}: " + @@ -465,7 +525,10 @@ private static int ParseQaInt( } #endif - private bool AcquireSingleInstance(bool backgroundLaunch, bool isolatedUiTest) + private bool AcquireSingleInstance( + bool backgroundLaunch, + bool shutdownExisting, + bool isolatedUiTest) { string userId = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName; @@ -480,9 +543,27 @@ private bool AcquireSingleInstance(bool backgroundLaunch, bool isolatedUiTest) if (createdNew) return true; + SendActivationCommand( + shutdownExisting + ? "EXIT" + : backgroundLaunch ? "PING" : "SHOW"); + if (shutdownExisting) + { + bool acquired = false; + try + { + acquired = _singleInstanceMutex.WaitOne(TimeSpan.FromSeconds(50)); + } + catch (AbandonedMutexException) + { + acquired = true; + } + if (acquired) + _singleInstanceMutex.ReleaseMutex(); + _shutdownExistingSucceeded = acquired; + } _singleInstanceMutex.Dispose(); _singleInstanceMutex = null; - SendActivationCommand(backgroundLaunch ? "PING" : "SHOW"); return false; } @@ -532,9 +613,25 @@ private async Task ActivationServerLoopAsync(CancellationToken cancellationToken PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); await server.WaitForConnectionAsync(cancellationToken); using var reader = new StreamReader(server); - string? command = await reader.ReadLineAsync(cancellationToken); - if (string.Equals(command, "SHOW", StringComparison.OrdinalIgnoreCase)) + string command = await ReadActivationCommandAsync( + reader, + cancellationToken); + if (string.Equals( + command, + "SHOW", + StringComparison.OrdinalIgnoreCase)) + { await Dispatcher.InvokeAsync(OpenSettings); + } + else if (string.Equals( + command, + "EXIT", + StringComparison.OrdinalIgnoreCase)) + { + await Dispatcher.InvokeAsync( + () => _ = RequestShutdownAsync()); + return; + } } catch (OperationCanceledException) { @@ -547,32 +644,23 @@ private async Task ActivationServerLoopAsync(CancellationToken cancellationToken } } - private static void TerminateLegacyInstances() + private static async Task ReadActivationCommandAsync( + StreamReader reader, + CancellationToken cancellationToken) { - int currentId = Environment.ProcessId; - foreach (Process process in Process.GetProcessesByName("Captail")) + var buffer = new char[5]; + int count = 0; + while (count < buffer.Length) { - using (process) - { - if (process.Id == currentId) - continue; - try - { - process.CloseMainWindow(); - if (!process.WaitForExit(1200)) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(1200); - } - } - catch (Exception exception) - { - Log.Write( - $"Failed to close the previous instance PID {process.Id}: " + - exception.Message); - } - } + int read = await reader.ReadAsync( + buffer.AsMemory(count, 1), + cancellationToken).ConfigureAwait(false); + if (read == 0 || buffer[count] is '\r' or '\n') + break; + count += read; } + + return new string(buffer, 0, count); } private void BindHotkeyAtStartup() @@ -604,8 +692,24 @@ private void SubscribeHotkeys() _hotkeys.ToggleRequested += ToggleReplayFromHotkey; } - private void ToggleReplayFromHotkey() => - SetReplayEnabled(!_config!.ReplayEnabled); + private void ToggleReplayFromHotkey() => _ = ToggleReplayAsync(); + + private async Task ToggleReplayAsync() + { + try + { + await SetReplayEnabledGuardedAsync(null); + } + catch (Exception exception) + { + Log.Write($"Replay hotkey toggle failed: {exception}"); + ShowOverlayNotification( + "!", + Localization.Text("L.Error.Attention"), + exception.Message, + OverlayTone.Error); + } + } private bool TryStartPipeline(bool showError) { @@ -620,6 +724,7 @@ private bool TryStartPipeline(bool showError) engine.Faulted += reason => OnPipelineFault(engine, reason); engine.Start(); _obs = engine; + _replayRunning = true; _capabilities = engine.Capabilities; if (!string.Equals( requestedCodec, @@ -661,6 +766,8 @@ private void StopPipeline() { ObsReplayEngine? engine = _obs; _obs = null; + _replayRunning = false; + _captureDescription = null; try { engine?.Dispose(); @@ -671,17 +778,136 @@ private void StopPipeline() } } + private async Task TryStartPipelineAsync(bool showError) + { + await _pipelineGate.WaitAsync(); + try + { + return await TryStartPipelineCoreAsync(showError); + } + finally + { + _pipelineGate.Release(); + } + } + + private async Task TryStartPipelineCoreAsync(bool showError) + { + if (IsReplayRunning) + return true; + + ObsReplayEngine? engine = null; + var stopwatch = Stopwatch.StartNew(); + try + { + string requestedCodec = _config!.Codec; + engine = new ObsReplayEngine(_config); + engine.Faulted += reason => OnPipelineFault(engine, reason); + string description = await RunOnObsThreadAsync(() => + { + engine.Start(); + return engine.Description; + }); + _obs = engine; + _replayRunning = true; + _captureDescription = description; + _capabilities = engine.Capabilities; + if (!string.Equals( + requestedCodec, + _config.Codec, + StringComparison.OrdinalIgnoreCase)) + { + _config.Save(); + } + _pipelineStartedUtc = DateTime.UtcNow; + _nextRecoveryUtc = DateTime.MinValue; + _recoveryFailures = 0; + Log.Write($"OBS pipeline started in {stopwatch.ElapsedMilliseconds} ms."); + UpdateUiState(); + return true; + } + catch (Exception exception) + { + if (engine is not null) + _capabilities = engine.Capabilities; + _obs = null; + _replayRunning = false; + _captureDescription = null; + if (engine is not null) + { + try + { + await RunOnObsThreadAsync(engine.Dispose); + } + catch (Exception disposeException) + { + Log.Write($"OBS pipeline cleanup failed: {disposeException}"); + } + } + Log.Write( + $"OBS pipeline startup failed after {stopwatch.ElapsedMilliseconds} ms: " + + exception); + if (showError) + { + ShowOverlayNotification( + "!", + Localization.Text("L.Notify.CaptureFailed"), + exception.Message, + OverlayTone.Error); + _pendingUiError = exception.Message; + _settingsWindow?.ShowError( + Localization.Text("L.Notify.CaptureFailed"), + exception.Message); + } + UpdateUiState(); + return false; + } + } + + private async Task StopPipelineCoreAsync() + { + ObsReplayEngine? engine = _obs; + _obs = null; + _replayRunning = false; + _captureDescription = null; + if (engine is null) + return; + + var stopwatch = Stopwatch.StartNew(); + try + { + await RunOnObsThreadAsync(engine.Dispose); + Log.Write($"OBS pipeline stopped in {stopwatch.ElapsedMilliseconds} ms."); + } + catch (Exception exception) + { + Log.Write($"OBS pipeline shutdown failed: {exception}"); + } + } + private void StartHealthMonitor() { _healthTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2), }; - _healthTimer.Tick += (_, _) => MonitorPipeline(); + _healthTimer.Tick += async (_, _) => await MonitorPipelineSafeAsync(); _healthTimer.Start(); } - private void MonitorPipeline() + private async Task MonitorPipelineSafeAsync() + { + try + { + await MonitorPipelineAsync(); + } + catch (Exception exception) + { + Log.Write($"Health monitor failed: {exception}"); + } + } + + private async Task MonitorPipelineAsync() { if (_uiOnly || _config?.ReplayEnabled != true || @@ -693,13 +919,32 @@ private void MonitorPipeline() if (_obs is null) { - RecoverPipeline(Localization.Text("L.Recovery.ModuleStopped")); + await RecoverPipelineAsync(Localization.Text("L.Recovery.ModuleStopped")); return; } if (DateTime.UtcNow - _pipelineStartedUtc < TimeSpan.FromSeconds(8)) return; - if (!_obs.IsHealthy) - RecoverPipeline(Localization.Text("L.Recovery.NoFrames")); + if (!await _pipelineGate.WaitAsync(0)) + return; + + bool healthy; + try + { + ObsReplayEngine? engine = _obs; + (healthy, string? description) = engine is null + ? (false, null) + : await RunOnObsThreadAsync(() => + (engine.IsHealthy, engine.Description)); + if (healthy) + _captureDescription = description; + } + finally + { + _pipelineGate.Release(); + } + + if (!healthy) + await RecoverPipelineAsync(Localization.Text("L.Recovery.NoFrames")); else UpdateUiState(); } @@ -709,11 +954,27 @@ private void OnPipelineFault(ObsReplayEngine source, string reason) Dispatcher.BeginInvoke(() => { if (ReferenceEquals(source, _obs)) - RecoverPipeline(reason); + _ = RecoverPipelineSafeAsync(reason); }); } - private void RecoverPipeline(string reason) + private async Task RecoverPipelineSafeAsync(string reason) + { + try + { + await RecoverPipelineAsync(reason); + } + catch (Exception exception) + { + Log.Write($"Pipeline recovery failed unexpectedly: {exception}"); + _pendingUiError = exception.Message; + _settingsWindow?.ShowError( + Localization.Text("L.Notify.RecoveryFailedTitle"), + exception.Message); + } + } + + private async Task RecoverPipelineAsync(string reason) { if (_config?.ReplayEnabled != true || DateTime.UtcNow < _nextRecoveryUtc || @@ -722,17 +983,23 @@ private void RecoverPipeline(string reason) return; } + bool gateHeld = false; try { + await _pipelineGate.WaitAsync(); + gateHeld = true; + if (_config?.ReplayEnabled != true) + return; + Log.Write($"Watchdog: {reason}"); ShowOverlayNotification( "↻", Localization.Text("L.Notify.RecoveryTitle"), reason, OverlayTone.Warning); - StopPipeline(); + await StopPipelineCoreAsync(); - if (TryStartPipeline(showError: false)) + if (await TryStartPipelineCoreAsync(showError: false)) { _recoveryFailures = 0; _nextRecoveryUtc = DateTime.MinValue; @@ -771,6 +1038,8 @@ private void RecoverPipeline(string reason) } finally { + if (gateHeld) + _pipelineGate.Release(); Interlocked.Exchange(ref _recoveryInProgress, 0); } } @@ -796,11 +1065,7 @@ private void CreateTrayIcon() _openFolderMenuItem = CreateMenuItem( Localization.Text("L.Tray.OpenFolder")); - _openFolderMenuItem.Click += (_, _) => - { - Directory.CreateDirectory(_config.OutputDirectory); - Process.Start("explorer.exe", _config.OutputDirectory); - }; + _openFolderMenuItem.Click += async (_, _) => await OpenOutputFolderAsync(); menu.Items.Add(_openFolderMenuItem); _settingsMenuItem = CreateMenuItem( @@ -813,7 +1078,7 @@ private void CreateTrayIcon() }); _exitMenuItem = CreateMenuItem(Localization.Text("L.Tray.Exit")); - _exitMenuItem.Click += (_, _) => Shutdown(); + _exitMenuItem.Click += (_, _) => _ = RequestShutdownAsync(); menu.Items.Add(_exitMenuItem); _tray = new TaskbarIcon @@ -844,7 +1109,7 @@ private void ShowOverlayNotification( { if (!Dispatcher.CheckAccess()) { - Dispatcher.Invoke(() => ShowOverlayNotification( + Dispatcher.BeginInvoke(() => ShowOverlayNotification( glyph, title, detail, @@ -872,14 +1137,14 @@ private void OpenSettings() return; } - EncoderCapabilities capabilities = EnsureCapabilities(); + EncoderCapabilities capabilities = _capabilities ?? EncoderCapabilities.Preview(); _settingsWindow = new SettingsWindow( _config!, IsReplayRunning, SaveReplay, - SetReplayEnabled, - SetAudioSources, - ApplySettings, + SetReplayEnabledAsync, + SetAudioSourcesAsync, + ApplySettingsAsync, capabilities); _settingsWindow.Closed += (_, _) => { @@ -889,6 +1154,12 @@ private void OpenSettings() }; _settingsWindow.Show(); _settingsWindow.Activate(); + if (_config!.ReplayEnabled != true && + (_capabilities is null || + !string.IsNullOrWhiteSpace(_capabilities.ProbeError))) + { + _ = EnsureCapabilitiesSafeAsync(); + } if (!string.IsNullOrWhiteSpace(_pendingUiError)) { _settingsWindow.ShowError( @@ -898,15 +1169,26 @@ private void OpenSettings() } } - private EncoderCapabilities EnsureCapabilities() + private async Task EnsureCapabilitiesAsync() { if (_capabilities is not null && string.IsNullOrWhiteSpace(_capabilities.ProbeError)) { - return _capabilities; + return; } - _capabilities = ObsReplayEngine.ProbeCapabilities(_config!); + if (!await _pipelineGate.WaitAsync(0)) + return; + + try + { + _capabilities = await RunOnObsThreadAsync( + () => ObsReplayEngine.ProbeCapabilities(_config!)); + } + finally + { + _pipelineGate.Release(); + } if (_uiOnly && !string.IsNullOrWhiteSpace(_capabilities.ProbeError)) _capabilities = EncoderCapabilities.Preview(); @@ -916,14 +1198,87 @@ private EncoderCapabilities EnsureCapabilities() _config.Codec = fallback; _config.Save(); } - return _capabilities; + UpdateUiState(); + } + + private async Task EnsureCapabilitiesSafeAsync() + { + try + { + await EnsureCapabilitiesAsync(); + } + catch (Exception exception) + { + Log.Write($"GPU capability refresh failed: {exception}"); + _settingsWindow?.ShowError( + Localization.Text("L.Error.Attention"), + exception.Message); + } + } + + private async Task OpenOutputFolderAsync() + { + try + { + string outputDirectory = _config!.OutputDirectory; + await Task.Run(() => Directory.CreateDirectory(outputDirectory)); + Process.Start(new ProcessStartInfo + { + FileName = "explorer.exe", + ArgumentList = { outputDirectory }, + UseShellExecute = true, + }); + } + catch (Exception exception) + { + Log.Write($"Open output folder failed: {exception}"); + ShowOverlayNotification( + "!", + Localization.Text("L.Error.FolderTitle"), + exception.Message, + OverlayTone.Error); + } + } + + private Task SetReplayEnabledAsync(bool enabled) => + SetReplayEnabledGuardedAsync(enabled); + + private async Task SetReplayEnabledGuardedAsync(bool? requestedState) + { + await _pipelineGate.WaitAsync(); + bool enabled = requestedState ?? !_config!.ReplayEnabled; + bool previousEnabled = _config!.ReplayEnabled; + bool wasRunning = IsReplayRunning; + try + { + return await SetReplayEnabledCoreAsync(enabled); + } + catch (Exception exception) + { + Log.Write($"Replay toggle failed; rolling back: {exception}"); + _config.ReplayEnabled = previousEnabled; + SaveRollbackConfig("replay toggle"); + if (wasRunning && !IsReplayRunning) + await TryStartPipelineCoreAsync(showError: false); + else if (!wasRunning && IsReplayRunning) + await StopPipelineCoreAsync(); + UpdateUiState(); + _settingsWindow?.ShowError( + Localization.Text("L.Error.Attention"), + exception.Message); + return IsReplayRunning; + } + finally + { + _pipelineGate.Release(); + } } - private bool SetReplayEnabled(bool enabled) + private async Task SetReplayEnabledCoreAsync(bool enabled) { if (enabled) { - bool started = TryStartPipeline(showError: true); + bool started = await TryStartPipelineCoreAsync(showError: true); if (started) { _config!.ReplayEnabled = true; @@ -939,7 +1294,7 @@ private bool SetReplayEnabled(bool enabled) return started; } - StopPipeline(); + await StopPipelineCoreAsync(); _config!.ReplayEnabled = false; _config.Save(); _nextRecoveryUtc = DateTime.MinValue; @@ -953,98 +1308,111 @@ private bool SetReplayEnabled(bool enabled) return false; } - private bool SetAudioSources( + private async Task SetAudioSourcesAsync( bool captureSystemAudio, bool captureMicrophone, string systemAudioDeviceId, string microphoneDeviceId) { - bool oldSystemAudio = _config!.CaptureSystemAudio; - bool oldMicrophone = _config.CaptureMicrophone; - string oldSystemDevice = _config.SystemAudioDeviceId; - string oldMicrophoneDevice = _config.MicrophoneDeviceId; - if (oldSystemAudio == captureSystemAudio && - oldMicrophone == captureMicrophone && - oldSystemDevice == systemAudioDeviceId && - oldMicrophoneDevice == microphoneDeviceId) + await _pipelineGate.WaitAsync(); + Config previous = _config!.Clone(); + bool wasRunning = IsReplayRunning; + try { - return true; - } + if (previous.CaptureSystemAudio == captureSystemAudio && + previous.CaptureMicrophone == captureMicrophone && + previous.SystemAudioDeviceId == systemAudioDeviceId && + previous.MicrophoneDeviceId == microphoneDeviceId) + { + return true; + } - _config.CaptureSystemAudio = captureSystemAudio; - _config.CaptureMicrophone = captureMicrophone; - _config.SystemAudioDeviceId = systemAudioDeviceId; - _config.MicrophoneDeviceId = microphoneDeviceId; - _config.Save(); + _config.CaptureSystemAudio = captureSystemAudio; + _config.CaptureMicrophone = captureMicrophone; + _config.SystemAudioDeviceId = systemAudioDeviceId; + _config.MicrophoneDeviceId = microphoneDeviceId; + _config.Normalize(); + + if (!IsReplayRunning) + { + _config.Save(); + UpdateUiState(); + return true; + } - if (!IsReplayRunning) + await StopPipelineCoreAsync(); + if (await TryStartPipelineCoreAsync(showError: true)) + { + _config.Save(); + return true; + } + throw new InvalidOperationException( + Localization.Text("L.Error.AudioSourceMessage")); + } + catch (Exception exception) { + Log.Write($"Audio source change failed; rolling back: {exception}"); + if (IsReplayRunning) + await StopPipelineCoreAsync(); + _config.CopyFrom(previous); + SaveRollbackConfig("audio source change"); + if (wasRunning) + await TryStartPipelineCoreAsync(showError: false); UpdateUiState(); - return true; + return false; + } + finally + { + _pipelineGate.Release(); } - - StopPipeline(); - if (TryStartPipeline(showError: true)) - return true; - - _config.CaptureSystemAudio = oldSystemAudio; - _config.CaptureMicrophone = oldMicrophone; - _config.SystemAudioDeviceId = oldSystemDevice; - _config.MicrophoneDeviceId = oldMicrophoneDevice; - _config.Save(); - TryStartPipeline(showError: false); - UpdateUiState(); - _settingsWindow?.ShowError( - Localization.Text("L.Error.AudioSourceTitle"), - Localization.Text("L.Error.AudioSourceMessage")); - return false; } - private bool ApplySettings() + private async Task ApplySettingsAsync( + Config candidate, + bool autostartEnabled) { + candidate.Normalize(); if (_uiOnly) { + _config!.CopyFrom(candidate); + _config.Save(); UpdateUiState(); return true; } - bool hotkeysApplied = true; + await _pipelineGate.WaitAsync(); + Config previous = _config!.Clone(); + bool previousAutostart = Autostart.IsEnabled(); + bool wasRunning = IsReplayRunning; + bool pipelineChanged = !previous.PipelineEquals(candidate); + bool pipelineTouched = false; try { - if (_hotkeys is null) + ApplyHotkeys(candidate); + + bool mustStop = wasRunning && + (!candidate.ReplayEnabled || pipelineChanged); + if (mustStop) { - _hotkeys = new HotkeyManager( - _config!.Hotkey, - _config.ToggleReplayHotkey); - SubscribeHotkeys(); + pipelineTouched = true; + await StopPipelineCoreAsync(); } - else + + _config.CopyFrom(candidate); + bool mustStart = candidate.ReplayEnabled && + (!wasRunning || pipelineChanged); + if (mustStart) { - _hotkeys.Rebind( - _config!.Hotkey, - _config.ToggleReplayHotkey); + pipelineTouched = true; + if (!await TryStartPipelineCoreAsync(showError: true)) + throw new InvalidOperationException( + Localization.Text("L.Engine.BufferStartFailed")); } - _boundHotkey = _config!.Hotkey; - _boundToggleHotkey = _config.ToggleReplayHotkey; - } - catch (Exception exception) - { - _config!.Hotkey = _boundHotkey; - _config.ToggleReplayHotkey = _boundToggleHotkey; - _config.Save(); - hotkeysApplied = false; - _settingsWindow?.ShowError( - Localization.Text("L.Error.BindTitle"), - exception.Message); - } - StopPipeline(); - bool running = !_config!.ReplayEnabled || - TryStartPipeline(showError: true); - UpdateUiState(); + Autostart.SetEnabled(autostartEnabled); + _config.Save(); + UpdateUiState(); - if (running) - { ShowOverlayNotification( "✓", Localization.Text("L.Notify.SettingsApplied"), @@ -1054,14 +1422,72 @@ private bool ApplySettings() $"{FormatDuration(_config.BufferSeconds)}" : Localization.Text("L.Status.Disabled"), OverlayTone.Success); + return true; + } + catch (Exception exception) + { + Log.Write($"Apply settings failed; rolling back: {exception}"); + if (pipelineTouched && IsReplayRunning) + await StopPipelineCoreAsync(); + + _config.CopyFrom(previous); + SaveRollbackConfig("settings apply"); + try + { + ApplyHotkeys(previous); + } + catch (Exception rollbackException) + { + Log.Write($"Hotkey rollback failed: {rollbackException}"); + } + try + { + Autostart.SetEnabled(previousAutostart); + } + catch (Exception rollbackException) + { + Log.Write($"Autostart rollback failed: {rollbackException}"); + } + if (wasRunning && !IsReplayRunning) + await TryStartPipelineCoreAsync(showError: false); + + UpdateUiState(); + _settingsWindow?.ShowError( + Localization.Text("L.Error.Attention"), + exception.Message); + return false; + } + finally + { + _pipelineGate.Release(); + } + } + + private void ApplyHotkeys(Config config) + { + if (_hotkeys is null) + { + _hotkeys = new HotkeyManager(config.Hotkey, config.ToggleReplayHotkey); + SubscribeHotkeys(); + } + else + { + _hotkeys.Rebind(config.Hotkey, config.ToggleReplayHotkey); + } + _boundHotkey = config.Hotkey; + _boundToggleHotkey = config.ToggleReplayHotkey; + } + + private void SaveRollbackConfig(string operation) + { + try + { + _config!.Save(); } - if (!hotkeysApplied) + catch (Exception exception) { - _settingsWindow?.UpdateRuntimeState( - IsReplayRunning, - _obs?.ActiveCodec); + Log.Write($"Could not persist {operation} rollback: {exception}"); } - return running; } private void UpdateUiState() @@ -1073,7 +1499,7 @@ private void UpdateUiState() _settingsWindow?.UpdateRuntimeState( active, codec, - _obs?.Description); + _captureDescription); if (_tray is not null) { _tray.ToolTipText = active @@ -1092,7 +1518,8 @@ private void UpdateUiState() private void SaveReplay() { ObsReplayEngine? engine = _obs; - if (engine?.IsActive != true) + if (engine is null || !IsReplayRunning || + Volatile.Read(ref _exiting) != 0) { ShowOverlayNotification( "!", @@ -1121,7 +1548,7 @@ private async Task SaveReplayCoreAsync(ObsReplayEngine engine) FormatDuration(_config!.BufferSeconds)), OverlayTone.Neutral, 30_000); - string path = await engine.SaveReplayAsync(); + string path = await SaveReplayGuardedAsync(engine); ShowOverlayNotification( "✓", Localization.Text("L.Notify.Saved"), @@ -1143,6 +1570,28 @@ private async Task SaveReplayCoreAsync(ObsReplayEngine engine) } } + private async Task SaveReplayGuardedAsync(ObsReplayEngine engine) + { + await _pipelineGate.WaitAsync().ConfigureAwait(false); + try + { + if (!ReferenceEquals(engine, _obs) || !IsReplayRunning || + Volatile.Read(ref _exiting) != 0) + { + throw new InvalidOperationException( + Localization.Text("L.Notify.EnableBeforeSave")); + } + Task saveOperation = await RunOnObsThreadAsync( + () => engine.SaveReplayAsync()) + .ConfigureAwait(false); + return await saveOperation.ConfigureAwait(false); + } + finally + { + _pipelineGate.Release(); + } + } + private void OnLanguageChanged() { if (!Dispatcher.CheckAccess()) @@ -1177,8 +1626,46 @@ private static Icon CreateIcon() return (Icon)icon.Clone(); } + private Task RunOnObsThreadAsync(Action action) => + Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + _obsTaskScheduler); + + private Task RunOnObsThreadAsync(Func action) => + Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + _obsTaskScheduler); + + private async Task RequestShutdownAsync() + { + if (Interlocked.Exchange(ref _exiting, 1) != 0) + return; + + _healthTimer?.Stop(); + await _pipelineGate.WaitAsync(); + try + { + await StopPipelineCoreAsync(); + } + catch (Exception exception) + { + Log.Write($"Graceful shutdown failed: {exception}"); + } + finally + { + _pipelineGate.Release(); + } + Shutdown(); + } + protected override void OnExit(ExitEventArgs e) { + bool gracefulShutdownCompleted = + Interlocked.Exchange(ref _exiting, 1) != 0 && _obs is null; Localization.Changed -= OnLanguageChanged; _healthTimer?.Stop(); _activationServerCts?.Cancel(); @@ -1186,7 +1673,27 @@ protected override void OnExit(ExitEventArgs e) _settingsWindow?.Close(); _hotkeys?.Dispose(); _tray?.Dispose(); - StopPipeline(); + bool gateHeld = false; + try + { + if (!gracefulShutdownCompleted) + { + gateHeld = _pipelineGate.Wait(TimeSpan.FromSeconds(50)); + if (!gateHeld) + Log.Write("Timed out waiting for replay save during shutdown."); + RunOnObsThreadAsync(StopPipeline).GetAwaiter().GetResult(); + } + } + catch (Exception exception) + { + Log.Write($"OBS shutdown worker failed: {exception}"); + } + finally + { + if (gateHeld) + _pipelineGate.Release(); + } + _obsTaskScheduler.Dispose(); _overlayNotification?.ClosePermanently(); if (_singleInstanceMutex is not null) { diff --git a/src/Captail/AssemblyInfo.cs b/src/Captail/AssemblyInfo.cs index 7025726..3bd9a19 100644 --- a/src/Captail/AssemblyInfo.cs +++ b/src/Captail/AssemblyInfo.cs @@ -1,5 +1,3 @@ -using System.Windows; +using System.Runtime.InteropServices; -[assembly: ThemeInfo( - ResourceDictionaryLocation.None, - ResourceDictionaryLocation.SourceAssembly)] +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)] diff --git a/src/Captail/Autostart.cs b/src/Captail/Autostart.cs index d9da7b7..4123c53 100644 --- a/src/Captail/Autostart.cs +++ b/src/Captail/Autostart.cs @@ -11,7 +11,16 @@ public static class Autostart public static bool IsEnabled() { using var key = Registry.CurrentUser.OpenSubKey(RunKey); - return HasCommand(key, ValueName); + return string.Equals( + ReadCommand(key), + ExpectedCommand(), + StringComparison.OrdinalIgnoreCase); + } + + internal static bool HasEntry() + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey); + return !string.IsNullOrWhiteSpace(ReadCommand(key)); } public static void SetEnabled(bool enabled) @@ -19,12 +28,9 @@ public static void SetEnabled(bool enabled) using var key = Registry.CurrentUser.CreateSubKey(RunKey); if (enabled) { - string executablePath = Environment.ProcessPath ?? - throw new InvalidOperationException( - Localization.Text("L.App.ExecutablePathError")); key.SetValue( ValueName, - $"\"{executablePath}\" --background", + ExpectedCommand(), RegistryValueKind.String); } else @@ -33,10 +39,17 @@ public static void SetEnabled(bool enabled) } } - private static bool HasCommand(RegistryKey? key, string valueName) => + private static string? ReadCommand(RegistryKey? key) => key?.GetValue( - valueName, + ValueName, defaultValue: null, - RegistryValueOptions.DoNotExpandEnvironmentNames) is string command && - !string.IsNullOrWhiteSpace(command); + RegistryValueOptions.DoNotExpandEnvironmentNames) as string; + + private static string ExpectedCommand() + { + string executablePath = Environment.ProcessPath ?? + throw new InvalidOperationException( + Localization.Text("L.App.ExecutablePathError")); + return $"\"{executablePath}\" --background"; + } } diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj index 6a89227..e06d31a 100644 --- a/src/Captail/Captail.csproj +++ b/src/Captail/Captail.csproj @@ -14,13 +14,16 @@ Captail Captail Assets\Captail.ico - 0.1.1 + 0.1.2 + true - + + - + @@ -71,6 +74,16 @@ + + OBS_RUNTIME_VERSION + PreserveNewest + PreserveNewest + + + OBS_RUNTIME_SHA256 + PreserveNewest + PreserveNewest + %(RecursiveDir)%(Filename)%(Extension) true @@ -85,6 +98,7 @@ data\%(RecursiveDir)%(Filename)%(Extension) + true PreserveNewest PreserveNewest diff --git a/src/Captail/Config.cs b/src/Captail/Config.cs index 28081ff..c2246fd 100644 --- a/src/Captail/Config.cs +++ b/src/Captail/Config.cs @@ -6,6 +6,11 @@ namespace Captail; public sealed class Config { + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + }; + public string Language { get; set; } = "en"; public int BufferSeconds { get; set; } = 300; /// 0 = duration-only limit. @@ -53,20 +58,21 @@ public sealed class Config Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Captail", "config.json"); public static Config Load() { - try + if (TryLoad(ConfigPath, out Config? config) && config is not null) + return config; + + string backupPath = ConfigPath + ".bak"; + if (TryLoad(backupPath, out config) && config is not null) { - if (File.Exists(ConfigPath)) + try { - Config config = - JsonSerializer.Deserialize(File.ReadAllText(ConfigPath)) ?? - new Config(); - config.Language = NormalizeLanguage(config.Language); - return config; + config.Save(); } - } - catch - { - // A damaged config must not prevent startup. + catch (Exception exception) + { + Log.Write($"Config backup restore failed: {exception.Message}"); + } + return config; } var defaultConfig = new Config(); @@ -74,10 +80,188 @@ public static Config Load() return defaultConfig; } + private static bool TryLoad(string path, out Config? config) + { + config = null; + if (!File.Exists(path)) + return false; + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(path)); + config?.Normalize(); + return config is not null; + } + catch (Exception exception) + { + Log.Write($"Config load failed ({Path.GetFileName(path)}): {exception.Message}"); + return false; + } + } + public void Save() { - Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); - File.WriteAllText(ConfigPath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + Normalize(); + string directory = Path.GetDirectoryName(ConfigPath)!; + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $"config.{Environment.ProcessId}.{Guid.NewGuid():N}.tmp"); + string backupPath = ConfigPath + ".bak"; + try + { + File.WriteAllText( + temporaryPath, + JsonSerializer.Serialize(this, SerializerOptions)); + if (File.Exists(ConfigPath)) + File.Replace(temporaryPath, ConfigPath, backupPath, ignoreMetadataErrors: true); + else + File.Move(temporaryPath, ConfigPath); + } + finally + { + File.Delete(temporaryPath); + } + } + + public Config Clone() + { + var clone = new Config(); + clone.CopyFrom(this); + return clone; + } + + public void CopyFrom(Config source) + { + ArgumentNullException.ThrowIfNull(source); + Language = source.Language; + BufferSeconds = source.BufferSeconds; + MaxReplaySizeMb = source.MaxReplaySizeMb; + FrameRate = source.FrameRate; + BitrateMbps = source.BitrateMbps; + Hotkey = source.Hotkey; + ToggleReplayHotkey = source.ToggleReplayHotkey; + ReplayEnabled = source.ReplayEnabled; + Codec = source.Codec; + MonitorIndex = source.MonitorIndex; + RecordingResolution = source.RecordingResolution; + CaptureSource = source.CaptureSource; + GameExecutablePath = source.GameExecutablePath; + CaptureSystemAudio = source.CaptureSystemAudio; + SystemAudioVolume = source.SystemAudioVolume; + SystemAudioDeviceId = source.SystemAudioDeviceId; + CaptureMicrophone = source.CaptureMicrophone; + MicrophoneVolume = source.MicrophoneVolume; + MicrophoneBoostDb = source.MicrophoneBoostDb; + MicrophoneDeviceId = source.MicrophoneDeviceId; + AudioBitrateKbps = source.AudioBitrateKbps; + AudioCodec = source.AudioCodec; + SeparateAudioTracks = source.SeparateAudioTracks; + OutputDirectory = source.OutputDirectory; + Normalize(); + } + + public bool PipelineEquals(Config other) => + BufferSeconds == other.BufferSeconds && + MaxReplaySizeMb == other.MaxReplaySizeMb && + FrameRate == other.FrameRate && + BitrateMbps == other.BitrateMbps && + string.Equals(Codec, other.Codec, StringComparison.Ordinal) && + MonitorIndex == other.MonitorIndex && + string.Equals(RecordingResolution, other.RecordingResolution, StringComparison.Ordinal) && + string.Equals(CaptureSource, other.CaptureSource, StringComparison.Ordinal) && + string.Equals(GameExecutablePath, other.GameExecutablePath, StringComparison.OrdinalIgnoreCase) && + CaptureSystemAudio == other.CaptureSystemAudio && + SystemAudioVolume == other.SystemAudioVolume && + string.Equals(SystemAudioDeviceId, other.SystemAudioDeviceId, StringComparison.Ordinal) && + CaptureMicrophone == other.CaptureMicrophone && + MicrophoneVolume == other.MicrophoneVolume && + MicrophoneBoostDb == other.MicrophoneBoostDb && + string.Equals(MicrophoneDeviceId, other.MicrophoneDeviceId, StringComparison.Ordinal) && + AudioBitrateKbps == other.AudioBitrateKbps && + string.Equals(AudioCodec, other.AudioCodec, StringComparison.Ordinal) && + SeparateAudioTracks == other.SeparateAudioTracks && + string.Equals(OutputDirectory, other.OutputDirectory, StringComparison.OrdinalIgnoreCase); + + public void Normalize() + { + Language = NormalizeLanguage(Language); + BufferSeconds = AllowedValue(BufferSeconds, [15, 30, 60, 120, 300, 600, 900], 300); + MaxReplaySizeMb = AllowedValue(MaxReplaySizeMb, [0, 250, 500, 1000, 2000, 5000, 10000], 0); + FrameRate = AllowedValue(FrameRate, [30, 60, 120, 144, 240], 60); + BitrateMbps = AllowedValue(BitrateMbps, [0, 10, 20, 50, 80], 0); + Hotkey = NormalizeHotkey(Hotkey, "Ctrl+Shift+F10"); + ToggleReplayHotkey = NormalizeHotkey(ToggleReplayHotkey, "Ctrl+Shift+F9"); + if (!HotkeyManager.IsValid(Hotkey)) + Hotkey = "Ctrl+Shift+F10"; + if (!HotkeyManager.IsValid(ToggleReplayHotkey) || + !HotkeyManager.AreDistinct(Hotkey, ToggleReplayHotkey)) + { + ToggleReplayHotkey = "Ctrl+Shift+F9"; + } + Codec = AllowedText(Codec, ["h264", "hevc", "av1"], "h264"); + MonitorIndex = Math.Clamp(MonitorIndex, 0, 63); + RecordingResolution = AllowedText( + RecordingResolution, + ["source", "720p", "1080p", "1440p", "2160p"], + "source"); + CaptureSource = AllowedText(CaptureSource, ["desktop", "game"], "desktop"); + GameExecutablePath = NormalizePath(GameExecutablePath, allowEmpty: true); + SystemAudioVolume = Math.Clamp(SystemAudioVolume, 0, 100); + SystemAudioDeviceId = NormalizeIdentifier(SystemAudioDeviceId); + MicrophoneVolume = Math.Clamp(MicrophoneVolume, 0, 100); + MicrophoneBoostDb = Math.Clamp(MicrophoneBoostDb, 0, 20); + MicrophoneDeviceId = NormalizeIdentifier(MicrophoneDeviceId); + AudioBitrateKbps = Math.Clamp(AudioBitrateKbps, 64, 512); + AudioCodec = AllowedText(AudioCodec, ["aac", "opus"], "aac"); + OutputDirectory = NormalizePath(OutputDirectory, allowEmpty: false); + } + + private static int AllowedValue(int value, int[] allowed, int fallback) => + allowed.Contains(value) ? value : fallback; + + private static string AllowedText( + string? value, + string[] allowed, + string fallback) + { + string normalized = value?.Trim().ToLowerInvariant() ?? ""; + return allowed.Contains(normalized, StringComparer.Ordinal) + ? normalized + : fallback; + } + + private static string NormalizeHotkey(string? value, string fallback) + { + string normalized = value?.Trim() ?? ""; + return normalized.Length is > 0 and <= 64 ? normalized : fallback; + } + + private static string NormalizeIdentifier(string? value) + { + string normalized = value?.Trim() ?? ""; + return normalized.Length <= 1024 ? normalized : ""; + } + + private static string NormalizePath(string? value, bool allowEmpty) + { + string fallback = allowEmpty + ? "" + : Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), + "Captail"); + string normalized = value?.Trim() ?? ""; + if (normalized.Length == 0) + return fallback; + if (normalized.Length > 1024 || normalized.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + return fallback; + try + { + return Path.GetFullPath(normalized); + } + catch + { + return fallback; + } } private static string NormalizeLanguage(string? language) => diff --git a/src/Captail/EncoderCapabilities.cs b/src/Captail/EncoderCapabilities.cs index 8193e1a..65ed1c6 100644 --- a/src/Captail/EncoderCapabilities.cs +++ b/src/Captail/EncoderCapabilities.cs @@ -59,9 +59,9 @@ public static EncoderCapabilities Preview() => new( Localization.Text("L.Gpu.Preview"), [ - new("h264", "obs_nvenc_h264_tex", "nvenc", Localization.Text("L.Gpu.HardwareEncoder")), - new("hevc", "obs_nvenc_hevc_tex", "nvenc", Localization.Text("L.Gpu.HardwareEncoder")), - new("av1", "obs_nvenc_av1_tex", "nvenc", Localization.Text("L.Gpu.HardwareEncoder")), + new("h264", "preview_h264", "hw", Localization.Text("L.Gpu.HardwareEncoder")), + new("hevc", "preview_hevc", "hw", Localization.Text("L.Gpu.HardwareEncoder")), + new("av1", "preview_av1", "hw", Localization.Text("L.Gpu.HardwareEncoder")), ]); } diff --git a/src/Captail/HotkeyManager.cs b/src/Captail/HotkeyManager.cs index 7e1f2ca..8280aa4 100644 --- a/src/Captail/HotkeyManager.cs +++ b/src/Captail/HotkeyManager.cs @@ -135,21 +135,26 @@ private static (uint Modifiers, uint Vk) Parse(string hotkey) { uint modifiers = 0; uint vk = 0; + int keyCount = 0; foreach (string rawPart in hotkey.Split('+')) { string part = rawPart.Trim(); + if (part.Length == 0) + throw new FormatException( + Localization.Format("L.Hotkey.ParseError", hotkey)); switch (part.ToUpperInvariant()) { case "CTRL": modifiers |= MOD_CONTROL; break; case "SHIFT": modifiers |= MOD_SHIFT; break; case "ALT": modifiers |= MOD_ALT; break; default: - var key = (Key)Enum.Parse(typeof(Key), NormalizeKeyName(part), ignoreCase: true); + keyCount++; + var key = Enum.Parse(NormalizeKeyName(part), ignoreCase: true); vk = (uint)KeyInterop.VirtualKeyFromKey(key); break; } } - if (vk == 0) + if (vk == 0 || keyCount != 1) throw new FormatException( Localization.Format("L.Hotkey.ParseError", hotkey)); return (modifiers, vk); diff --git a/src/Captail/Interop/CaptureInterop.cs b/src/Captail/Interop/CaptureInterop.cs index cc9a37e..ef93415 100644 --- a/src/Captail/Interop/CaptureInterop.cs +++ b/src/Captail/Interop/CaptureInterop.cs @@ -29,6 +29,9 @@ private static extern bool EnumDisplayDevices( [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(nint window, StringBuilder className, int maxCount); + [DllImport("user32.dll")] + private static extern bool GetClientRect(nint window, out Rect rect); + [StructLayout(LayoutKind.Sequential)] private struct Rect { @@ -136,6 +139,20 @@ public static bool IsProcessRunning(string executablePath) return process is not null; } + public static (int Width, int Height)? GetGameClientSize(string executablePath) + { + using Process? process = FindProcess(executablePath, requireWindow: true); + if (process is null || + !GetClientRect(process.MainWindowHandle, out Rect rect)) + { + return null; + } + + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + return width > 0 && height > 0 ? (width, height) : null; + } + private static Process? FindProcess(string executablePath, bool requireWindow) { foreach (Process process in Process.GetProcesses()) diff --git a/src/Captail/Languages/Strings.en.xaml b/src/Captail/Languages/Strings.en.xaml index 2332008..0b7a40f 100644 --- a/src/Captail/Languages/Strings.en.xaml +++ b/src/Captail/Languages/Strings.en.xaml @@ -182,7 +182,7 @@ Could not parse hotkey: {0} Graphics adapter GPU not detected - Preview graphics adapter + Detecting graphics adapter… Hardware encoder GPU capabilities have not been checked yet. diff --git a/src/Captail/Languages/Strings.ru.xaml b/src/Captail/Languages/Strings.ru.xaml index 25af5df..4ab406d 100644 --- a/src/Captail/Languages/Strings.ru.xaml +++ b/src/Captail/Languages/Strings.ru.xaml @@ -182,7 +182,7 @@ Не удалось разобрать бинд: {0} Графический адаптер GPU не определён - Тестовый графический адаптер + Определение видеокарты… Аппаратный кодировщик Возможности GPU ещё не проверены. diff --git a/src/Captail/Log.cs b/src/Captail/Log.cs index fe477c5..7405bdd 100644 --- a/src/Captail/Log.cs +++ b/src/Captail/Log.cs @@ -5,16 +5,65 @@ namespace Captail; public static class Log { private static readonly Lock _lock = new(); + private static StreamWriter? _writer; + private static int _pendingLines; public static readonly string Path = System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Captail", "log.txt"); + static Log() => AppDomain.CurrentDomain.ProcessExit += (_, _) => Close(); + public static void Write(string message) { lock (_lock) { - Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); - File.AppendAllText(Path, $"{DateTime.Now:HH:mm:ss.fff} {message}{Environment.NewLine}"); + try + { + _writer ??= CreateWriter(); + _writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {message}"); + _pendingLines++; + if (_pendingLines >= 32 || IsUrgent(message)) + { + _writer.Flush(); + _pendingLines = 0; + } + } + catch + { + _writer?.Dispose(); + _writer = null; + _pendingLines = 0; + } + } + } + + public static void Close() + { + lock (_lock) + { + _writer?.Dispose(); + _writer = null; + _pendingLines = 0; } } + + private static StreamWriter CreateWriter() + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + return new StreamWriter(new FileStream( + Path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite, + 16 * 1024, + FileOptions.SequentialScan)) + { + AutoFlush = false, + }; + } + + private static bool IsUrgent(string message) => + message.Contains("error", StringComparison.OrdinalIgnoreCase) || + message.Contains("fail", StringComparison.OrdinalIgnoreCase) || + message.Contains("crash", StringComparison.OrdinalIgnoreCase); } diff --git a/src/Captail/ObsLogBridge.cs b/src/Captail/ObsLogBridge.cs index 5ef1a5d..99410d0 100644 --- a/src/Captail/ObsLogBridge.cs +++ b/src/Captail/ObsLogBridge.cs @@ -13,20 +13,20 @@ private delegate void LogCallback( [DllImport("CaptailObsBridge.dll", CallingConvention = CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] - private static extern bool everloop_install_obs_log_handler(LogCallback callback); + private static extern bool captail_install_obs_log_handler(LogCallback callback); [DllImport("CaptailObsBridge.dll", CallingConvention = CallingConvention.Cdecl)] - private static extern void everloop_remove_obs_log_handler(); + private static extern void captail_remove_obs_log_handler(); internal static bool Install() { - bool installed = everloop_install_obs_log_handler(Callback); + bool installed = captail_install_obs_log_handler(Callback); if (!installed) Log.Write("OBS log bridge could not be installed."); return installed; } - internal static void Remove() => everloop_remove_obs_log_handler(); + internal static void Remove() => captail_remove_obs_log_handler(); private static void Write(int level, string message) => Log.Write($"libobs[{level}]: {message}"); diff --git a/src/Captail/ObsNative.cs b/src/Captail/ObsNative.cs index bef1587..2d10dd9 100644 --- a/src/Captail/ObsNative.cs +++ b/src/Captail/ObsNative.cs @@ -2,6 +2,10 @@ namespace Captail; +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", + "CA2101:Specify marshaling for P/Invoke string arguments", + Justification = "libobs APIs require explicit UTF-8 strings via LPUTF8Str.")] internal static class ObsNative { internal const string Library = "obs.dll"; @@ -11,7 +15,6 @@ internal enum VideoColorSpace { Default, Cs601, Cs709, Srgb } internal enum VideoRange { Default, Partial, Full } internal enum ScaleType { Disable, Point, Bicubic, Bilinear, Lanczos, Area } internal enum SpeakerLayout { Unknown, Mono, Stereo } - internal enum BoundsType { None, Stretch, ScaleInner } [StructLayout(LayoutKind.Sequential)] internal struct VideoInfo @@ -38,13 +41,6 @@ internal struct AudioInfo internal SpeakerLayout Speakers; } - [StructLayout(LayoutKind.Sequential)] - internal struct Vec2 - { - internal float X; - internal float Y; - } - [StructLayout(LayoutKind.Sequential)] internal struct CallData { @@ -74,6 +70,9 @@ internal static extern bool obs_startup( [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void obs_shutdown(); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + internal static extern void obs_wait_for_destroy_queue(); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool obs_initialized(); @@ -151,6 +150,9 @@ internal static extern nint gs_effect_create_from_file( [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern uint obs_get_total_frames(); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + internal static extern uint obs_get_lagged_frames(); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern nint obs_data_create(); @@ -185,6 +187,9 @@ internal static extern nint obs_source_create( [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void obs_source_release(nint source); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + internal static extern void obs_source_remove(nint source); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void obs_source_set_audio_mixers(nint source, uint mixers); @@ -203,37 +208,6 @@ internal static extern nint obs_source_create( [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern nint obs_source_get_proc_handler(nint source); - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern nint obs_scene_create( - [MarshalAs(UnmanagedType.LPUTF8Str)] string name); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_scene_release(nint scene); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern nint obs_scene_get_source(nint scene); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern nint obs_scene_add(nint scene, nint source); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_alignment(nint item, uint alignment); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_bounds_alignment(nint item, uint alignment); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_bounds_type(nint item, BoundsType type); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_bounds(nint item, ref Vec2 bounds); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_pos(nint item, ref Vec2 position); - - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] - internal static extern void obs_sceneitem_set_scale_filter(nint item, ScaleType filter); - [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] internal static extern void obs_set_output_source(uint channel, nint source); diff --git a/src/Captail/ObsReplayEngine.cs b/src/Captail/ObsReplayEngine.cs index 0baf990..83efade 100644 --- a/src/Captail/ObsReplayEngine.cs +++ b/src/Captail/ObsReplayEngine.cs @@ -1,12 +1,23 @@ using System.IO; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Captail.Interop; namespace Captail; +[SuppressMessage( + "Usage", + "CA2216:Disposable types should declare finalizer", + Justification = "libobs is thread-affine; finalizer-thread native shutdown is unsafe.")] public sealed class ObsReplayEngine : IDisposable { private const string RequiredObsVersion = "32.1.2"; + private static readonly string[] CapabilityCodecNames = ["h264", "hevc", "av1"]; + private static readonly string[] DiagnosticEffectNames = + [ + "default.effect", "opaque.effect", "solid.effect", + "format_conversion.effect", "premultiplied_alpha.effect", + ]; private static readonly object ContextGate = new(); private static nint _obsLibrary; private static bool _contextOwned; @@ -19,7 +30,6 @@ public sealed class ObsReplayEngine : IDisposable private readonly List _audioEncoders = []; private nint _videoSource; - private nint _scene; private nint _videoEncoder; private nint _output; private nint _outputSignals; @@ -32,6 +42,8 @@ public sealed class ObsReplayEngine : IDisposable private DateTime _previousFrameCheckUtc; private uint _outputWidth; private uint _outputHeight; + private uint _baseWidth; + private uint _baseHeight; public event Action? Faulted; @@ -96,6 +108,8 @@ public bool IsHealthy public ulong BufferedBytes => _output == 0 ? 0 : ObsNative.obs_output_get_total_bytes(_output); + public uint TotalRenderedFrames => ObsNative.obs_get_total_frames(); + public uint LaggedRenderedFrames => ObsNative.obs_get_lagged_frames(); public ObsReplayEngine(Config config) { @@ -147,7 +161,6 @@ public void Start() public static EncoderCapabilities ProbeCapabilities(Config config) { - var probe = new ObsReplayEngine(config); lock (ContextGate) { if (_contextOwned) @@ -156,6 +169,19 @@ public static EncoderCapabilities ProbeCapabilities(Config config) _contextOwned = true; } + ObsReplayEngine probe; + try + { + probe = new ObsReplayEngine(config); + } + catch + { + lock (ContextGate) + _contextOwned = false; + throw; + } + + using (probe) try { probe.InitializeObs(); @@ -166,10 +192,6 @@ public static EncoderCapabilities ProbeCapabilities(Config config) Log.Write($"GPU capability detection failed: {exception}"); return EncoderCapabilities.Failed(exception.Message); } - finally - { - probe.Dispose(); - } } public async Task SaveReplayAsync(CancellationToken cancellationToken = default) @@ -241,7 +263,7 @@ private void InitializeObs() Localization.Text("L.Engine.InitFailed")); string version = ObsVersion(); - if (!version.StartsWith(RequiredObsVersion, StringComparison.Ordinal)) + if (!string.Equals(version, RequiredObsVersion, StringComparison.Ordinal)) { throw new InvalidOperationException( Localization.Text("L.Engine.VersionMismatch")); @@ -258,8 +280,15 @@ private void InitializeObs() : monitors.FirstOrDefault() ?? throw new InvalidOperationException( Localization.Text("L.Engine.MonitorMissing")); + (int captureWidth, int captureHeight) = IsGameCapture + ? CaptureInterop.GetGameClientSize(_config.GameExecutablePath) ?? + (monitor.Width, monitor.Height) + : (monitor.Width, monitor.Height); + _baseWidth = (uint)captureWidth; + _baseHeight = (uint)captureHeight; (uint outputWidth, uint outputHeight) = ResolveOutputSize( - monitor, + _baseWidth, + _baseHeight, _config.RecordingResolution); _outputWidth = outputWidth; _outputHeight = outputHeight; @@ -273,8 +302,8 @@ private void InitializeObs() GraphicsModule = graphicsModule, FpsNum = (uint)_config.FrameRate, FpsDen = 1, - BaseWidth = (uint)monitor.Width, - BaseHeight = (uint)monitor.Height, + BaseWidth = _baseWidth, + BaseHeight = _baseHeight, OutputWidth = outputWidth, OutputHeight = outputHeight, OutputFormat = ObsNative.VideoFormat.Nv12, @@ -282,7 +311,9 @@ private void InitializeObs() GpuConversion = true, ColorSpace = ObsNative.VideoColorSpace.Cs709, Range = ObsNative.VideoRange.Partial, - ScaleType = ObsNative.ScaleType.Bicubic, + ScaleType = _config.FrameRate >= 120 + ? ObsNative.ScaleType.Bilinear + : ObsNative.ScaleType.Bicubic, }; int result = ObsNative.obs_reset_video(ref video); if (result != 0) @@ -348,7 +379,7 @@ private EncoderCapabilities DetectCapabilities() EncoderCatalog.Available(registered, adapterName)); string available = string.Join( ", ", - new[] { "h264", "hevc", "av1" } + CapabilityCodecNames .Where(capabilities.Supports) .Select(codec => $"{codec}:{capabilities.Preferred(codec)!.FamilyDisplayName}")); @@ -381,7 +412,8 @@ private void EnsureConfiguredCodecIsSupported() private static string ToObsPath(string path) => path.Replace('\\', '/'); private static (uint Width, uint Height) ResolveOutputSize( - CaptureInterop.MonitorInfo monitor, + uint sourceWidth, + uint sourceHeight, string setting) => setting.ToLowerInvariant() switch { @@ -389,7 +421,7 @@ private static (uint Width, uint Height) ResolveOutputSize( "1080p" => (1920, 1080), "1440p" => (2560, 1440), "2160p" => (3840, 2160), - _ => ((uint)monitor.Width, (uint)monitor.Height), + _ => (sourceWidth, sourceHeight), }; private static void DiagnoseEffects(string baseDirectory, string dataRoot) @@ -410,11 +442,7 @@ private static void DiagnoseEffects(string baseDirectory, string dataRoot) ObsNative.gs_enter_context(graphics); entered = true; - foreach (string name in new[] - { - "default.effect", "opaque.effect", "solid.effect", - "format_conversion.effect", "premultiplied_alpha.effect" - }) + foreach (string name in DiagnosticEffectNames) { nint error = 0; nint effect = ObsNative.gs_effect_create_from_file( @@ -504,7 +532,7 @@ private void CreateSources() throw new InvalidOperationException( Localization.Text("L.Engine.VideoSourceFailed")); - uint systemMix = _config.SeparateAudioTracks ? 1u : 1u; + const uint systemMix = 1u; if (IsGameCapture && _config.CaptureSystemAudio) { ObsNative.obs_source_set_audio_mixers(_videoSource, systemMix); @@ -513,32 +541,9 @@ private void CreateSources() NormalizeVolume(_config.SystemAudioVolume)); } - _scene = ObsNative.obs_scene_create("Captail Scene"); - if (_scene == 0) - throw new InvalidOperationException( - Localization.Text("L.Engine.SceneFailed")); - nint item = ObsNative.obs_scene_add(_scene, _videoSource); - if (item == 0) - throw new InvalidOperationException( - Localization.Text("L.Engine.SourceAttachFailed")); - - var bounds = new ObsNative.Vec2 - { - X = monitor.Width, - Y = monitor.Height, - }; - var position = new ObsNative.Vec2 - { - X = monitor.Width / 2f, - Y = monitor.Height / 2f, - }; - ObsNative.obs_sceneitem_set_alignment(item, 0); - ObsNative.obs_sceneitem_set_bounds_alignment(item, 0); - ObsNative.obs_sceneitem_set_bounds_type(item, ObsNative.BoundsType.ScaleInner); - ObsNative.obs_sceneitem_set_bounds(item, ref bounds); - ObsNative.obs_sceneitem_set_pos(item, ref position); - ObsNative.obs_sceneitem_set_scale_filter(item, ObsNative.ScaleType.Bicubic); - ObsNative.obs_set_output_source(0, ObsNative.obs_scene_get_source(_scene)); + // Captail always has one video source. Connecting it directly avoids an + // extra scene-composition pass, which matters at 144/240 FPS. + ObsNative.obs_set_output_source(0, _videoSource); if (!IsGameCapture && _config.CaptureSystemAudio) { @@ -1095,14 +1100,14 @@ public void Dispose() ObsNative.obs_encoder_release(encoder); _audioEncoders.Clear(); - for (uint channel = 0; channel <= 6; channel++) + for (uint channel = 0; channel < 6; channel++) ObsNative.obs_set_output_source(channel, 0); - if (_scene != 0) - { - ObsNative.obs_scene_release(_scene); - _scene = 0; - } + if (_videoSource != 0) + ObsNative.obs_source_remove(_videoSource); + foreach (nint source in _audioSources) + ObsNative.obs_source_remove(source); + if (_videoSource != 0) { ObsNative.obs_source_release(_videoSource); @@ -1112,6 +1117,7 @@ public void Dispose() ObsNative.obs_source_release(source); _audioSources.Clear(); + ObsNative.obs_wait_for_destroy_queue(); ObsNative.obs_shutdown(); _obsStarted = false; if (_logBridgeInstalled) diff --git a/src/Captail/OverlayNotificationWindow.xaml.cs b/src/Captail/OverlayNotificationWindow.xaml.cs index c4273fc..94aeec5 100644 --- a/src/Captail/OverlayNotificationWindow.xaml.cs +++ b/src/Captail/OverlayNotificationWindow.xaml.cs @@ -122,9 +122,21 @@ private void HideAnimated() private void MakeClickThrough() { nint hwnd = new WindowInteropHelper(this).Handle; + Marshal.SetLastPInvokeError(0); int styles = GetWindowLong(hwnd, GwlExStyle); - SetWindowLong(hwnd, GwlExStyle, + int error = Marshal.GetLastPInvokeError(); + if (styles == 0 && error != 0) + { + Log.Write($"Could not read overlay window style: Win32 error {error}."); + return; + } + + Marshal.SetLastPInvokeError(0); + int previousStyles = SetWindowLong(hwnd, GwlExStyle, styles | WsExTransparent | WsExToolWindow | WsExNoActivate); + error = Marshal.GetLastPInvokeError(); + if (previousStyles == 0 && error != 0) + Log.Write($"Could not make overlay click-through: Win32 error {error}."); } [DllImport("user32.dll", SetLastError = true)] diff --git a/src/Captail/SettingsWindow.xaml.cs b/src/Captail/SettingsWindow.xaml.cs index 9f4f1a0..ca273dc 100644 --- a/src/Captail/SettingsWindow.xaml.cs +++ b/src/Captail/SettingsWindow.xaml.cs @@ -16,11 +16,12 @@ public partial class SettingsWindow : Window private readonly Config _config; private readonly Action _saveReplay; - private readonly Func _setReplayEnabled; - private readonly Func _setAudioSources; - private readonly Func _applySettings; + private readonly Func> _setReplayEnabled; + private readonly Func> _setAudioSources; + private readonly Func> _applySettings; private EncoderCapabilities _capabilities; private readonly DispatcherTimer _diskTimer; + private readonly CancellationTokenSource _lifetimeCts = new(); private string _outputDirectory; private string _pendingSaveHotkey; private string _pendingToggleHotkey; @@ -28,6 +29,10 @@ public partial class SettingsWindow : Window private bool _updatingUi; private bool _runtimeActive; private bool? _animatedRecordingState; + private int _deviceRefreshVersion; + private int _processRefreshVersion; + private int _diskRefreshInProgress; + private int _actionInProgress; public bool Applied { get; private set; } @@ -35,9 +40,9 @@ public SettingsWindow( Config config, bool runtimeActive, Action saveReplay, - Func setReplayEnabled, - Func setAudioSources, - Func applySettings, + Func> setReplayEnabled, + Func> setAudioSources, + Func> applySettings, EncoderCapabilities capabilities) { _config = config; @@ -53,19 +58,27 @@ public SettingsWindow( InitializeComponent(); ApplyHardwareCapabilities(); - _diskTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) }; - _diskTimer.Tick += (_, _) => RefreshDisk(); + _diskTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + _diskTimer.Tick += async (_, _) => await RefreshDiskAsync(); Localization.Changed += OnLanguageChanged; Closed += (_, _) => { Localization.Changed -= OnLanguageChanged; _diskTimer.Stop(); + _lifetimeCts.Cancel(); + _lifetimeCts.Dispose(); }; - LoadDeviceLists(); + ResetDeviceLists(); LoadSettingsControls(); UpdateRuntimeState(runtimeActive); - RefreshDisk(); + Loaded += async (_, _) => await RunUiActionAsync(async () => + { + await Task.WhenAll( + LoadDeviceListsAsync(), + PopulateGameProcessesAsync(), + RefreshDiskAsync()); + }); _diskTimer.Start(); } @@ -146,7 +159,7 @@ private static string ShortEncoderName(string family) => _ => "HW", }; - private void LoadDeviceLists() + private void ResetDeviceLists() { AudioDeviceBox.Items.Clear(); MicDeviceBox.Items.Clear(); @@ -161,30 +174,50 @@ private void LoadDeviceLists() Tag = "", Content = Localization.Text("L.Audio.DefaultWindows"), }); - - try - { - foreach (var (id, name) in AudioDevices.ListRenderDevices()) - AudioDeviceBox.Items.Add(new ComboBoxItem { Tag = id, Content = name }); - } - catch (Exception ex) + MonitorBox.Items.Add(new ComboBoxItem { - Log.Write($"Output-device list unavailable: {ex.Message}"); - } + Tag = "0", + Content = Localization.Text("L.Video.PrimaryMonitor"), + }); + } + private async Task LoadDeviceListsAsync() + { + int version = Interlocked.Increment(ref _deviceRefreshVersion); + string systemDevice = GetSelectedTag( + AudioDeviceBox, + _config.SystemAudioDeviceId); + string microphoneDevice = GetSelectedTag( + MicDeviceBox, + _config.MicrophoneDeviceId); + string monitorId = GetSelectedTag( + MonitorBox, + _config.MonitorIndex.ToString()); + + DeviceListsSnapshot snapshot; try { - foreach (var (id, name) in AudioDevices.ListCaptureDevices()) - MicDeviceBox.Items.Add(new ComboBoxItem { Tag = id, Content = name }); + snapshot = await Task.Run( + CollectDeviceLists, + _lifetimeCts.Token); } - catch (Exception ex) + catch (OperationCanceledException) { - Log.Write($"Microphone list unavailable: {ex.Message}"); + return; } - try + if (version != _deviceRefreshVersion || _lifetimeCts.IsCancellationRequested) + return; + + ResetDeviceLists(); + foreach (var (id, name) in snapshot.RenderDevices) + AudioDeviceBox.Items.Add(new ComboBoxItem { Tag = id, Content = name }); + foreach (var (id, name) in snapshot.CaptureDevices) + MicDeviceBox.Items.Add(new ComboBoxItem { Tag = id, Content = name }); + if (snapshot.Monitors.Count > 0) { - foreach (var monitor in CaptureInterop.EnumerateMonitors()) + MonitorBox.Items.Clear(); + foreach (var monitor in snapshot.Monitors) { MonitorBox.Items.Add(new ComboBoxItem { @@ -197,26 +230,93 @@ private void LoadDeviceLists() }); } } + + SelectByTag(AudioDeviceBox, systemDevice); + SelectByTag(MicDeviceBox, microphoneDevice); + SelectByTag(MonitorBox, monitorId); + UpdateAudioDeviceState(); + } + + private static DeviceListsSnapshot CollectDeviceLists() + { + IReadOnlyList<(string Id, string Name)> renderDevices = []; + IReadOnlyList<(string Id, string Name)> captureDevices = []; + IReadOnlyList monitors = []; + try + { + renderDevices = AudioDevices.ListRenderDevices(); + } + catch (Exception ex) + { + Log.Write($"Output-device list unavailable: {ex.Message}"); + } + try + { + captureDevices = AudioDevices.ListCaptureDevices(); + } + catch (Exception ex) + { + Log.Write($"Microphone list unavailable: {ex.Message}"); + } + try + { + monitors = CaptureInterop.EnumerateMonitors(); + } catch (Exception ex) { Log.Write($"Monitor list unavailable: {ex.Message}"); } + return new DeviceListsSnapshot(renderDevices, captureDevices, monitors); + } - if (MonitorBox.Items.Count == 0) - MonitorBox.Items.Add(new ComboBoxItem - { - Tag = "0", - Content = Localization.Text("L.Video.PrimaryMonitor"), - }); + private async Task PopulateGameProcessesAsync() + { + int version = Interlocked.Increment(ref _processRefreshVersion); + string selectedPath = GetSelectedTag(GameProcessBox, _config.GameExecutablePath); + IReadOnlyList<(string Path, string Label)> choices; + try + { + choices = await Task.Run(CollectGameProcesses, _lifetimeCts.Token); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + Log.Write($"Game-process list unavailable: {exception.Message}"); + return; + } + + if (version != _processRefreshVersion || _lifetimeCts.IsCancellationRequested) + return; + + var visibleChoices = choices.ToDictionary( + choice => choice.Path, + choice => choice.Label, + StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(selectedPath) && + !visibleChoices.ContainsKey(selectedPath)) + { + visibleChoices[selectedPath] = Localization.Format( + "L.Video.GameNotRunning", + Path.GetFileName(selectedPath)); + } - PopulateGameProcesses(); + GameProcessBox.Items.Clear(); + GameProcessBox.Items.Add(new ComboBoxItem + { + Tag = "", + Content = Localization.Text("L.Video.ChooseGame"), + IsEnabled = false, + }); + foreach ((string path, string label) in visibleChoices.OrderBy(pair => pair.Value)) + GameProcessBox.Items.Add(new ComboBoxItem { Tag = path, Content = label }); + SelectByTag(GameProcessBox, selectedPath); } - private void PopulateGameProcesses() + private static IReadOnlyList<(string Path, string Label)> CollectGameProcesses() { - string selectedPath = GetSelectedTag( - GameProcessBox, - _config.GameExecutablePath); var choices = new Dictionary(StringComparer.OrdinalIgnoreCase); HashSet shellProcesses = new(StringComparer.OrdinalIgnoreCase) { @@ -264,31 +364,10 @@ private void PopulateGameProcesses() } } - if (!string.IsNullOrWhiteSpace(selectedPath) && - !choices.ContainsKey(selectedPath)) - { - choices[selectedPath] = - Localization.Format( - "L.Video.GameNotRunning", - Path.GetFileName(selectedPath)); - } - - GameProcessBox.Items.Clear(); - GameProcessBox.Items.Add(new ComboBoxItem - { - Tag = "", - Content = Localization.Text("L.Video.ChooseGame"), - IsEnabled = false, - }); - foreach ((string path, string label) in choices.OrderBy(pair => pair.Value)) - { - GameProcessBox.Items.Add(new ComboBoxItem - { - Tag = path, - Content = label, - }); - } - SelectByTag(GameProcessBox, selectedPath); + return choices + .OrderBy(pair => pair.Value) + .Select(pair => (pair.Key, pair.Value)) + .ToArray(); } private void CaptureSourceBox_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -297,8 +376,8 @@ private void CaptureSourceBox_SelectionChanged(object sender, SelectionChangedEv UpdateCaptureSourceState(); } - private void GameProcessBox_DropDownOpened(object sender, EventArgs e) => - PopulateGameProcesses(); + private async void GameProcessBox_DropDownOpened(object sender, EventArgs e) => + await RunUiActionAsync(PopulateGameProcessesAsync); private void UpdateCaptureSourceState() { @@ -442,10 +521,28 @@ private void Settings_Click(object sender, RoutedEventArgs e) private void Language_Click(object sender, RoutedEventArgs e) { - _config.Language = Localization.IsRussian ? "en" : "ru"; - _config.Save(); - Localization.SetLanguage(_config.Language); - AnimatePress(LanguageButton); + string previousLanguage = _config.Language; + try + { + _config.Language = Localization.IsRussian ? "en" : "ru"; + _config.Save(); + Localization.SetLanguage(_config.Language); + AnimatePress(LanguageButton); + } + catch (Exception exception) + { + _config.Language = previousLanguage; + try + { + _config.Save(); + Localization.SetLanguage(previousLanguage); + } + catch (Exception rollbackException) + { + Log.Write($"Language rollback failed: {rollbackException}"); + } + HandleUiActionError("Language change", exception); + } } private void OnLanguageChanged() @@ -456,24 +553,13 @@ private void OnLanguageChanged() return; } - string systemDevice = GetSelectedTag( - AudioDeviceBox, - _config.SystemAudioDeviceId); - string microphoneDevice = GetSelectedTag( - MicDeviceBox, - _config.MicrophoneDeviceId); - string monitor = GetSelectedTag( - MonitorBox, - _config.MonitorIndex.ToString()); - - LoadDeviceLists(); - SelectByTag(AudioDeviceBox, systemDevice); - SelectByTag(MicDeviceBox, microphoneDevice); - SelectByTag(MonitorBox, monitor); + _ = RunUiActionAsync(async () => await Task.WhenAll( + LoadDeviceListsAsync(), + PopulateGameProcessesAsync())); ApplyHardwareCapabilities(); UpdateCaptureSourceState(); UpdateRuntimeState(_runtimeActive); - RefreshDisk(); + _ = RefreshDiskAsync(); } private void ShowSettings() @@ -535,14 +621,31 @@ private void SetWindowHeight(double height) SystemParameters.WorkArea.Bottom - height - 16); } - private void ReplayToggle_Click(object sender, RoutedEventArgs e) + private async void ReplayToggle_Click(object sender, RoutedEventArgs e) { if (_updatingUi) return; + if (!TryBeginAction()) + { + UpdateRuntimeState(_runtimeActive); + return; + } - bool active = _setReplayEnabled(ReplayToggle.IsChecked == true); - UpdateRuntimeState(active); - AnimatePress(ReplayToggle); + try + { + bool active = await _setReplayEnabled(ReplayToggle.IsChecked == true); + UpdateRuntimeState(active); + AnimatePress(ReplayToggle); + } + catch (Exception exception) + { + HandleUiActionError("Replay toggle", exception); + UpdateRuntimeState(_runtimeActive); + } + finally + { + EndAction(); + } } private void SaveReplay_Click(object sender, RoutedEventArgs e) @@ -551,22 +654,39 @@ private void SaveReplay_Click(object sender, RoutedEventArgs e) _saveReplay(); } - private void SourceChip_Click(object sender, RoutedEventArgs e) + private async void SourceChip_Click(object sender, RoutedEventArgs e) { if (_updatingUi) return; + if (!TryBeginAction()) + { + UpdateRuntimeState(_runtimeActive); + return; + } - bool applied = _setAudioSources( - SystemSourceChip.IsChecked == true, - MicSourceChip.IsChecked == true, - _config.SystemAudioDeviceId, - _config.MicrophoneDeviceId); - UpdateRuntimeState(_runtimeActive); - AnimatePress((FrameworkElement)sender); - if (!applied) - ShowError( - Localization.Text("L.Error.SourceTitle"), - Localization.Text("L.Error.AudioSourceMessage")); + try + { + bool applied = await _setAudioSources( + SystemSourceChip.IsChecked == true, + MicSourceChip.IsChecked == true, + _config.SystemAudioDeviceId, + _config.MicrophoneDeviceId); + UpdateRuntimeState(_runtimeActive); + AnimatePress((FrameworkElement)sender); + if (!applied) + ShowError( + Localization.Text("L.Error.SourceTitle"), + Localization.Text("L.Error.AudioSourceMessage")); + } + catch (Exception exception) + { + HandleUiActionError("Audio source toggle", exception); + UpdateRuntimeState(_runtimeActive); + } + finally + { + EndAction(); + } } private void AudioDeviceMenu_Opened(object sender, RoutedEventArgs e) @@ -627,27 +747,47 @@ private void AudioDeviceMenu_Opened(object sender, RoutedEventArgs e) } } - private void AudioDeviceMenuItem_Click(object sender, RoutedEventArgs e) + private async void AudioDeviceMenuItem_Click(object sender, RoutedEventArgs e) { if (((MenuItem)sender).Tag is not AudioDeviceSelection selection) return; + if (!TryBeginAction()) + return; - string systemDeviceId = selection.IsSystem ? selection.Id : _config.SystemAudioDeviceId; - string microphoneDeviceId = selection.IsSystem ? _config.MicrophoneDeviceId : selection.Id; - bool applied = _setAudioSources( - SystemSourceChip.IsChecked == true, - MicSourceChip.IsChecked == true, - systemDeviceId, - microphoneDeviceId); - - SelectByTag( - selection.IsSystem ? AudioDeviceBox : MicDeviceBox, - selection.IsSystem ? _config.SystemAudioDeviceId : _config.MicrophoneDeviceId); - UpdateRuntimeState(_runtimeActive); - if (!applied) - ShowError( - Localization.Text("L.Error.DeviceTitle"), - Localization.Text("L.Error.AudioSourceMessage")); + try + { + string systemDeviceId = selection.IsSystem + ? selection.Id + : _config.SystemAudioDeviceId; + string microphoneDeviceId = selection.IsSystem + ? _config.MicrophoneDeviceId + : selection.Id; + bool applied = await _setAudioSources( + SystemSourceChip.IsChecked == true, + MicSourceChip.IsChecked == true, + systemDeviceId, + microphoneDeviceId); + + SelectByTag( + selection.IsSystem ? AudioDeviceBox : MicDeviceBox, + selection.IsSystem + ? _config.SystemAudioDeviceId + : _config.MicrophoneDeviceId); + UpdateRuntimeState(_runtimeActive); + if (!applied) + ShowError( + Localization.Text("L.Error.DeviceTitle"), + Localization.Text("L.Error.AudioSourceMessage")); + } + catch (Exception exception) + { + HandleUiActionError("Audio device selection", exception); + UpdateRuntimeState(_runtimeActive); + } + finally + { + EndAction(); + } } private void AudioToggle_Click(object sender, RoutedEventArgs e) @@ -674,7 +814,7 @@ private void BrowseOutput_Click(object sender, RoutedEventArgs e) _outputDirectory = dialog.FolderName; OutputDirText.Text = _outputDirectory; - RefreshDisk(); + _ = RefreshDiskAsync(); } private void HotkeyCapture_Click(object sender, RoutedEventArgs e) @@ -744,7 +884,7 @@ private void CancelHotkeyCapture() _capturingHotkeyButton = null; } - private void Apply_Click(object sender, RoutedEventArgs e) + private async void Apply_Click(object sender, RoutedEventArgs e) { CancelHotkeyCapture(); if (!HotkeyManager.IsValid(_pendingSaveHotkey) || @@ -779,13 +919,14 @@ private void Apply_Click(object sender, RoutedEventArgs e) bool separateAudioTracks = GetSelectedTag(AudioTrackModeBox, "mixed") == "separate"; - _config.ReplayEnabled = SettingsReplayToggle.IsChecked == true; - _config.BufferSeconds = GetSelectedRadioInt(BufferOptions, _config.BufferSeconds); - _config.MaxReplaySizeMb = GetSelectedInt(ReplaySizeLimitBox, 0); - _config.CaptureSource = GetSelectedTag(CaptureSourceBox, "desktop"); - _config.GameExecutablePath = GetSelectedTag(GameProcessBox, ""); - if (_config.CaptureSource == "game" && - string.IsNullOrWhiteSpace(_config.GameExecutablePath)) + Config candidate = _config.Clone(); + candidate.ReplayEnabled = SettingsReplayToggle.IsChecked == true; + candidate.BufferSeconds = GetSelectedRadioInt(BufferOptions, _config.BufferSeconds); + candidate.MaxReplaySizeMb = GetSelectedInt(ReplaySizeLimitBox, 0); + candidate.CaptureSource = GetSelectedTag(CaptureSourceBox, "desktop"); + candidate.GameExecutablePath = GetSelectedTag(GameProcessBox, ""); + if (candidate.CaptureSource == "game" && + string.IsNullOrWhiteSpace(candidate.GameExecutablePath)) { ShowError( Localization.Text("L.Error.GameTitle"), @@ -800,79 +941,167 @@ private void Apply_Click(object sender, RoutedEventArgs e) Localization.Text("L.Error.CodecMessage")); return; } - _config.Codec = selectedCodec; - _config.BitrateMbps = GetSelectedInt(BitrateBox, _config.BitrateMbps); - _config.FrameRate = GetSelectedRadioInt(FpsOptions, _config.FrameRate); - _config.MonitorIndex = GetSelectedInt(MonitorBox, _config.MonitorIndex); - _config.RecordingResolution = GetSelectedTag(ResolutionBox, "source"); - _config.CaptureSystemAudio = SystemAudioBox.IsChecked == true; - _config.SystemAudioVolume = (int)Math.Round(SystemVolumeSlider.Value); - _config.SystemAudioDeviceId = GetSelectedTag(AudioDeviceBox, ""); - _config.CaptureMicrophone = MicBox.IsChecked == true; - _config.MicrophoneVolume = (int)Math.Round(MicVolumeSlider.Value); - _config.MicrophoneBoostDb = (int)Math.Round(MicBoostSlider.Value); - _config.MicrophoneDeviceId = GetSelectedTag(MicDeviceBox, ""); - _config.AudioCodec = GetSelectedTag(AudioCodecBox, "aac"); - _config.SeparateAudioTracks = separateAudioTracks; - _config.OutputDirectory = _outputDirectory; - _config.Hotkey = _pendingSaveHotkey; - _config.ToggleReplayHotkey = _pendingToggleHotkey; - _config.Save(); + if (!TryBeginAction()) + return; try { - Autostart.SetEnabled(AutostartBox.IsChecked == true); + candidate.Codec = selectedCodec; + candidate.BitrateMbps = GetSelectedInt(BitrateBox, _config.BitrateMbps); + candidate.FrameRate = GetSelectedRadioInt(FpsOptions, _config.FrameRate); + candidate.MonitorIndex = GetSelectedInt(MonitorBox, _config.MonitorIndex); + candidate.RecordingResolution = GetSelectedTag(ResolutionBox, "source"); + candidate.CaptureSystemAudio = SystemAudioBox.IsChecked == true; + candidate.SystemAudioVolume = (int)Math.Round(SystemVolumeSlider.Value); + candidate.SystemAudioDeviceId = GetSelectedTag(AudioDeviceBox, ""); + candidate.CaptureMicrophone = MicBox.IsChecked == true; + candidate.MicrophoneVolume = (int)Math.Round(MicVolumeSlider.Value); + candidate.MicrophoneBoostDb = (int)Math.Round(MicBoostSlider.Value); + candidate.MicrophoneDeviceId = GetSelectedTag(MicDeviceBox, ""); + candidate.AudioCodec = GetSelectedTag(AudioCodecBox, "aac"); + candidate.SeparateAudioTracks = separateAudioTracks; + candidate.OutputDirectory = _outputDirectory; + candidate.Hotkey = _pendingSaveHotkey; + candidate.ToggleReplayHotkey = _pendingToggleHotkey; + candidate.Normalize(); + + if (!await _applySettings( + candidate, + AutostartBox.IsChecked == true)) + { + LoadSettingsControls(); + return; + } + + Applied = true; + _ = RefreshDiskAsync(); + ShowDashboard(); } - catch (Exception ex) + catch (Exception exception) { - ShowError(Localization.Text("L.Error.AutostartTitle"), ex.Message); - return; + Log.Write($"Apply settings UI failed: {exception}"); + ShowError( + Localization.Text("L.Error.Attention"), + exception.Message); + LoadSettingsControls(); } - - if (!_applySettings()) + finally { - LoadSettingsControls(); - return; + EndAction(); } + } + + private bool TryBeginAction() + { + if (Interlocked.Exchange(ref _actionInProgress, 1) != 0) + return false; + + ReplayToggle.IsEnabled = false; + SystemSourceChip.IsEnabled = false; + MicSourceChip.IsEnabled = false; + SettingsReplayToggle.IsEnabled = false; + DoneButton.IsEnabled = false; + return true; + } - Applied = true; - RefreshDisk(); - ShowDashboard(); + private void EndAction() + { + Interlocked.Exchange(ref _actionInProgress, 0); + ReplayToggle.IsEnabled = true; + SystemSourceChip.IsEnabled = true; + MicSourceChip.IsEnabled = true; + SettingsReplayToggle.IsEnabled = true; + DoneButton.IsEnabled = true; } - private void RefreshDisk() + private async Task RunUiActionAsync(Func action) { try { - string? root = Path.GetPathRoot(Path.GetFullPath(_outputDirectory)); - if (string.IsNullOrEmpty(root)) - throw new IOException("Could not resolve the target drive."); + await action(); + } + catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) + { + // Window is closing. + } + catch (Exception exception) + { + HandleUiActionError("Background UI action", exception); + } + } + + private void HandleUiActionError(string operation, Exception exception) + { + Log.Write($"{operation} failed: {exception}"); + ShowError( + Localization.Text("L.Error.Attention"), + exception.Message); + } + + private async Task RefreshDiskAsync() + { + if (Interlocked.Exchange(ref _diskRefreshInProgress, 1) != 0) + return; - var drive = new DriveInfo(root); - long used = drive.TotalSize - drive.AvailableFreeSpace; - double percent = drive.TotalSize == 0 ? 0 : used * 100d / drive.TotalSize; + string outputDirectory = _outputDirectory; + try + { + DiskSnapshot snapshot = await Task.Run( + () => ReadDiskSnapshot(outputDirectory), + _lifetimeCts.Token); + if (_lifetimeCts.IsCancellationRequested || + !string.Equals(outputDirectory, _outputDirectory, StringComparison.Ordinal)) + { + return; + } DiskSummaryText.Text = Localization.Format( "L.Storage.FreeOn", - FormatBytes(drive.AvailableFreeSpace), - drive.Name.TrimEnd('\\')); - DiskSummaryProgress.Value = percent; + FormatBytes(snapshot.FreeBytes), + snapshot.DriveName); + DiskSummaryProgress.Value = snapshot.UsedPercent; DiskUsedText.Text = Localization.Format( "L.Storage.Used", - FormatBytes(used)); + FormatBytes(snapshot.UsedBytes)); DiskFreeText.Text = Localization.Format( "L.Storage.Free", - FormatBytes(drive.AvailableFreeSpace)); - DiskProgress.Value = percent; + FormatBytes(snapshot.FreeBytes)); + DiskProgress.Value = snapshot.UsedPercent; } - catch + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) { + Log.Write($"Disk-space query unavailable: {exception.Message}"); DiskSummaryText.Text = Localization.Text("L.Storage.Unavailable"); DiskSummaryProgress.Value = 0; DiskUsedText.Text = Localization.Text("L.Storage.NoData"); DiskFreeText.Text = ""; DiskProgress.Value = 0; } + finally + { + Interlocked.Exchange(ref _diskRefreshInProgress, 0); + } + } + + private static DiskSnapshot ReadDiskSnapshot(string outputDirectory) + { + string? root = Path.GetPathRoot(Path.GetFullPath(outputDirectory)); + if (string.IsNullOrEmpty(root)) + throw new IOException("Could not resolve the target drive."); + + var drive = new DriveInfo(root); + long total = drive.TotalSize; + long free = drive.AvailableFreeSpace; + long used = total - free; + return new DiskSnapshot( + drive.Name.TrimEnd('\\'), + used, + free, + total == 0 ? 0 : used * 100d / total); } private void Header_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) @@ -1034,6 +1263,17 @@ private string LocalizedCaptureSource(string? _) => private sealed record AudioDeviceSelection(bool IsSystem, string Id); + private sealed record DeviceListsSnapshot( + IReadOnlyList<(string Id, string Name)> RenderDevices, + IReadOnlyList<(string Id, string Name)> CaptureDevices, + IReadOnlyList Monitors); + + private sealed record DiskSnapshot( + string DriveName, + long UsedBytes, + long FreeBytes, + double UsedPercent); + private static void SelectRadioByTag(Panel panel, string tag) { RadioButton? fallback = null; diff --git a/src/Captail/SingleThreadTaskScheduler.cs b/src/Captail/SingleThreadTaskScheduler.cs new file mode 100644 index 0000000..53dfede --- /dev/null +++ b/src/Captail/SingleThreadTaskScheduler.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; + +namespace Captail; + +internal sealed class SingleThreadTaskScheduler : TaskScheduler, IDisposable +{ + private readonly BlockingCollection _tasks = new(); + private readonly Thread _thread; + private int _disposed; + + internal SingleThreadTaskScheduler(string threadName) + { + _thread = new Thread(Run) + { + IsBackground = true, + Name = threadName, + }; + _thread.SetApartmentState(ApartmentState.MTA); + _thread.Start(); + } + + protected override IEnumerable? GetScheduledTasks() => + _tasks.ToArray(); + + protected override void QueueTask(Task task) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + try + { + _tasks.Add(task); + } + catch (InvalidOperationException) when (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(SingleThreadTaskScheduler)); + } + } + + protected override bool TryExecuteTaskInline( + Task task, + bool taskWasPreviouslyQueued) => + !taskWasPreviouslyQueued && + ReferenceEquals(Thread.CurrentThread, _thread) && + TryExecuteTask(task); + + private void Run() + { + foreach (Task task in _tasks.GetConsumingEnumerable()) + TryExecuteTask(task); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + _tasks.CompleteAdding(); + if (!ReferenceEquals(Thread.CurrentThread, _thread)) + _thread.Join(TimeSpan.FromSeconds(5)); + // Do not dispose the collection after a timed-out join: worker may still + // be completing a native libobs call and must finish enumeration safely. + } +} diff --git a/src/Captail/packages.lock.json b/src/Captail/packages.lock.json new file mode 100644 index 0000000..64ef898 --- /dev/null +++ b/src/Captail/packages.lock.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows10.0.22621": { + "H.NotifyIcon.Wpf": { + "type": "Direct", + "requested": "[2.1.4, )", + "resolved": "2.1.4", + "contentHash": "JXwEhOXo8smlx6p4SjbxRQ8C0WDDgm2j/3nitA9yeksY3kTaMADL34zxaheQM0Qbf9efoaV2PTC7DXjNaBUHXA==", + "dependencies": { + "H.NotifyIcon": "2.1.4" + } + }, + "NAudio": { + "type": "Direct", + "requested": "[2.3.0, )", + "resolved": "2.3.0", + "contentHash": "xN+Lzlu9DmXqBiL6XkP0VZmgTxZPdSGjVSWtKG9HKuar3LhIYNOIYQETieigS+QDS+nrs1QmHbBWWOru0nWA5A==", + "dependencies": { + "NAudio.Asio": "2.3.0", + "NAudio.Core": "2.3.0", + "NAudio.Midi": "2.3.0", + "NAudio.Wasapi": "2.3.0", + "NAudio.WinForms": "2.3.0", + "NAudio.WinMM": "2.3.0" + } + }, + "System.Drawing.Common": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "R9q9TI//kGavXWTxvaSS9ERj79s+Vm7V7769zEmRbzdrIOJzAnGw+wWSo/JkaVdQfAAefV2V1bWYKCHF7Zs5oQ==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "10.0.10" + } + }, + "H.GeneratedIcons.System.Drawing": { + "type": "Transitive", + "resolved": "2.1.4", + "contentHash": "MjW8AmrbVHnFl17WzTqVA0LvUcgKtV/1gL04fTQdYgHotrjkz9Q8mgDxtrxevPCYE8SGvQBrKTPttCwkS1zsMw==", + "dependencies": { + "System.Drawing.Common": "8.0.10" + } + }, + "H.NotifyIcon": { + "type": "Transitive", + "resolved": "2.1.4", + "contentHash": "puxd5k0RQQilmIReQZjz+lyJWWmko/WWYkNbEUpqymPXZsCv9ILrhdz1j6YARR1OJCl9RWykV7WWvxYJRsVdMw==", + "dependencies": { + "H.GeneratedIcons.System.Drawing": "2.1.4" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "IY0lTfC2uVwTizjtGgQ03ViwyXGQ9lMDblzB889/HyguAZJ2J8pRrjPown8h2eJe8WFc8nh+BUEsEPkshzizYQ==" + }, + "NAudio.Asio": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "I+rAAPT8vmSEw4d4ie+AoSkrvNK6ylRrXznnjQKS+qZgTA9Jnt1Pxe0EaU8nXxOOq5h8ucBSWccBEw6I/J0nkQ==", + "dependencies": { + "NAudio.Core": "2.3.0" + } + }, + "NAudio.Core": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "jMd7r6dB6tAtXhOYL58ntPqwERNm1/Rhw5MKOIYvsnXzuX+PTGsa2VMam6n0npZYSwlSidKa4GAm4bFcXFUlcg==" + }, + "NAudio.Midi": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "t8wvPFPHHQOHhoNMCaDE8OiYZoJuxmY4H6UPBtmnf+xx7oQuajFCqlG4Q00ID+tiEyj471WeVb4Nkylr4tDoow==", + "dependencies": { + "NAudio.Core": "2.3.0" + } + }, + "NAudio.Wasapi": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "y5K2BxrLnvohgu5znzg5wRsai3YcuNXfrpo68i7PmCQqpK5MC/R24e5aO3OyN/XhfRr12e0QYV0OnUWEHi5SzA==", + "dependencies": { + "NAudio.Core": "2.3.0" + } + }, + "NAudio.WinForms": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "vyKqFUlAZrZ0QPcCM1T+zimjYgC1cNDloHaiOdtV4S1eK4AGu3Dz1EE4yy/WgcneJwQNjGFaELY7b2TsL+g56w==", + "dependencies": { + "NAudio.WinMM": "2.3.0" + } + }, + "NAudio.WinMM": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "5G1dRjsZm50T3luyuqcmI2BSvj3K4ZJaD/x776/0Epj88qOsOryDZG40+MufwIk1UFJSFWhRobBqtJYFc8Ss4g==", + "dependencies": { + "NAudio.Core": "2.3.0" + } + } + }, + "net9.0-windows10.0.22621/win-x64": { + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "IY0lTfC2uVwTizjtGgQ03ViwyXGQ9lMDblzB889/HyguAZJ2J8pRrjPown8h2eJe8WFc8nh+BUEsEPkshzizYQ==" + } + } + } +} \ No newline at end of file diff --git a/tools/AcquireObsRuntime.ps1 b/tools/AcquireObsRuntime.ps1 index 6c027f8..968a586 100644 --- a/tools/AcquireObsRuntime.ps1 +++ b/tools/AcquireObsRuntime.ps1 @@ -6,28 +6,40 @@ param( $ErrorActionPreference = "Stop" $version = "32.1.2" +$expectedArchiveSha256 = "8d97e4563bd8d22d03e63042aa7dccede1d555c9bd35ce8a9e5019b0d0201bf6" +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +$allowedRuntimeRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot "runtime")) +$temporaryExtract = "" if (-not $Destination) { - $Destination = Join-Path $PSScriptRoot "..\runtime\obs" -} - -if (-not $ObsRoot) { - $installed = Join-Path $env:ProgramFiles "obs-studio" - if (Test-Path (Join-Path $installed "bin\64bit\obs64.exe")) { - $ObsRoot = $installed - } + $Destination = Join-Path $allowedRuntimeRoot "obs" } if (-not $ObsRoot) { $archive = Join-Path $env:TEMP "OBS-Studio-$version-Windows-x64.zip" - $extract = Join-Path $env:TEMP "Captail-OBS-$version" - if (-not (Test-Path $archive)) { + $extract = Join-Path $env:TEMP ` + "Captail-OBS-$version-$PID-$([Guid]::NewGuid().ToString('N'))" + $temporaryExtract = [IO.Path]::GetFullPath($extract) + if (Test-Path -LiteralPath $archive) { + $existingHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + if (-not $existingHash.Equals( + $expectedArchiveSha256, + [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $archive -Force + } + } + if (-not (Test-Path -LiteralPath $archive)) { $url = "https://github.com/obsproject/obs-studio/releases/download/$version/OBS-Studio-$version-Windows-x64.zip" Write-Host "Downloading OBS Studio $version runtime..." Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive } - if (-not (Test-Path $extract)) { - Expand-Archive -LiteralPath $archive -DestinationPath $extract + $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash + if (-not $actualHash.Equals( + $expectedArchiveSha256, + [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $archive -Force + throw "OBS archive SHA-256 mismatch. Expected $expectedArchiveSha256; found $actualHash." } + Expand-Archive -LiteralPath $archive -DestinationPath $extract $obsExe = Get-ChildItem -LiteralPath $extract -Filter obs64.exe -Recurse | Select-Object -First 1 if (-not $obsExe) { @@ -41,14 +53,27 @@ if (-not (Test-Path $obsExePath)) { throw "Invalid OBS root: $ObsRoot" } $actualVersion = (Get-Item $obsExePath).VersionInfo.ProductVersion -if (-not $actualVersion.StartsWith($version, [StringComparison]::Ordinal)) { +if ($actualVersion -notmatch "^$([regex]::Escape($version))(?:\.0)?$") { throw "OBS $version required; found $actualVersion." } $Destination = [IO.Path]::GetFullPath($Destination) +$allowedPrefix = $allowedRuntimeRoot.TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if (-not $Destination.StartsWith( + $allowedPrefix, + [StringComparison]::OrdinalIgnoreCase)) { + throw "OBS runtime destination must stay under $allowedRuntimeRoot" +} $binDestination = Join-Path $Destination "bin" $pluginDestination = Join-Path $Destination "obs-plugins\64bit" $dataDestination = Join-Path $Destination "data" +foreach ($path in @($binDestination, (Join-Path $Destination "obs-plugins"), $dataDestination)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } +} New-Item -ItemType Directory -Force -Path $binDestination, $pluginDestination, $dataDestination | Out-Null @@ -63,8 +88,31 @@ foreach ($helper in @( Copy-Item -LiteralPath $helperPath -Destination $binDestination -Force } } -Get-ChildItem -LiteralPath (Join-Path $ObsRoot "bin\64bit") -Filter *.dll | - Copy-Item -Destination $binDestination -Force +$runtimeLibraries = @( + "avcodec-61.dll", + "avdevice-61.dll", + "avfilter-10.dll", + "avformat-61.dll", + "avutil-59.dll", + "libcurl.dll", + "libobs-d3d11.dll", + "libobs-winrt.dll", + "librist.dll", + "libx264-164.dll", + "obs.dll", + "srt.dll", + "swresample-5.dll", + "swscale-8.dll", + "w32-pthreads.dll", + "zlib.dll" +) +foreach ($library in $runtimeLibraries) { + $libraryPath = Join-Path $ObsRoot "bin\64bit\$library" + if (-not (Test-Path -LiteralPath $libraryPath)) { + throw "Required OBS runtime library not found: $libraryPath" + } + Copy-Item -LiteralPath $libraryPath -Destination $binDestination -Force +} $plugins = @( "obs-ffmpeg", @@ -79,7 +127,8 @@ foreach ($plugin in $plugins) { -Destination $pluginDestination -Force $pluginData = Join-Path $ObsRoot "data\obs-plugins\$plugin" if (Test-Path $pluginData) { - Copy-Item -LiteralPath $pluginData -Destination (Join-Path $dataDestination "obs-plugins") ` + $pluginDataDestination = Join-Path $dataDestination "obs-plugins\$plugin" + Copy-Item -LiteralPath $pluginData -Destination $pluginDataDestination ` -Recurse -Force } } @@ -98,7 +147,22 @@ Get-ChildItem -LiteralPath $dataDestination -Directory -Filter locale -Recurse | Remove-Item -LiteralPath $_.FullName -Force } } +Get-ChildItem -LiteralPath $dataDestination -File -Filter *.pdb -Recurse | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } Set-Content -LiteralPath (Join-Path $Destination "VERSION") ` -Value $version -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $Destination "SOURCE_SHA256") ` + -Value $expectedArchiveSha256 -Encoding ascii +if ($temporaryExtract) { + $tempRoot = [IO.Path]::GetFullPath($env:TEMP).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $temporaryExtract.StartsWith( + $tempRoot, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clean temporary extraction outside $tempRoot" + } + Remove-Item -LiteralPath $temporaryExtract -Recurse -Force +} Write-Host "OBS runtime $version ready: $Destination" diff --git a/tools/BuildRelease.ps1 b/tools/BuildRelease.ps1 index fc5d1ab..4bdcc48 100644 --- a/tools/BuildRelease.ps1 +++ b/tools/BuildRelease.ps1 @@ -25,6 +25,7 @@ if (-not $outputRoot.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCa } $stagingRoot = Join-Path $outputRoot "staging" +$dotnetArtifacts = Join-Path $stagingRoot "dotnet" $portableName = "Captail-$Version" $publishDirectory = Join-Path $stagingRoot $portableName $portableArchive = Join-Path $outputRoot "$portableName-Portable-win-x64.zip" @@ -46,9 +47,15 @@ foreach ($path in @($stagingRoot, $portableArchive, $setupPath, $checksumPath)) New-Item -ItemType Directory -Force -Path $publishDirectory | Out-Null Write-Host "Publishing Captail $Version..." +dotnet restore $project --locked-mode --artifacts-path $dotnetArtifacts +if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed with exit code $LASTEXITCODE." +} dotnet publish $project ` -c Release ` -r win-x64 ` + --no-restore ` + --artifacts-path $dotnetArtifacts ` --self-contained true ` -o $publishDirectory ` -p:Version=$Version ` From 69ec3052960a15af2ac80d2937d77c0b84b264c1 Mon Sep 17 00:00:00 2001 From: FaulMit <48646918+FaulMit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:39:25 +0200 Subject: [PATCH 3/3] Use repository CodeQL default setup --- .github/workflows/codeql.yml | 53 ------------------------------------ 1 file changed, 53 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 2d1397e..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: CodeQL - -on: - push: - branches: - - main - pull_request: - schedule: - - cron: '23 4 * * 1' - -permissions: - contents: read - security-events: write - -concurrency: - group: codeql-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - analyze: - name: C# and C++ - runs-on: windows-2022 - timeout-minutes: 45 - - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 - with: - dotnet-version: 9.0.x - cache: true - cache-dependency-path: src/Captail/packages.lock.json - - - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 - with: - languages: csharp,c-cpp - - - name: Acquire verified OBS runtime - shell: pwsh - run: ./tools/AcquireObsRuntime.ps1 - - - name: Build - shell: pwsh - run: | - dotnet restore ./src/Captail/Captail.csproj --locked-mode - dotnet build ./Captail.sln -c Release --no-restore ` - -p:ContinuousIntegrationBuild=true - - - name: Analyze - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4