diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7f81ea4..16182f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: labels: - dependencies - security + # Keeps the SHA-pinned workflow actions current. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + - security diff --git a/.github/workflows/ci-devsecops.yml b/.github/workflows/ci-devsecops.yml index 4063708..b50e453 100644 --- a/.github/workflows/ci-devsecops.yml +++ b/.github/workflows/ci-devsecops.yml @@ -17,10 +17,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" @@ -76,7 +76,7 @@ jobs: } - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: files: '**/coverage.cobertura.xml' fail_ci_if_error: false @@ -103,23 +103,22 @@ jobs: $version = "8.24.3" $baseUrl = "https://github.com/gitleaks/gitleaks/releases/download/v$version" $zipAsset = "gitleaks_${version}_windows_x64.zip" - $tarAsset = "gitleaks_${version}_windows_x64.tar.gz" $toolDir = Join-Path $env:RUNNER_TEMP "gitleaks-bin" if (Test-Path $toolDir) { Remove-Item -Path $toolDir -Recurse -Force } - try { - Invoke-WebRequest -Uri "$baseUrl/$zipAsset" -OutFile "gitleaks.zip" - Expand-Archive -Path "gitleaks.zip" -DestinationPath $toolDir -Force - } - catch { - Invoke-WebRequest -Uri "$baseUrl/$tarAsset" -OutFile "gitleaks.tar.gz" - New-Item -ItemType Directory -Force -Path $toolDir | Out-Null - tar -xzf "gitleaks.tar.gz" -C $toolDir + # Pinned hash from gitleaks_8.24.3_checksums.txt: refuse a swapped binary. + $expectedZipSha256 = "3f1a35578631dbfe633cc5b49e6c906e55ff14a4bfd7336a10fb27fe33b6dcd2" + Invoke-WebRequest -Uri "$baseUrl/$zipAsset" -OutFile "gitleaks.zip" + $actualZipSha256 = (Get-FileHash -Path "gitleaks.zip" -Algorithm SHA256).Hash + if ($actualZipSha256 -ne $expectedZipSha256) { + throw "Gitleaks archive checksum mismatch: $actualZipSha256" } + Expand-Archive -Path "gitleaks.zip" -DestinationPath $toolDir -Force + # Remove vendored tool docs from scan scope to avoid known upstream sample false positives. if (Test-Path ".\\gitleaks-bin") { Remove-Item -Path ".\\gitleaks-bin" -Recurse -Force @@ -130,7 +129,7 @@ jobs: - name: Upload security artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: security-reports path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 518220a..0c5d2c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,15 +33,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -53,6 +53,6 @@ jobs: run: dotnet build "ThreadPilot_1.sln" --configuration Release --no-restore - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 8a7ed4a..33175d5 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup .NET Core 8.0 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: 8.0.x diff --git a/.github/workflows/publish-chocolatey.yml b/.github/workflows/publish-chocolatey.yml index ebf9450..5d91460 100644 --- a/.github/workflows/publish-chocolatey.yml +++ b/.github/workflows/publish-chocolatey.yml @@ -27,17 +27,20 @@ jobs: - name: Resolve release metadata id: release shell: pwsh + env: + TAG_INPUT: ${{ github.event.inputs.tag }} + PUBLISH_INPUT: ${{ github.event.inputs.publish }} run: | $ErrorActionPreference = "Stop" - $tag = "${{ github.event.inputs.tag }}".Trim() + $tag = "$env:TAG_INPUT".Trim() if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "Tag must match vX.Y.Z. Received '$tag'." } $version = $tag.Substring(1) - $publish = "${{ github.event.inputs.publish }}" -eq "true" + $publish = $env:PUBLISH_INPUT -eq "true" $installerName = "ThreadPilot_$($tag)_Setup.exe" "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -46,7 +49,7 @@ jobs: "installer_name=$installerName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download installer from GitHub release shell: pwsh @@ -176,7 +179,7 @@ jobs: - name: Upload chocolatey package artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: chocolatey-package-artifacts path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b75da5..3e962ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,14 @@ on: description: "Build and validate release without winget publication" required: false default: "false" + type: choice + options: + - "false" + - "true" +# Read-only by default: only the job that publishes the GitHub release gets write access. permissions: - contents: write + contents: read jobs: build: @@ -25,20 +30,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: dotnet-version: "8.0.x" - name: Resolve version id: version shell: pwsh + env: + REF_NAME: ${{ github.ref_name }} run: | - $refName = "${{ github.ref_name }}" + $refName = $env:REF_NAME if ($refName -match '^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { $version = $refName.TrimStart('v') $tag = $refName @@ -75,7 +82,7 @@ jobs: - name: Install Inno Setup shell: pwsh - run: choco install innosetup --no-progress -y + run: choco install innosetup --version=6.7.1 --no-progress -y - name: Build Inno Setup installer shell: pwsh @@ -234,7 +241,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - dotnet tool install --global Microsoft.Sbom.DotNetTool + dotnet tool install --global Microsoft.Sbom.DotNetTool --version 4.1.13 $env:PATH += ";$env:USERPROFILE\.dotnet\tools" sbom-tool generate -b ./artifacts -bc . -pn ThreadPilot -pv "${{ steps.version.outputs.version }}" -ps PrimeBuild -nsb https://github.com/PrimeBuild-pc/ThreadPilot @@ -253,38 +260,38 @@ jobs: Copy-Item -LiteralPath $sbomManifest.FullName -Destination (Join-Path $sbomReleaseDir "manifest.spdx.json") -Force - name: Upload portable artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-portable path: artifacts/release/singlefile - name: Upload installer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-installer path: artifacts/release/installer - name: Upload packages artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-packages path: artifacts/release/packages - name: Upload checksums artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-checksums path: artifacts/release/SHA256SUMS.txt - name: Upload winget manifests if: steps.version.outputs.prerelease != 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: winget-manifests path: winget-manifests - name: Upload SBOM - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sbom path: artifacts/release/sbom/manifest.spdx.json @@ -295,7 +302,7 @@ jobs: needs: build steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-portable path: smoke @@ -320,43 +327,45 @@ jobs: runs-on: ubuntu-latest # Protected environment: waits for the maintainer's approval before it can write the release. environment: release + permissions: + contents: write needs: - build - smoke-test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Download installer artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-installer path: release-assets/installer - name: Download packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-packages path: release-assets/packages - name: Download checksums artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-checksums path: release-assets - name: Download winget manifests if: needs.build.outputs.prerelease != 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: winget-manifests path: winget-manifests - name: Download SBOM artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: sbom path: sbom @@ -395,7 +404,7 @@ jobs: fi - name: Install git-cliff - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: tool: git-cliff@2.10.0 @@ -426,7 +435,7 @@ jobs: Write-Host "CONTRIBUTORS.md is maintained manually in this workflow." - name: Publish GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: ${{ needs.build.outputs.tag }} name: ThreadPilot ${{ needs.build.outputs.tag }} @@ -451,9 +460,12 @@ jobs: - name: Resolve winget publish policy id: policy shell: pwsh + env: + EVENT_NAME: ${{ github.event_name }} + DRY_RUN_INPUT: ${{ github.event.inputs.dry_run_publish }} run: | $ErrorActionPreference = "Stop" - $dryRun = "${{ github.event_name }}" -eq "workflow_dispatch" -and "${{ github.event.inputs.dry_run_publish }}" -eq "true" + $dryRun = $env:EVENT_NAME -eq "workflow_dispatch" -and $env:DRY_RUN_INPUT -eq "true" "dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Check winget publish policy @@ -505,11 +517,11 @@ jobs: - name: Checkout if: steps.gate.outputs.enabled == 'true' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Download winget manifests if: steps.gate.outputs.enabled == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: winget-manifests path: winget-manifests @@ -561,7 +573,7 @@ jobs: - name: Upload winget publish logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: winget-publish-logs path: release-channel-logs/winget-submit-log.txt diff --git a/App.xaml.cs b/App.xaml.cs index 95c04b6..7f75966 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -77,12 +77,6 @@ protected override void OnStartup(StartupEventArgs e) if (isRunningAsAdministrator) { logger.LogInformation("Application is running with administrator privileges"); - - var launchTaskEnsured = Task.Run(async () => await elevatedTaskService.EnsureLaunchTaskAsync()).GetAwaiter().GetResult(); - if (!launchTaskEnsured) - { - logger.LogWarning("Failed to ensure managed elevated launch task during startup. Future launches may require one-time elevation again."); - } } else { @@ -130,7 +124,16 @@ protected override void OnStartup(StartupEventArgs e) // Enforce single-instance after elevation bootstrap logic to avoid mutex races during handoff. bool createdNew; - this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + try + { + this.singleInstanceMutex = new Mutex(initiallyOwned: true, name: "Global\\ThreadPilot_SingleInstance", createdNew: out createdNew); + } + catch (UnauthorizedAccessException) + { + // Created by another user's session with an ACL we cannot open: an instance is already running there. + createdNew = false; + } + if (!createdNew) { System.Windows.MessageBox.Show( @@ -188,6 +191,32 @@ protected override void OnStartup(StartupEventArgs e) logger.LogWarning(ex, "Failed to preload theme settings during startup"); } + if (isRunningAsAdministrator) + { + // Off the UI thread and in this order: the launch-task pass deletes tasks pointing outside Program + // Files, then autostart is restored for this copy if the user asked for it (e.g. right after a + // portable copy installed itself in Program Files). + var autostartService = this.ServiceProvider.GetRequiredService(); + var wantsAutostart = loadedSettings?.AutostartWithWindows == true; + var autostartMinimized = loadedSettings?.StartMinimized ?? true; + TaskSafety.FireAndForget( + Task.Run(async () => + { + if (!await elevatedTaskService.EnsureLaunchTaskAsync()) + { + logger.LogInformation("Elevated launch task not available; the next launch will ask for elevation."); + } + + if (wantsAutostart) + { + await autostartService.EnsureAutostartAsync(autostartMinimized); + } + + new UpdateTempDirectoryProvider().CleanupStale(TimeSpan.FromDays(1)); + }), + ex => logger.LogWarning(ex, "Failed to ensure elevated tasks during startup")); + } + var mainWindow = this.ServiceProvider.GetRequiredService(); this.MainWindow = mainWindow; @@ -202,7 +231,7 @@ protected override void OnStartup(StartupEventArgs e) throw new InvalidOperationException("MainWindow could not be created"); } - var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized); + var startupWindowBehavior = StartupWindowBehavior.Resolve(startupMode.IsAutostart, effectiveStartMinimized, UiStateStore.Current.WindowMaximized); var showStartupSuggestion = loadedSettings != null && StartupMinimizedSuggestionPolicy.ShouldShow(loadedSettings, startupWindowBehavior); mainWindow.ConfigureStartupMode( diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index f5ca8da..cb9d74c 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -3,6 +3,9 @@ [assembly: InternalsVisibleTo("ThreadPilot.Core.Tests")] +// The app runs elevated: never resolve a P/Invoke DLL (powrprof, dwmapi...) from the app folder. +[assembly: System.Runtime.InteropServices.DefaultDllImportSearchPaths(System.Runtime.InteropServices.DllImportSearchPath.System32)] + [assembly: ThemeInfo( ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located // (used if a resource is not found in the page, diff --git a/Directory.Publish.props b/Directory.Publish.props index 6f68263..fb2e421 100644 --- a/Directory.Publish.props +++ b/Directory.Publish.props @@ -1,13 +1,15 @@ - - - true - embedded - false - true - false - - - - true - - + + + true + embedded + false + + false + false + + + + true + + diff --git a/Helpers/StartupWindowBehavior.cs b/Helpers/StartupWindowBehavior.cs index 1021880..0700511 100644 --- a/Helpers/StartupWindowBehavior.cs +++ b/Helpers/StartupWindowBehavior.cs @@ -10,7 +10,7 @@ public readonly record struct StartupWindowBehavior( bool HideAfterShow, bool ActivateAfterShow) { - public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized) + public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimized, bool restoreMaximized = false) { if (isAutostart && startMinimized) { @@ -38,7 +38,7 @@ public static StartupWindowBehavior Resolve(bool isAutostart, bool startMinimize ShouldShowWindow: true, ShowInTaskbar: true, Visibility: Visibility.Visible, - WindowState: WindowState.Normal, + WindowState: restoreMaximized ? WindowState.Maximized : WindowState.Normal, HideAfterShow: false, ActivateAfterShow: true); } diff --git a/Installer/Installer.iss b/Installer/Installer.iss index 3ba8794..ea75481 100644 --- a/Installer/Installer.iss +++ b/Installer/Installer.iss @@ -5,7 +5,7 @@ #define MyAppPublisher "ThreadPilot" #define MyAppURL "https://github.com/" #define MyAppExeName "ThreadPilot.exe" -#define MyAppVersion "1.7.8-beta.1" +#define MyAppVersion "1.7.8-beta.2" #ifndef MyWizardStyle #define MyWizardStyle "modern dynamic windows11" diff --git a/Installer/setup.iss b/Installer/setup.iss index 7e6fb7e..82f415c 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.7.8-beta.1" + #define MyAppVersion "1.7.8-beta.2" #endif #ifndef MyAppSourceDir @@ -43,6 +43,10 @@ ArchitecturesAllowed=x64compatible MinVersion=10.0.17763 UninstallDisplayIcon={app}\{#MyAppExeName} DisableReadyPage=yes +; Always Program Files: ThreadPilot runs elevated, and only there can a non-admin program not replace it. +; Installs in another folder move here on upgrade (see MovePreviousInstall below). +DisableDirPage=yes +UsePreviousAppDir=no [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -81,6 +85,10 @@ Type: filesandordirs; Name: "{userappdata}\ThreadPilot" const LegacyBetaUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A2A4C8B5-4A9A-4B1B-93F4-5F8B1C7E8C2A}_is1'; LegacyBetaDisplayName = 'ThreadPilot 0.1.0-beta'; + CurrentUninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{E8F7A3B2-5C4D-4E6F-8A9B-1C2D3E4F5A6B}_is1'; + +var + PreviousInstallDir: string; function IsLegacyThreadPilotInstallPath(InstallLocation: string): Boolean; var @@ -110,9 +118,72 @@ function InitializeSetup(): Boolean; begin DeleteLegacyBetaUninstallEntry(HKLM); DeleteLegacyBetaUninstallEntry(HKCU); + if not RegQueryStringValue(HKLM, CurrentUninstallKey, 'InstallLocation', PreviousInstallDir) then + PreviousInstallDir := ''; Result := True; end; +function GetFileAttributes(lpFileName: string): Cardinal; + external 'GetFileAttributesW@kernel32.dll stdcall'; + +// True when the folder or any folder above it is a junction/symlink (or unreadable). +function HasReparsePointOnPath(Dir: string): Boolean; +var + Attributes: Cardinal; + Parent: string; +begin + Result := True; + while True do + begin + Attributes := GetFileAttributes(Dir); + if (Attributes = $FFFFFFFF) or ((Attributes and $400) <> 0) then + exit; + Parent := ExtractFileDir(Dir); + if (Parent = '') or (Length(Parent) >= Length(Dir)) then + break; + Dir := Parent; + end; + Result := False; +end; + +// An earlier install in another folder: remove only the program files ThreadPilot put there. +// The old uninstaller is not run because it also deletes the user's settings in AppData. +procedure MovePreviousInstall(); +var + OldDir: string; +begin + OldDir := RemoveBackslashUnlessRoot(RemoveQuotes(PreviousInstallDir)); + if (OldDir = '') or (CompareText(OldDir, RemoveBackslashUnlessRoot(ExpandConstant('{app}'))) = 0) or + not FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then + exit; + + // The old folder may be user-writable: if it was swapped for a junction, admin deletes would land elsewhere. + if HasReparsePointOnPath(OldDir) then + exit; + + // A copy that is still closing (in-app update) holds the exe for a moment. + DelayDeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}', 40); + if FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then + exit; + + DeleteFile(AddBackslash(OldDir) + 'ThreadPilot.pdb'); + DeleteFile(AddBackslash(OldDir) + 'D3DCompiler_47_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'PenImc_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'PresentationNative_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'vcruntime140_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'wpfgfx_cor3.dll'); + DeleteFile(AddBackslash(OldDir) + 'unins000.exe'); + DeleteFile(AddBackslash(OldDir) + 'unins000.dat'); + DeleteFile(AddBackslash(OldDir) + 'unins000.msg'); + RemoveDir(OldDir); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + MovePreviousInstall(); +end; + function IsThreadPilotUpdate(): Boolean; begin Result := CompareText(ExpandConstant('{param:THREADPILOTUPDATE|0}'), '1') = 0; diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 88f5d72..9d7d6ed 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -500,6 +500,10 @@ ThreadPilot-Konfiguration exportieren ThreadPilot-Konfiguration importieren Der Autostart von Windows konnte nicht aktualisiert werden. Den vorherigen Autostart-Status beibehalten. + Der Autostart erfordert eine Installation von ThreadPilot unter „Programme“: Eine portable Kopie könnte von jedem Programm ersetzt und dann als Administrator gestartet werden. + Es wurde kein offizielles Installationsprogramm dieser oder einer neueren Version gefunden. Bei einer Vorabversion aktivieren Sie „Fügen Sie Vorabversionsaktualisierungen hinzu“ und speichern Sie erneut. + ThreadPilot für den Autostart installieren + Der Autostart erfordert ThreadPilot unter „Programme“. Jetzt die offizielle Version herunterladen, prüfen und installieren? ThreadPilot startet danach aus „Programme“ mit aktivem Autostart neu; diesen portablen Ordner kannst du anschließend löschen. ThreadPilot Einstellungen @@ -559,11 +563,11 @@ Ein Aus Game Boost - Während ein Spiel läuft, setzt Game Boost die Standard-CPU-Sets des Spiels und ausgewählter Hintergrund-Apps, damit sie auf getrennten Kernen laufen. Windows plant weiterhin jeden Thread darin, und beim Beenden des Spiels wird alles wiederhergestellt. + Während ein Spiel läuft, setzt Game Boost die Standard-CPU-Sets des Spiels und ausgewählter Hintergrund-Apps, damit sie auf getrennten Kernen laufen, und bittet Windows, das Spiel nicht energiesparend zu drosseln. Windows plant weiterhin jeden Thread: Hält das Spiel alle seine Kerne ausgelastet, darf es alle Kerne nutzen, bis die Last sinkt. Beim Beenden des Spiels wird alles wiederhergestellt. Modus Automatisch Isolation - Isolation verschiebt zusätzlich weitere Neben-Apps (Launcher-Helfer, Mediaplayer, Office-Apps) und hält das Spiel auf CPUs mit zwei gleichen CCDs auf CCD0. + Isolation verschiebt zusätzlich weitere Neben-Apps (Launcher-Helfer, Mediaplayer, Office-Apps), versetzt die ausgewählten Hintergrund-Apps in den Effizienzmodus (EcoQoS) und hält das Spiel auf CPUs mit zwei gleichen CCDs auf CCD0. Spielpriorität auf Höher als normal setzen Experimentell: in deinen Spielen messen. Niemals Hoch oder Echtzeit. Spiele und Apps @@ -576,7 +580,7 @@ Spiel auf CCD0 ({0} CPUs), Hintergrund-Apps auf dem anderen CCD ({1} CPUs). Windows platziert das Spiel; ausgewählte Hintergrund-Apps verlassen CCD0 ({0} CPUs). Isolation hält zusätzlich das Spiel auf CCD0. Spiel auf den P-Kernen ({0} CPUs), ausgewählte Hintergrund-Apps auf den E-Kernen ({1} CPUs). - Auf dieser CPU entscheidet Windows über die Kerne; Game Boost setzt nur die optionale Priorität. + Auf dieser CPU entscheidet Windows über die Kerne; Game Boost bittet Windows, das Spiel nicht zu drosseln, setzt die optionale Priorität und in Isolation den Effizienzmodus für Hintergrund-Apps. Von Windows verwaltet Windows-Einstellungen konnten nicht geöffnet werden Einstellungen für {0} geöffnet diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 8fca21c..22cb72e 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -500,6 +500,10 @@ Export ThreadPilot Configuration Import ThreadPilot Configuration Failed to update Windows autostart. Keeping previous autostart state. + Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator. + No official installer of this version or newer was found. For a preview version, turn on "Include prerelease updates" and save again. + Install ThreadPilot for autostart + Autostart needs ThreadPilot installed in Program Files. Download, verify and install the official version now? ThreadPilot restarts from Program Files with autostart on; you can delete this portable folder afterwards. ThreadPilot Settings @@ -559,11 +563,11 @@ On Off Game Boost - While a game runs, sets the default CPU Sets of the game and of selected background apps so they run on separate cores. Windows still schedules every thread inside them, and everything is restored when the game closes. + While a game runs, sets the default CPU Sets of the game and of selected background apps so they run on separate cores, and asks Windows not to power-throttle the game. Windows still schedules every thread: if the game keeps all its cores busy, Game Boost lets it use every core until the load drops. Everything is restored when the game closes. Mode Automatic Isolation - Isolation also moves more secondary apps (launcher helpers, media players, office apps) and, on CPUs with two equal CCDs, keeps the game on CCD0. + Isolation also moves more secondary apps (launcher helpers, media players, office apps), puts the selected background apps in efficiency mode (EcoQoS) and, on CPUs with two equal CCDs, keeps the game on CCD0. Raise game priority to Above Normal Experimental: measure it on your games. Never High or Realtime. Games and apps @@ -576,7 +580,7 @@ Game on CCD0 ({0} CPUs), background apps on the other CCD ({1} CPUs). Windows places the game; selected background apps move off CCD0 ({0} CPUs). Isolation also keeps the game on CCD0. Game on the P-cores ({0} CPUs), selected background apps on the E-cores ({1} CPUs). - On this CPU Windows decides the cores; Game Boost only applies the optional priority. + On this CPU Windows decides the cores; Game Boost asks Windows not to throttle the game, plus the optional priority and, in Isolation, efficiency mode for background apps. Managed by Windows Could not open Windows settings Opened settings for {0} diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 1087a59..f199574 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -500,6 +500,10 @@ Exportar configuración ThreadPilot Importar configuración ThreadPilot No se pudo actualizar el inicio automático Windows. Manteniendo el estado de inicio automático anterior. + El inicio automático requiere ThreadPilot instalado en Archivos de programa: cualquier programa podría reemplazar una copia portable y ejecutarla como administrador. + No se encontró ningún instalador oficial de esta versión o de una más reciente. Si es una versión preliminar, activa «Incluir actualizaciones preliminares» y guarda de nuevo. + Instalar ThreadPilot para el inicio automático + El inicio automático requiere ThreadPilot instalado en Archivos de programa. ¿Descargar, verificar e instalar ahora la versión oficial? ThreadPilot se reiniciará desde Archivos de programa con el inicio automático activado; después podrás eliminar esta carpeta portable. ThreadPilot Configuración @@ -559,11 +563,11 @@ Activado Desactivado Game Boost - Mientras se ejecuta un juego, establece los CPU Sets predeterminados del juego y de las aplicaciones en segundo plano seleccionadas para que usen núcleos separados. Windows sigue planificando cada hilo dentro de ellos y todo se restaura al cerrar el juego. + Mientras se ejecuta un juego, establece los CPU Sets predeterminados del juego y de las aplicaciones en segundo plano seleccionadas para que usen núcleos separados, y pide a Windows que no limite el juego para ahorrar energía. Windows sigue planificando cada hilo: si el juego mantiene ocupados todos sus núcleos, Game Boost le deja usar todos los núcleos hasta que baje la carga. Todo se restaura al cerrar el juego. Modo Automático Aislamiento - Aislamiento también mueve otras aplicaciones secundarias (ayudantes de lanzadores, reproductores multimedia, aplicaciones de oficina) y, en CPU con dos CCD iguales, mantiene el juego en el CCD0. + Aislamiento también mueve otras aplicaciones secundarias (ayudantes de lanzadores, reproductores multimedia, aplicaciones de oficina), pone las aplicaciones en segundo plano seleccionadas en modo de eficiencia (EcoQoS) y, en CPU con dos CCD iguales, mantiene el juego en el CCD0. Subir la prioridad del juego a Por encima de lo normal Experimental: mídelo en tus juegos. Nunca Alta ni Tiempo real. Juegos y aplicaciones @@ -576,7 +580,7 @@ Juego en el CCD0 ({0} CPU), aplicaciones en segundo plano en el otro CCD ({1} CPU). Windows coloca el juego; las aplicaciones en segundo plano seleccionadas salen del CCD0 ({0} CPU). Aislamiento también mantiene el juego en el CCD0. Juego en los núcleos P ({0} CPU), aplicaciones en segundo plano seleccionadas en los núcleos E ({1} CPU). - En esta CPU Windows decide los núcleos; Game Boost solo aplica la prioridad opcional. + En esta CPU Windows decide los núcleos; Game Boost pide a Windows que no limite el juego, además de la prioridad opcional y, en Aislamiento, el modo de eficiencia para las aplicaciones en segundo plano. Administrado por Windows No se pudo abrir la configuración de Windows Configuración abierta para {0} diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 6bc06a7..ea3d136 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -500,6 +500,10 @@ Exporter la configuration ThreadPilot Importer la configuration ThreadPilot Échec de la mise à jour du démarrage automatique Windows. Conserver l'état de démarrage automatique précédent. + Le démarrage automatique nécessite ThreadPilot installé dans Program Files : n'importe quel programme pourrait remplacer une copie portable, qui serait ensuite lancée en administrateur. + Aucun programme d'installation officiel de cette version ou d'une version plus récente n'a été trouvé. Pour une préversion, activez « Inclure les mises à jour préliminaires » et enregistrez à nouveau. + Installer ThreadPilot pour le démarrage automatique + Le démarrage automatique nécessite ThreadPilot installé dans Program Files. Télécharger, vérifier et installer la version officielle maintenant ? ThreadPilot redémarrera depuis Program Files avec le démarrage automatique activé ; vous pourrez ensuite supprimer ce dossier portable. ThreadPilot Paramètres @@ -559,11 +563,11 @@ Activé Désactivé Game Boost - Pendant qu'un jeu tourne, définit les CPU Sets par défaut du jeu et des applications d'arrière-plan sélectionnées pour qu'ils s'exécutent sur des cœurs séparés. Windows planifie toujours chaque thread à l'intérieur, et tout est restauré à la fermeture du jeu. + Pendant qu'un jeu tourne, définit les CPU Sets par défaut du jeu et des applications d'arrière-plan sélectionnées pour qu'ils s'exécutent sur des cœurs séparés, et demande à Windows de ne pas brider le jeu pour économiser l'énergie. Windows planifie toujours chaque thread : si le jeu occupe tous ses cœurs, Game Boost le laisse utiliser tous les cœurs jusqu'à ce que la charge baisse. Tout est restauré à la fermeture du jeu. Mode Automatique Isolation - L'isolation déplace aussi d'autres applications secondaires (assistants de lanceurs, lecteurs multimédias, applications bureautiques) et, sur les CPU à deux CCD identiques, garde le jeu sur le CCD0. + L'isolation déplace aussi d'autres applications secondaires (assistants de lanceurs, lecteurs multimédias, applications bureautiques), met les applications d'arrière-plan sélectionnées en mode efficacité (EcoQoS) et, sur les CPU à deux CCD identiques, garde le jeu sur le CCD0. Passer la priorité du jeu à Supérieure à la normale Expérimental : mesurez-le sur vos jeux. Jamais Haute ni Temps réel. Jeux et applications @@ -576,7 +580,7 @@ Jeu sur le CCD0 ({0} CPU), applications d'arrière-plan sur l'autre CCD ({1} CPU). Windows place le jeu ; les applications d'arrière-plan sélectionnées quittent le CCD0 ({0} CPU). L'isolation garde aussi le jeu sur le CCD0. Jeu sur les cœurs P ({0} CPU), applications d'arrière-plan sélectionnées sur les cœurs E ({1} CPU). - Sur ce CPU, Windows choisit les cœurs ; Game Boost applique seulement la priorité facultative. + Sur ce CPU, Windows choisit les cœurs ; Game Boost demande à Windows de ne pas brider le jeu, applique la priorité facultative et, en isolation, le mode efficacité des applications d'arrière-plan. Géré par Windows Impossible d'ouvrir les paramètres Windows Paramètres ouverts pour {0} diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index edd0868..8496980 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -500,6 +500,10 @@ Esporta ThreadPilot Configurazione Importa ThreadPilot Configurazione Impossibile aggiornare l'avvio automatico Windows. Mantenimento dello stato di avvio automatico precedente. + L'avvio automatico richiede ThreadPilot installato in Programmi: una copia portable potrebbe essere sostituita da qualsiasi programma e poi avviata come amministratore. + Non è stato trovato un installer ufficiale di questa versione o di una più recente. Per una versione di anteprima, attiva «Includere aggiornamenti preliminari» e salva di nuovo. + Installa ThreadPilot per l'avvio automatico + L'avvio automatico richiede ThreadPilot installato in Programmi. Scaricare, verificare e installare ora la versione ufficiale? ThreadPilot si riavvierà da Programmi con l'avvio automatico attivo; poi potrai eliminare questa cartella portable. ThreadPilot Impostazioni @@ -559,11 +563,11 @@ Attivato Disattivato Game Boost - Mentre un gioco è in esecuzione, imposta i CPU Set predefiniti del gioco e delle app in background selezionate, così girano su core separati. Windows continua a gestire ogni thread al loro interno, e tutto viene ripristinato quando il gioco si chiude. + Mentre un gioco è in esecuzione, imposta i CPU Set predefiniti del gioco e delle app in background selezionate, così girano su core separati, e chiede a Windows di non limitare il gioco per risparmio energetico. Windows continua a gestire ogni thread: se il gioco tiene occupati tutti i suoi core, Game Boost gli lascia usare tutti i core finché il carico non scende. Tutto viene ripristinato quando il gioco si chiude. Modalità Automatico Isolamento - Isolamento sposta anche altre app secondarie (helper dei launcher, lettori multimediali, app per ufficio) e, sulle CPU con due CCD uguali, tiene il gioco sul CCD0. + Isolamento sposta anche altre app secondarie (helper dei launcher, lettori multimediali, app per ufficio), mette le app in background selezionate in modalità efficienza (EcoQoS) e, sulle CPU con due CCD uguali, tiene il gioco sul CCD0. Alza la priorità del gioco a Superiore al normale Sperimentale: misuralo sui tuoi giochi. Mai Alta o Tempo reale. Giochi e app @@ -576,7 +580,7 @@ Gioco sul CCD0 ({0} CPU), app in background sull'altro CCD ({1} CPU). Il gioco lo posiziona Windows; le app in background selezionate lasciano il CCD0 ({0} CPU). Isolamento tiene anche il gioco sul CCD0. Gioco sui P-core ({0} CPU), app in background selezionate sugli E-core ({1} CPU). - Su questa CPU i core li decide Windows; Game Boost applica solo la priorità facoltativa. + Su questa CPU i core li decide Windows; Game Boost chiede a Windows di non limitare il gioco, più la priorità facoltativa e, in Isolamento, la modalità efficienza per le app in background. Gestito da Windows Impossibile aprire le impostazioni di Windows Impostazioni aperte per {0} diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index e9f4bbd..12a2591 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -500,6 +500,10 @@ Экспорт конфигурации ThreadPilot Импорт конфигурации ThreadPilot Не удалось обновить автозапуск Windows. Сохранение предыдущего состояния автозапуска. + Для автозапуска ThreadPilot должен быть установлен в Program Files: портативную копию может подменить любая программа, и она запустится от имени администратора. + Официальный установщик этой или более новой версии не найден. Для предварительной версии включите «Включить предварительные обновления» и сохраните снова. + Установить ThreadPilot для автозапуска + Для автозапуска ThreadPilot должен быть установлен в Program Files. Скачать, проверить и установить официальную версию сейчас? ThreadPilot перезапустится из Program Files с включённым автозапуском; после этого портативную папку можно удалить. ThreadPilot Настройки @@ -559,11 +563,11 @@ Вкл. Откл. Game Boost - Пока запущена игра, задаёт наборы ЦП (CPU Sets) по умолчанию для игры и выбранных фоновых приложений, чтобы они работали на разных ядрах. Windows по-прежнему распределяет каждый поток внутри них, а после закрытия игры всё восстанавливается. + Пока запущена игра, задаёт наборы ЦП (CPU Sets) по умолчанию для игры и выбранных фоновых приложений, чтобы они работали на разных ядрах, и просит Windows не ограничивать игру ради энергосбережения. Windows по-прежнему распределяет каждый поток: если игра загружает все свои ядра, Game Boost разрешает ей использовать все ядра, пока нагрузка не снизится. После закрытия игры всё восстанавливается. Режим Автоматически Изоляция - Изоляция также переносит другие второстепенные приложения (помощники лаунчеров, медиаплееры, офисные приложения), а на ЦП с двумя одинаковыми CCD оставляет игру на CCD0. + Изоляция также переносит другие второстепенные приложения (помощники лаунчеров, медиаплееры, офисные приложения), включает для выбранных фоновых приложений режим эффективности (EcoQoS), а на ЦП с двумя одинаковыми CCD оставляет игру на CCD0. Повысить приоритет игры до «Выше среднего» Экспериментально: проверьте на своих играх. Никогда «Высокий» или «Реального времени». Игры и приложения @@ -576,7 +580,7 @@ Игра на CCD0 ({0} ЦП), фоновые приложения на другом CCD ({1} ЦП). Игру размещает Windows; выбранные фоновые приложения уходят с CCD0 ({0} ЦП). Изоляция также оставляет игру на CCD0. Игра на P-ядрах ({0} ЦП), выбранные фоновые приложения на E-ядрах ({1} ЦП). - На этом ЦП ядра выбирает Windows; Game Boost применяет только необязательный приоритет. + На этом ЦП ядра выбирает Windows; Game Boost просит Windows не ограничивать игру, применяет необязательный приоритет, а в режиме изоляции — режим эффективности для фоновых приложений. Управляется Windows Не удалось открыть параметры Windows Открыты параметры для {0} diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 91d8719..7e73372 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -500,6 +500,10 @@ 导出 ThreadPilot 配置 导入 ThreadPilot 配置 无法更新 Windows 自启动。将保留之前的自启动状态。 + 自启动需要将 ThreadPilot 安装在 Program Files 中:便携版可能被任何程序替换,并以管理员身份启动。 + 未找到此版本或更新版本的官方安装程序。如果是预览版,请开启“包含预发布版本”后再次保存。 + 安装 ThreadPilot 以启用自启动 + 自启动需要将 ThreadPilot 安装在 Program Files 中。现在下载、验证并安装官方版本吗?ThreadPilot 将从 Program Files 重新启动并启用自启动;之后可以删除此便携版文件夹。 ThreadPilot 设置 @@ -559,11 +563,11 @@ 开 关 游戏加速 - 游戏运行时,为游戏和选定的后台应用设置默认 CPU 集,使它们运行在不同的核心上。Windows 仍在其中调度每个线程,游戏关闭后一切都会恢复。 + 游戏运行时,为游戏和选定的后台应用设置默认 CPU 集,使它们运行在不同的核心上,并请求 Windows 不对游戏进行节能限速。Windows 仍调度每个线程:如果游戏持续占满它的所有核心,游戏加速会让它使用全部核心,直到负载下降。游戏关闭后一切都会恢复。 模式 自动 隔离 - 隔离模式还会移动更多次要应用(启动器辅助进程、媒体播放器、办公应用),并在两个 CCD 相同的 CPU 上让游戏保持在 CCD0。 + 隔离模式还会移动更多次要应用(启动器辅助进程、媒体播放器、办公应用),让选定的后台应用进入效率模式(EcoQoS),并在两个 CCD 相同的 CPU 上让游戏保持在 CCD0。 将游戏优先级提高到“高于正常” 实验性功能:请在你的游戏中实测。绝不使用“高”或“实时”。 游戏和应用 @@ -576,7 +580,7 @@ 游戏在 CCD0 上({0} 个 CPU),后台应用在另一个 CCD 上({1} 个 CPU)。 游戏由 Windows 放置;选定的后台应用移出 CCD0({0} 个 CPU)。隔离模式还会让游戏保持在 CCD0。 游戏在 P 核上({0} 个 CPU),选定的后台应用在 E 核上({1} 个 CPU)。 - 在此 CPU 上由 Windows 决定核心;游戏加速只应用可选的优先级。 + 在此 CPU 上由 Windows 决定核心;游戏加速会请求 Windows 不对游戏限速,并应用可选的优先级,在隔离模式下还会让后台应用进入效率模式。 由 Windows 管理 无法打开 Windows 设置 已打开 {0} 的设置 diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 793bd56..e749691 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -6,7 +6,6 @@ namespace ThreadPilot using System.Linq; using System.Threading; using System.Threading.Tasks; - using System.Timers; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -60,21 +59,6 @@ private void InitializeLoadingOverlay() { this.ApplyUIContentBlur(15); } - - // Start spinner animation if available - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - if (!this.isSilentStartupMode) - { - spinnerAnimation?.Begin(); - } - - // Set a timeout guard for initialization - this.initializationTimeoutTimer = new System.Timers.Timer(15000) - { - AutoReset = false, - }; - this.initializationTimeoutTimer.Elapsed += this.OnInitializationTimeout; - this.initializationTimeoutTimer.Start(); } catch (Exception ex) { @@ -108,7 +92,6 @@ private async Task InitializeApplicationAsync() await this.Dispatcher.InvokeAsync(() => this.UpdateLoadingStatus("Finalizing startup...", "Applying final UI state and startup checks.")); this.LogDebug("Finalizing startup..."); - await Task.Delay(500); // Brief delay to show final status this.CompleteInitializationTask("Finalization"); // All initialization complete @@ -245,8 +228,6 @@ private void HideLoadingOverlay() { System.Diagnostics.Debug.WriteLine("=== Starting HideLoadingOverlay ==="); this.isInitializationComplete = true; - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); if (this.isSilentStartupMode) { @@ -262,11 +243,6 @@ private void HideLoadingOverlay() return; } - // Stop spinner animation - var spinnerAnimation = this.FindResource("SpinnerAnimation") as Storyboard; - spinnerAnimation?.Stop(); - System.Diagnostics.Debug.WriteLine("Spinner animation stopped"); - // Start fade-out animation var fadeOutAnimation = this.FindResource("FadeOutAnimation") as Storyboard; if (fadeOutAnimation != null) @@ -356,17 +332,6 @@ private void ClearUIContentBlur() this.UIContent.Effect = null; } - private void OnInitializationTimeout(object? sender, ElapsedEventArgs e) - { - this.Dispatcher.InvokeAsync(() => - { - if (!this.isInitializationComplete) - { - this.ShowInitializationError(new TimeoutException("Application initialization timed out after 15 seconds")); - } - }); - } - private void ShowInitializationError(Exception ex) { try @@ -561,9 +526,7 @@ private async Task InitializeSettingsAsync() { try { - await this.settingsService.LoadSettingsAsync(); - - // Apply initial settings + // App.OnStartup already loaded the settings and applied the theme before creating this window. var settings = this.settingsService.Settings; var useDarkTheme = settings.HasUserThemePreference ? settings.UseDarkTheme @@ -727,6 +690,9 @@ private void OnDashboardRequested(object? sender, EventArgs e) this.ShowWindowFromTray("Process"); } + // An update must not stop at the unsaved-settings prompt: the installer is already running. + internal Task ShutdownForUpdateAsync() => this.PerformGracefulShutdownAsync(validateUnsavedChanges: false); + private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = true) { if (this.isPerformingShutdown) @@ -746,7 +712,7 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr this.LogDebug("Starting graceful shutdown..."); this.selfResourceManagementService.RestoreForegroundMode(); - // 1. Stop monitoring services; Game Boost first restores what it changed + // 1. Game Boost restores what it changed try { await this.serviceProvider.GetRequiredService().StopAsync(); @@ -756,18 +722,7 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr this.LogDebug($"Error stopping Game Boost: {ex.Message}"); } - try - { - this.LogDebug("Stopping process monitoring manager..."); - await this.processMonitorManagerService.StopAsync(); - this.LogDebug("Process monitoring manager stopped"); - } - catch (Exception ex) - { - this.LogDebug($"Error stopping process monitoring: {ex.Message}"); - } - - // 2. Cleanup applied CPU masks (like CPU Set Setter's ClearAllProcessMasksNoSave) + // 2. Reset tracked masks/priorities before the monitor stops: stopping it untracks every process if (this.settingsService.Settings.ClearMasksOnClose) { try @@ -796,6 +751,17 @@ private async Task PerformGracefulShutdownAsync(bool validateUnsavedChanges = tr } } + try + { + this.LogDebug("Stopping process monitoring manager..."); + await this.processMonitorManagerService.StopAsync(); + this.LogDebug("Process monitoring manager stopped"); + } + catch (Exception ex) + { + this.LogDebug($"Error stopping process monitoring: {ex.Message}"); + } + // 3. Restore default power plan if configured if (this.settingsService.Settings.RestoreDefaultPowerPlanOnExit) { @@ -2271,9 +2237,6 @@ protected override void OnClosed(EventArgs e) this.systemTrayUpdateTimer?.Stop(); this.systemTrayUpdateTimer?.Dispose(); - this.initializationTimeoutTimer?.Stop(); - this.initializationTimeoutTimer?.Dispose(); - this.performanceViewModel?.Dispose(); this.settingsViewModel.Dispose(); this.powerPlanViewModel.Dispose(); diff --git a/MainWindow.xaml b/MainWindow.xaml index f9cda54..27b68fb 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -20,13 +20,6 @@ KeyboardNavigation.ControlTabNavigation="Cycle"> - - - - - - + + initializationTasks = new(); private readonly object initializationLock = new(); - private System.Timers.Timer? initializationTimeoutTimer; private readonly string debugLogPath = Path.Combine(Path.GetTempPath(), "ThreadPilot_Debug.log"); // Flag to skip process monitoring during startup if it causes issues @@ -170,11 +169,6 @@ public void ConfigureStartupMode(bool isSilentStartupMode, bool showStartupMinim this.LoadingOverlay.Visibility = Visibility.Collapsed; this.ClearUIContentBlur(); - if (this.FindResource("SpinnerAnimation") is Storyboard spinnerAnimation) - { - spinnerAnimation.Stop(); - } - this.isSystemTrayUpdatesSuspended = true; this.lastAppliedRefreshState = AppActivityState.TrayHidden; this.processViewModel.SetProcessViewActive(false); diff --git a/Properties/PublishProfiles/WinX64-SingleFile.pubxml b/Properties/PublishProfiles/WinX64-SingleFile.pubxml index d21b406..927baea 100644 --- a/Properties/PublishProfiles/WinX64-SingleFile.pubxml +++ b/Properties/PublishProfiles/WinX64-SingleFile.pubxml @@ -1,14 +1,14 @@ - - - FileSystem - Release - net8.0-windows10.0.22000.0 - win-x64 - true - true - false - false - true - artifacts\release\singlefile\ - - + + + FileSystem + Release + net8.0-windows10.0.22000.0 + win-x64 + true + true + false + false + false + artifacts\release\singlefile\ + + diff --git a/README.md b/README.md index 6d702fa..1d60124 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,9 @@ choco upgrade threadpilot ### Download from GitHub Releases [![Download Latest Stable](https://img.shields.io/badge/Download-Latest%20Stable-2ea44f?logo=github)](https://github.com/PrimeBuild-pc/ThreadPilot/releases/latest) -[![Download Pre-release — Game Boost Preview](https://img.shields.io/badge/Download-Pre--release%20%28Game%20Boost%29-f59e0b?logo=github)](https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.8-beta.1) +[![Download Pre-release — Game Boost Preview](https://img.shields.io/badge/Download-Pre--release%20%28Game%20Boost%29-f59e0b?logo=github)](https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.8-beta.2) -**Stable v1.7.7 does not include Game Boost.** The **v1.7.8-beta.1 pre-release** includes it for volunteers and early adopters; see its [test steps and issue-reporting checklist](docs/releases/v1.7.8-beta.1.md). Preview downloads are manual and do not replace the stable update channel. +**Stable v1.7.7 does not include Game Boost.** The **v1.7.8-beta.2 pre-release** includes it for volunteers and early adopters; see its [test steps and issue-reporting checklist](docs/releases/v1.7.8-beta.2.md). Preview downloads are manual and do not replace the stable update channel. Both channels use the same application/settings location. Export a configuration backup before switching; they are not separate side-by-side installations. diff --git a/Services/AdminOnlyDirectory.cs b/Services/AdminOnlyDirectory.cs new file mode 100644 index 0000000..9d39f51 --- /dev/null +++ b/Services/AdminOnlyDirectory.cs @@ -0,0 +1,59 @@ +/* + * ThreadPilot - directories only administrators and SYSTEM can write, for files the elevated app later runs or hands to Windows. + */ +namespace ThreadPilot.Services +{ + using System; + using System.IO; + using System.Security.AccessControl; + using System.Security.Principal; + + public static class AdminOnlyDirectory + { + /// + /// Gets the default parent: in %SystemRoot%\Temp users may create folders but cannot rename or delete + /// another account's, unlike their own %TEMP%, where they could swap a file between check and use. + /// + public static string DefaultRoot { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Temp"); + + /// Creates (which must not exist) with an Administrators + SYSTEM only DACL. + public static string Create(string path) + { + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + Allow(security, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null)); + Allow(security, new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)); + + // Not elevated (tests, dev runs): there is no privilege gap to protect, and without this the + // process could not write its own files. + using var identity = WindowsIdentity.GetCurrent(); + if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + // Owned by the group, not the user: with UAC off or another owner policy the user could otherwise rewrite the DACL. + security.SetOwner(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)); + } + else if (identity.User != null) + { + Allow(security, identity.User); + } + + if (Directory.Exists(path) || File.Exists(path)) + { + throw new IOException($"Refusing to reuse existing path '{path}'."); + } + + security.CreateDirectory(path); + return path; + } + + private static void Allow(DirectorySecurity security, IdentityReference identity) + { + security.AddAccessRule(new FileSystemAccessRule( + identity, + FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, + AccessControlType.Allow)); + } + } +} diff --git a/Services/AutostartService.cs b/Services/AutostartService.cs index db205ce..55b70b3 100644 --- a/Services/AutostartService.cs +++ b/Services/AutostartService.cs @@ -36,6 +36,18 @@ public AutostartService(ILogger logger, IElevationService elev }); } + public bool IsAutostartSupported => ElevatedTaskService.IsProtectedInstallPath(this.GetExecutablePath()); + + public async Task EnsureAutostartAsync(bool startMinimized) + { + if (!this.IsAutostartSupported || await this.CheckAutostartStatusAsync()) + { + return; + } + + await this.EnableAutostartAsync(startMinimized); + } + public async Task EnableAutostartAsync(bool startMinimized = true) { try @@ -151,7 +163,8 @@ public async Task CheckAutostartStatusAsync() var taskRegistered = await this.elevatedTaskService.IsAutostartTaskRegisteredAsync(); var legacyRegistryValue = this.TryReadLegacyRegistryAutostart(); - this.isAutostartEnabled = taskRegistered || !string.IsNullOrWhiteSpace(legacyRegistryValue); + // Only the task starts an admin-only exe at logon; a leftover HKCU\Run entry does not. + this.isAutostartEnabled = taskRegistered; this.autostartPath = taskRegistered ? $"task://{this.elevatedTaskService.AutostartTaskName}" : legacyRegistryValue; @@ -221,7 +234,7 @@ public string GetAutostartArguments(bool startMinimized = true) } } - private string? TryReadLegacyRegistryAutostart() + internal virtual string? TryReadLegacyRegistryAutostart() { try { diff --git a/Services/CpuTopologyService.cs b/Services/CpuTopologyService.cs index 2406135..5d80038 100644 --- a/Services/CpuTopologyService.cs +++ b/Services/CpuTopologyService.cs @@ -149,15 +149,19 @@ private async Task DetectBasicCpuInfoAsync(CpuTopologyModel topology) { try { - using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); - using var collection = searcher.Get(); - - foreach (ManagementObject processor in collection) + // A cold WMI query takes hundreds of ms; this runs during startup, so keep it off the UI thread. + await Task.Run(() => { - topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; - topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; - break; // Take first processor for basic info - } + using var searcher = new ManagementObjectSearcher("SELECT Name, Architecture FROM Win32_Processor"); + using var collection = searcher.Get(); + + foreach (ManagementObject processor in collection) + { + topology.CpuBrand = processor["Name"]?.ToString() ?? "Unknown"; + topology.CpuArchitecture = processor["Architecture"]?.ToString() ?? "Unknown"; + break; // Take first processor for basic info + } + }).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Services/ElevatedTaskService.cs b/Services/ElevatedTaskService.cs index fe9ad83..d6ca71e 100644 --- a/Services/ElevatedTaskService.cs +++ b/Services/ElevatedTaskService.cs @@ -4,10 +4,13 @@ namespace ThreadPilot.Services using System.Collections.Generic; using System.Diagnostics; using System.IO; + using System.Linq; + using System.Net; using System.Reflection; using System.Security; using System.Security.Principal; using System.Text; + using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using ThreadPilot.Services.Abstractions; @@ -21,6 +24,7 @@ public partial class ElevatedTaskService : IElevatedTaskService private readonly IProcessRunner processRunner; private readonly Func? executablePathProvider; private readonly Func? currentUserProvider; + private readonly Func isProtectedInstallPath; public ElevatedTaskService(ILogger logger, IProcessRunner processRunner) : this(logger, processRunner, null, null) @@ -31,30 +35,70 @@ public ElevatedTaskService( ILogger logger, IProcessRunner processRunner, Func? executablePathProvider, - Func? currentUserProvider) + Func? currentUserProvider, + Func? isProtectedInstallPath = null) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); this.executablePathProvider = executablePathProvider; this.currentUserProvider = currentUserProvider; + this.isProtectedInstallPath = isProtectedInstallPath ?? IsProtectedInstallPath; } public string LaunchTaskName => "ThreadPilot_Launch"; public string AutostartTaskName => "ThreadPilot_Startup"; + /// + /// True when the path is inside Program Files. The tasks run with highest privileges and no UAC prompt, + /// so they may only point at an exe that a non-admin user cannot replace. + /// + public static bool IsProtectedInstallPath(string? executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath)) + { + return false; + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(executablePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + + return new[] { Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 } + .Select(Environment.GetFolderPath) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(root => Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar) + .Any(root => fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase)); + } + public async Task EnsureLaunchTaskAsync() { try { var executablePath = this.executablePathProvider?.Invoke() ?? this.GetExecutablePath(); + await this.RemoveTasksOutsideProtectedLocationsAsync(); + if (!IsValidExecutablePath(executablePath)) { LogSkipEnsureLaunchTaskInvalidPath(this.logger, executablePath ?? "(null)"); return false; } - var taskXmlPath = Path.Combine(Path.GetTempPath(), $"threadpilot-launch-task-{Guid.NewGuid():N}.xml"); + if (!this.isProtectedInstallPath(executablePath)) + { + LogLaunchTaskNotRegisteredUnprotectedPath(this.logger, executablePath!); + return false; + } + + // schtasks reads the definition from a file: keep it where only admins can change it before it does. + var taskXmlDirectory = AdminOnlyDirectory.Create(Path.Combine(AdminOnlyDirectory.DefaultRoot, $"ThreadPilot-task-{Guid.NewGuid():N}")); + var taskXmlPath = Path.Combine(taskXmlDirectory, "launch-task.xml"); try { WriteLaunchTaskDefinition( @@ -81,7 +125,7 @@ public async Task EnsureLaunchTaskAsync() } finally { - TryDeleteFile(taskXmlPath, this.logger); + TryDeleteDirectory(taskXmlDirectory, this.logger); } } catch (Exception ex) @@ -127,6 +171,12 @@ public async Task EnsureAutostartTaskAsync(string executablePath, string a return false; } + if (!this.isProtectedInstallPath(executablePath)) + { + LogAutostartTaskNotRegisteredUnprotectedPath(this.logger, executablePath); + return false; + } + var taskRunCommand = BuildCommand(executablePath, arguments); var result = await this.RunSchTasksAsync(new List { @@ -210,6 +260,35 @@ public async Task IsAutostartTaskRegisteredAsync() } } + // Deletes ThreadPilot tasks left pointing at a user-writable exe (portable or dev copies, older versions). + private async Task RemoveTasksOutsideProtectedLocationsAsync() + { + foreach (var taskName in new[] { this.LaunchTaskName, this.AutostartTaskName }) + { + var command = await this.QueryTaskCommandAsync(taskName); + if (command == null || this.isProtectedInstallPath(command)) + { + continue; + } + + var result = await this.RunSchTasksAsync(new List { "/Delete", "/TN", taskName, "/F" }); + LogUnprotectedTaskRemoved(this.logger, taskName, command, result.ExitCode); + } + } + + // schtasks prints the XML in the console code page despite its UTF-16 header, so read it as text. + private async Task QueryTaskCommandAsync(string taskName) + { + var result = await this.RunSchTasksAsync(new List { "/Query", "/TN", taskName, "/XML" }); + if (result.ExitCode != 0) + { + return null; + } + + var match = Regex.Match(result.StandardOutput ?? string.Empty, "(.*?)", RegexOptions.Singleline); + return match.Success ? WebUtility.HtmlDecode(match.Groups[1].Value).Trim().Trim('"') : null; + } + private string? GetExecutablePath() { try @@ -252,7 +331,7 @@ private static string BuildCommand(string executablePath, string arguments) private static bool IsValidExecutablePath(string? executablePath) { - if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathRooted(executablePath)) + if (string.IsNullOrWhiteSpace(executablePath) || !Path.IsPathFullyQualified(executablePath)) { return false; } @@ -339,13 +418,13 @@ private Task RunSchTasksAsync(IReadOnlyList arguments) return this.processRunner.RunAsync(SchTasksExecutablePath, arguments, ScheduledTaskTimeout); } - private static void TryDeleteFile(string path, ILogger logger) + private static void TryDeleteDirectory(string path, ILogger logger) { try { - if (File.Exists(path)) + if (Directory.Exists(path)) { - File.Delete(path); + Directory.Delete(path, recursive: true); } } catch (Exception ex) @@ -410,5 +489,14 @@ private static void TryDeleteFile(string path, ILogger logger) [LoggerMessage(EventId = 4268, Level = LogLevel.Debug, Message = "Failed to delete temporary file '{Path}'")] private static partial void LogDeleteTemporaryFileFailed(ILogger logger, string path, Exception ex); + + [LoggerMessage(EventId = 4269, Level = LogLevel.Information, Message = "Elevated launch task not registered: '{ExecutablePath}' is outside Program Files, so a non-admin user could replace it")] + private static partial void LogLaunchTaskNotRegisteredUnprotectedPath(ILogger logger, string executablePath); + + [LoggerMessage(EventId = 4270, Level = LogLevel.Warning, Message = "Autostart task not registered: '{ExecutablePath}' is outside Program Files, so a non-admin user could replace it")] + private static partial void LogAutostartTaskNotRegisteredUnprotectedPath(ILogger logger, string executablePath); + + [LoggerMessage(EventId = 4271, Level = LogLevel.Warning, Message = "Removed task '{TaskName}' pointing at '{Command}' outside Program Files (schtasks exit code {ExitCode})")] + private static partial void LogUnprotectedTaskRemoved(ILogger logger, string taskName, string command, int exitCode); } } diff --git a/Services/ElevationService.cs b/Services/ElevationService.cs index 83cd81d..398682e 100644 --- a/Services/ElevationService.cs +++ b/Services/ElevationService.cs @@ -5,7 +5,6 @@ namespace ThreadPilot.Services using System.IO; using System.Linq; using System.Security.Principal; - using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -85,18 +84,22 @@ public async Task RestartWithElevation(string[]? arguments = null) // Combine current arguments with any additional arguments var currentArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); var allArgs = arguments != null ? currentArgs.Concat(arguments).ToArray() : currentArgs; - var argumentString = string.Join(" ", allArgs.Select(EscapeCommandLineArgument)); + var argumentString = string.Join(" ", allArgs); var workingDirectory = Path.GetDirectoryName(executablePath); var startInfo = new ProcessStartInfo { FileName = executablePath, - Arguments = argumentString, UseShellExecute = true, Verb = "runas", // This triggers UAC elevation WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.SystemDirectory : workingDirectory, }; + foreach (var argument in allArgs) + { + startInfo.ArgumentList.Add(argument); + } + LogStartingElevatedProcess(this.logger, executablePath, argumentString); var elevatedProcess = Process.Start(startInfo); @@ -157,32 +160,6 @@ public string GetElevationStatus() : "Running with limited privileges"; } - private static string EscapeCommandLineArgument(string argument) - { - if (string.IsNullOrEmpty(argument)) - { - return "\"\""; - } - - var escaped = new StringBuilder(); - escaped.Append('"'); - - foreach (var c in argument) - { - if (c == '"') - { - escaped.Append("\\\""); - } - else - { - escaped.Append(c); - } - } - - escaped.Append('"'); - return escaped.ToString(); - } - [LoggerMessage(EventId = 4100, Level = LogLevel.Debug, Message = "Administrator privilege check: {IsAdmin}")] private static partial void LogAdministratorPrivilegeCheck(ILogger logger, bool isAdmin); diff --git a/Services/EnhancedLoggingService.cs b/Services/EnhancedLoggingService.cs index 4158c1f..206132e 100644 --- a/Services/EnhancedLoggingService.cs +++ b/Services/EnhancedLoggingService.cs @@ -321,7 +321,7 @@ private string FormatLogEntry(LogEntry entry) private async Task CheckLogRotationAsync() { var fileInfo = new FileInfo(this.currentLogFilePath); - var maxSizeBytes = this.settingsService.Settings.MaxLogFileSizeMb * 1024 * 1024; + var maxSizeBytes = Math.Clamp(this.settingsService.Settings.MaxLogFileSizeMb, 1, 1024) * 1024L * 1024L; if (fileInfo.Exists && fileInfo.Length > maxSizeBytes) { @@ -430,7 +430,7 @@ public async Task CleanupOldLogsAsync() await this.fileLock.WaitAsync(); try { - var retentionDate = DateTime.UtcNow.AddDays(-this.settingsService.Settings.LogRetentionDays); + var retentionDate = DateTime.UtcNow.AddDays(-Math.Clamp(this.settingsService.Settings.LogRetentionDays, 1, 365)); var logFiles = Directory.GetFiles(this.logDirectory, "*.log"); foreach (var logFile in logFiles) diff --git a/Services/GameBoostPolicy.cs b/Services/GameBoostPolicy.cs index 562ee35..76e95b8 100644 --- a/Services/GameBoostPolicy.cs +++ b/Services/GameBoostPolicy.cs @@ -181,4 +181,71 @@ private static int CountPhysicalCores(CpuTopologySnapshot topology, IEnumerable< .Distinct() .Count(); } + + public enum GameLoadAction + { + None = 0, + + /// The game keeps its CPU Set busy: drop it so Windows can use every core. + Release, + + /// The load has been low for a while: put the CPU Set back. + Confine, + } + + /// + /// Decides when a confined game's CPU Set gets in the way. Fed one CPU-time sample per tick, it releases + /// the CPU Set after a sustained high load and restores it after a longer low one. The two thresholds and + /// the different lengths are the hysteresis; after the game stays on all cores. + /// + public sealed class GameLoadTracker + { + // shortcut: thresholds are first guesses, not measured; tune them with the beta testers' frame-time data. + public const double ReleaseLoad = 0.8; + public const double ConfineLoad = 0.5; + public const int ReleaseSamples = 4; + public const int ConfineSamples = 20; + public const int MaxReleases = 2; + + private readonly int gameCpuCount; + private int streak; + + public GameLoadTracker(int gameCpuCount) + { + ArgumentOutOfRangeException.ThrowIfLessThan(gameCpuCount, 1); + this.gameCpuCount = gameCpuCount; + } + + public bool Released { get; private set; } + + public int Releases { get; private set; } + + /// Adds one load sample and says whether the game's CPU Set should change now. + /// CPU time the game used over the sample divided by its length (2.0 = two CPUs busy). + public GameLoadAction Observe(double cpusUsed) + { + if (this.Released && this.Releases >= MaxReleases) + { + return GameLoadAction.None; + } + + var load = cpusUsed / this.gameCpuCount; + var pressing = this.Released ? load <= ConfineLoad : load >= ReleaseLoad; + this.streak = pressing ? this.streak + 1 : 0; + if (this.streak < (this.Released ? ConfineSamples : ReleaseSamples)) + { + return GameLoadAction.None; + } + + this.streak = 0; + this.Released = !this.Released; + if (this.Released) + { + this.Releases++; + return GameLoadAction.Release; + } + + return GameLoadAction.Confine; + } + } } diff --git a/Services/GameBoostProcessLists.cs b/Services/GameBoostProcessLists.cs index fe99b04..41808fa 100644 --- a/Services/GameBoostProcessLists.cs +++ b/Services/GameBoostProcessLists.cs @@ -91,6 +91,12 @@ public static bool IsListedBackground(string? processName, string? executablePat || (mode == GameBoostMode.Isolation && IsolationExtraProcesses.Any(entry => Normalize(entry).Equals(name, StringComparison.OrdinalIgnoreCase)))); } + /// Every name can match (path entries by file name): a cheap prefilter, not the decision. + public static HashSet BackgroundNames(GameBoostMode mode, IReadOnlyCollection? userBackground) => + new HashSet( + (userBackground ?? []).Concat(mode == GameBoostMode.Isolation ? IsolationExtraProcesses : []).Select(Normalize).Where(name => name.Length > 0), + StringComparer.OrdinalIgnoreCase); + /// Process name without directory and ".exe", so "C:\x\Game.exe", "Game.exe" and "game" all match. public static string Normalize(string? processName) { diff --git a/Services/GameSessionService.cs b/Services/GameSessionService.cs index 8ad8cbd..bde1940 100644 --- a/Services/GameSessionService.cs +++ b/Services/GameSessionService.cs @@ -5,17 +5,20 @@ namespace ThreadPilot.Services using System.Diagnostics; using System.IO; using System.Linq; + using System.Runtime.InteropServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Win32; + using Microsoft.Win32.SafeHandles; using ThreadPilot.Models; public enum GameBoostSetting { CpuSets = 0, Priority = 1, + EfficiencyMode = 2, } /// @@ -38,6 +41,8 @@ public sealed record GameBoostJournalEntry public ProcessPriorityClass? AppliedPriority { get; init; } + public ProcessEfficiencyMode? AppliedEfficiencyMode { get; init; } + public bool Confirmed { get; init; } } @@ -45,6 +50,21 @@ public interface IGameBoostProcessProbe { /// Start time and Windows session of a running process; false when it is gone or unreadable. bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId); + + /// Total CPU time a running process has used; false when it is gone or unreadable. + bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime); + + /// + /// Holds the process open and calls once when it ends. Null when it cannot be + /// opened (anti-cheat, protected, gone) or the PID now belongs to a process with another start time. + /// + IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited); + } + + /// The game held open for its session: the PID cannot be reused while this is alive. + public interface IGameProcessHandle : IDisposable + { + bool TryGetCpuTime(out TimeSpan totalProcessorTime); } public sealed class GameBoostProcessProbe : IGameBoostProcessProbe @@ -55,7 +75,13 @@ public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int se { using var process = Process.GetProcessById(processId); startTimeUtcTicks = process.StartTime.ToUniversalTime().Ticks; - sessionId = process.SessionId; + + // Process.SessionId would take a snapshot of every process on the machine for this one value. + if (!ProcessIdToSessionId(processId, out sessionId)) + { + return false; + } + return !process.HasExited; } catch @@ -65,6 +91,96 @@ public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int se return false; } } + + public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) + { + try + { + using var process = Process.GetProcessById(processId); + totalProcessorTime = process.TotalProcessorTime; + return true; + } + catch + { + totalProcessorTime = TimeSpan.Zero; + return false; + } + } + + public IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited) + { + // Only the rights anti-cheats leave to outside tools: a full-access handle held for the whole game is + // what they look for, and a protected (PPL) game refuses it. + var wait = new ProcessWaitHandle(OpenProcess(Synchronize | ProcessQueryLimitedInformation, false, processId)); + try + { + // One wait first: a handle stripped of SYNCHRONIZE throws here, not on the runtime's shared wait thread. + // The start time read through the handle proves it is the same process. + if (!wait.SafeWaitHandle.IsInvalid + && !wait.WaitOne(0) + && GetProcessTimes(wait.SafeWaitHandle, out var created, out _, out _, out _) + && DateTime.FromFileTime(created).ToUniversalTime().Ticks == startTimeUtcTicks) + { + var registration = ThreadPool.RegisterWaitForSingleObject(wait, (_, _) => _ = onExited(), null, Timeout.Infinite, executeOnlyOnce: true); + return new GameProcessHandle(wait, registration); + } + } + catch + { + // Rights stripped: the caller polls instead. + } + + wait.Dispose(); + return null; + } + + private const uint Synchronize = 0x00100000; + private const uint ProcessQueryLimitedInformation = 0x1000; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeWaitHandle OpenProcess(uint access, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetProcessTimes(SafeWaitHandle process, out long creationTime, out long exitTime, out long kernelTime, out long userTime); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ProcessIdToSessionId(int processId, out int sessionId); + + private sealed class ProcessWaitHandle : WaitHandle + { + public ProcessWaitHandle(SafeWaitHandle process) => this.SafeWaitHandle = process; + } + + private sealed class GameProcessHandle(ProcessWaitHandle wait, RegisteredWaitHandle registration) : IGameProcessHandle + { + public bool TryGetCpuTime(out TimeSpan totalProcessorTime) + { + try + { + if (GetProcessTimes(wait.SafeWaitHandle, out _, out _, out var kernel, out var user)) + { + totalProcessorTime = TimeSpan.FromTicks(kernel + user); + return true; + } + } + catch (ObjectDisposedException) + { + // Disposed by a concurrent shutdown. + } + + totalProcessorTime = TimeSpan.Zero; + return false; + } + + // The registration keeps its own reference to the handle, so closing it here is safe while a wait ends. + public void Dispose() + { + registration.Unregister(null); + wait.Dispose(); + } + } } public interface IGameSessionService : IDisposable @@ -82,13 +198,16 @@ public interface IGameSessionService : IDisposable } /// - /// Applies the Game Boost policy for one game session at a time: the game's and the listed background - /// processes' default CPU Sets (and optionally Above Normal priority for the game), each applied once and - /// restored when the game exits, Game Boost is turned off or ThreadPilot closes. User rules always win. + /// Applies the Game Boost policy for one game session at a time. Constraints: the game's and the listed + /// background processes' default CPU Sets, released while the game keeps its own cores saturated. Hints: + /// high QoS for the game, EcoQoS for the listed background in Isolation, optionally Above Normal priority. + /// Each change is journaled and restored when the game exits, Game Boost is turned off or ThreadPilot + /// closes. User rules and selections made by anyone else always win. /// public sealed class GameSessionService : IGameSessionService { private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(3); + private static readonly TimeSpan RejectedForegroundMemory = TimeSpan.FromSeconds(30); // Background is rescanned every few ticks (about 15 s), not on every one. private const int BackgroundRescanTicks = 5; @@ -106,18 +225,29 @@ public sealed class GameSessionService : IGameSessionService private readonly ProcessFilterService processFilterService; private readonly ProcessPolicyApplyGate applyGate; private readonly IGameBoostProcessProbe processProbe; + private readonly IProcessMemoryPriorityService efficiencyService; + private readonly TimeProvider timeProvider; private readonly ILogger logger; private readonly IActivityAuditService? activityAuditService; private readonly string journalPath; private readonly SemaphoreSlim stateLock = new(1, 1); - private readonly HashSet<(int ProcessId, long StartTimeUtcTicks)> attempted = new(); + private readonly HashSet<(int ProcessId, long StartTimeUtcTicks, GameBoostSetting Setting)> attempted = new(); + private readonly object timerGate = new(); private List journal = new(); private Timer? timer; + private bool started; + + // Set by StopAsync: a tick already queued when the timer was disposed must not begin a session. + private bool stopped; + private (int ProcessId, long StartTicks, long Timestamp)? rejectedForeground; private GameSession? session; + private IGameProcessHandle? gameHandle; private GameBoostDecision? cachedDecision; private GameBoostMode cachedDecisionMode; private int tickCount; + private GameLoadTracker? loadTracker; + private (TimeSpan CpuTime, long Timestamp)? lastLoadSample; private bool disposed; public GameSessionService( @@ -132,9 +262,11 @@ public GameSessionService( ProcessFilterService processFilterService, ProcessPolicyApplyGate applyGate, IGameBoostProcessProbe processProbe, + IProcessMemoryPriorityService efficiencyService, ILogger logger, string? journalPath = null, - IActivityAuditService? activityAuditService = null) + IActivityAuditService? activityAuditService = null, + TimeProvider? timeProvider = null) { this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.topologyProvider = topologyProvider ?? throw new ArgumentNullException(nameof(topologyProvider)); @@ -147,6 +279,8 @@ public GameSessionService( this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); this.applyGate = applyGate ?? throw new ArgumentNullException(nameof(applyGate)); this.processProbe = processProbe ?? throw new ArgumentNullException(nameof(processProbe)); + this.efficiencyService = efficiencyService ?? throw new ArgumentNullException(nameof(efficiencyService)); + this.timeProvider = timeProvider ?? TimeProvider.System; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.journalPath = journalPath ?? Path.Combine(StoragePaths.AppDataRoot, "game_boost_journal.json"); this.activityAuditService = activityAuditService; @@ -205,7 +339,9 @@ await this.AuditAsync( DescribeRestore(restored, left, failed)).ConfigureAwait(false); } - this.timer ??= new Timer(_ => TaskSafety.FireAndForget(this.TickAsync(), ex => this.logger.LogWarning(ex, "Game Boost tick failed")), null, TickInterval, TickInterval); + this.started = true; + this.stopped = false; + this.SyncTimer(); } finally { @@ -215,8 +351,13 @@ await this.AuditAsync( public async Task StopAsync() { - this.timer?.Dispose(); - this.timer = null; + lock (this.timerGate) + { + this.started = false; + this.stopped = true; + this.SyncTimer(); + } + await this.stateLock.WaitAsync().ConfigureAwait(false); try { @@ -237,8 +378,24 @@ public void Dispose() this.disposed = true; this.settingsService.SettingsChanged -= this.OnSettingsChanged; - this.timer?.Dispose(); - this.timer = null; + this.SyncTimer(); + this.gameHandle?.Dispose(); + } + + // Ticks only while Game Boost is on (it is off by default); turning it off restores through ReapplyAsync. + private void SyncTimer() + { + lock (this.timerGate) + { + if (this.started && !this.disposed && this.Settings.GameBoostEnabled) + { + this.timer ??= new Timer(_ => TaskSafety.FireAndForget(this.TickAsync(), ex => this.logger.LogWarning(ex, "Game Boost tick failed")), null, TickInterval, TickInterval); + return; + } + + this.timer?.Dispose(); + this.timer = null; + } } internal async Task TickAsync() @@ -250,6 +407,11 @@ internal async Task TickAsync() try { + if (this.stopped || this.disposed) + { + return; + } + if (!this.Settings.GameBoostEnabled) { await this.EndSessionAsync("Game Boost turned off").ConfigureAwait(false); @@ -262,12 +424,15 @@ internal async Task TickAsync() return; } - if (!this.IsSameProcessAlive(this.session.Game.ProcessId, this.session.GameStartTimeUtcTicks)) + // A held game reports its exit through OnGameExitedAsync; only a game that denied the handle is polled. + if (this.gameHandle == null && !this.IsSameProcessAlive(this.session.Game.ProcessId, this.session.GameStartTimeUtcTicks)) { await this.EndSessionAsync("the game exited").ConfigureAwait(false); return; } + await this.AdaptGameCpuSetsAsync().ConfigureAwait(false); + if (++this.tickCount % BackgroundRescanTicks == 0) { await this.ApplyToSessionAsync(includeGame: false).ConfigureAwait(false); @@ -287,16 +452,38 @@ private async Task TryBeginSessionAsync() return; } + // The browser or desktop usually stays in front for minutes: building its full process model every + // 3 s only to reject it again costs ~8 process queries and a window enumeration per tick. + // Keyed by start time too: Windows soon reuses the PID of an exited process, possibly for a game. + this.processProbe.TryGetIdentity(processId, out var foregroundStartTicks, out _); + if (this.rejectedForeground is { } rejected + && rejected.ProcessId == processId + && rejected.StartTicks == foregroundStartTicks + && this.timeProvider.GetElapsedTime(rejected.Timestamp) < RejectedForegroundMemory) + { + return; + } + var game = await this.processService.GetProcessByIdAsync(processId).ConfigureAwait(false); - if (game == null - || !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames) - || !this.processProbe.TryGetIdentity(processId, out var startTicks, out var sessionId)) + if (game != null + && !GameBoostProcessLists.IsGame(game.Name, game.ExecutablePath, this.Settings.GameBoostManualGames, this.Settings.GameBoostExcludedGames)) + { + // Remembered only when the path was readable: a game just starting may not expose it yet. + if (!string.IsNullOrEmpty(game.ExecutablePath)) + { + this.rejectedForeground = (processId, foregroundStartTicks, this.timeProvider.GetTimestamp()); + } + + return; + } + + if (game == null || !this.processProbe.TryGetIdentity(processId, out var startTicks, out var sessionId)) { return; } this.session = new GameSession(game, startTicks, sessionId, Path.GetDirectoryName(game.ExecutablePath)); - this.tickCount = 0; + this.ResetSessionState(); this.logger.LogInformation("Game Boost session started for {Game} (PID {ProcessId})", game.Name, game.ProcessId); await this.AuditSessionStartAsync(game).ConfigureAwait(false); await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); @@ -316,6 +503,11 @@ private async Task AuditSessionStartAsync(ProcessModel game) GameBoostTopologyKind.IntelHybrid => $"Intel hybrid: game on {gameCpus} P-core CPUs, listed background on {background} E-core CPUs", _ => "CPU Sets unchanged: this CPU topology is not supported, Windows decides the cores", }; + if (this.Settings.GameBoostMode == GameBoostMode.Isolation) + { + policy += "; listed background in efficiency mode (EcoQoS)"; + } + await this.AuditAsync(ActivityAuditSeverity.Info, $"Session started: {game.Name} ({mode})", policy).ConfigureAwait(false); } @@ -336,26 +528,38 @@ private async Task ApplyToSessionAsync(bool includeGame) if (includeGame) { var game = currentSession.Game; + var startTicks = currentSession.GameStartTimeUtcTicks; if (decision.GameSelection != null) { - var (outcome, reason) = await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + var (outcome, reason) = await this.TryApplyCpuSetsAsync(game, startTicks, decision.GameSelection, rules).ConfigureAwait(false); await this.AuditGameCpuSetsAsync(game.Name, outcome, reason, decision.GameSelection.GlobalLogicalProcessorIndexes.Count).ConfigureAwait(false); + if (outcome == ApplyOutcome.Applied) + { + this.loadTracker = new GameLoadTracker(decision.GameSelection.GlobalLogicalProcessorIndexes.Count); + } } + // A hint, not a constraint: Windows never power-throttles the game, even while it is not in the foreground. + var (qosOutcome, qosReason) = await this.TryApplyEfficiencyModeAsync(game, startTicks, ProcessEfficiencyMode.Off, rules).ConfigureAwait(false); + await this.AuditGameQosAsync(game.Name, qosOutcome, qosReason).ConfigureAwait(false); + if (this.Settings.GameBoostAboveNormalPriority) { - await this.TryApplyAboveNormalAsync(game, currentSession.GameStartTimeUtcTicks, rules).ConfigureAwait(false); + await this.TryApplyAboveNormalAsync(game, startTicks, rules).ConfigureAwait(false); } } - if (decision.BackgroundSelection == null) + var useEcoQos = this.Settings.GameBoostMode == GameBoostMode.Isolation; + if (decision.BackgroundSelection == null && !useEcoQos) { return; } var moved = new List(); + var eco = new List(); var notMoved = new List(); - var processes = await this.processService.GetProcessesWithPathsAsync().ConfigureAwait(false); + var listedNames = GameBoostProcessLists.BackgroundNames(this.Settings.GameBoostMode, this.Settings.GameBoostBackgroundProcesses); + var processes = await this.processService.GetProcessesWithPathsAsync(name => listedNames.Contains(GameBoostProcessLists.Normalize(name))).ConfigureAwait(false); foreach (var process in processes) { if (!this.IsBackgroundCandidate(process, currentSession) @@ -366,22 +570,21 @@ private async Task ApplyToSessionAsync(bool includeGame) continue; } - var (outcome, reason) = await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); - switch (outcome) + if (decision.BackgroundSelection != null) { - case CpuSetOutcome.Applied: - moved.Add(process.Name); - break; - case CpuSetOutcome.UserRule: - notMoved.Add($"{process.Name} (your rule)"); - break; - case CpuSetOutcome.ExistingSelection: - notMoved.Add($"{process.Name} (already has CPU Sets)"); - break; - case CpuSetOutcome.Unreadable: - case CpuSetOutcome.Failed: - notMoved.Add($"{process.Name} ({reason})"); - break; + var (outcome, reason) = await this.TryApplyCpuSetsAsync(process, startTicks, decision.BackgroundSelection, rules).ConfigureAwait(false); + Collect(outcome, reason, "CPU Sets", process.Name, moved, notMoved); + } + + if (useEcoQos) + { + var (outcome, reason) = await this.TryApplyEfficiencyModeAsync(process, startTicks, ProcessEfficiencyMode.On, rules).ConfigureAwait(false); + + // Browsers set their own QoS on new processes all the time: expected, so not logged on every rescan. + if (outcome != ApplyOutcome.ExistingSelection) + { + Collect(outcome, reason, "efficiency mode", process.Name, eco, notMoved); + } } } @@ -390,7 +593,12 @@ private async Task ApplyToSessionAsync(bool includeGame) { await this.AuditAsync( ActivityAuditSeverity.Success, - $"Background moved to {decision.BackgroundSelection.GlobalLogicalProcessorIndexes.Count} CPUs: {Summarize(moved)}").ConfigureAwait(false); + $"Background moved to {decision.BackgroundSelection!.GlobalLogicalProcessorIndexes.Count} CPUs: {Summarize(moved)}").ConfigureAwait(false); + } + + if (eco.Count > 0) + { + await this.AuditAsync(ActivityAuditSeverity.Success, $"Background in efficiency mode: {Summarize(eco)}").ConfigureAwait(false); } if (notMoved.Count > 0) @@ -399,12 +607,161 @@ await this.AuditAsync( } } - private Task AuditGameCpuSetsAsync(string game, CpuSetOutcome outcome, string reason, int cpuCount) => outcome switch + private static void Collect(ApplyOutcome outcome, string reason, string what, string name, List applied, List notApplied) { - CpuSetOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: CPU Sets set to {cpuCount} CPUs"), - CpuSetOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: cores left to your rule"), - CpuSetOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: already has CPU Sets, left unchanged"), - CpuSetOutcome.Unreadable or CpuSetOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: CPU Sets not changed", reason), + switch (outcome) + { + case ApplyOutcome.Applied: + applied.Add(name); + break; + case ApplyOutcome.UserRule: + notApplied.Add($"{name} (your rule)"); + break; + case ApplyOutcome.ExistingSelection: + notApplied.Add($"{name} ({what} already set)"); + break; + case ApplyOutcome.Unreadable: + case ApplyOutcome.Failed: + notApplied.Add($"{name} ({reason})"); + break; + } + } + + private void ResetSessionState() + { + this.tickCount = 0; + this.loadTracker = null; + this.lastLoadSample = null; + this.gameHandle?.Dispose(); + var current = this.session!; + this.gameHandle = this.processProbe.TryOpen(current.Game.ProcessId, current.GameStartTimeUtcTicks, () => this.OnGameExitedAsync(current)); + } + + // Raised on a thread-pool thread by the held game handle, so it takes the same lock as the tick. + private async Task OnGameExitedAsync(GameSession exited) + { + await this.stateLock.WaitAsync().ConfigureAwait(false); + try + { + if (this.session == exited && !this.disposed) + { + await this.EndSessionAsync("the game exited").ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Game Boost could not end the session after the game exited"); + } + finally + { + this.stateLock.Release(); + } + } + + // The dynamic part: a confined game that keeps its cores saturated gets every core back from Windows, + // and is confined again once its load has stayed low for a while (see GameLoadTracker). + private async Task AdaptGameCpuSetsAsync() + { + var currentSession = this.session; + if (currentSession == null || this.loadTracker == null) + { + return; + } + + var game = currentSession.Game; + var now = this.timeProvider.GetTimestamp(); + var read = this.gameHandle != null + ? this.gameHandle.TryGetCpuTime(out var cpuTime) + : this.processProbe.TryGetCpuTime(game.ProcessId, out cpuTime); + if (!read) + { + return; + } + + var previous = this.lastLoadSample; + this.lastLoadSample = (cpuTime, now); + if (previous is not var (previousCpu, previousStamp)) + { + return; + } + + var elapsed = this.timeProvider.GetElapsedTime(previousStamp, now); + if (elapsed <= TimeSpan.Zero) + { + return; + } + + var action = this.loadTracker.Observe((cpuTime - previousCpu) / elapsed); + if (action == GameLoadAction.None) + { + return; + } + + var decision = await this.GetDecisionAsync().ConfigureAwait(false); + var cpuCount = decision.GameSelection?.GlobalLogicalProcessorIndexes.Count ?? 0; + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + using var gate = await this.applyGate.EnterAsync().ConfigureAwait(false); + + if (action == GameLoadAction.Release) + { + var entry = this.journal.FirstOrDefault(e => e.ProcessId == game.ProcessId + && e.StartTimeUtcTicks == currentSession.GameStartTimeUtcTicks + && e.Setting == GameBoostSetting.CpuSets); + var outcome = entry == null ? RestoreOutcome.LeftAlone : await this.RestoreAsync(entry, rules).ConfigureAwait(false); + if (outcome != RestoreOutcome.Failed && entry != null) + { + this.journal.Remove(entry); + await this.SaveJournalAsync().ConfigureAwait(false); + } + + if (outcome != RestoreOutcome.Restored) + { + // Someone else owns the cores now, or the call failed: stop adapting for this session. + this.loadTracker = null; + return; + } + + var final = this.loadTracker.Releases >= GameLoadTracker.MaxReleases; + await this.AuditAsync( + ActivityAuditSeverity.Info, + $"{game.Name}: kept its {cpuCount} CPUs busy, CPU Sets released so Windows can use every core", + final ? "It stays on all cores for the rest of the session" : "They are set again once its load stays low for about a minute").ConfigureAwait(false); + return; + } + + if (decision.GameSelection == null) + { + this.loadTracker = null; + return; + } + + this.attempted.Remove((game.ProcessId, currentSession.GameStartTimeUtcTicks, GameBoostSetting.CpuSets)); + var (applied, reason) = await this.TryApplyCpuSetsAsync(game, currentSession.GameStartTimeUtcTicks, decision.GameSelection, rules).ConfigureAwait(false); + if (applied != ApplyOutcome.Applied) + { + this.loadTracker = null; + await this.AuditGameCpuSetsAsync(game.Name, applied, reason, cpuCount).ConfigureAwait(false); + return; + } + + await this.AuditAsync(ActivityAuditSeverity.Info, $"{game.Name}: load is low again, CPU Sets set back to {cpuCount} CPUs").ConfigureAwait(false); + } + + private Task AuditGameCpuSetsAsync(string game, ApplyOutcome outcome, string reason, int cpuCount) => outcome switch + { + ApplyOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: CPU Sets set to {cpuCount} CPUs"), + ApplyOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: cores left to your rule"), + ApplyOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: already has CPU Sets, left unchanged"), + ApplyOutcome.Unreadable or ApplyOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: CPU Sets not changed", reason), + _ => Task.CompletedTask, + }; + + private Task AuditGameQosAsync(string game, ApplyOutcome outcome, string reason) => outcome switch + { + ApplyOutcome.Applied => this.AuditAsync(ActivityAuditSeverity.Success, $"{game}: high QoS, Windows will not power-throttle it"), + ApplyOutcome.UserRule => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: efficiency mode left to your rule"), + ApplyOutcome.ExistingSelection => this.AuditAsync(ActivityAuditSeverity.Info, $"{game}: efficiency mode already set by the game or you, left unchanged"), + ApplyOutcome.Unreadable or ApplyOutcome.Failed => this.AuditAsync(ActivityAuditSeverity.Warning, $"{game}: QoS not changed", reason), _ => Task.CompletedTask, }; @@ -415,33 +772,33 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess && !this.processFilterService.IsSystemProcess(process) && !IsUnderDirectory(process.ExecutablePath, currentSession.GameDirectory); - private async Task<(CpuSetOutcome Outcome, string Reason)> TryApplyCpuSetsAsync( + private async Task<(ApplyOutcome Outcome, string Reason)> TryApplyCpuSetsAsync( ProcessModel process, long startTicks, CpuSelection selection, IReadOnlyList rules) { // Applied at most once per process: a later manual change is the user's and is never undone or redone. - if (!this.attempted.Add((process.ProcessId, startTicks))) + if (!this.attempted.Add((process.ProcessId, startTicks, GameBoostSetting.CpuSets))) { - return (CpuSetOutcome.AlreadyAttempted, string.Empty); + return (ApplyOutcome.AlreadyAttempted, string.Empty); } if (this.HasUserRule(rules, process, GameBoostSetting.CpuSets)) { - return (CpuSetOutcome.UserRule, string.Empty); + return (ApplyOutcome.UserRule, string.Empty); } // null: unreadable, so unknown; non-empty: someone else's selection. Only an empty set is ours to change. var current = await this.processService.GetCpuAssignmentLogicalProcessorIndexesAsync(process, CpuAssignmentMode.CpuSets).ConfigureAwait(false); if (current == null) { - return (CpuSetOutcome.Unreadable, "CPU Sets unreadable: access denied or protected process"); + return (ApplyOutcome.Unreadable, "CPU Sets unreadable: access denied or protected process"); } if (current.Count > 0) { - return (CpuSetOutcome.ExistingSelection, string.Empty); + return (ApplyOutcome.ExistingSelection, string.Empty); } var entry = new GameBoostJournalEntry @@ -463,7 +820,7 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess process.Name, process.ProcessId, entry.AppliedCpuIndexes.Count); - return (CpuSetOutcome.Applied, string.Empty); + return (ApplyOutcome.Applied, string.Empty); } else { @@ -473,8 +830,58 @@ private bool IsBackgroundCandidate(ProcessModel process, GameSession currentSess process.Name, process.ProcessId, result.TechnicalMessage); - return (CpuSetOutcome.Failed, string.IsNullOrWhiteSpace(result.UserMessage) ? "not applied" : result.UserMessage.TrimEnd('.')); + return (ApplyOutcome.Failed, string.IsNullOrWhiteSpace(result.UserMessage) ? "not applied" : result.UserMessage.TrimEnd('.')); + } + } + + // Same ownership rules as CPU Sets: only a process Windows still manages (no explicit QoS from the app, + // the user or a rule) is changed, once, and restored only if the value is still Game Boost's. + private async Task<(ApplyOutcome Outcome, string Reason)> TryApplyEfficiencyModeAsync( + ProcessModel process, + long startTicks, + ProcessEfficiencyMode mode, + IReadOnlyList rules) + { + if (!this.attempted.Add((process.ProcessId, startTicks, GameBoostSetting.EfficiencyMode))) + { + return (ApplyOutcome.AlreadyAttempted, string.Empty); + } + + if (this.HasUserRule(rules, process, GameBoostSetting.EfficiencyMode)) + { + return (ApplyOutcome.UserRule, string.Empty); } + + var current = await this.efficiencyService.GetEfficiencyModeAsync(process).ConfigureAwait(false); + if (current == null) + { + return (ApplyOutcome.Unreadable, "efficiency mode unreadable: access denied or protected process"); + } + + if (current != ProcessEfficiencyMode.SystemManaged) + { + return (ApplyOutcome.ExistingSelection, string.Empty); + } + + var entry = new GameBoostJournalEntry + { + ProcessId = process.ProcessId, + StartTimeUtcTicks = startTicks, + ProcessName = process.Name, + Setting = GameBoostSetting.EfficiencyMode, + AppliedEfficiencyMode = mode, + }; + await this.AddJournalEntryAsync(entry).ConfigureAwait(false); + + var result = await this.efficiencyService.SetEfficiencyModeAsync(process, mode).ConfigureAwait(false); + if (!result.Success) + { + this.logger.LogInformation("Game Boost could not set efficiency mode {Mode} on {ProcessName} (PID {ProcessId})", mode, process.Name, process.ProcessId); + return (ApplyOutcome.Failed, "efficiency mode not applied"); + } + + await this.ConfirmJournalEntryAsync(entry).ConfigureAwait(false); + return (ApplyOutcome.Applied, string.Empty); } private async Task TryApplyAboveNormalAsync(ProcessModel game, long startTicks, IReadOnlyList rules) @@ -534,6 +941,8 @@ private async Task EndSessionAsync(string reason) } this.session = null; + this.gameHandle?.Dispose(); + this.gameHandle = null; this.attempted.Clear(); var (restored, left, failed) = await this.RestoreAllAsync().ConfigureAwait(false); await this.AuditAsync( @@ -581,7 +990,7 @@ await this.AuditAsync( left++; break; default: - failed.Add($"{entry.ProcessName} {(entry.Setting == GameBoostSetting.CpuSets ? "CPU Sets" : "priority")}"); + failed.Add($"{entry.ProcessName} {entry.Setting switch { GameBoostSetting.CpuSets => "CPU Sets", GameBoostSetting.EfficiencyMode => "efficiency mode", _ => "priority" }}"); break; } @@ -624,6 +1033,18 @@ private async Task RestoreAsync(GameBoostJournalEntry entry, IRe return RestoreOutcome.Restored; } + if (entry.Setting == GameBoostSetting.EfficiencyMode) + { + if (entry.AppliedEfficiencyMode == null + || await this.efficiencyService.GetEfficiencyModeAsync(process).ConfigureAwait(false) != entry.AppliedEfficiencyMode) + { + return RestoreOutcome.LeftAlone; + } + + var result = await this.efficiencyService.SetEfficiencyModeAsync(process, ProcessEfficiencyMode.SystemManaged).ConfigureAwait(false); + return result.Success ? RestoreOutcome.Restored : RestoreOutcome.Failed; + } + if (entry.OriginalPriority is not ProcessPriorityClass original || process.Priority != entry.AppliedPriority) { return RestoreOutcome.LeftAlone; @@ -669,9 +1090,12 @@ private Task AuditAsync(ActivityAuditSeverity severity, string message, string? } private bool HasUserRule(IReadOnlyList rules, ProcessModel process, GameBoostSetting setting) => - rules.Any(rule => this.ruleMatcher.IsMatch(rule, process) && (setting == GameBoostSetting.CpuSets - ? rule.CpuSelection != null || rule.LegacyAffinityMask.HasValue - : rule.Priority.HasValue)); + rules.Any(rule => this.ruleMatcher.IsMatch(rule, process) && setting switch + { + GameBoostSetting.CpuSets => rule.CpuSelection != null || rule.LegacyAffinityMask.HasValue, + GameBoostSetting.EfficiencyMode => rule.EfficiencyMode.HasValue, + _ => rule.Priority.HasValue, + }); private bool IsSameProcessAlive(int processId, long startTicks) => this.processProbe.TryGetIdentity(processId, out var currentStartTicks, out _) && currentStartTicks == startTicks; @@ -693,6 +1117,8 @@ private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventAr || !oldSettings.GameBoostExcludedGames.SequenceEqual(newSettings.GameBoostExcludedGames); if (changed) { + this.rejectedForeground = null; + this.SyncTimer(); TaskSafety.FireAndForget(this.ReapplyAsync(), ex => this.logger.LogWarning(ex, "Game Boost could not apply the new settings")); } } @@ -713,8 +1139,10 @@ internal async Task ReapplyAsync() return; } - this.session = previous; - this.tickCount = 0; + // The restore just changed the live process; the old model still shows what Game Boost had applied. + var fresh = await this.processService.GetProcessByIdAsync(previous.Game.ProcessId).ConfigureAwait(false); + this.session = fresh == null ? previous : previous with { Game = fresh }; + this.ResetSessionState(); await this.AuditSessionStartAsync(previous.Game).ConfigureAwait(false); await this.ApplyToSessionAsync(includeGame: true).ConfigureAwait(false); } @@ -793,7 +1221,7 @@ private static void LogAmdVCacheDriver(ILogger logger) } } - private enum CpuSetOutcome + private enum ApplyOutcome { AlreadyAttempted, Applied, diff --git a/Services/IAutostartService.cs b/Services/IAutostartService.cs index 3aad691..3004fe7 100644 --- a/Services/IAutostartService.cs +++ b/Services/IAutostartService.cs @@ -11,6 +11,9 @@ public interface IAutostartService string? AutostartPath { get; } + /// Gets a value indicating whether this copy can autostart: only an install in Program Files can. + bool IsAutostartSupported { get; } + Task EnableAutostartAsync(bool startMinimized = true); Task DisableAutostartAsync(); @@ -19,6 +22,9 @@ public interface IAutostartService Task UpdateAutostartAsync(bool startMinimized = true); + /// Turns autostart on when the user asked for it but this copy has no task yet (e.g. just installed). + Task EnsureAutostartAsync(bool startMinimized); + string GetAutostartArguments(bool startMinimized = true); } diff --git a/Services/IProcessService.cs b/Services/IProcessService.cs index 47a5754..c025780 100644 --- a/Services/IProcessService.cs +++ b/Services/IProcessService.cs @@ -1,5 +1,6 @@ namespace ThreadPilot.Services { + using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; @@ -34,7 +35,8 @@ public interface IProcessService Task IsProcessRunningAsync(string executableName); - Task> GetProcessesWithPathsAsync(); + /// Models only for processes whose snapshot name (no ".exe") passes , so the rest cost no queries. + Task> GetProcessesWithPathsAsync(Func nameFilter); Task> GetActiveApplicationsAsync(); diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index f275ea3..228936e 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -26,6 +26,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private const string PriorityNotObservedErrorCode = "PriorityNotObserved"; private const string PriorityVerificationUnavailableErrorCode = "PriorityVerificationUnavailable"; private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; + private const string ProtectedProcessErrorCode = "PersistentRuleProtectedProcess"; private readonly IPersistentProcessRuleStore ruleStore; private readonly IPersistentProcessRuleMatcher matcher; @@ -34,6 +35,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private readonly IProcessMemoryPriorityService memoryPriorityService; private readonly ProcessPowerRequestService? powerRequestService; private readonly ProcessPolicyApplyGate? applyGate; + private readonly ISecurityService? securityService; private readonly ILogger logger; public PersistentRulesEngine( @@ -44,7 +46,8 @@ public PersistentRulesEngine( IProcessMemoryPriorityService memoryPriorityService, ILogger logger, ProcessPowerRequestService? powerRequestService = null, - ProcessPolicyApplyGate? applyGate = null) + ProcessPolicyApplyGate? applyGate = null, + ISecurityService? securityService = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -54,6 +57,7 @@ public PersistentRulesEngine( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.powerRequestService = powerRequestService; this.applyGate = applyGate; + this.securityService = securityService; } public async Task> ApplyMatchingRulesAsync( @@ -99,6 +103,18 @@ private async Task ApplyRuleAsync( "This saved rule has no actions to apply."); } + if (this.IsProtectedProcess(process)) + { + const string message = "Saved rules never change protected system processes."; + return result with + { + Success = false, + ErrorCode = ProtectedProcessErrorCode, + UserMessage = message, + TechnicalMessage = $"Persistent rule '{rule.Name}' ({rule.Id}) skipped for protected process {process.Name} ({process.ProcessId}).", + }; + } + if (rule.ApplyAffinityOnStart) { if (rule.CpuSelection == null && !rule.LegacyAffinityMask.HasValue) @@ -391,6 +407,26 @@ private Task ApplyAffinityAsync(PersistentProcessRule rule, return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); } + // Rules come from a file any program of the user can edit, so the elevated engine checks the live + // process (critical-process list + PPL) itself instead of relying on each setter to do it. + private bool IsProtectedProcess(ProcessModel process) + { + if (this.securityService == null) + { + return false; + } + + try + { + using var liveProcess = Process.GetProcessById(process.ProcessId); + return this.securityService.IsProtected(liveProcess); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; // Exited: the apply calls below report it. + } + } + private static PersistentRuleApplyResult MarkRuleConfigurationFailure( PersistentRuleApplyResult result, PersistentProcessRule rule, diff --git a/Services/PowerPlanService.cs b/Services/PowerPlanService.cs index bb5c071..5327f47 100644 --- a/Services/PowerPlanService.cs +++ b/Services/PowerPlanService.cs @@ -172,8 +172,11 @@ public async Task SetActivePowerPlanByGuidAsync(string powerPlanGuid, bool } } - var previousPowerPlan = await this.GetActivePowerPlan().ConfigureAwait(false); - var targetPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + // One "/list" names both plans and marks the active one: no separate /getactivescheme, and no + // second /list after the switch (each call starts powercfg.exe). + var plans = await this.GetPowerPlansAsync().ConfigureAwait(false); + var previousPowerPlan = plans.FirstOrDefault(plan => plan.IsActive); + var targetPowerPlan = plans.FirstOrDefault(plan => string.Equals(plan.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)); this.logger.LogInformation( "Attempting to change power plan from '{FromPlan}' to '{ToPlan}'", @@ -194,7 +197,9 @@ await this.enhancedLogger.LogPowerPlanChangeAsync( this.lastActivePowerPlanGuid = powerPlanGuid; } - var newPowerPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + var newPowerPlan = targetPowerPlan == null + ? null + : new PowerPlanModel { Guid = targetPowerPlan.Guid, Name = targetPowerPlan.Name, IsActive = true }; this.logger.LogInformation("Power plan successfully changed to '{PowerPlan}'", newPowerPlan?.Name ?? "Unknown"); diff --git a/Services/ProcessListDeltaUpdater.cs b/Services/ProcessListDeltaUpdater.cs index 2523b2c..49bc3ff 100644 --- a/Services/ProcessListDeltaUpdater.cs +++ b/Services/ProcessListDeltaUpdater.cs @@ -64,6 +64,57 @@ public static ProcessListDeltaResult ApplyDelta( return new ProcessListDeltaResult(selectedProcess, selectedProcessTerminated); } + /// + /// Makes hold exactly , in order, by removing, moving + /// and inserting items instead of replacing the collection: the grid keeps its rows, selection and the + /// sort the user picked from a column header. + /// + public static void SyncInPlace(ObservableCollection target, IReadOnlyList desired) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(desired); + + var wanted = new HashSet(desired, ReferenceEqualityComparer.Instance); + for (var i = target.Count - 1; i >= 0; i--) + { + if (!wanted.Contains(target[i])) + { + target.RemoveAt(i); + } + } + + for (var i = 0; i < desired.Count; i++) + { + if (i < target.Count && ReferenceEquals(target[i], desired[i])) + { + continue; + } + + var current = IndexOf(target, desired[i], i + 1); + if (current >= 0) + { + target.Move(current, i); + } + else + { + target.Insert(i, desired[i]); + } + } + } + + private static int IndexOf(ObservableCollection items, ProcessModel item, int start) + { + for (var i = start; i < items.Count; i++) + { + if (ReferenceEquals(items[i], item)) + { + return i; + } + } + + return -1; + } + private static void CopyProcessState(ProcessModel source, ProcessModel target) { target.Name = source.Name; diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index 5a922ba..38371ce 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -364,7 +364,7 @@ public Task SetEfficiencyModeAsync(ProcessModel process, try { using var handle = this.nativeApi.OpenProcess( - ProcessAccessFlags.PROCESS_SET_INFORMATION, + ProcessAccessFlags.PROCESS_SET_INFORMATION | ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, inheritHandle: false, (uint)process.ProcessId); if (handle.IsInvalid) @@ -372,12 +372,18 @@ public Task SetEfficiencyModeAsync(ProcessModel process, return Task.FromResult(this.FromLastError("OpenProcess failed before SetProcessInformation(ProcessPowerThrottling).", FailureMessage)); } - var state = new PowerThrottlingState + // Windows replaces the whole state on set: keep the process's other throttling flags + // (e.g. its timer-resolution opt-out) and change only the execution-speed bit. + var state = new PowerThrottlingState { Version = PowerThrottlingState.CurrentVersion }; + if (!this.nativeApi.GetPowerThrottling(handle, ref state)) { - Version = PowerThrottlingState.CurrentVersion, - ControlMask = mode == ProcessEfficiencyMode.SystemManaged ? 0 : PowerThrottlingState.ExecutionSpeed, - StateMask = mode == ProcessEfficiencyMode.On ? PowerThrottlingState.ExecutionSpeed : 0, - }; + state = default; + } + + const uint speed = PowerThrottlingState.ExecutionSpeed; + state.Version = PowerThrottlingState.CurrentVersion; + state.ControlMask = mode == ProcessEfficiencyMode.SystemManaged ? state.ControlMask & ~speed : state.ControlMask | speed; + state.StateMask = mode == ProcessEfficiencyMode.On ? state.StateMask | speed : state.StateMask & ~speed; if (!this.nativeApi.SetPowerThrottling(handle, ref state)) { return Task.FromResult(this.FromLastError("SetProcessInformation(ProcessPowerThrottling) failed.", FailureMessage)); diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index 7ee5202..8658a5e 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -114,7 +114,7 @@ public async Task> GetProcessesAsync() new ObservableCollection(this.EnumerateProcessModels())).ConfigureAwait(false); } - private List EnumerateProcessModels(Func? filter = null) + private List EnumerateProcessModels(Func? filter = null, Func? nameFilter = null) { var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var models = new List(); @@ -122,6 +122,12 @@ private List EnumerateProcessModels(Func? filt { using (process) { + // The name is already in the snapshot GetProcesses took, so skipping here costs no query. + if (nameFilter != null && !nameFilter(process.ProcessName)) + { + continue; + } + var model = this.TryCreateProcessModel(process, foregroundProcessId); if (model != null && (filter == null || filter(model))) { @@ -638,6 +644,11 @@ public async Task SaveProcessProfile(string profileName, ProcessModel proc this.cpuSelectionMigrationService.PrepareProcessProfileForSave(profile, topology); } + if (!IsValidProfileName(profileName)) + { + return false; + } + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); @@ -646,6 +657,11 @@ public async Task SaveProcessProfile(string profileName, ProcessModel proc public async Task LoadProcessProfile(string profileName, ProcessModel process) { + if (!IsValidProfileName(profileName)) + { + return false; + } + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); if (!File.Exists(filePath)) { @@ -1027,10 +1043,10 @@ public async Task IsProcessRunningAsync(string executableName) return processes.Any(); } - public async Task> GetProcessesWithPathsAsync() + public async Task> GetProcessesWithPathsAsync(Func nameFilter) { return await Task.Run>(() => - this.EnumerateProcessModels(process => !string.IsNullOrEmpty(process.ExecutablePath))).ConfigureAwait(false); + this.EnumerateProcessModels(process => !string.IsNullOrEmpty(process.ExecutablePath), nameFilter)).ConfigureAwait(false); } public async Task> GetActiveApplicationsAsync() @@ -1079,34 +1095,42 @@ public async Task SetRegistryPriorityAsync(ProcessModel process, bool enab this.EnsureProcessOperationAllowed(process, "SetProcessPriority"); + // Without an image name the key below would be the IFEO root itself. + var imageName = Path.GetFileName(process.ExecutablePath); + if (string.IsNullOrWhiteSpace(imageName)) + { + this.AuditProcessOperation("SetProcessPriority", process.Name, success: false); + return false; + } + return await Task.Run(() => { try { - using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey( - @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + - Path.GetFileName(process.ExecutablePath)); + var imageKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\" + imageName; + using (var legacyKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(imageKeyPath, writable: true)) + { + // Earlier versions wrote this value, which Windows never reads. + legacyKey?.DeleteValue("PriorityClass", false); + } + using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(imageKeyPath + @"\PerfOptions"); if (enable) { - // Convert ProcessPriorityClass to registry priority value int priorityValue = priority switch { - ProcessPriorityClass.Idle => 4, - ProcessPriorityClass.BelowNormal => 6, - ProcessPriorityClass.Normal => 8, - ProcessPriorityClass.AboveNormal => 10, - ProcessPriorityClass.High => 13, - ProcessPriorityClass.RealTime => 24, - _ => 8, // Default to Normal + ProcessPriorityClass.Idle => 1, + ProcessPriorityClass.BelowNormal => 5, + ProcessPriorityClass.AboveNormal => 6, + ProcessPriorityClass.High => 3, + _ => 2, // Normal }; - key.SetValue("PriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); + key.SetValue("CpuPriorityClass", priorityValue, Microsoft.Win32.RegistryValueKind.DWord); } else { - // Remove the registry entry to disable enforcement - key.DeleteValue("PriorityClass", false); + key.DeleteValue("CpuPriorityClass", false); } this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); @@ -1121,6 +1145,13 @@ public async Task SetRegistryPriorityAsync(ProcessModel process, bool enab }).ConfigureAwait(false); } + // A plain file name: no folders, no "..", nothing Windows rejects in a file name. + internal static bool IsValidProfileName(string? profileName) => + !string.IsNullOrWhiteSpace(profileName) + && profileName.Trim('.').Length > 0 + && string.Equals(Path.GetFileName(profileName), profileName, StringComparison.Ordinal) + && profileName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + private void EnsureProcessOperationAllowed(ProcessModel process, string operation) { ArgumentNullException.ThrowIfNull(process); diff --git a/Services/SecurityService.cs b/Services/SecurityService.cs index cd4f777..9985d99 100644 --- a/Services/SecurityService.cs +++ b/Services/SecurityService.cs @@ -47,6 +47,12 @@ public class SecurityService : ISecurityService "MsMpEng", "SecurityHealthService", "audiodg", + "Registry", + "MemCompression", + "Secure System", + "LsaIso", + "fontdrvhost", + "sihost", }; private const uint ProcessQueryLimitedInformation = 0x1000; diff --git a/Services/SemanticVersion.cs b/Services/SemanticVersion.cs index d006f2a..4086eb3 100644 --- a/Services/SemanticVersion.cs +++ b/Services/SemanticVersion.cs @@ -84,7 +84,26 @@ public int CompareTo(SemanticVersion other) return -1; } - return string.Compare(this.Prerelease, other.Prerelease, StringComparison.OrdinalIgnoreCase); + return ComparePrerelease(this.Prerelease ?? string.Empty, other.Prerelease ?? string.Empty); + } + + // SemVer: dot-separated parts, numeric parts by value (beta.10 > beta.9), more parts wins a tie. + private static int ComparePrerelease(string left, string right) + { + var a = left.Split('.'); + var b = right.Split('.'); + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + var result = long.TryParse(a[i], out var x) && long.TryParse(b[i], out var y) + ? x.CompareTo(y) + : string.Compare(a[i], b[i], StringComparison.OrdinalIgnoreCase); + if (result != 0) + { + return result; + } + } + + return a.Length.CompareTo(b.Length); } public override string ToString() diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 39e734b..4933c18 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -87,7 +87,6 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -134,7 +133,6 @@ private static IServiceCollection ConfigureApplicationLevelServices(this IServic // User interface services services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); // System integration services diff --git a/Services/SystemTweaksService.cs b/Services/SystemTweaksService.cs index a616d21..6119170 100644 --- a/Services/SystemTweaksService.cs +++ b/Services/SystemTweaksService.cs @@ -830,15 +830,18 @@ private async Task SetRegistryTargetsAsync( if (changedBackups) { - this.SaveRegistryBackups(backups); + await SaveRegistryBackupsAsync(backups); } foreach (var target in targets) { var backupKey = target.BackupKey; + + // The backup file is user-writable: only restore a value this tweak itself could have written. var desiredValue = enabled ? enabledValue : restoreOriginalWhenDisabled && backups.TryGetValue(backupKey, out var backup) + && (backup.Value == enabledValue || backup.Value == disabledValue) ? backup.Value : disabledValue; using var key = Registry.LocalMachine.OpenSubKey(target.Path, writable: true); @@ -917,10 +920,10 @@ private Dictionary LoadRegistryBackups() } } - private void SaveRegistryBackups(Dictionary backups) + private static async Task SaveRegistryBackupsAsync(Dictionary backups) { StoragePaths.EnsureAppDataDirectories(); - File.WriteAllText( + await AtomicFileWriter.WriteAllTextAsync( StoragePaths.DeviceTweakBackupsFilePath, JsonSerializer.Serialize(backups, new JsonSerializerOptions { WriteIndented = true })); } diff --git a/Services/UpdateAssetSelector.cs b/Services/UpdateAssetSelector.cs index f3d619c..7468adf 100644 --- a/Services/UpdateAssetSelector.cs +++ b/Services/UpdateAssetSelector.cs @@ -23,7 +23,8 @@ public static bool TrySelectInstaller(UpdateReleaseInfo release, out UpdateAsset public static UpdateAsset? SelectChecksumAsset(UpdateReleaseInfo release) { return release.Assets.FirstOrDefault(asset => - string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase)); + string.Equals(asset.Name, "SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase) && + IsSafeGitHubAssetUrl(asset.DownloadUrl)); } public static bool IsSafeGitHubAssetUrl(Uri uri) diff --git a/Services/UpdateDownloadService.cs b/Services/UpdateDownloadService.cs index 54c9181..36cb8df 100644 --- a/Services/UpdateDownloadService.cs +++ b/Services/UpdateDownloadService.cs @@ -35,6 +35,10 @@ public async Task DownloadInstallerAsync(UpdateReleaseInfo throw new InvalidOperationException("Release does not contain a ThreadPilot installer asset."); } + // The installer runs elevated and silent: never without a checksum to check it against. + var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release) + ?? throw new InvalidOperationException("Release has no SHA256SUMS.txt, so the installer cannot be verified."); + var tempDirectory = this.tempDirectoryProvider.CreateUpdateTempDirectory(release.Version); try { @@ -42,24 +46,17 @@ public async Task DownloadInstallerAsync(UpdateReleaseInfo await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installerPath, cancellationToken) .ConfigureAwait(false); - var checksumVerified = false; - var checksumAsset = UpdateAssetSelector.SelectChecksumAsset(release); - if (checksumAsset != null) + var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(checksumText) || + !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash)) { - var checksumText = await this.downloadClient.TryDownloadStringAsync(checksumAsset.DownloadUrl, cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(checksumText) || - !UpdateChecksumVerifier.TryFindExpectedHash(checksumText, installerAsset.Name, out var expectedHash)) - { - throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); - } - - if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) - { - throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); - } + throw new InvalidOperationException("SHA256SUMS.txt did not contain the installer checksum."); + } - checksumVerified = true; + if (!UpdateChecksumVerifier.Verify(installerPath, expectedHash)) + { + throw new InvalidOperationException("Installer SHA256 checksum did not match SHA256SUMS.txt."); } var signatureStatus = this.signatureVerifier.Verify(installerPath); @@ -69,17 +66,15 @@ await this.downloadClient.DownloadFileAsync(installerAsset.DownloadUrl, installe } this.logger.LogInformation( - "Downloaded ThreadPilot update installer {InstallerName}; checksum verified: {ChecksumVerified}; signature: {SignatureStatus}", + "Downloaded ThreadPilot update installer {InstallerName}; checksum verified; signature: {SignatureStatus}", installerAsset.Name, - checksumVerified, signatureStatus); return new UpdateDownloadResult( installerPath, tempDirectory, - checksumVerified, signatureStatus, - checksumVerified ? "Installer checksum verified." : "No SHA256SUMS.txt asset was available."); + "Installer checksum verified."); } catch { diff --git a/Services/UpdateModels.cs b/Services/UpdateModels.cs index 68f8f05..364578d 100644 --- a/Services/UpdateModels.cs +++ b/Services/UpdateModels.cs @@ -58,7 +58,6 @@ public sealed record UpdateCheckResult( public sealed record UpdateDownloadResult( string InstallerPath, string TempDirectory, - bool ChecksumVerified, UpdateSignatureStatus SignatureStatus, string Message); diff --git a/Services/UpdateTempDirectoryProvider.cs b/Services/UpdateTempDirectoryProvider.cs index 4f9a8d0..d29fa51 100644 --- a/Services/UpdateTempDirectoryProvider.cs +++ b/Services/UpdateTempDirectoryProvider.cs @@ -5,13 +5,21 @@ namespace ThreadPilot.Services { using System; using System.IO; + using System.Security.Principal; + /// + /// Each download gets its own admin-only folder directly under the root, with an unguessable name, so a + /// non-admin process cannot swap the verified installer (or plant DLLs beside it) before it runs elevated. + /// public sealed class UpdateTempDirectoryProvider : IUpdateTempDirectoryProvider { + private const string FolderPrefix = "ThreadPilot-update-"; + private readonly string rootDirectory; + private readonly System.Collections.Concurrent.ConcurrentDictionary created = new(StringComparer.OrdinalIgnoreCase); public UpdateTempDirectoryProvider() - : this(Path.Combine(Path.GetTempPath(), "ThreadPilot", "Updates")) + : this(AdminOnlyDirectory.DefaultRoot) { } @@ -22,8 +30,8 @@ public UpdateTempDirectoryProvider(string rootDirectory) public string CreateUpdateTempDirectory(SemanticVersion version) { - var directory = Path.Combine(this.rootDirectory, version.ToString(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); + var directory = AdminOnlyDirectory.Create(Path.Combine(this.rootDirectory, $"{FolderPrefix}{version}-{Guid.NewGuid():N}")); + this.created[directory] = 0; return directory; } @@ -34,10 +42,11 @@ public bool IsSafeUpdateTempPath(string path) return false; } - var fullPath = Path.GetFullPath(path); - var rootWithSeparator = this.rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - return fullPath.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); + // Only folders this instance created: anyone may create a ThreadPilot-update-* folder in the root. + var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + return this.created.Keys.Any(directory => + string.Equals(fullPath, directory, StringComparison.OrdinalIgnoreCase) + || fullPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); } public void Cleanup(string path) @@ -48,20 +57,33 @@ public void Cleanup(string path) } Directory.Delete(path, recursive: true); - this.DeleteEmptyParentsUntilRoot(Path.GetDirectoryName(Path.GetFullPath(path))); + this.created.TryRemove(Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), out _); } - private void DeleteEmptyParentsUntilRoot(string? directory) + /// + /// Deletes update folders left by earlier runs (a successful update exits before it can clean up). + /// Only Administrators-owned folders: a user cannot create one, nor change what is inside it. + /// + public void CleanupStale(TimeSpan olderThan) { - while (!string.IsNullOrWhiteSpace(directory) && this.IsSafeUpdateTempPath(directory)) + var administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + foreach (var directory in new DirectoryInfo(this.rootDirectory).EnumerateDirectories(FolderPrefix + "*")) { - if (Directory.GetFileSystemEntries(directory).Length > 0) + try { - return; - } + if (directory.Attributes.HasFlag(FileAttributes.ReparsePoint) + || DateTime.UtcNow - directory.CreationTimeUtc < olderThan + || !administrators.Equals(directory.GetAccessControl().GetOwner(typeof(SecurityIdentifier)))) + { + continue; + } - Directory.Delete(directory); - directory = Path.GetDirectoryName(directory); + directory.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // In use or not ours: the next start tries again. + } } } } diff --git a/Services/WpfApplicationShutdownService.cs b/Services/WpfApplicationShutdownService.cs index fcca799..2e22fe3 100644 --- a/Services/WpfApplicationShutdownService.cs +++ b/Services/WpfApplicationShutdownService.cs @@ -15,7 +15,17 @@ public void RequestShutdownForUpdate() return; } - application.Dispatcher.InvokeAsync(application.Shutdown); + // The graceful path restores Game Boost, process changes and the power plan, then always exits. + application.Dispatcher.InvokeAsync(async () => + { + if (application.MainWindow is MainWindow window) + { + await window.ShutdownForUpdateAsync(); + return; + } + + application.Shutdown(); + }); } } } diff --git a/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs new file mode 100644 index 0000000..bbe9a8a --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/AdminOnlyDirectoryTests.cs @@ -0,0 +1,85 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Security.AccessControl; + using System.Security.Principal; + using ThreadPilot.Services; + + public sealed class AdminOnlyDirectoryTests + { + [Fact] + public void Create_GrantsOnlyAdministratorsAndSystem_WithoutInheritingTheParentAcl() + { + var path = Path.Combine(Path.GetTempPath(), $"threadpilot-acl-{Guid.NewGuid():N}"); + try + { + AdminOnlyDirectory.Create(path); + + var security = new DirectoryInfo(path).GetAccessControl(); + Assert.True(security.AreAccessRulesProtected); + + var allowed = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)) + .Cast() + .Select(rule => (SecurityIdentifier)rule.IdentityReference) + .ToHashSet(); + var expected = new HashSet + { + new(WellKnownSidType.LocalSystemSid, null), + new(WellKnownSidType.BuiltinAdministratorsSid, null), + }; + using var identity = WindowsIdentity.GetCurrent(); + if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + Assert.Equal(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null), security.GetOwner(typeof(SecurityIdentifier))); + } + else + { + expected.Add(identity.User!); + } + + Assert.Equal(expected, allowed); + } + finally + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + } + + [Fact] + public void Create_RefusesAnExistingFolder() + { + var path = Directory.CreateTempSubdirectory("threadpilot-acl-").FullName; + try + { + Assert.Throws(() => AdminOnlyDirectory.Create(path)); + } + finally + { + Directory.Delete(path, recursive: true); + } + } + + [Fact] + public void UpdateFolders_AreCreatedDirectlyUnderTheRoot_AndOnlyThoseAreTrusted() + { + var root = Directory.CreateTempSubdirectory("threadpilot-updates-").FullName; + try + { + var provider = new UpdateTempDirectoryProvider(root); + + var created = provider.CreateUpdateTempDirectory(new SemanticVersion(1, 8, 0)); + + Assert.Equal(root, Path.GetDirectoryName(created)); + Assert.True(provider.IsSafeUpdateTempPath(Path.Combine(created, "ThreadPilot_v1.8.0_Setup.exe"))); + Assert.False(provider.IsSafeUpdateTempPath(Path.Combine(root, "other", "ThreadPilot_v1.8.0_Setup.exe"))); + Assert.False(provider.IsSafeUpdateTempPath(Path.Combine(created, "..", "..", "Setup.exe"))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs index 91307f7..9358eb6 100644 --- a/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AutostartServiceTests.cs @@ -96,6 +96,25 @@ public async Task DisableAutostartAsync_WhenAdmin_RemovesScheduledTask() elevatedTaskService.Verify(x => x.RemoveAutostartTaskAsync(), Times.Once); } + [Fact] + public async Task LegacyRunEntryAlone_DoesNotCountAsAutostart() + { + // Windows does not start an admin-only exe from HKCU\Run at logon, so a leftover entry from an + // old version must not stop the scheduled task from being created or show autostart as on. + var elevatedTask = new Mock(MockBehavior.Loose); + elevatedTask.Setup(x => x.IsAutostartTaskRegisteredAsync()).ReturnsAsync(false); + var service = new LegacyRunEntryAutostartService(new Mock().Object, elevatedTask.Object); + + Assert.False(await service.CheckAutostartStatusAsync()); + Assert.False(service.IsAutostartEnabled); + } + + private sealed class LegacyRunEntryAutostartService(IElevationService elevation, IElevatedTaskService elevatedTask) + : AutostartService(NullLogger.Instance, elevation, elevatedTask) + { + internal override string? TryReadLegacyRegistryAutostart() => @"""C:\Apps\ThreadPilot\ThreadPilot.exe"" --autostart"; + } + private static AutostartService CreateService( Mock? elevationService = null, Mock? elevatedTaskService = null) diff --git a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs index 89dfe0e..8277bf5 100644 --- a/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ElevatedTaskServiceTests.cs @@ -51,6 +51,11 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() { ResultFactory = invocation => { + if (invocation.Arguments[0] != "/Create") + { + return new ProcessRunResult(1, string.Empty, "not found"); + } + var xmlIndex = invocation.Arguments.IndexOf("/XML"); Assert.True(xmlIndex >= 0); xmlPath = invocation.Arguments[xmlIndex + 1]; @@ -64,7 +69,8 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() var service = CreateService( runner, executablePathProvider: () => executablePath, - currentUserProvider: () => @"TEST\User"); + currentUserProvider: () => @"TEST\User", + isProtectedInstallPath: _ => true); var result = await service.EnsureLaunchTaskAsync(); @@ -89,16 +95,115 @@ public async Task EnsureLaunchTaskAsync_WritesExpectedLaunchTaskDefinition() } } + [Theory] + [InlineData(@"{PF}\ThreadPilot\ThreadPilot.exe", true)] + [InlineData(@"{PF}\..\Users\Public\ThreadPilot.exe", false)] + [InlineData(@"{PF} Evil\ThreadPilot.exe", false)] + [InlineData(@"C:\Users\Someone\Downloads\ThreadPilot\ThreadPilot.exe", false)] + [InlineData(@"ThreadPilot.exe", false)] + [InlineData(@"\ThreadPilot.exe", false)] + [InlineData("", false)] + public void IsProtectedInstallPath_OnlyAcceptsProgramFiles(string path, bool expected) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + Assert.Equal(expected, ElevatedTaskService.IsProtectedInstallPath(path.Replace("{PF}", programFiles))); + } + + [Fact] + public async Task EnsureLaunchTaskAsync_FromAWritableFolder_DeletesStaleTasksAndRegistersNothing() + { + var executablePath = CreateTemporaryExecutablePath(); + var installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "ThreadPilot", "ThreadPilot.exe"); + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments switch + { + ["/Query", "/TN", "ThreadPilot_Launch", "/XML"] => TaskXml(@"C:\Users\Someone\Documents\ThreadPilot.exe"), + ["/Query", "/TN", "ThreadPilot_Startup", "/XML"] => TaskXml($"\"{installed}\""), + _ => new ProcessRunResult(0, string.Empty, string.Empty), + }, + }; + + try + { + var service = CreateService(runner, executablePathProvider: () => executablePath); + + var result = await service.EnsureLaunchTaskAsync(); + + Assert.False(result); + Assert.Contains(runner.Invocations, invocation => invocation.Arguments.SequenceEqual(new[] { "/Delete", "/TN", "ThreadPilot_Launch", "/F" })); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments.Contains("ThreadPilot_Startup") && invocation.Arguments[0] == "/Delete"); + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments[0] == "/Create"); + } + finally + { + File.Delete(executablePath); + } + } + + [Fact] + public async Task EnsureLaunchTaskAsync_KeepsATaskInProgramFiles_AndParsesQuotedEscapedCommands() + { + var executablePath = CreateTemporaryExecutablePath(); + var installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Tools & Co", "ThreadPilot.exe"); + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments[0] == "/Query" + ? TaskXml($"\"{installed.Replace("&", "&")}\"") + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + + try + { + var service = CreateService(runner, executablePathProvider: () => executablePath); + + await service.EnsureLaunchTaskAsync(); + + Assert.DoesNotContain(runner.Invocations, invocation => invocation.Arguments[0] == "/Delete"); + } + finally + { + File.Delete(executablePath); + } + } + + [Fact] + public async Task EnsureAutostartTaskAsync_FromAWritableFolder_RegistersNothing() + { + var executablePath = CreateTemporaryExecutablePath(); + var runner = new RecordingProcessRunner(); + + try + { + var service = CreateService(runner); + + Assert.False(await service.EnsureAutostartTaskAsync(executablePath, "--autostart")); + Assert.Empty(runner.Invocations); + } + finally + { + File.Delete(executablePath); + } + } + + private static ProcessRunResult TaskXml(string command) => new( + 0, + $"\r\r\n{command}", + string.Empty); + private static ElevatedTaskService CreateService( IProcessRunner runner, Func? executablePathProvider = null, - Func? currentUserProvider = null) + Func? currentUserProvider = null, + Func? isProtectedInstallPath = null) { return new ElevatedTaskService( NullLogger.Instance, runner, executablePathProvider, - currentUserProvider); + currentUserProvider, + isProtectedInstallPath); } private static string CreateTemporaryExecutablePath() diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs index 4b405f5..5188f2f 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionNativeIntegrationTests.cs @@ -6,6 +6,7 @@ namespace ThreadPilot.Core.Tests using Microsoft.Win32.SafeHandles; using Moq; using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; using ThreadPilot.Services; using Xunit.Abstractions; @@ -20,6 +21,7 @@ public sealed class GameSessionNativeIntegrationTests(ITestOutputHelper output) private readonly string root = Path.Combine(Path.GetTempPath(), $"tp-gb-{Guid.NewGuid():N}"); private readonly List children = new(); + private readonly ProcessMemoryPriorityService efficiency = new(ProcessMemoryPriorityNativeApi.Instance, NullLogger.Instance); public void Dispose() { @@ -81,6 +83,7 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() { GameBoostEnabled = true, GameBoostAboveNormalPriority = true, + GameBoostMode = GameBoostMode.Isolation, GameBoostBackgroundProcesses = [browserPath], }; @@ -90,6 +93,8 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Equal(gameCpus, ReadDefaultCpuSetIndexes(game, topology)); Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.AboveNormal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.Off, await this.ReadEfficiencyAsync(game)); + Assert.Equal(ProcessEfficiencyMode.On, await this.ReadEfficiencyAsync(browser)); Assert.True(File.Exists(journal)); // 2. Turned off: everything restored without a restart. @@ -98,6 +103,8 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(browser)); Assert.False(File.Exists(journal)); // 3. On again, then a "crash": the service is dropped without StopAsync. @@ -112,8 +119,59 @@ public async Task RealProcesses_ApplyThenRestore_AndRecoverAfterCrash() Assert.Empty(ReadDefaultCpuSetIndexes(game, topology)); Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); Assert.Equal(ProcessPriorityClass.Normal, ReadPriority(game)); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, await this.ReadEfficiencyAsync(browser)); Assert.False(File.Exists(journal)); await second.StopAsync(); + + // 5. The game exits: its held handle ends the session and restores without waiting for a tick. + using var third = this.CreateService(topology, settings, game.Id, journal); + await third.TickAsync(); + Assert.Equal(backgroundCpus, ReadDefaultCpuSetIndexes(browser, topology)); + game.Kill(); + await WaitUntilAsync(() => !File.Exists(journal)); + Assert.Empty(ReadDefaultCpuSetIndexes(browser, topology)); + Assert.Null(third.ActiveGameName); + } + + [Fact] + public async Task Probe_HoldsTheSameProcess_ReportsItsExit_AndRejectsAnotherStartTime() + { + var game = this.StartCopyOfPing(Path.Combine(this.root, "game"), "FakeGame.exe"); + var probe = new GameBoostProcessProbe(); + Assert.True(probe.TryGetIdentity(game.Id, out var startTicks, out _)); + var exited = new TaskCompletionSource(); + + Assert.Null(probe.TryOpen(game.Id, startTicks + 1, () => Task.CompletedTask)); // the PID now names another process + using var handle = probe.TryOpen(game.Id, startTicks, () => + { + exited.TrySetResult(); + return Task.CompletedTask; + }); + Assert.NotNull(handle); + Assert.True(handle.TryGetCpuTime(out _)); + Assert.False(exited.Task.IsCompleted); + + game.Kill(); + await exited.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void Probe_HoldsAProtectedProcess_WithLimitedRights() + { + // A protected (PPL) process refuses full access, as an anti-cheat-protected game may: only the minimal + // rights an anti-cheat leaves to outside tools can open it, and only those are held for a session. + var csrss = Process.GetProcessesByName("csrss").First(); + var probe = new GameBoostProcessProbe(); + if (!probe.TryGetIdentity(csrss.Id, out var startTicks, out _)) + { + output.WriteLine("Skipped: not elevated, csrss cannot be queried."); + return; + } + + using var handle = probe.TryOpen(csrss.Id, startTicks, () => Task.CompletedTask); + Assert.NotNull(handle); + Assert.True(handle.TryGetCpuTime(out var cpu)); + Assert.True(cpu > TimeSpan.Zero); } private GameSessionService CreateService(CpuTopologySnapshot topology, ApplicationSettingsModel settings, int gamePid, string journal) @@ -147,6 +205,7 @@ private GameSessionService CreateService(CpuTopologySnapshot topology, Applicati new ProcessFilterService(), new ProcessPolicyApplyGate(), new GameBoostProcessProbe(), + this.efficiency, NullLogger.Instance, journal); } @@ -166,6 +225,19 @@ private Process StartCopyOfPing(string directory, string fileName) return process; } + private static async Task WaitUntilAsync(Func condition) + { + for (var i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(100); + } + + Assert.True(condition()); + } + + private Task ReadEfficiencyAsync(Process process) => + this.efficiency.GetEfficiencyModeAsync(new ProcessModel { ProcessId = process.Id, Name = process.ProcessName }); + private static ProcessPriorityClass ReadPriority(Process process) { process.Refresh(); diff --git a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs index 8eded2a..0e5df94 100644 --- a/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/GameSessionServiceTests.cs @@ -22,6 +22,7 @@ public sealed class GameSessionServiceTests : IDisposable private readonly FakeProcesses processes = new(); private readonly List rules = new(); private readonly ActivityAuditService audit = new(NullLogger.Instance); + private readonly FakeClock clock = new(); private int? foregroundPid = GamePid; public GameSessionServiceTests() @@ -60,6 +61,42 @@ public async Task NotAGame_NoSession() Assert.All(this.processes.CpuSets.Values, Assert.Empty); } + [Fact] + public async Task RejectedPid_ReusedByAGame_StartsASessionAtTheNextTick() + { + // The 30 s memory of a rejected foreground process was keyed by PID only. + this.processes.Add(700, "notepad", @"C:\Windows\notepad.exe"); + this.foregroundPid = 700; + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Null(service.ActiveGameName); + + this.processes.Add(700, "Game2", @"D:\SteamLibrary\steamapps\common\Game2\Game2.exe"); + this.processes.Restart(700); + this.clock.Advance(TimeSpan.FromSeconds(3)); + await service.TickAsync(); + + Assert.Equal("Game2", service.ActiveGameName); + } + + [Fact] + public async Task NonGameForeground_IsNotReexaminedEveryTick_ButIsAfterAWhile() + { + this.foregroundPid = ChromePid; + using var service = this.CreateService(); + await service.TickAsync(); + + // Changed behind the service's back (no settings event), so only a fresh look can notice it. + this.settings.GameBoostManualGames.Add("chrome"); + this.clock.Advance(TimeSpan.FromSeconds(3)); + await service.TickAsync(); + Assert.Null(service.ActiveGameName); + + this.clock.Advance(TimeSpan.FromSeconds(30)); + await service.TickAsync(); + Assert.Equal("chrome", service.ActiveGameName); + } + [Fact] public async Task GameExit_RestoresBackground_AndDeletesJournal() { @@ -74,6 +111,149 @@ public async Task GameExit_RestoresBackground_AndDeletesJournal() Assert.False(File.Exists(this.journalPath)); } + [Fact] + public async Task HeldGame_ExitEvent_EndsTheSessionAndRestores_WithoutATick() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Equal(1, this.processes.OpenHandles); + + await this.processes.ExitWithEventAsync(GamePid); + + Assert.Null(service.ActiveGameName); + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + Assert.Equal(0, this.processes.OpenHandles); + var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); + Assert.Contains("Session ended for Game: the game exited", messages); + } + + [Fact] + public async Task HeldGame_TicksDoNotLookItUp_AndItsLoadIsReadFromTheHandle() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + this.processes.Lookups.Clear(); + + await this.RunLoadAsync(service, cpusBusy: 8, ticks: GameLoadTracker.ReleaseSamples); + Assert.False(this.processes.Lookups.ContainsKey(GamePid)); + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + Assert.Empty(this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task HeldGame_SettingsChange_KeepsWatchingTheSameGame() + { + this.processes.HandleAllowed = true; + using var service = this.CreateService(); + await service.TickAsync(); + + this.settings.GameBoostMode = GameBoostMode.Isolation; + await service.ReapplyAsync(); + Assert.Equal(1, this.processes.OpenHandles); + + await this.processes.ExitWithEventAsync(GamePid); + Assert.Null(service.ActiveGameName); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task HeldGame_HandleReleased_WhenDisposedOrStopped() + { + this.processes.HandleAllowed = true; + using (var crashed = this.CreateService()) + { + await crashed.TickAsync(); + } + + Assert.Equal(0, this.processes.OpenHandles); + + using var service = this.CreateService(); + await service.StartAsync(); + await service.TickAsync(); + Assert.Equal(1, this.processes.OpenHandles); + await service.StopAsync(); + Assert.Equal(0, this.processes.OpenHandles); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task TickQueuedBeforeStopOrDispose_StartsNoSession() + { + // Timer.Dispose does not wait for a callback already queued on the thread pool. + var stopped = this.CreateService(); + await stopped.StartAsync(); + await stopped.StopAsync(); + await stopped.TickAsync(); + Assert.Null(stopped.ActiveGameName); + + var disposed = this.CreateService(); + disposed.Dispose(); + await disposed.TickAsync(); + Assert.Null(disposed.ActiveGameName); + + Assert.Equal(0, this.processes.OpenHandles); + Assert.Empty(this.processes.ApplyCalls); + Assert.False(File.Exists(this.journalPath)); + stopped.Dispose(); + } + + [Fact] + public async Task HeldGame_LateExitEventOfAnEndedSession_DoesNotEndTheNextOne() + { + this.processes.HandleAllowed = true; + this.processes.Add(500, "Other", @"D:\SteamLibrary\steamapps\common\Other\Other.exe"); + using var service = this.CreateService(); + await service.TickAsync(); + var lateExit = this.processes.ExitCallbacks.Single(); + + this.settings.GameBoostEnabled = false; + await service.ReapplyAsync(); + this.settings.GameBoostEnabled = true; + this.foregroundPid = 500; + await service.TickAsync(); + await lateExit(); + + Assert.Equal("Other", service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[500]); + } + + [Fact] + public async Task HandleDenied_PidReusedDuringTheSession_EndsIt_WithoutTouchingTheNewProcess() + { + using var service = this.CreateService(); // the handle is denied, as by an anti-cheat: polled every tick + await service.TickAsync(); + Assert.Equal(0, this.processes.OpenHandles); + + this.processes.Restart(GamePid); + await service.TickAsync(); + + Assert.Null(service.ActiveGameName); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); // the new process is not ours + Assert.Empty(this.processes.CpuSets[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task Rescan_BuildsModelsOnlyForListedNames_ThenChecksTheExactPath() + { + this.settings.GameBoostBackgroundProcesses = ["chrome", @"C:\Tools\Sync\sync.exe"]; + this.processes.Add(600, "sync", @"C:\Tools\Sync\sync.exe"); + this.processes.Add(601, "sync", @"D:\Other\sync.exe"); // same name, another executable + this.processes.Add(602, "notepad", @"C:\Windows\notepad.exe"); + using var service = this.CreateService(); + + await service.TickAsync(); + + Assert.Equal(["chrome", "sync", "sync"], this.processes.ScannedModels.Order()); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[600]); + Assert.Empty(this.processes.CpuSets[601]); + } + [Fact] public async Task AltTab_KeepsTheSession() { @@ -285,14 +465,15 @@ public async Task Activity_ShowsSessionChangesAndRestore_WithoutRepeatingOnResca [ "Session started: Game (Automatic)", "Game: CPU Sets set to 16 CPUs", + "Game: high QoS, Windows will not power-throttle it", "Background moved to 16 CPUs: chrome x2", "Background left unchanged", "Session ended for Game: the game exited", ], entries.Select(entry => entry.Message)); Assert.Contains("AMD 3D V-Cache", entries[0].Details, StringComparison.Ordinal); - Assert.Equal("steam (your rule)", entries[3].Details); - Assert.Equal("Restored 2 change(s); 1 left as they are (changed by you or a rule since, or the process exited)", entries[4].Details); + Assert.Equal("steam (your rule)", entries[4].Details); + Assert.Equal("Restored 2 change(s); 2 left as they are (changed by you or a rule since, or the process exited)", entries[5].Details); } [Fact] @@ -311,7 +492,150 @@ public async Task Activity_CrashRecoveryAndSkippedPriority() var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); Assert.Contains("Game: priority left at BelowNormal", messages); - Assert.Contains("Restored 2 change(s) left by an unexpected close", messages); + Assert.Contains("Restored 3 change(s) left by an unexpected close", messages); + } + + [Fact] + public async Task Game_GetsHighQos_RestoredWhenTheSessionEnds() + { + using var service = this.CreateService(); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[GamePid]); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[ChromePid]); // Automatic: no EcoQoS + + this.settings.GameBoostEnabled = false; + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task Game_HighQosEvenWhenTheCpuIsNotSupported() + { + using var service = this.CreateService(TopologyFixtures.Amd([8], [96 * MB])); + + await service.TickAsync(); + + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task ExplicitQos_FromTheAppOrARule_NotTouched() + { + this.processes.Efficiency[GamePid] = ProcessEfficiencyMode.On; + this.settings.GameBoostMode = GameBoostMode.Isolation; + this.rules.Add(new PersistentProcessRule { IsEnabled = true, ProcessName = "chrome", EfficiencyMode = ProcessEfficiencyMode.Off }); + this.processes.Efficiency[ChromePid] = ProcessEfficiencyMode.Off; + using var service = this.CreateService(); + + await service.TickAsync(); + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[ChromePid]); + Assert.Equal(ProcessEfficiencyMode.On, this.processes.Efficiency[GamePid]); + } + + [Fact] + public async Task Isolation_ListedBackgroundGetsEcoQos_EvenWithoutCpuSets_AndIsRestored() + { + this.settings.GameBoostMode = GameBoostMode.Isolation; + using var service = this.CreateService(TopologyFixtures.Amd([8], [96 * MB])); + + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.On, this.processes.Efficiency[ChromePid]); + Assert.Equal(ProcessEfficiencyMode.SystemManaged, this.processes.Efficiency[DiscordPid]); + Assert.Empty(this.processes.CpuSets[ChromePid]); + + this.processes.Efficiency[ChromePid] = ProcessEfficiencyMode.Off; // the app changed it itself + this.processes.Exit(GamePid); + await service.TickAsync(); + + Assert.Equal(ProcessEfficiencyMode.Off, this.processes.Efficiency[ChromePid]); + Assert.False(File.Exists(this.journalPath)); + } + + [Fact] + public async Task SaturatedGame_CpuSetsReleased_ThenConfinedAgainWhenLoadDrops() + { + using var service = this.CreateService(); + await service.TickAsync(); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + + await this.RunLoadAsync(service, cpusBusy: 15, ticks: GameLoadTracker.ReleaseSamples); // 15 of 16 CPUs + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); // the first tick only sets the baseline + + await this.RunLoadAsync(service, cpusBusy: 15, ticks: 1); + Assert.Empty(this.processes.CpuSets[GamePid]); + Assert.Equal(OtherCcdCpus, this.processes.CpuSets[ChromePid]); // background stays moved + + await this.RunLoadAsync(service, cpusBusy: 4, ticks: GameLoadTracker.ConfineSamples); + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + + this.processes.Exit(GamePid); + await service.TickAsync(); + Assert.False(File.Exists(this.journalPath)); + var messages = (await this.audit.GetEntriesAsync()).Select(entry => entry.Message).ToList(); + Assert.Contains("Game: kept its 16 CPUs busy, CPU Sets released so Windows can use every core", messages); + Assert.Contains("Game: load is low again, CPU Sets set back to 16 CPUs", messages); + } + + [Fact] + public async Task SaturatedGame_AfterTheSecondRelease_StaysOnAllCores() + { + using var service = this.CreateService(); + await service.TickAsync(); + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + await this.RunLoadAsync(service, cpusBusy: 2, ticks: GameLoadTracker.ConfineSamples); + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples); + Assert.Empty(this.processes.CpuSets[GamePid]); + + await this.RunLoadAsync(service, cpusBusy: 1, ticks: GameLoadTracker.ConfineSamples * 2); + Assert.Empty(this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task SaturatedGame_UserChangedItsCores_NotReleasedNorConfinedAgain() + { + using var service = this.CreateService(); + await service.TickAsync(); + this.processes.CpuSets[GamePid] = [0, 1, 2, 3]; + + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples + 1); + await this.RunLoadAsync(service, cpusBusy: 1, ticks: GameLoadTracker.ConfineSamples + 1); + + Assert.Equal([0, 1, 2, 3], this.processes.CpuSets[GamePid]); + } + + [Fact] + public async Task ShortSpikes_DoNotReleaseTheGame() + { + using var service = this.CreateService(); + await service.TickAsync(); + + for (var i = 0; i < 10; i++) + { + await this.RunLoadAsync(service, cpusBusy: 16, ticks: GameLoadTracker.ReleaseSamples - 1); + await this.RunLoadAsync(service, cpusBusy: 3, ticks: 1); + } + + Assert.Equal(VCacheCpus, this.processes.CpuSets[GamePid]); + } + + // One tick every 3 s; over each one the game uses as much CPU time as cpusBusy fully busy CPUs. + private async Task RunLoadAsync(GameSessionService service, double cpusBusy, int ticks) + { + for (var i = 0; i < ticks; i++) + { + var interval = TimeSpan.FromSeconds(3); + this.clock.Advance(interval); + this.processes.CpuTime[GamePid] += interval * cpusBusy; + await service.TickAsync(); + } } private GameSessionService CreateService(CpuTopologySnapshot? topology = null) @@ -338,9 +662,23 @@ private GameSessionService CreateService(CpuTopologySnapshot? topology = null) new ProcessFilterService(), new ProcessPolicyApplyGate(), this.processes, + this.processes.EfficiencyService, NullLogger.Instance, this.journalPath, - this.audit); + this.audit, + this.clock); + } + + /// Timestamps in TimeSpan ticks, moved by hand. + private sealed class FakeClock : TimeProvider + { + public long Now { get; private set; } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => this.Now; + + public void Advance(TimeSpan by) => this.Now += by.Ticks; } /// In-memory processes: CPU Sets, priorities and identities the service reads and writes. @@ -348,14 +686,20 @@ private sealed class FakeProcesses : IGameBoostProcessProbe { private readonly Dictionary models = new(); private readonly Dictionary startTimes = new(); + private readonly Dictionary handles = new(); public FakeProcesses() { var processService = new Mock(); processService.Setup(s => s.GetProcessByIdAsync(It.IsAny())) .ReturnsAsync((int pid) => this.startTimes.ContainsKey(pid) ? this.Snapshot(pid) : null); - processService.Setup(s => s.GetProcessesWithPathsAsync()) - .ReturnsAsync(() => this.startTimes.Keys.Select(this.Snapshot).ToList()); + processService.Setup(s => s.GetProcessesWithPathsAsync(It.IsAny>())) + .ReturnsAsync((Func nameFilter) => + { + var listed = this.startTimes.Keys.Where(pid => nameFilter(this.models[pid].Name)).ToList(); + this.ScannedModels.AddRange(listed.Select(pid => this.models[pid].Name)); + return listed.Select(this.Snapshot).ToList(); + }); processService.Setup(s => s.GetCpuAssignmentLogicalProcessorIndexesAsync(It.IsAny(), CpuAssignmentMode.CpuSets)) .ReturnsAsync((ProcessModel p, CpuAssignmentMode _) => this.Unreadable.Contains(p.ProcessId) ? null : this.CpuSets[p.ProcessId].ToList()); processService.Setup(s => s.ClearProcessCpuSetAsync(It.IsAny())) @@ -365,7 +709,12 @@ public FakeProcesses() return true; }); processService.Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((ProcessModel p, ProcessPriorityClass priority, ProcessPriorityWriteSource _) => this.Priorities[p.ProcessId] = priority) + .Callback((ProcessModel p, ProcessPriorityClass priority, ProcessPriorityWriteSource _) => + { + // Like ProcessService: the write also updates the model it was given. + this.Priorities[p.ProcessId] = priority; + p.Priority = priority; + }) .Returns(Task.CompletedTask); this.ProcessService = processService.Object; @@ -380,8 +729,25 @@ public FakeProcesses() : AffinityApplyResult.SucceededWithCpuSets("ok"); }); this.AffinityService = affinityService.Object; + + var efficiencyService = new Mock(); + efficiencyService.Setup(s => s.GetEfficiencyModeAsync(It.IsAny())) + .ReturnsAsync((ProcessModel p) => this.Unreadable.Contains(p.ProcessId) ? null : this.Efficiency[p.ProcessId]); + efficiencyService.Setup(s => s.SetEfficiencyModeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ProcessModel p, ProcessEfficiencyMode mode) => + { + this.Efficiency[p.ProcessId] = mode; + return ProcessOperationResult.Succeeded("ok", "ok"); + }); + this.EfficiencyService = efficiencyService.Object; } + public IProcessMemoryPriorityService EfficiencyService { get; } + + public Dictionary Efficiency { get; } = new(); + + public Dictionary CpuTime { get; } = new(); + public IProcessService ProcessService { get; } public IAffinityApplyService AffinityService { get; } @@ -396,24 +762,81 @@ public FakeProcesses() public List ApplyCalls { get; } = new(); + /// Gets the names the background rescans built a full model for. + public List ScannedModels { get; } = new(); + public void Add(int pid, string name, string path) { this.models[pid] = new ProcessModel { ProcessId = pid, Name = name, ExecutablePath = path }; this.startTimes[pid] = 1000 + pid; this.CpuSets[pid] = []; this.Priorities[pid] = ProcessPriorityClass.Normal; + this.Efficiency[pid] = ProcessEfficiencyMode.SystemManaged; + this.CpuTime[pid] = TimeSpan.Zero; } + /// Gets or sets a value indicating whether the game can be held open; false acts like an anti-cheat that denies the handle. + public bool HandleAllowed { get; set; } + + public int OpenHandles => this.handles.Count; + + /// Gets every exit callback handed out, including those of handles already released. + public List> ExitCallbacks { get; } = new(); + + public Dictionary Lookups { get; } = new(); + public void Exit(int pid) => this.startTimes.Remove(pid); + /// The game exits and its held handle raises the exit event; completes when the service has handled it. + public Task ExitWithEventAsync(int pid) + { + this.Exit(pid); + return this.handles.TryGetValue(pid, out var handle) ? handle.OnExited() : Task.CompletedTask; + } + public void Restart(int pid) => this.startTimes[pid] += 1; public bool TryGetIdentity(int processId, out long startTimeUtcTicks, out int sessionId) { + this.Lookups[processId] = this.Lookups.GetValueOrDefault(processId) + 1; sessionId = 1; return this.startTimes.TryGetValue(processId, out startTimeUtcTicks); } + public bool TryGetCpuTime(int processId, out TimeSpan totalProcessorTime) + { + this.Lookups[processId] = this.Lookups.GetValueOrDefault(processId) + 1; + return this.CpuTime.TryGetValue(processId, out totalProcessorTime) && this.startTimes.ContainsKey(processId); + } + + public IGameProcessHandle? TryOpen(int processId, long startTimeUtcTicks, Func onExited) + { + if (!this.HandleAllowed || !this.startTimes.TryGetValue(processId, out var current) || current != startTimeUtcTicks) + { + return null; + } + + var handle = new FakeHandle(this, processId, onExited); + this.ExitCallbacks.Add(onExited); + this.handles[processId] = handle; + return handle; + } + + private sealed class FakeHandle(FakeProcesses owner, int pid, Func onExited) : IGameProcessHandle + { + public Func OnExited { get; } = onExited; + + public bool TryGetCpuTime(out TimeSpan totalProcessorTime) => owner.CpuTime.TryGetValue(pid, out totalProcessorTime); + + public void Dispose() + { + if (owner.handles.TryGetValue(pid, out var held) && held == this) + { + owner.handles.Remove(pid); + } + } + } + private ProcessModel Snapshot(int pid) { var model = this.models[pid]; diff --git a/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs b/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs new file mode 100644 index 0000000..a8cc6a3 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/IdleAnimationXamlTests.cs @@ -0,0 +1,29 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class IdleAnimationXamlTests + { + private static readonly string RepoRoot = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + // A hard-coded indeterminate progress control animates forever, even collapsed: WPF then renders + // every frame (measured: ~25% of a core with the window open, ~2% in the tray). + [Fact] + public void NoProgressControl_IsIndeterminateForever() + { + var xamlFiles = Directory.GetFiles(Path.Combine(RepoRoot, "Views"), "*.xaml") + .Append(Path.Combine(RepoRoot, "MainWindow.xaml")); + + var offenders = xamlFiles + .SelectMany(file => XDocument.Load(file).Descendants() + .Where(element => element.Name.LocalName is "ProgressRing" or "ProgressBar") + .Where(element => string.Equals( + (string?)element.Attribute("IsIndeterminate"), "True", StringComparison.OrdinalIgnoreCase)) + .Select(_ => Path.GetFileName(file))) + .ToList(); + + Assert.Empty(offenders); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 9419f68..c2daf20 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -4,7 +4,7 @@ namespace ThreadPilot.Core.Tests public sealed partial class PackagingMetadataTests { - private const string ReleaseVersion = "1.7.8-beta.1"; + private const string ReleaseVersion = "1.7.8-beta.2"; private const string ReleaseAssemblyVersion = "1.7.8.0"; [Fact] @@ -29,6 +29,33 @@ public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() } } + [Fact] + public void PrimaryInstaller_AlwaysInstallsInProgramFiles_AndMovesOldFoldersWithoutTheOldUninstaller() + { + var root = FindRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "Installer", "setup.iss")); + + Assert.Contains(@"DefaultDirName={autopf}\{#MyAppName}", script, StringComparison.Ordinal); + Assert.Contains("DisableDirPage=yes", script, StringComparison.Ordinal); + Assert.Contains("UsePreviousAppDir=no", script, StringComparison.Ordinal); + Assert.Contains("MovePreviousInstall", script, StringComparison.Ordinal); + + // The old uninstaller would also delete %AppData%\ThreadPilot, the user's settings. + Assert.DoesNotContain("unins000.exe'", script.Replace("DeleteFile(AddBackslash(OldDir) + 'unins000.exe');", string.Empty), StringComparison.Ordinal); + Assert.DoesNotContain("DelTree", script, StringComparison.Ordinal); + + // The old folder may be user-writable: swapped for a junction, admin deletes would land elsewhere. + var move = script[script.IndexOf("procedure MovePreviousInstall()", StringComparison.Ordinal)..]; + Assert.True( + move.IndexOf("if HasReparsePointOnPath(OldDir) then", StringComparison.Ordinal) + < move.IndexOf("DelayDeleteFile(", StringComparison.Ordinal)); + Assert.Contains("external 'GetFileAttributesW@kernel32.dll stdcall'", script, StringComparison.Ordinal); + + // A copy still closing (in-app update) holds the exe for a moment; the rest goes only once it is gone. + Assert.Contains("DelayDeleteFile(AddBackslash(OldDir) + '{#MyAppExeName}', 40);", move, StringComparison.Ordinal); + Assert.Contains("if FileExists(AddBackslash(OldDir) + '{#MyAppExeName}') then", move, StringComparison.Ordinal); + } + [Fact] public void PrimaryInstaller_RemovesThreadPilotOwnedDataOnlyDuringUninstall() { @@ -133,7 +160,7 @@ private static string FindRepositoryRoot() throw new InvalidOperationException("Repository root could not be located."); } - [GeneratedRegex("#define MyAppVersion \"1\\.7\\.8-beta\\.1\"", RegexOptions.CultureInvariant)] + [GeneratedRegex("#define MyAppVersion \"1\\.7\\.8-beta\\.2\"", RegexOptions.CultureInvariant)] private static partial Regex MyAppVersionRegex(); } } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 774d9bd..88ee4d8 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -521,6 +521,31 @@ public async Task ApplyMatchingRulesAsync_WaitsForTheSharedApplyGate() affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_ProtectedProcess_ChangesNothing() + { + var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); + var affinity = new Mock(MockBehavior.Strict); + var security = new Mock(MockBehavior.Strict); + security.Setup(s => s.IsProtected(It.IsAny())).Returns(true); + var engine = new PersistentRulesEngine( + new FakePersistentProcessRuleStore([rule]), + new PersistentProcessRuleMatcher(), + affinity.Object, + new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + securityService: security.Object); + var process = CreateProcess(); + process.ProcessId = Environment.ProcessId; + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(process)); + + Assert.False(result.Success); + Assert.Equal("PersistentRuleProtectedProcess", result.ErrorCode); + security.Verify(s => s.IsProtected(It.IsAny()), Times.Once); + } + private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs index 596adf5..8a580ba 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs @@ -118,6 +118,39 @@ public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() Assert.Equal(new[] { "/getactivescheme" }, invocation.Arguments); } + [Fact] + public async Task SetActivePowerPlanByGuidAsync_SwitchesWithOneListAndOneSetActive() + { + const string balanced = "381b4222-f694-41f0-9685-ff5bb260df2e"; + const string ultimate = "e9a42b02-d5df-448d-aa00-03f14749eb61"; + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => invocation.Arguments[0] == "/list" + ? new ProcessRunResult( + 0, + string.Join( + Environment.NewLine, + "Existing Power Schemes (* Active)", + "-----------------------------------", + $"Power Scheme GUID: {balanced} (Balanced) *", + $"Power Scheme GUID: {ultimate} (Ultimate Performance)"), + string.Empty) + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + var service = CreateService(runner); + PowerPlanChangedEventArgs? changed = null; + service.PowerPlanChanged += (_, e) => changed = e; + + var result = await service.SetActivePowerPlanByGuidAsync(ultimate, preventDuplicateChanges: false); + + Assert.True(result); + Assert.Equal( + new[] { new[] { "/list" }, new[] { "/setactive", ultimate } }, + runner.Invocations.Select(invocation => invocation.Arguments.ToArray())); + Assert.Equal("Balanced", changed?.PreviousPowerPlan?.Name); + Assert.Equal("Ultimate Performance", changed?.NewPowerPlan?.Name); + } + [Fact] public async Task DeletePowerPlanAsync_InvokesPowerCfgDelete_WhenPlanIsNotActive() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs index 17c37f1..008b6dc 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs @@ -7,6 +7,39 @@ namespace ThreadPilot.Core.Tests public sealed class ProcessListDeltaUpdaterTests { + [Theory] + [InlineData("ABCDE", "ABCDE")] + [InlineData("ABCDE", "EDCBA")] + [InlineData("ABCDE", "BXDAY")] + [InlineData("", "ABC")] + [InlineData("ABC", "")] + [InlineData("ABCD", "CAB")] + public void SyncInPlace_ReachesTheDesiredOrder_KeepingTheSameInstances(string before, string after) + { + var pool = "ABCDEXY".ToDictionary(letter => letter, letter => new ProcessModel { ProcessId = letter, Name = letter.ToString() }); + var target = new ObservableCollection(before.Select(letter => pool[letter])); + var resets = 0; + target.CollectionChanged += (_, e) => resets += e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset ? 1 : 0; + + ProcessListDeltaUpdater.SyncInPlace(target, after.Select(letter => pool[letter]).ToList()); + + Assert.Equal(after.Select(letter => pool[letter]), target); + Assert.Equal(0, resets); + } + + [Fact] + public void SyncInPlace_UnchangedOrder_RaisesNoChange() + { + var items = Enumerable.Range(1, 5).Select(pid => new ProcessModel { ProcessId = pid }).ToList(); + var target = new ObservableCollection(items); + var changes = 0; + target.CollectionChanged += (_, _) => changes++; + + ProcessListDeltaUpdater.SyncInPlace(target, items); + + Assert.Equal(0, changes); + } + [Fact] public void ApplyDelta_PreservesExistingInstancesAndUpdatesProperties() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 8df9c63..2b37043 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -38,7 +38,7 @@ public async Task SetEfficiencyModeAsync_MapsEachModeToItsThrottlingMasks(Proces var result = await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), mode); Assert.True(result.Success); - Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION, nativeApi.LastOpenAccess); + Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION | ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, nativeApi.LastOpenAccess); Assert.Equal(PowerThrottlingState.CurrentVersion, nativeApi.LastSetThrottling!.Value.Version); Assert.Equal(control, nativeApi.LastSetThrottling.Value.ControlMask); Assert.Equal(state, nativeApi.LastSetThrottling.Value.StateMask); @@ -48,6 +48,24 @@ public async Task SetEfficiencyModeAsync_MapsEachModeToItsThrottlingMasks(Proces Assert.Equal(mode, readBack); } + [Theory] + [InlineData(ProcessEfficiencyMode.On, 5u, 5u)] + [InlineData(ProcessEfficiencyMode.Off, 5u, 4u)] + [InlineData(ProcessEfficiencyMode.SystemManaged, 4u, 4u)] + public async Task SetEfficiencyModeAsync_KeepsTheProcessesOtherThrottlingFlags(ProcessEfficiencyMode mode, uint control, uint state) + { + // 0x4 = PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION, set by the process itself. + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + ThrottlingToReturn = new PowerThrottlingState { Version = 1, ControlMask = 0x5, StateMask = 0x5 }, + }; + + await CreateService(nativeApi).SetEfficiencyModeAsync(CreateProcess(), mode); + + Assert.Equal(control, nativeApi.LastSetThrottling!.Value.ControlMask); + Assert.Equal(state, nativeApi.LastSetThrottling.Value.StateMask); + } + [Fact] public async Task SetEfficiencyModeAsync_WhenWindowsDenies_ReportsAccessDenied() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index 1022c55..820396f 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -329,6 +329,20 @@ public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedM } } + [Theory] + [InlineData("Gaming", true)] + [InlineData("My profile 2", true)] + [InlineData(@"..\..\Windows\evil", false)] + [InlineData(@"C:\Temp\evil", false)] + [InlineData("sub/evil", false)] + [InlineData("..", false)] + [InlineData("", false)] + [InlineData("bad|name", false)] + public void IsValidProfileName_KeepsProfilesInsideTheirFolder(string name, bool expected) + { + Assert.Equal(expected, ProcessService.IsValidProfileName(name)); + } + [Fact] public async Task SetRegistryPriorityAsync_WithRealtime_ReturnsFalse() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 90c08db..a6fba0e 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -292,6 +292,31 @@ public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup() Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); } + [Fact] + public void WideLayout_TableSizesToItsColumnsAndLeavesTheRestToTheDetails() + { + // A star column in the process table swallowed all free width and left a wide empty strip + // next to a fixed 400 px details panel. The table must size to its columns; the code-behind + // switches Priority to star only in the narrow layout, where the details sit below the table. + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var columns = document.Descendants() + .Single(element => element.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "ProcessList") + .Descendants() + .Where(element => element.Name.LocalName.StartsWith("DataGrid", StringComparison.Ordinal) + && element.Name.LocalName.EndsWith("Column", StringComparison.Ordinal)) + .ToList(); + + Assert.NotEmpty(columns); + Assert.All(columns, column => Assert.DoesNotContain("*", column.Attribute("Width")?.Value ?? string.Empty, StringComparison.Ordinal)); + + // A pixel width from the visible columns, never Auto: an Auto column re-measured the DataGrid at + // infinite width on every list refresh and doubled the CPU with the window open. + var codeBehind = File.ReadAllText(ProcessViewPath + ".cs"); + Assert.DoesNotContain("GridLength.Auto", codeBehind, StringComparison.Ordinal); + Assert.Contains("Math.Min(columns + chrome, this.workspaceWidth - DetailMinWidth)", codeBehind, StringComparison.Ordinal); + Assert.Contains("private const double DetailMinWidth = 400;", codeBehind, StringComparison.Ordinal); + } + [Fact] public void LegacyActionSidePanel_IsGoneAndItsControlsLiveInTheVisiblePicker() { diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index 3f22c60..4411d18 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -152,6 +152,75 @@ public async Task SaveSettingsCommand_PersistsSelectedLanguage() Assert.False(viewModel.HasUnsavedChanges); } + [Theory] + [InlineData(true, 1, 4, 0, true)] // accepted, release 1.4.0 >= current 1.3.1: installs, autostart stays on + [InlineData(false, 1, 4, 0, false)] // declined: nothing installed, autostart back off + [InlineData(true, 1, 2, 0, false)] // only an older release exists: never downgrade + public async Task SaveSettingsCommand_PortableAutostart_OffersTheVerifiedInstaller( + bool accept, int major, int minor, int patch, bool expectInstall) + { + var harness = new Harness(); + harness.Autostart.Setup(service => service.CheckAutostartStatusAsync()).ReturnsAsync(false); + harness.Autostart.Setup(service => service.EnableAutostartAsync(It.IsAny())).ReturnsAsync(false); + harness.Autostart.SetupGet(service => service.IsAutostartSupported).Returns(false); + var release = new UpdateReleaseInfo( + new SemanticVersion(major, minor, patch), + $"v{major}.{minor}.{patch}", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases"), + false, + []); + harness.Updates + .Setup(service => service.CheckForUpdatesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateCheckResult(UpdateCheckStatus.UpdateAvailable, new SemanticVersion(1, 3, 1), release, "ok")); + harness.Updates + .Setup(service => service.DownloadAndInstallAsync(release, It.IsAny())) + .ReturnsAsync(new UpdateInstallResult(UpdateInstallStatus.Started, "started")); + var viewModel = harness.CreateViewModel(); + var prompts = 0; + viewModel.ConfirmPrompt = (_, _) => + { + prompts++; + return accept; + }; + viewModel.Settings.AutostartWithWindows = true; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.Equal(1, prompts); + harness.Updates.Verify( + service => service.DownloadAndInstallAsync(It.IsAny(), It.IsAny()), + expectInstall ? Times.Once() : Times.Never()); + Assert.Equal(expectInstall, viewModel.Settings.AutostartWithWindows); + } + + [Fact] + public async Task SaveSettingsCommand_PortableBetaAutostart_SaysWhyNothingWasInstalled() + { + // A beta copy with prerelease updates off finds only the older stable release: it must say so + // instead of repeating the generic "needs Program Files" warning. + var harness = new Harness(); + harness.Autostart.Setup(service => service.CheckAutostartStatusAsync()).ReturnsAsync(false); + harness.Autostart.Setup(service => service.EnableAutostartAsync(It.IsAny())).ReturnsAsync(false); + harness.Autostart.SetupGet(service => service.IsAutostartSupported).Returns(false); + var stable = new UpdateReleaseInfo( + new SemanticVersion(1, 7, 7), + "v1.7.7", + new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases"), + false, + []); + harness.Updates + .Setup(service => service.CheckForUpdatesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UpdateCheckResult(UpdateCheckStatus.UpToDate, new SemanticVersion(1, 7, 8, "beta.1"), stable, "ok")); + var viewModel = harness.CreateViewModel(); + viewModel.ConfirmPrompt = (_, _) => true; + viewModel.Settings.AutostartWithWindows = true; + + await ((IAsyncRelayCommand)viewModel.SaveSettingsCommand).ExecuteAsync(null); + + Assert.Contains("Include prerelease updates", viewModel.StatusMessage, StringComparison.Ordinal); + Assert.False(viewModel.Settings.AutostartWithWindows); + } + [Fact] public async Task SaveSettingsCommand_WhenStartMinimizedIsDisabled_UpdatesEnabledAutostart() { diff --git a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs index 8808dd8..3194f4d 100644 --- a/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs +++ b/Tests/ThreadPilot.Core.Tests/StartupWindowBehaviorTests.cs @@ -18,6 +18,18 @@ public void Resolve_ShowsNormalWindow_ForManualLaunchWithoutStartMinimized() Assert.True(behavior.ActivateAfterShow); } + [Fact] + public void Resolve_KeepsAWindowTheUserLeftMaximized() + { + // App applies this state after MainWindow restored its saved placement, so a plain + // Normal here turned every saved maximized window back into a normal one. + var visible = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: false, restoreMaximized: true); + var hidden = StartupWindowBehavior.Resolve(isAutostart: false, startMinimized: true, restoreMaximized: true); + + Assert.Equal(WindowState.Maximized, visible.WindowState); + Assert.Equal(WindowState.Minimized, hidden.WindowState); + } + [Fact] public void Resolve_HidesToTray_ForManualLaunchWithStartMinimized() { diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs index 584edfe..00e8769 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksStartupAndStyleTests.cs @@ -16,6 +16,33 @@ public void Startup_LoadsPersistedTweakStateBeforePageNavigation() Assert.Contains("this.initializedSections.Add(\"Tweaks\");", startupSection, StringComparison.Ordinal); } + [Fact] + public void Shutdown_ResetsTrackedChangesBeforeTheMonitorForgetsThem() + { + // ProcessMonitorManagerService.StopAsync untracks every process it changed, so + // "reset process changes on exit" found nothing to reset when it ran afterwards. + var source = File.ReadAllText(GetRepositoryFilePath("MainWindow.Behaviors.partial.cs")); + var start = source.IndexOf("private async Task PerformGracefulShutdownAsync(", StringComparison.Ordinal); + var shutdown = source[start..source.IndexOf("// 3. Restore default power plan", start, StringComparison.Ordinal)]; + + var reset = shutdown.IndexOf("await processService.ClearAllAppliedMasksAsync();", StringComparison.Ordinal); + var stopMonitor = shutdown.IndexOf("await this.processMonitorManagerService.StopAsync();", StringComparison.Ordinal); + Assert.True(reset >= 0 && stopMonitor >= 0); + Assert.True(reset < stopMonitor); + } + + [Fact] + public void UpdateShutdown_GoesThroughTheGracefulShutdown() + { + // Application.Shutdown ignores the window's cancel, so the restore of Game Boost, process + // changes and power plan in PerformGracefulShutdownAsync never ran before an update. + var service = File.ReadAllText(GetRepositoryFilePath("Services", "WpfApplicationShutdownService.cs")); + var window = File.ReadAllText(GetRepositoryFilePath("MainWindow.Behaviors.partial.cs")); + + Assert.Contains("ShutdownForUpdateAsync()", service, StringComparison.Ordinal); + Assert.Contains("internal Task ShutdownForUpdateAsync() => this.PerformGracefulShutdownAsync(validateUnsavedChanges: false);", window, StringComparison.Ordinal); + } + [Fact] public void TweaksView_UsesWindows11ToggleWithStateLabel() { diff --git a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs index 461b181..6bdeadf 100644 --- a/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/UpdateServiceTests.cs @@ -21,6 +21,21 @@ public void SemanticVersion_OrdersStableAbovePrerelease() Assert.True(SemanticVersion.TryParse("1.4.0", out var stable)); Assert.True(stable > prerelease); + Assert.Equal(0, stable.CompareTo(new SemanticVersion(1, 4, 0))); + } + + [Theory] + [InlineData("1.7.8-beta.10", "1.7.8-beta.9")] + [InlineData("1.7.8-rc.1", "1.7.8-beta.10")] + [InlineData("1.7.8-beta.1.1", "1.7.8-beta.1")] + public void SemanticVersion_ComparesNumericPrereleasePartsAsNumbers(string newer, string older) + { + // Text order put beta.10 before beta.9, so a beta.9 user was never offered beta.10. + Assert.True(SemanticVersion.TryParse(newer, out var high)); + Assert.True(SemanticVersion.TryParse(older, out var low)); + + Assert.True(high > low); + Assert.True(low < high); } [Fact] @@ -88,7 +103,6 @@ public async Task DownloadInstallerAsync_VerifiesChecksum() var result = await service.DownloadInstallerAsync(CreateReleaseWithInstallerAndChecksum()); - Assert.True(result.ChecksumVerified); Assert.True(File.Exists(result.InstallerPath)); } @@ -103,6 +117,20 @@ public async Task DownloadInstallerAsync_RejectsInvalidChecksumAndCleansTemp() Assert.Empty(Directory.GetDirectories(tempRoot.Path)); } + [Fact] + public async Task DownloadInstallerAsync_WithoutChecksumAsset_RefusesBeforeDownloading() + { + using var tempRoot = new TempDirectory(); + var client = new FakeUpdateDownloadClient(Encoding.UTF8.GetBytes("installer-content"), null); + var service = CreateDownloadService(tempRoot.Path, client); + var release = CreateRelease( + new UpdateAsset("ThreadPilot_v1.4.0_Setup.exe", new Uri("https://github.com/PrimeBuild-pc/ThreadPilot/releases/download/v1.4.0/ThreadPilot_v1.4.0_Setup.exe"), 10), + new UpdateAsset("SHA256SUMS.txt", new Uri("https://example.com/SHA256SUMS.txt"), 10)); + + await Assert.ThrowsAsync(() => service.DownloadInstallerAsync(release)); + Assert.Empty(Directory.GetFileSystemEntries(tempRoot.Path)); + } + [Fact] public void UpdateTempDirectoryProvider_DoesNotDeleteOutsideUpdateRoot() { @@ -137,7 +165,6 @@ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() .ReturnsAsync(new UpdateDownloadResult( Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), harness.TempDirectory, - true, UpdateSignatureStatus.Unknown, "ok")); File.WriteAllText(Path.Combine(harness.TempDirectory, "ThreadPilot_v1.4.0_Setup.exe"), "installer"); @@ -150,6 +177,36 @@ public async Task DownloadAndInstallAsync_StartsInstallerAndRequestsShutdown() harness.TempProvider.Verify(provider => provider.Cleanup(It.IsAny()), Times.Never); } + [Fact] + public void CleanupStale_DeletesOnlyOldAdminOwnedUpdateFolders() + { + // A successful update exits right after launching the installer, so its folder (~55 MB) was never deleted. + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + if (!new System.Security.Principal.WindowsPrincipal(identity).IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) + { + return; // only an elevated process creates Administrators-owned folders + } + + using var tempRoot = new TempDirectory(); + var old = new UpdateTempDirectoryProvider(tempRoot.Path).CreateUpdateTempDirectory(new SemanticVersion(1, 5, 0)); + var recent = new UpdateTempDirectoryProvider(tempRoot.Path).CreateUpdateTempDirectory(new SemanticVersion(1, 6, 0)); + var userOwned = Directory.CreateDirectory(Path.Combine(tempRoot.Path, "ThreadPilot-update-planted")).FullName; + var userSecurity = new DirectoryInfo(userOwned).GetAccessControl(); + userSecurity.SetOwner(identity.User!); + new DirectoryInfo(userOwned).SetAccessControl(userSecurity); + File.WriteAllText(Path.Combine(old, "setup.exe"), "installer"); + foreach (var directory in new[] { old, userOwned }) + { + Directory.SetCreationTimeUtc(directory, DateTime.UtcNow.AddDays(-2)); + } + + new UpdateTempDirectoryProvider(tempRoot.Path).CleanupStale(TimeSpan.FromDays(1)); + + Assert.False(Directory.Exists(old)); + Assert.True(Directory.Exists(recent)); + Assert.True(Directory.Exists(userOwned)); + } + [Fact] public async Task UpdateInstallerService_UsesSilentRestartingUpdateMode() { diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 306335a..a164631 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -15,11 +15,18 @@ false link true + + false + + false CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.7.8-beta.1 + 1.7.8-beta.2 1.7.8.0 1.7.8.0 - 1.7.8-beta.1 + 1.7.8-beta.2 @@ -47,6 +54,8 @@ + + diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 4176751..9cadc9d 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -304,7 +304,9 @@ private void OpenLogDirectory() var logDirectory = this.loggingService.LogDirectoryPath; if (Directory.Exists(logDirectory)) { - System.Diagnostics.Process.Start("explorer.exe", logDirectory); + System.Diagnostics.Process.Start( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), + logDirectory); } else { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index cb0d294..609c7b8 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -2222,7 +2222,7 @@ private void FilterProcesses() }; var filteredResults = this.processFilterService.FilterAndSort(this.Processes, criteria); - this.FilteredProcesses = new ObservableCollection(filteredResults); + ProcessListDeltaUpdater.SyncInPlace(this.FilteredProcesses, filteredResults); // The batch total is a snapshot from the first load; refreshes add processes, so the // counter showed "157 / 151". Once everything is loaded the list itself is the total. diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 9ae4c29..8b56b6d 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -309,7 +309,7 @@ private static void OpenExecutableLocationInExplorer(string executablePath) { var startInfo = new ProcessStartInfo { - FileName = "explorer.exe", + FileName = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), Arguments = $"/select,\"{executablePath}\"", UseShellExecute = true, }; diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 7c765e5..0612717 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -339,7 +339,27 @@ private async Task SaveSettingsAsync() autostartUpdated = await this.autostartService.DisableAutostartAsync(); } - if (!autostartUpdated) + if (!autostartUpdated && this.Settings.AutostartWithWindows && !this.autostartService.IsAutostartSupported) + { + var installFailure = await this.TryInstallForAutostartAsync(); + if (installFailure == null) + { + this.StatusMessage = this.GetLocalizedString("Settings_StatusUpdateInstallerStarted", "Update installer started."); + } + else + { + warnings.Add(this.GetLocalizedString( + "Settings_WarningAutostartNeedsInstall", + "Autostart needs ThreadPilot installed in Program Files: a portable copy could be replaced by any program and then started as administrator.")); + if (installFailure.Length > 0) + { + warnings.Add(installFailure); + } + + this.Settings.AutostartWithWindows = currentAutostartState; + } + } + else if (!autostartUpdated) { warnings.Add(this.GetLocalizedString( "Settings_WarningAutostartFailed", @@ -738,6 +758,45 @@ private async Task RefreshPowerPlansAsync() } } + internal Func ConfirmPrompt { get; set; } = (message, title) => + System.Windows.MessageBox.Show(message, title, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes; + + // A portable copy cannot autostart safely, so offer the official installer through the verified update + // path; the installed copy turns autostart on at its first start (App.OnStartup). + // Null when the installer started; otherwise why not (empty when the user declined). + private async Task TryInstallForAutostartAsync() + { + var confirmed = this.ConfirmPrompt( + this.GetLocalizedString( + "Settings_AutostartInstallConfirmMessage", + "Autostart needs ThreadPilot installed in Program Files. Download, verify and install the official version now? ThreadPilot restarts from Program Files with autostart on; you can delete this portable folder afterwards."), + this.GetLocalizedString("Settings_AutostartInstallConfirmTitle", "Install ThreadPilot for autostart")); + if (!confirmed) + { + return string.Empty; + } + + var check = await this.updateService.CheckForUpdatesAsync(new UpdateCheckRequest(UpdateCheckTrigger.Manual)); + if (check.Release == null) + { + return check.Message; + } + + // Never downgrade: a portable copy newer than the latest release (a beta or dev build) has nothing to install. + if (check.Release.Version < check.CurrentVersion) + { + return this.GetLocalizedString( + "Settings_WarningAutostartNoInstallableRelease", + "No official installer of this version or newer was found. For a preview version, turn on \"Include prerelease updates\" and save again."); + } + + // Saved before the installer starts, since this copy is closed for it. + this.Settings.AutostartWithWindows = true; + await this.settingsService.UpdateSettingsAsync(this.Settings); + var result = await this.updateService.DownloadAndInstallAsync(check.Release); + return result.Status == UpdateInstallStatus.Started ? null : result.Message; + } + private async Task CheckUpdatesAsync() { try diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index c29baac..ca604a6 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -180,7 +180,7 @@ - + @@ -442,7 +442,7 @@ - + @@ -471,16 +471,33 @@ - - - + + + - + + + + - + + + + + + + + + + + - + - + @@ -580,7 +597,7 @@ - + @@ -703,7 +720,7 @@ - + - - + + diff --git a/Views/ProcessView.xaml.cs b/Views/ProcessView.xaml.cs index dcafe57..753afbe 100644 --- a/Views/ProcessView.xaml.cs +++ b/Views/ProcessView.xaml.cs @@ -27,12 +27,36 @@ public ProcessView() item.Checked += save; item.Unchecked += save; } + + // The table follows its columns when one is shown, hidden or resized by the user. + foreach (var column in this.ProcessList.Columns) + { + foreach (var property in new[] { DataGridColumn.ActualWidthProperty, DataGridColumn.VisibilityProperty }) + { + System.ComponentModel.DependencyPropertyDescriptor.FromProperty(property, typeof(DataGridColumn)) + .AddValueChanged(column, (_, _) => this.SizeTable()); + } + } } + private const double DetailMinWidth = 400; + + private bool wideLayout; + private double workspaceWidth; + private void ProcessWorkspace_SizeChanged(object sender, System.Windows.SizeChangedEventArgs e) { var wide = e.NewSize.Width >= 960; - this.DetailColumn.Width = new System.Windows.GridLength(wide ? 400 : 0); + this.wideLayout = wide; + this.workspaceWidth = e.NewSize.Width; + + // Wide: the table is only as wide as its visible columns and the details panel takes the rest. + // Narrow: the table fills the width (Priority stretches) and the details go below it. + this.SizeTable(); + this.DetailColumn.Width = wide ? new System.Windows.GridLength(1, System.Windows.GridUnitType.Star) : new System.Windows.GridLength(0); + this.PriorityColumn.Width = wide ? new DataGridLength(120) : new DataGridLength(1, DataGridLengthUnitType.Star); + this.SelectProcessHint.HorizontalAlignment = wide ? System.Windows.HorizontalAlignment.Center : System.Windows.HorizontalAlignment.Stretch; + this.SelectProcessHint.VerticalAlignment = wide ? System.Windows.VerticalAlignment.Center : System.Windows.VerticalAlignment.Top; Grid.SetRow(this.ProcessDetails, wide ? 1 : 2); Grid.SetColumn(this.ProcessDetails, wide ? 1 : 0); this.ProcessDetails.MaxHeight = wide ? double.PositiveInfinity : Math.Max(160, e.NewSize.Height * 0.48); @@ -45,6 +69,31 @@ private void ProcessWorkspace_SizeChanged(object sender, System.Windows.SizeChan this.ToolbarActions.Margin = stacked ? new System.Windows.Thickness(-8, 8, 0, 0) : default; } + // A pixel width, not Auto: an Auto column re-measured the DataGrid at infinite width on every list + // refresh, which doubled the CPU with the window open (~2.5% -> ~5% of a core). + private void SizeTable() + { + if (!this.wideLayout) + { + this.TableColumn.Width = new System.Windows.GridLength(1, System.Windows.GridUnitType.Star); + return; + } + + var columns = this.ProcessList.Columns.Where(c => c.Visibility == System.Windows.Visibility.Visible).Sum(c => c.ActualWidth); + var chrome = System.Windows.SystemParameters.VerticalScrollBarWidth + this.ProcessList.BorderThickness.Left + this.ProcessList.BorderThickness.Right + 4; + this.TableColumn.Width = new System.Windows.GridLength(Math.Max(0, Math.Min(columns + chrome, this.workspaceWidth - DetailMinWidth))); + } + + private void ProcessDetails_SizeChanged(object sender, System.Windows.SizeChangedEventArgs e) + { + // A wide details panel puts Priority and CPU cores side by side instead of stretching every picker. + var twoColumns = this.DetailColumn.Width.IsStar && e.NewSize.Width >= 880; + this.DetailSecondColumn.Width = twoColumns ? new System.Windows.GridLength(1, System.Windows.GridUnitType.Star) : new System.Windows.GridLength(0); + Grid.SetRow(this.CpuCoresCard, twoColumns ? 1 : 2); + Grid.SetColumn(this.CpuCoresCard, twoColumns ? 1 : 0); + this.PriorityCard.Margin = new System.Windows.Thickness(0, 0, twoColumns ? 10 : 0, 10); + } + private void ProcessRow_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) { if (sender is not DataGridRow row) diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index ffd57df..5d8d474 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8-beta.1", + [string]$Version = "1.7.8-beta.2", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 0c3b8af..0056b03 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8-beta.1", + [string]$Version = "1.7.8-beta.2", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/install-threadpilot.ps1 b/build/install-threadpilot.ps1 index c3323ea..ab02019 100644 --- a/build/install-threadpilot.ps1 +++ b/build/install-threadpilot.ps1 @@ -25,9 +25,22 @@ if (-not $asset) { $exePath = Join-Path $destPath $asset.name +$checksumAsset = $release.assets | Where-Object { $_.name -eq "SHA256SUMS.txt" } | Select-Object -First 1 +if (-not $checksumAsset) { + throw "The latest release has no SHA256SUMS.txt, so the installer cannot be verified." +} + Write-Host "Downloading installer $($asset.name)..." Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $exePath -UseBasicParsing +$checksums = (Invoke-WebRequest -Uri $checksumAsset.browser_download_url -UseBasicParsing).Content +$expected = ($checksums -split "`n" | Where-Object { $_ -match "^([0-9a-fA-F]{64})\s+\*?$([regex]::Escape($asset.name))\s*$" } | ForEach-Object { $Matches[1] } | Select-Object -First 1) +$actual = (Get-FileHash -Path $exePath -Algorithm SHA256).Hash +if (-not $expected -or $actual -ne $expected) { + Remove-Item -LiteralPath $exePath -Force + throw "Installer checksum does not match SHA256SUMS.txt; the download was deleted." +} + # Optional machine PATH update for command-line convenience. $currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine") if ($currentPath -notmatch [regex]::Escape($destPath)) { diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index 78cfe23..65e8ae4 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8-beta.1" + [string]$Version = "1.7.8-beta.2" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index f9d28e4..7101d59 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.7.8-beta.1 + 1.7.8-beta.2 ThreadPilot Prime Build https://github.com/PrimeBuild-pc/ThreadPilot @@ -15,7 +15,7 @@ false Advanced Windows process and power plan manager with rules automation and performance controls. ThreadPilot process and power plan manager. - https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.8-beta.1 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.8-beta.2 threadpilot process powerplan performance windows diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index a1d78e4..71fb340 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,3 +1,9 @@ +## ThreadPilot v1.7.8-beta.2 + +Pre-release on top of v1.7.8-beta.1: a full security and performance audit (window-open CPU ~25–30% → ~2.3% of a core, near-zero in the tray), Game Boost orchestration with a minimal-rights game handle, an installer that always installs in Program Files, and portable copies that can install themselves for autostart. Dual-CCD Ryzen and Intel hybrid testers wanted. + +[Full notes, performance table and tester checklist](../releases/v1.7.8-beta.2.md). v1.7.7 remains stable/latest. No winget/Chocolatey beta publication; artifacts unsigned. + ## ThreadPilot v1.7.8-beta.1 Pre-release with the complete local Game Boost implementation, Activity diagnostics and CPU topology fixes, on top of stable v1.7.7. Real dual-CCD Ryzen/X3D, Intel hybrid, benchmark and anti-cheat validation remain pending. diff --git a/docs/releases/v1.7.8-beta.2.md b/docs/releases/v1.7.8-beta.2.md new file mode 100644 index 0000000..8e04081 --- /dev/null +++ b/docs/releases/v1.7.8-beta.2.md @@ -0,0 +1,74 @@ +# ThreadPilot v1.7.8-beta.2 — Game Boost orchestration, security hardening and a much lighter app + +**Pre-release, not stable.** This preview contains everything in v1.7.8-beta.1 plus a full security and performance audit, smarter Game Boost and installer changes. The stable/latest release remains **v1.7.7**; this beta is not published to winget, Chocolatey or the stable automatic-update channel. Export a configuration backup before switching channels. + +## Performance: before and after + +Measured on the Release build, Ryzen 7 5800X3D, Processes page open, ~200 running processes. "% of a core" = CPU time of ThreadPilot divided by wall time. + +| | before | after | +|---|---|---| +| window open, idle | ~25–30% of a core | ~2.3% of a core | +| minimized to tray | ~2% of a core | 0 ms of CPU in 90 s (0–0.03%) | +| wake-ups in tray (context switches/s) | ~275 | 0–10 | +| startup CPU (first 15–20 s) | ~8.4–9.9 s | ~6.0 s | +| main window appears | ~1.3 s | ~1.3 s | +| during a game, Game Boost in Isolation (tray) | ~0.42–0.62% of a core | ~0.10–0.15% of a core | +| restore after the game closes | ~1.4–3 s | ~0.05–0.1 s | +| private memory, steady state | ~225 MB | ~220–230 MB (unchanged: ~145 MB is WPF and native drivers) | + +Where it came from: the startup spinner no longer animates forever behind the hidden overlay, the process list is updated in place instead of rebuilt every 5 s, startup does less work and no fixed waits, dynamic PGO is off (it recompiled hot code in the background for seconds after launch), and Game Boost only runs a timer while it is on. + +## Security + +- **Elevated tasks only for Program Files installs.** The no-UAC scheduled tasks are registered only when ThreadPilot runs from Program Files, where a normal program cannot replace the exe. Tasks that point anywhere else are deleted at every elevated start. +- **No native DLLs extracted to %TEMP%.** The WPF native libraries now ship next to `ThreadPilot.exe` instead of being unpacked into a user-writable folder and loaded by the admin process. +- **Admin-only temp folders** for the scheduled-task definition and the downloaded update installer (random name under `%SystemRoot%\Temp`, Administrators and SYSTEM only). Leftover update folders are cleaned at startup. +- **Updates require a trusted `SHA256SUMS.txt`.** The updater refuses a release whose checksum file is missing or not hosted on GitHub, before downloading anything. +- **Saved rules respect protected processes** for every action (CPU Sets, memory/I/O priority, EcoQoS), not only affinity. Driver-tweak restores accept only the tweak's own on/off values. +- **Installer hardening:** when it moves an old install out of another folder it deletes nothing if that folder (or any folder above it) is a junction or symbolic link. +- Smaller fixes: `explorer.exe` launched by full path, DLL search limited to System32, BinaryFormatter disabled, registry CPU priority now writes the correct `PerfOptions` key. +- Release pipeline: GitHub Actions pinned to commit SHAs, write access only for the release job, publishing behind a protected environment with a required reviewer. + +## Game Boost + +- **Orchestration (beyond CPU Sets):** the game gets high QoS (Windows does not throttle it when it is not in front), on any CPU. In **Isolation**, listed background apps get EcoQoS (on Intel hybrid Windows moves them to E-cores by itself). If the game uses ≥ 80% of its confined cores for ~12 s, its CPU Set is lifted and Windows may use every core; after ~60 s of low load it is confined again (at most twice per session). User rules and an app's own QoS choice always win. +- **The game handle:** ThreadPilot now keeps the game open for the session with a minimal-rights handle (`SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION`, not full access, which is what anti-cheats look for). The session ends the moment the game exits, so restoring takes ~0.05–0.1 s instead of up to 3 s, and a reused process ID can never be mistaken for the game. If a game refuses even that handle, ThreadPilot falls back to polling. +- The 15-second background rescan only builds process details for names in your lists. +- Fixed: changing a Game Boost setting during a session no longer drops the game's Above Normal priority. +- Fixed: a game that gets the process ID of an app that just closed is detected at once, instead of after up to 30 s. +- Fixed: EcoQoS changes no longer wipe other power-throttling flags a process set for itself (e.g. timer resolution). + +## Installer and portable + +- **The installer always installs in Program Files.** It no longer asks for a folder. An existing install in another folder (e.g. `D:\Apps\ThreadPilot`) is moved: the new version goes to Program Files and only ThreadPilot's own program files are removed from the old folder. Settings, rules and your other files there are kept; the old uninstaller is not run (it would delete your settings). +- **A portable copy can install itself for autostart.** Turning on "Start with Windows" in a portable copy offers to download, verify (SHA256) and install the official version in Program Files; ThreadPilot then restarts from there with autostart on. It never installs an older version. On a preview build, turn on **Include prerelease updates** first, otherwise only the older stable release is found (the app now tells you). +- **Update a portable copy by replacing the whole folder,** not only `ThreadPilot.exe`: the native DLLs now live next to the exe. + +## Other fixes + +- Processes page: the table is as wide as its columns and the details panel takes the free space (Priority and CPU cores side by side on wide windows); the narrow layout is unchanged. +- The main window reopens maximized if it was closed maximized. +- "Reset process changes on exit" works again: it ran after the process monitor had already forgotten what it changed. +- In-app updates close ThreadPilot through the normal shutdown, so Game Boost and power-plan changes are restored before the installer runs. +- A leftover autostart entry from very old versions no longer hides that autostart is off. +- Update checks order prereleases correctly (beta.10 is newer than beta.9). + +## Testers wanted: dual-CCD Ryzen and Intel hybrid + +Game Boost's core split and the new dynamic release only act where the game is confined: **AMD dual-CCD** (7900X3D / 7950X3D / 9900X3D / 9950X3D, and 7950X / 9950X in Isolation) and **Intel hybrid** (12th–14th gen, Core Ultra). We have tested them only with unit tests and a simulated topology, so reports from real hardware are the most useful thing you can send. + +Please use the [quick test from beta.1](https://github.com/PrimeBuild-pc/ThreadPilot/blob/main/docs/releases/v1.7.8-beta.1.md#quick-test) and also note: + +- whether the CPU Set is lifted during heavy scenes (Activity shows it) and whether that helps or hurts frame times; +- Isolation with and without a browser video or Steam download in the background; +- on Intel, a heavily multithreaded game; +- any anti-cheat refusal (ThreadPilot never bypasses process protection). + +Open an [issue](https://github.com/PrimeBuild-pc/ThreadPilot/issues) titled **[1.7.8-beta.2][Game Boost] CPU model — result/problem** with CPU, SMT state, Windows build, chipset driver, game, mode, Activity entries and, if you can, CapFrameX/PresentMon captures (at least three runs per scenario). + +## Known limits + +- An old install in another folder that is still running from the tray during the upgrade keeps its exe there; close it and delete the old folder by hand. The new version removes any elevated task that points to it. +- Artifacts are **unsigned** while code-signing enrollment is pending. SmartScreen may warn; verify the SHA256 checksums. +- Upgrades preserve settings; uninstalling removes ThreadPilot-owned user data. diff --git a/sonar-project.properties b/sonar-project.properties index 82b9cfd..e7b1f46 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=threadpilot sonar.projectName=ThreadPilot -sonar.projectVersion=1.7.8-beta.1 +sonar.projectVersion=1.7.8-beta.2 sonar.sourceEncoding=UTF-8 sonar.sources=.