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
35 changes: 23 additions & 12 deletions src/DLSSUpdater/Addon/AddonServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,29 @@ internal static List<string> Reverted(string targetDir)
{
var m = InstallManifest.Load(targetDir);
if (m is null) return [];
var root = FileUtil.Normalize(Path.Combine(targetDir, m.RootRel));
var dlssNames = ComponentStore.DlssFiles;
var out_ = new List<string>();
foreach (var b in m.Backups.Where(b => b.Kind == "dlss" && b.InstalledSha is not null))
try
{
var root = FileUtil.Normalize(Path.Combine(targetDir, m.RootRel));
string Full(string entry) => InstallManifest.Resolve(entry, targetDir, root);
var dlssNames = ComponentStore.DlssFiles;
var out_ = new List<string>();
foreach (var b in m.Backups.Where(b => b.Kind == "dlss" && b.InstalledSha is not null))
{
var path = Full(b.Original);
if (!File.Exists(path) || !string.Equals(HashCache.Get(path), b.InstalledSha, StringComparison.OrdinalIgnoreCase))
out_.Add(b.Original);
}
foreach (var rel in m.Files.Where(f => dlssNames.Contains(Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)))
if (!File.Exists(Full(rel)) && !out_.Contains(rel, StringComparer.OrdinalIgnoreCase))
out_.Add(rel);
return out_;
}
catch (UnsafeManifestException ex)
{
var path = Path.Combine(root, b.Original);
if (!File.Exists(path) || !string.Equals(HashCache.Get(path), b.InstalledSha, StringComparison.OrdinalIgnoreCase))
out_.Add(b.Original);
// Display only: an install or uninstall reports the same record properly.
Log.Error(ex.Message);
return [];
}
foreach (var rel in m.Files.Where(f => dlssNames.Contains(Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)))
if (!File.Exists(Path.Combine(root, rel)) && !out_.Contains(rel, StringComparer.OrdinalIgnoreCase))
out_.Add(rel);
return out_;
}

private static string Short(Version v) => $"{v.Major}.{v.Minor}.{Math.Max(v.Build, 0)}";
Expand Down Expand Up @@ -254,7 +264,8 @@ private async Task<JsonObject> BeforeLaunchAsync(GameInfo info, CancellationToke
Progress($"A game update replaced {string.Join(", ", reverted.Select(Path.GetFileName).Distinct())}; putting DLSS back");
await EnsureStoreAsync(ct);
var vm = Model(info);
var o = vm.UpdateOptions(m);
// Put back what was installed; moving OptiScaler or MFG Unlock to a new release takes a click in DLSS Updater.
var o = vm.UpdateOptions(m).KeepingInstalledVersions(m);
if (vm.HasAntiCheat && vm.NeedsInjection(o) && !m.AntiCheatConfirmed)
throw new AddonException(-32000, $"{info.Name} uses {info.AntiCheat}; update it in DLSS Updater");
await _services.Value.Installer.InstallAsync(info, target, o, null, ct);
Expand Down
48 changes: 47 additions & 1 deletion src/DLSSUpdater/Core/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DLSSUpdater.Scan;
Expand Down Expand Up @@ -40,6 +42,10 @@ public sealed class AppSettings
public static readonly string[] ReShadeProxyNames =
["dxgi.dll", "d3d12.dll", "d3d11.dll", "d3d9.dll", "dinput8.dll", "opengl32.dll"];

/// <summary>A proxy name DLSS Updater would ever install under (OptiScaler or ReShade-only).</summary>
public static bool IsKnownProxy(string? name) =>
name is not null && (ProxyNames.Contains(name, StringComparer.OrdinalIgnoreCase) || ReShadeProxyNames.Contains(name, StringComparer.OrdinalIgnoreCase));

public string DefaultProxy { get; set; } = "dxgi.dll";
/// <summary>What a new install puts in: OptiScaler-NR (the main purpose) or ReShade with add-ons only.</summary>
public InstallMode DefaultMode { get; set; } = InstallMode.OptiScaler;
Expand All @@ -54,7 +60,29 @@ public sealed class AppSettings
public bool CarryOverGameIni { get; set; } = true;
/// <summary>keep | apply | fresh — how existing game configs are treated.</summary>
public string IniMode { get; set; } = "keep";
public string? GitHubToken { get; set; }
/// <summary>GitHub token; settings.json holds it only encrypted for this Windows user (DPAPI).</summary>
[JsonIgnore]
public string? GitHubToken
{
get => Unprotect(GitHubTokenProtected);
set => GitHubTokenProtected = Protect(value);
}
public string? GitHubTokenProtected { get; set; }

/// <summary>The plain-text token 1.5.1 and earlier saved: read once, then saved encrypted, never written.</summary>
[JsonPropertyName("GitHubToken")]
public string? LegacyGitHubToken
{
get => null;
set
{
if (string.IsNullOrWhiteSpace(value)) return;
GitHubToken = value;
_legacyTokenRead = true;
}
}

private bool _legacyTokenRead;
public List<IniOverride> IniOverrides { get; set; } = ConfigProfile.Defaults();
public List<IniOverride> ReShadeOverrides { get; set; } = ConfigProfile.ReShadeDefaults();
public bool InstallStreamline { get; set; }
Expand Down Expand Up @@ -88,6 +116,7 @@ public static AppSettings Load()
if (!s.CarryOverGameIni && s.IniMode == "keep") s.IniMode = "fresh";
s.CarryOverGameIni = true;
s.Presets.RemoveAll(p => p.Name.Equals(ConfigProfile.RecommendedName, StringComparison.OrdinalIgnoreCase));
if (s._legacyTokenRead) s.Save();
return s;
}
}
Expand All @@ -104,6 +133,23 @@ public void Save()
try { FileUtil.AtomicWriteText(AppPaths.SettingsFile, JsonSerializer.Serialize(this, JsonCtx.Default.AppSettings)); }
catch (IOException ex) { Log.Error("Could not save settings", ex); }
}

