Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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]
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ TestResults/
*.exe
*.zip
/OptiScaler.ini

# Secrets (safety baseline)
.env
.env.*
!.env.example
*.token
*.pem
59 changes: 59 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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=<version> installer\DLSSUpdater.iss` → `publish\DLSSUpdater-Setup-<version>.exe`

## Release flow
1. Bump `<Version>` 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<version>` (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<version>` 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.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
10 changes: 3 additions & 7 deletions src/DLSSUpdater/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
}

/// <summary>While a keybind button is waiting, the next key press becomes the binding.</summary>
Expand Down
12 changes: 10 additions & 2 deletions src/DLSSUpdater/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand All @@ -60,6 +59,15 @@ public MainViewModel()
public ReleaseNewsViewModel StreamlineNews { get; }
public ReleaseNewsViewModel ReShadeNews { get; }

internal const int MaxLogLines = 400;

/// <summary>Adds a log line and drops the oldest ones beyond <see cref="MaxLogLines"/>.</summary>
internal static void AppendLogLine(IList<string> 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";
Expand Down
19 changes: 19 additions & 0 deletions src/DLSSUpdater/Views/ListAutoScroll.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Collections.Specialized;
using System.Windows.Controls;
using System.Windows.Threading;

namespace DLSSUpdater.Views;

/// <summary>Keeps a ListBox scrolled to its newest item.</summary>
public static class ListAutoScroll
{
/// <summary>
/// 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.
/// </summary>
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);
}
13 changes: 13 additions & 0 deletions tests/DLSSUpdater.Tests/InstallerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,19 @@ public async Task ReShadeOnly_WithoutReShade_Throws()
await Assert.ThrowsAsync<InvalidOperationException>(() => 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);
Expand Down
121 changes: 121 additions & 0 deletions tests/DLSSUpdater.Tests/LogScrollTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Regression tests for the startup crash loop fixed in b5f78ef (log ListBox scrolled inside CollectionChanged).</summary>
public class LogScrollTests
{
[Fact]
public void AppendLogLine_KeepsNewestLines()
{
var lines = new List<string>();
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<string>();
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<DispatcherPriority>();
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<string>();
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<string> 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; }
}

/// <summary>Runs every queued dispatcher operation, down to Background priority.</summary>
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}");
}
}
Loading
Loading