diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d9bcb2d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + nuget: + patterns: ["*"] + update-types: [minor, patch] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: ["*"] + update-types: [minor, patch] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0e751b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Build + run: dotnet build -warnaserror + + - name: Test + run: dotnet test diff --git a/.gitignore b/.gitignore index 9e2f71f..949c5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ TestResults/ *.exe *.zip /OptiScaler.ini + +# Secrets (safety baseline) +.env +.env.* +!.env.example +*.token +*.pem diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91ac903 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# DLSS Updater + +Windows desktop app that pulls the latest OptiScaler-NR, DLSS, MFG Unlock, Streamline and ReShade releases and installs them into the user's games. It also runs as a Seaglass add-on (`Addon/`). + +## Stack +- Language/runtime: C# 12, .NET 8 (`net8.0-windows`, `win-x64`) +- UI: WPF with CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`). Theme tokens (colors, fonts, styles) are in `src/DLSSUpdater/Theme/Nvidia.xaml`. Reuse them, don't hard-code colors. +- Data: JSON files under `%LocalAppData%\DLSSUpdater` (`AppPaths`), serialized through the source-generated `JsonCtx` in `Core/AppSettings.cs`. Register new serialized types there. +- Tests: xUnit (`tests/DLSSUpdater.Tests`) +- Installer: Inno Setup 6 (`installer/DLSSUpdater.iss`) + +## Commands +- Build (warnings are errors): `dotnet build -warnaserror` +- Test: `dotnet test` (needs the .NET 8 runtime and Windows Desktop runtime installed, not just a newer SDK) +- Integration test (downloads real releases, off by default): `$env:DLSSU_INTEGRATION=1; dotnet test`. `DLSSU_COMPONENTS` can point at a folder that holds `nvngx_dlssnr.dll`. +- Publish the portable single-file exe: `dotnet publish src/DLSSUpdater -c Release -o publish` → `publish\DLSSUpdater.exe` +- Installer (after publish): `iscc /DAppVersion= installer\DLSSUpdater.iss` → `publish\DLSSUpdater-Setup-.exe` + +## Release flow +1. Bump `` in `src/DLSSUpdater/DLSSUpdater.csproj`, and the version in the `iscc` example lines of `installer/DLSSUpdater.iss` and `README.md`. All three must say the same thing. +2. Commit, then tag `v` (for example `v1.5.1`) and push the tag. +3. `.github/workflows/release.yml` (runs on `v*` tags only; PRs and pushes are covered by `ci.yml`) checks that the tag equals `v` + the csproj version and fails the build if it doesn't. It then runs the tests, publishes, builds the installer and attaches both exes to the GitHub release. + +## Layout +- `src/DLSSUpdater/Core`: business logic with no UI: GitHub client (ETag cache), component store, installer, ini merging, NVIDIA driver profiles (`NvDrs`), settings +- `src/DLSSUpdater/Scan`: game discovery (launchers, Steam VDF) and inspection (DLSS dlls, exe ranking, anti-cheat) +- `src/DLSSUpdater/ViewModels`: MVVM view models. `MainViewModel` owns the `Services` (settings, store, installer). +- `src/DLSSUpdater/Views`: dialogs, converters, small WPF helpers +- `src/DLSSUpdater/Addon`: the Seaglass add-on protocol (stdin/stdout JSON) +- `tests/DLSSUpdater.Tests`: one file per area. Tests that touch disk point `AppPaths.Root` at a temp folder. + +## Conventions +- Keep logic in `Core`/`Scan` as plain static or instance methods that can be tested without WPF. View models only orchestrate. +- Async UI code keeps continuations on the UI thread (no `ConfigureAwait(false)` in view models). Background work goes through `Task.Run`, and `Log.Line` is marshalled with `Dispatcher.BeginInvoke`. +- Never do work inside a `CollectionChanged` handler that makes a bound `ItemsControl` re-read its items (scrolling, for example). Defer it with `Dispatcher.BeginInvoke` (see `Views/ListAutoScroll.cs` and the startup crash fixed in b5f78ef). +- GitHub API calls go through `GitHubClient.GetApiAsync` so they share the ETag cache. When GitHub is unreachable or rate-limited, fall back to cached data and never throw at the user. +- Use `FileUtil.AtomicWriteText` for every file the app writes. + +## Hard rules +- **`nvngx_dlssnr.dll` must never be committed, bundled, uploaded or redistributed** (NVIDIA's terms don't allow it). The user supplies it, and it stays in `%LocalAppData%\DLSSUpdater\components`. `.gitignore` blocks `*.dll`, `*.exe`, `*.addon64` and `*.zip`. Don't force-add binaries. +- Tests and agents must not write NVIDIA driver profiles (`NvDrs` / NvAPI `Save`), launch real games or install into real game folders. Use temp directories. + +## Check command +`dotnet build -warnaserror && dotnet test` +Takes about 40 seconds. `.github/workflows/ci.yml` (job `build-test`, windows-latest) runs this same pair on every pull request and push to main. Keep it fast and keep it passing. + +## Definition of done +- The check command passes, with no new warnings. +- Tests were added or updated for changed behaviour. +- UI changes are checked in the running app. + +## Git workflow +- Remote: GitHub ApolloF/dlssupdater. +- Branch, PR, CI (`build-test`), then merge only when the user says "ship it" (auto-merge, squash). +- Releases: tag `v` as described in "Release flow"; `release.yml` publishes. + +## Secrets +- No config or secrets in the repo; the app keeps its data in `%LocalAppData%\DLSSUpdater`. If a key is ever needed it comes from env vars, and `.env.example` lists the keys with no values. +- Never commit real values. gitleaks runs on every commit. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/DLSSUpdater/MainWindow.xaml.cs b/src/DLSSUpdater/MainWindow.xaml.cs index da58ddb..fddd3da 100644 --- a/src/DLSSUpdater/MainWindow.xaml.cs +++ b/src/DLSSUpdater/MainWindow.xaml.cs @@ -1,10 +1,10 @@ -using System.Collections.Specialized; -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; using DLSSUpdater.ViewModels; +using DLSSUpdater.Views; namespace DLSSUpdater; @@ -35,11 +35,7 @@ public MainWindow() Dispatcher.BeginInvoke(() => ScrollToGuide(id), System.Windows.Threading.DispatcherPriority.Loaded); }; SourceInitialized += (_, _) => RoundCorners(); - // Deferred: scrolling inside the change notification forces item generation before the ListBox has processed the change. - ((INotifyCollectionChanged)_vm.LogLines).CollectionChanged += (_, _) => Dispatcher.BeginInvoke(() => - { - if (LogList.Items.Count > 0) LogList.ScrollIntoView(LogList.Items[^1]); - }, System.Windows.Threading.DispatcherPriority.Background); + ListAutoScroll.FollowNewest(LogList, _vm.LogLines); } /// While a keybind button is waiting, the next key press becomes the binding. diff --git a/src/DLSSUpdater/ViewModels/MainViewModel.cs b/src/DLSSUpdater/ViewModels/MainViewModel.cs index 49f716f..014e235 100644 --- a/src/DLSSUpdater/ViewModels/MainViewModel.cs +++ b/src/DLSSUpdater/ViewModels/MainViewModel.cs @@ -42,8 +42,7 @@ public MainViewModel() Log.Line += line => _ui.BeginInvoke(() => { - LogLines.Add(line); - if (LogLines.Count > 400) LogLines.RemoveAt(0); + AppendLogLine(LogLines, line); LastLog = line; }); } @@ -60,6 +59,15 @@ public MainViewModel() public ReleaseNewsViewModel StreamlineNews { get; } public ReleaseNewsViewModel ReShadeNews { get; } + internal const int MaxLogLines = 400; + + /// Adds a log line and drops the oldest ones beyond . + internal static void AppendLogLine(IList lines, string line) + { + lines.Add(line); + while (lines.Count > MaxLogLines) lines.RemoveAt(0); + } + [ObservableProperty] private GameViewModel? _selectedGame; [ObservableProperty] private string _search = ""; [ObservableProperty] private string _filter = "All"; diff --git a/src/DLSSUpdater/Views/ListAutoScroll.cs b/src/DLSSUpdater/Views/ListAutoScroll.cs new file mode 100644 index 0000000..248d3d2 --- /dev/null +++ b/src/DLSSUpdater/Views/ListAutoScroll.cs @@ -0,0 +1,19 @@ +using System.Collections.Specialized; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace DLSSUpdater.Views; + +/// Keeps a ListBox scrolled to its newest item. +public static class ListAutoScroll +{ + /// + /// Deferred: scrolling inside the change notification forces item generation before the ListBox has processed + /// the change, which threw at startup when the log filled up quickly. + /// + public static void FollowNewest(ListBox list, INotifyCollectionChanged items) => + items.CollectionChanged += (_, _) => list.Dispatcher.BeginInvoke(() => + { + if (list.Items.Count > 0) list.ScrollIntoView(list.Items[^1]); + }, DispatcherPriority.Background); +} diff --git a/tests/DLSSUpdater.Tests/InstallerTests.cs b/tests/DLSSUpdater.Tests/InstallerTests.cs index 078bf57..a14616e 100644 --- a/tests/DLSSUpdater.Tests/InstallerTests.cs +++ b/tests/DLSSUpdater.Tests/InstallerTests.cs @@ -495,6 +495,19 @@ public async Task ReShadeOnly_WithoutReShade_Throws() await Assert.ThrowsAsync(() => new Installer(_store).InstallAsync(Game(), _target, o, null, default)); } + [Fact] + public void ProxyChecks_FalseForPlainAndMissingFiles() + { + var plain = Path.Combine(_tmp, "dxgi.dll"); + File.WriteAllText(plain, "not a PE file"); + var missing = Path.Combine(_tmp, "winmm.dll"); + + Assert.False(Installer.IsOptiScaler(plain)); + Assert.False(Installer.IsReShade(plain)); + Assert.False(Installer.IsOptiScaler(missing)); + Assert.False(Installer.IsReShade(missing)); + } + // ---------- review 2026-10: crafted, moved or broken install records ---------- private string Engine(string file) => Path.Combine(_root, @"Engine\Plugins\Runtime\Nvidia\DLSS\Binaries\ThirdParty\Win64", file); diff --git a/tests/DLSSUpdater.Tests/LogScrollTests.cs b/tests/DLSSUpdater.Tests/LogScrollTests.cs new file mode 100644 index 0000000..5bcad0f --- /dev/null +++ b/tests/DLSSUpdater.Tests/LogScrollTests.cs @@ -0,0 +1,121 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using DLSSUpdater.ViewModels; +using DLSSUpdater.Views; + +namespace DLSSUpdater.Tests; + +/// Regression tests for the startup crash loop fixed in b5f78ef (log ListBox scrolled inside CollectionChanged). +public class LogScrollTests +{ + [Fact] + public void AppendLogLine_KeepsNewestLines() + { + var lines = new List(); + for (var i = 0; i < MainViewModel.MaxLogLines + 25; i++) MainViewModel.AppendLogLine(lines, $"line {i}"); + + Assert.Equal(MainViewModel.MaxLogLines, lines.Count); + Assert.Equal("line 25", lines[0]); + Assert.Equal($"line {MainViewModel.MaxLogLines + 24}", lines[^1]); + } + + [Fact] + public void FollowNewest_ScrollsAfterTheChange_NotDuringIt() + { + RunOnSta(() => + { + var lines = new ObservableCollection(); + var (window, list) = ShowLogList(lines); + ListAutoScroll.FollowNewest(list, lines); + for (var i = 0; i < 50; i++) lines.Add($"line {i}"); + Pump(); + var viewer = FindScrollViewer(list); + var before = viewer.VerticalOffset; + Assert.True(before > 0); + + var posted = new List(); + list.Dispatcher.Hooks.OperationPosted += (_, e) => posted.Add(e.Operation.Priority); + lines.Add("newest"); + Assert.Equal(before, viewer.VerticalOffset); + Assert.Contains(DispatcherPriority.Background, posted); + + Pump(); + Assert.True(viewer.VerticalOffset > before); + window.Close(); + }); + } + + [Fact] + public void FollowNewest_SurvivesAStartupBurstAtTheCap() + { + RunOnSta(() => + { + var lines = new ObservableCollection(); + var (window, list) = ShowLogList(lines); + ListAutoScroll.FollowNewest(list, lines); + + // Startup logs a burst of lines; once the cap is hit every Add is followed by a RemoveAt(0). + for (var i = 0; i < MainViewModel.MaxLogLines + 100; i++) + { + MainViewModel.AppendLogLine(lines, $"line {i}"); + if (i % 50 == 0) Pump(); + } + Pump(); + + var last = (ListBoxItem?)list.ItemContainerGenerator.ContainerFromItem(lines[^1]); + Assert.NotNull(last); + Assert.True(last.IsVisible); + window.Close(); + }); + } + + private static (Window, ListBox) ShowLogList(ObservableCollection lines) + { + var list = new ListBox { ItemsSource = lines, Height = 120 }; + VirtualizingPanel.SetIsVirtualizing(list, true); + var window = new Window + { + Content = list, Width = 300, Height = 160, ShowInTaskbar = false, ShowActivated = false, + WindowStyle = WindowStyle.None, Left = -10000, Top = -10000, + }; + window.Show(); + Pump(); + return (window, list); + } + + private static ScrollViewer FindScrollViewer(DependencyObject root) + { + if (root is ScrollViewer sv) return sv; + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + if (FindScrollViewerOrNull(VisualTreeHelper.GetChild(root, i)) is { } found) return found; + throw new InvalidOperationException("ListBox has no ScrollViewer"); + } + + private static ScrollViewer? FindScrollViewerOrNull(DependencyObject root) + { + try { return FindScrollViewer(root); } + catch (InvalidOperationException) { return null; } + } + + /// Runs every queued dispatcher operation, down to Background priority. + private static void Pump() => + Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle); + + private static void RunOnSta(Action test) + { + Exception? failure = null; + var thread = new Thread(() => + { + try { test(); } + catch (Exception ex) { failure = ex; } + finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + if (failure is not null) throw new Xunit.Sdk.XunitException($"Failed on the UI thread: {failure}"); + } +} diff --git a/tests/DLSSUpdater.Tests/ScanTests.cs b/tests/DLSSUpdater.Tests/ScanTests.cs index 31eef48..7f14e06 100644 --- a/tests/DLSSUpdater.Tests/ScanTests.cs +++ b/tests/DLSSUpdater.Tests/ScanTests.cs @@ -73,4 +73,115 @@ public void Inspect_UnityPlayerBeatsBiggerModTool() Assert.Equal(game, g.Exes[0]); } + + [Fact] + public void Vdf_UnescapesQuotedStrings() + { + var node = VdfNode.Parse(""" + "name" "a \"quoted\" C:\\Games\tTab" + """); + Assert.Equal("a \"quoted\" C:\\Games\tTab", node["name"]); + } + + [Fact] + public void Vdf_SkipsComments_ReadsUnquotedTokens_IgnoresKeyCase() + { + const string vdf = """ + // written by Steam + AppState + { + appid 1091500 // trailing comment + "InstallDir" "Cyberpunk 2077" + } + """; + var app = VdfNode.Parse(vdf).Child("appstate")!; + Assert.Equal("1091500", app["APPID"]); + Assert.Equal("Cyberpunk 2077", app["installdir"]); + Assert.Null(app["missing"]); + } + + [Theory] + [InlineData("\"a\" { \"b\" \"c\"")] // file cut off before the closing brace + [InlineData("\"a\" { \"b\" \"c")] // ... and inside a string + public void Vdf_TruncatedFile_KeepsWhatWasRead(string vdf) => + Assert.Equal("c", VdfNode.Parse(vdf).Child("a")!["b"]); + + [Fact] + public void Vdf_DanglingKey_IsIgnored() => Assert.Empty(VdfNode.Parse("\"a\"").Values); + + [Theory] + [InlineData("nvngx_dlss.dll", "SR")] + [InlineData("NVNGX_DLSSD.DLL", "RR")] + [InlineData("nvngx_dlssg.dll", "FG")] + [InlineData("sl.interposer.dll", "SL")] + [InlineData("nvngx_dlssnr.dll", "?")] + public void DlssDll_KindFromName(string name, string kind) => Assert.Equal(kind, new DlssDll { Name = name }.Kind); + + [Fact] + public void FgVersion_IsNewestFrameGenDll() + { + var g = new GameInfo + { + Dlss = + [ + new DlssDll { Name = "nvngx_dlss.dll", Version = new Version(310, 9, 1) }, + new DlssDll { Name = "nvngx_dlssg.dll", Version = new Version(3, 7, 10) }, + new DlssDll { Name = "nvngx_dlssg.dll", Version = new Version(310, 2, 0) }, + ], + }; + Assert.Equal(new Version(310, 2, 0), g.FgVersion); + Assert.Null(new GameInfo { Dlss = [new DlssDll { Name = "nvngx_dlss.dll", Version = new Version(1, 0) }] }.FgVersion); + } + + [Fact] + public void MakeId_IgnoresCaseAndTrailingSlash() => + Assert.Equal(GameInfo.MakeId(@"C:\Games\Foo\"), GameInfo.MakeId(@"c:\games\FOO")); + + private const string FakeRoot = @"X:\Games\Starfield"; + + [Fact] + public void RankExes_DropsInstallersCrashHandlersServersAndEngineTools() + { + var ranked = GameInspector.RankExes(FakeRoot, + [ + (FakeRoot + @"\unins000.exe", 3_000_000), + (FakeRoot + @"\UnityCrashHandler64.exe", 2_000_000), + (FakeRoot + @"\_CommonRedist\vc_redist.x64.exe", 25_000_000), + (FakeRoot + @"\StarfieldServer.exe", 90_000_000), + (FakeRoot + @"\Engine\Binaries\Win64\CrashReportClient.exe", 30_000_000), + (FakeRoot + @"\Engine\Binaries\Win64\Tool.exe", 30_000_000), + (FakeRoot + @"\Starfield.exe", 80_000_000), + ]); + Assert.Equal([FakeRoot + @"\Starfield.exe"], ranked); + } + + [Fact] + public void RankExes_PrefersFolderNameOverBiggerLauncherAndTools() + { + var ranked = GameInspector.RankExes(FakeRoot, + [ + (FakeRoot + @"\StarfieldLauncher.exe", 120_000_000), + (FakeRoot + @"\ModTool.exe", 200_000_000), + (FakeRoot + @"\Starfield.exe", 60_000_000), + ]); + Assert.Equal(FakeRoot + @"\Starfield.exe", ranked[0]); + Assert.Equal(3, ranked.Count); + } + + [Fact] + public void RankExes_UsesGameNameWhenFolderDiffers() + { + const string root = @"X:\SteamLibrary\common\SF"; + var ranked = GameInspector.RankExes(root, + [(root + @"\Other.exe", 60_000_000), (root + @"\Starfield.exe", 60_000_000)], "Starfield"); + Assert.Equal(root + @"\Starfield.exe", ranked[0]); + } + + [Fact] + public void RankExes_PrefersX64OverX86Build() + { + var ranked = GameInspector.RankExes(FakeRoot, + [(FakeRoot + @"\x86\Game.exe", 60_000_000), (FakeRoot + @"\bin\x64\Game.exe", 60_000_000)]); + Assert.Equal(FakeRoot + @"\bin\x64\Game.exe", ranked[0]); + } }