private static string? Protect(string? secret) =>
string.IsNullOrEmpty(secret)
? null
: Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(secret), null, DataProtectionScope.CurrentUser));

private static string? Unprotect(string? stored)
{
if (string.IsNullOrEmpty(stored)) return null;
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(stored), null, DataProtectionScope.CurrentUser)); }
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
// Encrypted by another Windows user or PC: the token has to be entered again.
Log.Error("Saved GitHub token can't be decrypted on this account", ex);
return null;
}
}
}

[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = true,
Expand Down
51 changes: 40 additions & 11 deletions src/DLSSUpdater/Core/ComponentStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,7 @@ private static async Task Resolve(Component c, Func<CancellationToken, Task<Rele
foreach (var r in await _gh.GetReleasesAsync(MfgRepo, 10, ct))
{
if (!Accept(r)) continue;
var addon = r.Assets.FirstOrDefault(a => a.Name.Equals(MfgFile, StringComparison.OrdinalIgnoreCase))
?? r.Assets.FirstOrDefault(a => a.Name.EndsWith(".addon64", StringComparison.OrdinalIgnoreCase));
var addon = r.Assets.FirstOrDefault(a => a.Name.Equals(MfgFile, StringComparison.OrdinalIgnoreCase));
if (addon is null) continue;
return new ReleaseInfo
{
Expand Down Expand Up @@ -292,10 +291,14 @@ private static void Prune(Component c, int keep)

// ---------- ensure (download + verify) ----------

/// <summary>Returns the folder holding the extracted OptiScaler-NR package.</summary>
public async Task<string> EnsureOptiAsync(IProgress<TransferProgress>? progress, CancellationToken ct)
/// <summary>
/// Returns the folder holding the extracted OptiScaler-NR package. <paramref name="tag"/> null means the newest
/// release; another tag must already be cached.
/// </summary>
public async Task<string> EnsureOptiAsync(string? tag, IProgress<TransferProgress>? progress, CancellationToken ct)
{
var r = Opti ?? throw new InvalidOperationException("OptiScaler-NR release unknown (offline and nothing cached).");
var r = CachedOrNewest(Component.OptiScaler, tag, Opti, "OptiScaler-NR")
?? throw new InvalidOperationException("OptiScaler-NR release unknown (offline and nothing cached).");
var dir = TagDir(Component.OptiScaler, r.Tag);
var pkg = Path.Combine(dir, "pkg");
await _locks[Component.OptiScaler].WaitAsync(ct);
Expand Down Expand Up @@ -324,12 +327,18 @@ public async Task<string> EnsureOptiAsync(IProgress<TransferProgress>? progress,
finally { _locks[Component.OptiScaler].Release(); }
}

/// <summary>Returns the path of the MFG Unlock addon.</summary>
public async Task<string> EnsureMfgAsync(IProgress<TransferProgress>? progress, CancellationToken ct)
/// <summary>
/// Returns the path of the MFG Unlock addon. <paramref name="tag"/> null means the newest release, "local" the
/// imported file; another tag must already be cached.
/// </summary>
public async Task<string> EnsureMfgAsync(string? tag, IProgress<TransferProgress>? progress, CancellationToken ct)
{
var local = Path.Combine(AppPaths.Components, MfgFile);
if (Mfg is null && File.Exists(local)) return local;
var r = Mfg ?? throw new InvalidOperationException("MFG Unlock release unknown (offline and nothing cached).");
if (tag == LocalMfgTag)
return File.Exists(local) ? local : throw new FileNotFoundException("The imported MFG Unlock add-on is gone.", local);
if (tag is null && Mfg is null && File.Exists(local)) return local;
var r = CachedOrNewest(Component.MfgUnlock, tag, Mfg, "MFG Unlock")
?? throw new InvalidOperationException("MFG Unlock release unknown (offline and nothing cached).");
var dir = TagDir(Component.MfgUnlock, r.Tag);
var file = Path.Combine(dir, MfgFile);
await _locks[Component.MfgUnlock].WaitAsync(ct);
Expand All @@ -348,6 +357,21 @@ public async Task<string> EnsureMfgAsync(IProgress<TransferProgress>? progress,
finally { _locks[Component.MfgUnlock].Release(); }
}

/// <summary>Tag recorded for an MFG Unlock add-on the user imported instead of a release.</summary>
public const string LocalMfgTag = "local";

/// <summary>True when that MFG Unlock version can be installed without a download.</summary>
public static bool HasMfg(string tag) =>
tag == LocalMfgTag ? File.Exists(Path.Combine(AppPaths.Components, MfgFile)) : IsCached(Component.MfgUnlock, tag);

private static ReleaseInfo? CachedOrNewest(Component c, string? tag, ReleaseInfo? newest, string label)
{
if (tag is null || (newest is not null && newest.Tag.Equals(tag, StringComparison.OrdinalIgnoreCase))) return newest;
return IsCached(c, tag)
? new ReleaseInfo { Tag = tag, FromCache = true }
: throw new InvalidOperationException($"{label} {tag} is no longer cached.");
}

/// <summary>
/// Returns file name -> cached path for the nvngx_dlss / dlssd / dlssg files the release has
/// (older SDKs ship without RR or FG). <paramref name="tag"/> null means the latest release.
Expand All @@ -372,7 +396,7 @@ IReadOnlyDictionary<string, string> Present() => DlssFiles
foreach (var (name, url, _) in r.Files)
{
var src = url;
if (!await GitHubClient.ExistsAsync(src, ct))
if (!await _gh.ExistsAsync(src, ct))
{
if (!latest) continue; // this SDK version has no such file
src = DlssRawUrl("main", name);
Expand Down Expand Up @@ -495,9 +519,14 @@ internal static int AppendedZipStart(byte[] data)
throw new InvalidDataException("No zip archive found in the ReShade setup.");
}

/// <summary>Checks a GitHub release asset against the digest GitHub published for it; no digest is a failure.</summary>
private static async Task VerifyAsync(string file, string? sha256, CancellationToken ct)
{
if (sha256 is null) return;
if (sha256 is null)
{
File.Delete(file);
throw new InvalidDataException($"{Path.GetFileName(file)}: GitHub published no SHA-256 digest for it, so it can't be verified.");
}
var actual = await Task.Run(() => FileUtil.Sha256(file), ct);
if (!actual.Equals(sha256, StringComparison.OrdinalIgnoreCase))
{
Expand Down
37 changes: 36 additions & 1 deletion src/DLSSUpdater/Core/FileUtil.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;

namespace DLSSUpdater.Core;

Expand Down Expand Up @@ -68,11 +69,16 @@ public static void AtomicCopy(string src, string dest)
File.Move(tmp, dest, true);
}

/// <summary>Writes via a temp file that is flushed to disk before the rename, so a power loss leaves the old or the new text, never a torn file.</summary>
public static void AtomicWriteText(string dest, string text)
{
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
var tmp = dest + ".dlssu-tmp";
File.WriteAllText(tmp, text);
using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
{
fs.Write(new UTF8Encoding(false).GetBytes(text));
fs.Flush(flushToDisk: true);
}
File.Move(tmp, dest, true);
}

Expand All @@ -85,6 +91,35 @@ public static bool IsUnder(string path, string root)

public static string Normalize(string path) => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);

/// <summary>
/// The first junction or symbolic link on the way from <paramref name="root"/> (not included) down to
/// <paramref name="path"/> (included), or null. Other reparse points (OneDrive placeholders, dedup) are not links.
/// </summary>
public static string? LinkBelow(string root, string path)
{
var r = Normalize(root);
var cur = Normalize(path);
while (cur.Length > r.Length && IsUnder(cur, r))
{
if (IsLink(cur)) return cur;
cur = Path.GetDirectoryName(cur)!;
}
return null;
}

private static bool IsLink(string path)
{
try
{
var attrs = File.GetAttributes(path);
if ((attrs & FileAttributes.ReparsePoint) == 0) return false;
FileSystemInfo info = (attrs & FileAttributes.Directory) != 0 ? new DirectoryInfo(path) : new FileInfo(path);
return info.LinkTarget is not null;
}
catch (FileNotFoundException) { return false; }
catch (DirectoryNotFoundException) { return false; }
}

public static void TryDeleteEmptyDir(string dir)
{
try
Expand Down
36 changes: 22 additions & 14 deletions src/DLSSUpdater/Core/GitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,25 @@ public sealed class ApiCacheEntry
/// <summary>Minimal GitHub REST client with ETag caching (304s don't count against the rate limit).</summary>
public sealed class GitHubClient
{
private static readonly HttpClient Http = CreateHttp();
private static readonly HttpClient SharedHttp = CreateHttp(new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
ConnectTimeout = TimeSpan.FromSeconds(15),
});
private readonly HttpClient _http;
private readonly object _cacheGate = new();
private Dictionary<string, ApiCacheEntry>? _cache;

public GitHubClient() => _http = SharedHttp;

/// <summary>For tests: every request goes to <paramref name="handler"/>.</summary>
internal GitHubClient(HttpMessageHandler handler) => _http = CreateHttp(handler);

public string? Token { get; set; }

private static HttpClient CreateHttp()
private static HttpClient CreateHttp(HttpMessageHandler handler)
{
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
ConnectTimeout = TimeSpan.FromSeconds(15),
};
var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DLSSUpdater", "1.0"));
return http;
Expand All @@ -78,7 +83,7 @@ public async Task<string> GetStringAsync(string url, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
using var res = await Http.GetAsync(url, cts.Token);
using var res = await _http.GetAsync(url, cts.Token);
res.EnsureSuccessStatusCode();
return await res.Content.ReadAsStringAsync(cts.Token);
}
Expand All @@ -97,7 +102,7 @@ private async Task<string> GetApiAsync(string url, CancellationToken ct)

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
using var res = await Http.SendAsync(req, cts.Token);
using var res = await _http.SendAsync(req, cts.Token);

if (res.StatusCode == HttpStatusCode.NotModified && cached is not null) return cached.Body;
if (res.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests && cached is not null)
Expand All @@ -123,7 +128,7 @@ public async Task DownloadAsync(string url, string dest, string label, IProgress
{
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
var tmp = dest + ".part";
using (var res = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
using (var res = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
{
res.EnsureSuccessStatusCode();
var total = res.Content.Headers.ContentLength;
Expand All @@ -149,11 +154,14 @@ public async Task DownloadAsync(string url, string dest, string label, IProgress
File.Move(tmp, dest, true);
}

public static async Task<bool> ExistsAsync(string url, CancellationToken ct)
/// <summary>True when the file is there, false only on a 404; anything else (rate limit, server error) throws.</summary>
public async Task<bool> ExistsAsync(string url, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Head, url);
using var res = await Http.SendAsync(req, ct);
return res.IsSuccessStatusCode;
using var res = await _http.SendAsync(req, ct);
if (res.StatusCode == HttpStatusCode.NotFound) return false;
res.EnsureSuccessStatusCode();
return true;
}

private Dictionary<string, ApiCacheEntry> LoadCache()
Expand Down
Loading
Loading