diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index d31f28b0..54b95470 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -111,6 +111,12 @@ jobs: - name: Package ${{ matrix.platform }} run: python .\utils\post-build.py .\bin\${{ matrix.platform }}\Release\output . ${{ env.VERSION }} + - name: Verify packaged translations from different launch directories + shell: pwsh + run: | + dotnet build .\utils\SatelliteResourceProbe\SatelliteResourceProbe.csproj -c Release + .\utils\SatelliteResourceProbe\Run-Probe.ps1 -SourceBuild ".\bin\${{ matrix.platform }}\Release\DS4Windows" + - name: Build standard installer shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9efdf49..de65c177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,20 @@ jobs: if ($dispatch -and -not $unsignedRc) { throw 'Draft dispatch is limited to named RC prereleases; other releases retain the signed published-event path.' } + if ($dispatch) { + # Saving a new draft does not create its Git tag. Materialize only + # a missing tag, from the explicitly selected dispatch branch. + $refs = @(gh api "repos/$env:GITHUB_REPOSITORY/git/matching-refs/tags/$tag" | ConvertFrom-Json) + $exact = @($refs | Where-Object { $_.ref -ceq "refs/tags/$tag" }) + if ($exact.Count -eq 0) { + $target = gh api "repos/$env:GITHUB_REPOSITORY/commits/$($release.target_commitish)" | ConvertFrom-Json + if ($target.sha -cne $env:GITHUB_SHA) { + throw 'A new draft tag must target the same commit as the selected workflow branch.' + } + gh api --method POST "repos/$env:GITHUB_REPOSITORY/git/refs" ` + -f "ref=refs/tags/$tag" -f "sha=$($target.sha)" + } + } "tag=$tag" >> $env:GITHUB_OUTPUT "release_id=$($release.id)" >> $env:GITHUB_OUTPUT "unsigned_rc=$($unsignedRc.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT @@ -174,6 +188,11 @@ jobs: - name: Post-Build script X64 shell: pwsh run: python .\utils\post-build.py .\bin\x64\Release\output . $env:VERSION + - name: Verify packaged translations from different launch directories + shell: pwsh + run: | + dotnet build .\utils\SatelliteResourceProbe\SatelliteResourceProbe.csproj -c Release + .\utils\SatelliteResourceProbe\Run-Probe.ps1 -SourceBuild ".\bin\x64\Release\DS4Windows" - name: Build standard installer X64 shell: pwsh env: diff --git a/DS4Windows/App.xaml.cs b/DS4Windows/App.xaml.cs index 705060d4..dacfcfb5 100644 --- a/DS4Windows/App.xaml.cs +++ b/DS4Windows/App.xaml.cs @@ -1,4 +1,4 @@ -/* +/* DS4Windows Copyright (C) 2023 Travis Nickles @@ -416,9 +416,8 @@ private void ApplicationStartupCore(object sender, StartupEventArgs e) logger.Info($"DS4Windows version {version}"); logger.Info($"DS4Windows exe file: {DS4Windows.Global.exeFileName}"); logger.Info($"DS4Windows Assembly Architecture: {(Environment.Is64BitProcess ? "x64" : "x86")}"); - logger.Info($"OS Version: {Environment.OSVersion}"); - logger.Info($"OS Product Name: {DS4Windows.Util.GetOSProductName()}"); - logger.Info($"OS Release ID: {DS4Windows.Util.GetOSReleaseId()}"); + foreach (string line in DS4Windows.WindowsVersionInfo.Read().LogLines) + logger.Info(line); logger.Info($"System Architecture: {(Environment.Is64BitOperatingSystem ? "x64" : "x86")}"); logger.Info("Logger created"); if (DS4Windows.PortableLabContext.Current is { } lab) diff --git a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs index c7b8d9c9..50946cd0 100644 --- a/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs +++ b/DS4Windows/DS4Control/Viiper/ViiperSetupManager.cs @@ -2611,7 +2611,7 @@ private static (bool Safe, string Message) } return (true, - "The loaded usbip-win2 0.9.7.7 driver files match the " + + "The registered usbip-win2 0.9.7.7 driver files match the " + "verified signed package."); } catch (Exception ex) @@ -2622,33 +2622,31 @@ private static (bool Safe, string Message) } } - private static bool TryGetSystemDriverSha256(string serviceName, - out string sha256, out string error) + private static string ReadSystemDriverImagePath(string serviceName) + { + // Win32_SystemDriver depends on WMI provider startup. At logon its + // two-second enumeration timeout can expire even for intact drivers. + // Read the same registered ImagePath directly; do not fall back to a + // guessed filename, cached hash, or the bundled copy. + using RegistryKey service = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Services\" + serviceName); + if (service == null) return null; + return service.GetValue("ImagePath", string.Empty, + RegistryValueOptions.DoNotExpandEnvironmentNames) as string + ?? string.Empty; + } + + internal static bool TryGetSystemDriverSha256(string serviceName, + out string sha256, out string error, + Func readImagePath = null) { sha256 = null; error = null; - string pathName = null; - int matches = 0; - - using ManagementObjectSearcher searcher = new( - "SELECT PathName FROM Win32_SystemDriver " + - $"WHERE Name='{serviceName}'"); - searcher.Options = CreateDependencyQueryOptions(); - using ManagementObjectCollection drivers = searcher.Get(); - foreach (ManagementObject driver in drivers) - { - using (driver) - { - matches++; - pathName = driver["PathName"] as string; - } - } + string pathName = (readImagePath ?? ReadSystemDriverImagePath)(serviceName); - if (matches != 1) + if (pathName == null) { - error = matches == 0 - ? $"the {serviceName} service is missing" - : $"multiple {serviceName} services were returned"; + error = $"the {serviceName} service is missing"; return false; } @@ -2656,7 +2654,7 @@ private static bool TryGetSystemDriverSha256(string serviceName, if (string.IsNullOrWhiteSpace(driverPath) || !File.Exists(driverPath)) { - error = $"the active {serviceName} driver file is missing"; + error = $"the registered {serviceName} driver file is missing"; return false; } @@ -2666,7 +2664,7 @@ private static bool TryGetSystemDriverSha256(string serviceName, return true; } - private static string ResolveSystemDriverPath(string pathName) + internal static string ResolveSystemDriverPath(string pathName) { if (string.IsNullOrWhiteSpace(pathName)) { diff --git a/DS4Windows/DS4Control/WindowsVersionInfo.cs b/DS4Windows/DS4Control/WindowsVersionInfo.cs new file mode 100644 index 00000000..bd69c891 --- /dev/null +++ b/DS4Windows/DS4Control/WindowsVersionInfo.cs @@ -0,0 +1,58 @@ +using System; +using System.Globalization; +using Microsoft.Win32; + +namespace DS4Windows; + +internal sealed record WindowsVersionInfo(string ProductName, string DisplayVersion, + string Build, string KernelVersion) +{ + internal string[] LogLines => new[] + { + $"OS Version: {ProductName} {DisplayVersion} (Build {Build})", + $"OS Kernel Version: {KernelVersion}", + }; + + internal static WindowsVersionInfo Read() + { + Version kernel = Environment.OSVersion.Version; + try + { + using RegistryKey machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, + Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); + using RegistryKey key = machine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + int build = int.TryParse(key?.GetValue("CurrentBuildNumber") as string, + NumberStyles.None, CultureInfo.InvariantCulture, out int parsed) ? parsed : kernel.Build; + int? revision = key?.GetValue("UBR") is int ubr && ubr >= 0 ? ubr : null; + return Create(key?.GetValue("ProductName") as string, + key?.GetValue("DisplayVersion") as string, key?.GetValue("ReleaseId") as string, + key?.GetValue("InstallationType") as string, build, revision, kernel); + } + catch + { + // Diagnostics must not prevent startup when registry access fails. + return Create(null, null, null, null, kernel.Build, null, kernel); + } + } + + internal static WindowsVersionInfo Create(string productName, string displayVersion, + string releaseId, string installationType, int build, int? revision, Version kernel) + { + string product = string.IsNullOrWhiteSpace(productName) ? "Windows" : productName; + // Windows 11 retains the legacy Windows 10 product string on many PCs. + // Restrict normalization to client editions; server build numbers overlap. + if (build >= 22000 && product.StartsWith("Windows 10", StringComparison.OrdinalIgnoreCase) && + (string.IsNullOrEmpty(installationType) || + string.Equals(installationType, "Client", StringComparison.OrdinalIgnoreCase))) + product = "Windows 11" + product.Substring("Windows 10".Length); + + string display = !string.IsNullOrWhiteSpace(displayVersion) ? displayVersion : + !string.IsNullOrWhiteSpace(releaseId) ? releaseId : "version unavailable"; + string buildText = build.ToString(CultureInfo.InvariantCulture) + + (revision.HasValue ? "." + revision.Value.ToString(CultureInfo.InvariantCulture) : + " (revision unavailable)"); + string kernelText = $"{kernel.Major}.{kernel.Minor}.{build}" + + (revision.HasValue ? "." + revision.Value.ToString(CultureInfo.InvariantCulture) : ""); + return new(product, display, buildText, kernelText); + } +} diff --git a/DS4Windows/DS4Forms/Converters/TrayIconConverter.cs b/DS4Windows/DS4Forms/Converters/TrayIconConverter.cs new file mode 100644 index 00000000..fbf030d8 --- /dev/null +++ b/DS4Windows/DS4Forms/Converters/TrayIconConverter.cs @@ -0,0 +1,88 @@ +using System; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Data; + +namespace DS4WinWPF.DS4Forms.Converters; + +public sealed class TrayIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not string resource || string.IsNullOrEmpty(resource)) return null; + using Stream stream = Application.GetResourceStream(new Uri(resource, UriKind.RelativeOrAbsolute)).Stream; + return TrayIconLoader.Load(stream, TrayIconLoader.GetPixelSize()); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +internal static class TrayIconLoader +{ + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindow(string className, string windowName); + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr window); + [DllImport("user32.dll")] + private static extern uint GetDpiForSystem(); + [DllImport("user32.dll")] + private static extern int GetSystemMetricsForDpi(int index, uint dpi); + + internal static int GetPixelSize() + { + // The tray belongs to Explorer's taskbar, which may be on another monitor. + uint dpi = GetDpiForWindow(FindWindow("Shell_TrayWnd", null)); + if (dpi == 0) dpi = GetDpiForSystem(); + return Math.Max(16, GetSystemMetricsForDpi(49 /* SM_CXSMICON */, dpi)); + } + + internal static Icon Load(Stream source, int pixelSize) + { + if (pixelSize <= 0) throw new ArgumentOutOfRangeException(nameof(pixelSize)); + using var bytes = new MemoryStream(); + source.CopyTo(bytes); + byte[] data = bytes.ToArray(); + using var reader = new BinaryReader(new MemoryStream(data)); + if (reader.ReadUInt16() != 0 || reader.ReadUInt16() != 1) + throw new InvalidDataException("Invalid tray icon header."); + int count = reader.ReadUInt16(); + int selected = -1, selectedSize = 0, selectedDepth = 0; + for (int index = 0; index < count; index++) + { + int offset = 6 + index * 16; + if (offset + 16 > data.Length) throw new InvalidDataException("Truncated tray icon directory."); + int width = data[offset] == 0 ? 256 : data[offset]; + int height = data[offset + 1] == 0 ? 256 : data[offset + 1]; + if (width != height) continue; + int depth = BitConverter.ToUInt16(data, offset + 6); + // Prefer an exact size, otherwise downsample the next larger frame. + // A smaller frame is used only when no sufficiently large one exists. + bool better = selected < 0 || + (width >= pixelSize && (selectedSize < pixelSize || width < selectedSize)) || + (width < pixelSize && selectedSize < pixelSize && width > selectedSize) || + (width == selectedSize && depth > selectedDepth); + if (better) { selected = offset; selectedSize = width; selectedDepth = depth; } + } + if (selected < 0) throw new InvalidDataException("Tray icon has no square frames."); + uint length = BitConverter.ToUInt32(data, selected + 8); + uint start = BitConverter.ToUInt32(data, selected + 12); + if (length == 0 || start < 6 + count * 16 || (ulong)start + length > (ulong)data.Length) + throw new InvalidDataException("Invalid tray icon frame."); + using var frame = new MemoryStream(); + using (var writer = new BinaryWriter(frame, System.Text.Encoding.UTF8, leaveOpen: true)) + { + writer.Write((ushort)0); + writer.Write((ushort)1); + writer.Write((ushort)1); + writer.Write(data, selected, 12); + writer.Write((uint)22); + writer.Write(data, checked((int)start), checked((int)length)); + } + frame.Position = 0; + return new Icon(frame, selectedSize, selectedSize); + } +} diff --git a/DS4Windows/DS4Forms/MainWindow.xaml b/DS4Windows/DS4Forms/MainWindow.xaml index 08190344..f736cbe5 100644 --- a/DS4Windows/DS4Forms/MainWindow.xaml +++ b/DS4Windows/DS4Forms/MainWindow.xaml @@ -20,9 +20,10 @@ + - + + + + + + + + + - - + + diff --git a/DS4Windows/DS4Forms/ViewModels/LogViewModel.cs b/DS4Windows/DS4Forms/ViewModels/LogViewModel.cs index cb8d386c..15ed490a 100644 --- a/DS4Windows/DS4Forms/ViewModels/LogViewModel.cs +++ b/DS4Windows/DS4Forms/ViewModels/LogViewModel.cs @@ -1,4 +1,4 @@ -/* +/* DS4Windows Copyright (C) 2023 Travis Nickles @@ -49,9 +49,8 @@ public LogViewModel(DS4Windows.ControlService service) : this() string version = DS4Windows.Global.exeDisplayVersion; logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"DS4Windows version {version}" }); logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"DS4Windows Assembly Architecture: {(Environment.Is64BitProcess ? "x64" : "x86")}" }); - logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"OS Version: {Environment.OSVersion}" }); - logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"OS Product Name: {DS4Windows.Util.GetOSProductName()}" }); - logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"OS Release ID: {DS4Windows.Util.GetOSReleaseId()}" }); + foreach (string line in WindowsVersionInfo.Read().LogLines) + logItems.Add(new LogItem { Datetime = DateTime.Now, Message = line }); logItems.Add(new LogItem { Datetime = DateTime.Now, Message = $"System Architecture: {(Environment.Is64BitOperatingSystem ? "x64" : "x32")}" }); service.Debug += AddLogMessage; diff --git a/DS4Windows/SatelliteResourceLoader.cs b/DS4Windows/SatelliteResourceLoader.cs new file mode 100644 index 00000000..e15692af --- /dev/null +++ b/DS4Windows/SatelliteResourceLoader.cs @@ -0,0 +1,57 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Threading; + +namespace DS4Windows; + +internal static class SatelliteResourceLoader +{ + private static int initialized; + [ModuleInitializer] + internal static void Initialize() + { + if (Interlocked.Exchange(ref initialized, 1) == 0) + AssemblyLoadContext.Default.Resolving += Resolve; + } + + private static Assembly Resolve(AssemblyLoadContext context, AssemblyName requested) + { + string path = GetSatellitePath(AppContext.BaseDirectory, requested); + if (path == null || !File.Exists(path)) return null; + try + { + AssemblyName actual = AssemblyName.GetAssemblyName(path); + if (!string.Equals(actual.Name, requested.Name, StringComparison.OrdinalIgnoreCase) || + !string.Equals(actual.CultureName, requested.CultureName, StringComparison.OrdinalIgnoreCase) || + (requested.Version != null && actual.Version != requested.Version) || + !(actual.GetPublicKeyToken() ?? Array.Empty()).SequenceEqual( + requested.GetPublicKeyToken() ?? Array.Empty())) return null; + return context.LoadFromAssemblyPath(path); + } + catch (IOException) { return null; } + catch (BadImageFormatException) { return null; } + catch (UnauthorizedAccessException) { return null; } + } + + internal static string GetSatellitePath(string applicationDirectory, AssemblyName requested) + { + const string suffix = ".resources"; + string name = requested.Name; + string culture = requested.CultureName; + if (string.IsNullOrEmpty(name) || !name.EndsWith(suffix, StringComparison.Ordinal) || + name.IndexOfAny(new[] { '/', '\\', ':' }) >= 0 || string.IsNullOrEmpty(culture) || + culture.IndexOfAny(new[] { '/', '\\', ':' }) >= 0) return null; + try { culture = CultureInfo.GetCultureInfo(culture).Name; } + catch (CultureNotFoundException) { return null; } + if (culture.Length == 0) return null; + string parent = name.Substring(0, name.Length - suffix.Length); + // Only resolve satellites for assemblies belonging to this application. + if (!File.Exists(Path.Combine(applicationDirectory, parent + ".dll"))) return null; + return Path.GetFullPath(Path.Combine(applicationDirectory, "Lang", culture, name + ".dll")); + } +} diff --git a/DS4WindowsTests/PortableBrokerIntegrationTests.cs b/DS4WindowsTests/PortableBrokerIntegrationTests.cs index 6bd87be5..8c5ba58f 100644 --- a/DS4WindowsTests/PortableBrokerIntegrationTests.cs +++ b/DS4WindowsTests/PortableBrokerIntegrationTests.cs @@ -176,8 +176,9 @@ public void StartupAndRepairRefreshDependencyAuthorityWithoutChangingOrdinarySta StringAssert.Contains(managedStart, "HasSafeRuntimePrerequisites(GetFreshDependencyStatus())"); Assert.IsFalse(setup.Contains("usbipDriverIntegrityStatus.Value", StringComparison.Ordinal)); Assert.IsFalse(setup.Contains("citrixUsbMonitorStatus.Value", StringComparison.Ordinal)); - Assert.AreEqual(2, setup.Split("searcher.Options = CreateDependencyQueryOptions();").Length - 1); - Assert.AreEqual(2, setup.Split("using ManagementObjectCollection drivers = searcher.Get();").Length - 1); + Assert.AreEqual(1, setup.Split("searcher.Options = CreateDependencyQueryOptions();").Length - 1, + "Only the Citrix runtime safety query still depends on WMI."); + Assert.AreEqual(1, setup.Split("using ManagementObjectCollection drivers = searcher.Get();").Length - 1); } [DataTestMethod] diff --git a/DS4WindowsTests/TrayIconResolutionTests.cs b/DS4WindowsTests/TrayIconResolutionTests.cs new file mode 100644 index 00000000..ea6da245 --- /dev/null +++ b/DS4WindowsTests/TrayIconResolutionTests.cs @@ -0,0 +1,58 @@ +using DS4WinWPF.DS4Forms.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Windows; + +namespace DS4Windows.Tests; + +[TestClass] +public class TrayIconResolutionTests +{ + [DataTestMethod] + [DataRow(16, 16)] + [DataRow(20, 24)] + [DataRow(24, 24)] + [DataRow(28, 32)] + [DataRow(32, 32)] + [DataRow(40, 48)] + [DataRow(48, 48)] + [DataRow(64, 64)] + public void RealControllerIconsNeverUpscaleASmallerFrame(int requested, int expected) + { + foreach (string name in new[] { "DS4W.ico", "DS4W - White.ico", "DS4W - Black.ico" }) + { + using Stream stream = Application.GetResourceStream(new Uri( + "/DS4Windows;component/Resources/" + name, UriKind.Relative)).Stream; + using var icon = TrayIconLoader.Load(stream, requested); + Assert.AreEqual(expected, icon.Width, name); + Assert.AreEqual(expected, icon.Height, name); + Assert.AreNotEqual(IntPtr.Zero, icon.Handle); + } + } + + [TestMethod] + public void BatteryIconsRemainLoadableWithIndependentNativeHandles() + { + for (int percentage = 0; percentage <= 100; percentage += 10) + { + using Stream stream = Application.GetResourceStream(new Uri( + $"/DS4Windows;component/Resources/{percentage}.ico", UriKind.Relative)).Stream; + using var first = TrayIconLoader.Load(stream, 32); + stream.Position = 0; + using var second = TrayIconLoader.Load(stream, 32); + Assert.AreNotEqual(first.Handle, second.Handle); + first.Dispose(); + Assert.AreNotEqual(IntPtr.Zero, second.Handle); + } + } + + [TestMethod] + public void InvalidIconDataFailsBeforeNativeHandleCreation() + { + using var truncated = new MemoryStream(new byte[] { 0, 0, 1, 0, 1, 0 }); + Assert.ThrowsException(() => TrayIconLoader.Load(truncated, 32)); + using var empty = new MemoryStream(); + Assert.ThrowsException(() => TrayIconLoader.Load(empty, 0)); + } +} diff --git a/DS4WindowsTests/ViiperSystemDriverIntegrityTests.cs b/DS4WindowsTests/ViiperSystemDriverIntegrityTests.cs new file mode 100644 index 00000000..0bc0be08 --- /dev/null +++ b/DS4WindowsTests/ViiperSystemDriverIntegrityTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DS4Windows.Tests; + +[TestClass] +public class ViiperSystemDriverIntegrityTests +{ + [TestMethod] + public void HashesRegisteredFileAndObservesReplacementOnNextInspection() + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sys"); + try + { + byte[] original = { 1, 2, 3 }; + File.WriteAllBytes(path, original); + string ReadPath(string service) + { + Assert.AreEqual("usbip2_ude", service); + return "\"" + path + "\""; + } + + Assert.IsTrue(ViiperSetupManager.TryGetSystemDriverSha256( + "usbip2_ude", out string first, out string error, ReadPath)); + Assert.IsNull(error); + Assert.AreEqual(Convert.ToHexString(SHA256.HashData(original)), first); + + byte[] replacement = { 4, 5, 6 }; + File.WriteAllBytes(path, replacement); + Assert.IsTrue(ViiperSetupManager.TryGetSystemDriverSha256( + "usbip2_ude", out string second, out error, ReadPath)); + Assert.AreEqual(Convert.ToHexString(SHA256.HashData(replacement)), second); + Assert.AreNotEqual(first, second); + Assert.IsFalse(ViiperSetupManager.AreSupportedUsbipDriverHashes(first, second)); + } + finally { File.Delete(path); } + } + + [DataTestMethod] + [DataRow(null, "service is missing")] + [DataRow("", "driver file is missing")] + [DataRow("\"", "driver file is missing")] + public void MissingRegistrationOrImageNeverAuthorizesStartup(string imagePath, string message) + { + Assert.IsFalse(ViiperSetupManager.TryGetSystemDriverSha256( + "usbip2_filter", out string hash, out string error, _ => imagePath)); + Assert.IsNull(hash); + StringAssert.Contains(error, message); + } + + [TestMethod] + public void MissingRegisteredFileNeverFallsBackToAnotherDriver() + { + string missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sys"); + Assert.IsFalse(ViiperSetupManager.TryGetSystemDriverSha256( + "usbip2_filter", out string hash, out string error, _ => missing)); + Assert.IsNull(hash); + StringAssert.Contains(error, "driver file is missing"); + } + + [TestMethod] + public void UnreadableRegistrationPropagatesToIntegrityFailureHandler() + { + Assert.ThrowsException(() => + ViiperSetupManager.TryGetSystemDriverSha256("usbip2_ude", out _, out _, + _ => throw new UnauthorizedAccessException("Denied"))); + } + + [DataTestMethod] + [DataRow(@"\SystemRoot\System32\drivers\usbip2_ude.sys")] + [DataRow(@"System32\drivers\usbip2_ude.sys")] + [DataRow(@"%SystemRoot%\System32\drivers\usbip2_ude.sys")] + public void ResolvesRegistryDriverPathForms(string imagePath) + { + string expected = Path.Combine(Environment.GetEnvironmentVariable("SystemRoot"), + @"System32\drivers\usbip2_ude.sys"); + Assert.AreEqual(expected, ViiperSetupManager.ResolveSystemDriverPath(imagePath), true); + Assert.AreEqual(expected, ViiperSetupManager.ResolveSystemDriverPath(@"\??\" + expected), true); + Assert.AreEqual(expected, ViiperSetupManager.ResolveSystemDriverPath(@"\\?\" + expected), true); + } +} diff --git a/DS4WindowsTests/WindowsVersionInfoTests.cs b/DS4WindowsTests/WindowsVersionInfoTests.cs new file mode 100644 index 00000000..e729c50e --- /dev/null +++ b/DS4WindowsTests/WindowsVersionInfoTests.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DS4Windows.Tests; + +[TestClass] +public class WindowsVersionInfoTests +{ + [TestMethod] + public void ReportsWindows11DisplayVersionAndFullServicingBuild() + { + var details = WindowsVersionInfo.Create("Windows 10 Pro", "26H2", "2009", + "Client", 26300, 9550, new Version(10, 0, 26300, 0)); + Assert.AreEqual("Windows 11 Pro", details.ProductName); + Assert.AreEqual("26H2", details.DisplayVersion); + Assert.AreEqual("26300.9550", details.Build); + Assert.AreEqual("10.0.26300.9550", details.KernelVersion); + Assert.AreEqual("OS Version: Windows 11 Pro 26H2 (Build 26300.9550)", details.LogLines[0]); + } + + [DataTestMethod] + [DataRow("Windows 10 Pro", "Client", 19045, "Windows 10 Pro")] + [DataRow("Windows Server 2025 Standard", "Server", 26100, "Windows Server 2025 Standard")] + [DataRow("Windows 10 Pro", "Server", 26100, "Windows 10 Pro")] + [DataRow("Windows 11 Pro", "Client", 26300, "Windows 11 Pro")] + public void PreservesWindows10AndServerProductIdentity(string product, string installation, + int build, string expected) + { + Assert.AreEqual(expected, WindowsVersionInfo.Create(product, "", "2009", + installation, build, null, new Version(10, 0, build, 0)).ProductName); + } + + [TestMethod] + public void MissingModernMetadataFallsBackWithoutInventingServicingRevision() + { + var legacy = WindowsVersionInfo.Create("Windows 10 Pro", null, "1809", + "Client", 17763, null, new Version(10, 0, 17763, 0)); + Assert.AreEqual("1809", legacy.DisplayVersion); + Assert.AreEqual("17763 (revision unavailable)", legacy.Build); + var unavailable = WindowsVersionInfo.Create(null, null, null, null, 26300, + null, new Version(10, 0, 26300, 0)); + Assert.AreEqual("Windows", unavailable.ProductName); + Assert.AreEqual("version unavailable", unavailable.DisplayVersion); + } +} diff --git a/docs/validation/usbip-driver-verification-timeout.md b/docs/validation/usbip-driver-verification-timeout.md new file mode 100644 index 00000000..dfd36c4c --- /dev/null +++ b/docs/validation/usbip-driver-verification-timeout.md @@ -0,0 +1,67 @@ +# USB/IP driver verification timeout: issues #118 and #126 + +## Finding + +`EvaluateUsbipDriverIntegrity` previously found the two installed driver files +using `Win32_SystemDriver` queries. `CreateDependencyQueryOptions` sets a +two-second enumeration timeout. A WMI exception is caught and displayed as +`usbip-win2 driver integrity could not be verified: Timed out`, even before +the files can be hashed. This is a concrete failure path in DS4Windows, not +evidence that the driver files are corrupt. + +Issue #118 reports intermittent success without changes to the installation. +Issue #126 reports that Settings Refresh succeeds after the startup failure. +Both observations are consistent with this query failure. They do not prove +why WMI timed out on those machines. The reported filter start-type change +and possible Sunshine conflict require separate runtime evidence; this patch +does not claim to fix or explain either. + +## Change + +Read `ImagePath` from the named driver's +`HKLM\SYSTEM\CurrentControlSet\Services` key instead of WMI. Microsoft documents +this value as the service binary path: +https://learn.microsoft.com/en-us/windows-hardware/drivers/install/hklm-system-currentcontrolset-services-registry-tree + +Resolve existing Windows driver path formats and hash the registered file. +Both hashes must still match the pinned USBip 0.9.7.7 values. Missing keys, +missing images, denied reads, and changed driver bytes cannot authorize the +backend. There is no fallback filename or hash cache. No service settings, +driver packages, or security settings are changed. The runtime USB/IP probe +and the Citrix safety check remain in place. + +Only DS4Windows changes. No VIIPER or DS4Updater release is needed. + +Source baseline: Pav-Osmolski/DS4Windows commit +`36502408cbba17fd20d84e6b258f2258714a39c6`. +Local branch: `fix/usbip-driver-verification-timeout`. + +## Automated coverage + +`ViiperSystemDriverIntegrityTests` covers actual file hashing, replacement on +the next inspection, missing service/image/file, denied configuration access, +and Windows driver path forms. Existing setup/readiness/probe tests verify +that startup still requires verified files and a working runtime. + +Validation on 9 October 2026: the x64 Debug application and test project built +successfully using .NET SDK 9.0.318, targeting .NET 8. The focused run passed +all 95 tests (zero failures or skips) from `ViiperSystemDriverIntegrityTests`, +`ViiperSetupManagerTests`, `ViiperDependencyReadinessTests`, +`ViiperStartupReadinessTests`, and `ViiperUsbipProbeOutputTests`. +The build reported existing unused-member and duplicate translation warnings. + +## Acceptance on an affected PC + +1. Back up settings and profiles; close DS4Windows before replacing its files. +2. Test the candidate with the existing USBip and VIIPER installations. +3. Launch repeatedly, both normally and elevated. Confirm that the WMI driver + integrity timeout no longer appears and virtual controller output works. +4. Reboot at least three times with the usual Run at Startup configuration. + Confirm output works without Install / Repair or Settings Refresh. +5. If runtime readiness still fails, collect the DS4Windows log, `usbip.exe + port` result, and the service configuration/state for `usbip2_ude` and + `usbip2_filter`. A disabled or blocked driver can remain a separate problem. + +Automated checks cannot establish successful reboot behavior on the reporter's +hardware. Do not treat this candidate as a confirmed field fix until that +acceptance test passes. diff --git a/utils/SatelliteResourceProbe/Program.cs b/utils/SatelliteResourceProbe/Program.cs index f8f8856a..f4cd93de 100644 --- a/utils/SatelliteResourceProbe/Program.cs +++ b/utils/SatelliteResourceProbe/Program.cs @@ -8,6 +8,8 @@ // construct App/ControlService, or initialize controller access. Assembly application = AssemblyLoadContext.Default.LoadFromAssemblyPath( Path.Combine(AppContext.BaseDirectory, "DS4Windows.dll")); +application.GetType("DS4Windows.SatelliteResourceLoader", throwOnError: true)! + .GetMethod("Initialize", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, null); var resources = new ResourceManager("DS4WinWPF.Translations.Strings", application); string? neutral = resources.GetString("Browse", CultureInfo.InvariantCulture); string? german = resources.GetString("Browse", CultureInfo.GetCultureInfo("de")); diff --git a/utils/SatelliteResourceProbe/README.md b/utils/SatelliteResourceProbe/README.md index 1b6fb89b..eb8d6af2 100644 --- a/utils/SatelliteResourceProbe/README.md +++ b/utils/SatelliteResourceProbe/README.md @@ -11,12 +11,14 @@ dotnet build utils/SatelliteResourceProbe/SatelliteResourceProbe.csproj -c Relea python utils/test-localization-package.py ``` -The first run reconstructs the old `Lang/` package using the actual -published dependency/resource identities, and verifies that launching outside -the package loses German translations. The second keeps the standard -`` folders and verifies both launch directories succeed without -additional probing. Each child also checks parent-culture fallback, neutral -fallback for an unavailable language, and rejection of an unrelated assembly. +The first run reconstructs the packaged `Lang/` layout using actual +dependency/resource identities. It initializes the application's resource +resolver without starting its UI or controller services and verifies German +translations from both the package directory and an unrelated directory. +The second run verifies the standard `` layout used by developer +builds. Neither layout requires additional probing paths. Each child also +checks parent-culture fallback, neutral fallback for an unavailable language, +dependency translations, and rejection of an unrelated assembly. The fixture copies are retained under `artifacts/issue60-*` for inspection. The Python regression separately runs the real `post-build.py` and WiX file diff --git a/utils/SatelliteResourceProbe/Run-Probe.ps1 b/utils/SatelliteResourceProbe/Run-Probe.ps1 index e4c0c94f..73a52804 100644 --- a/utils/SatelliteResourceProbe/Run-Probe.ps1 +++ b/utils/SatelliteResourceProbe/Run-Probe.ps1 @@ -49,9 +49,6 @@ Copy-Item -LiteralPath (Join-Path $probeBuild 'SatelliteResourceProbe.dll') -Des Copy-Item -LiteralPath (Join-Path $probeBuild 'SatelliteResourceProbe.runtimeconfig.json') -Destination $packageRoot $runtimeConfigPath = Join-Path $packageRoot 'SatelliteResourceProbe.runtimeconfig.json' $runtimeConfig = Get-Content -LiteralPath $runtimeConfigPath -Raw | ConvertFrom-Json -AsHashtable -if (-not $StandardLayout) { - $runtimeConfig.runtimeOptions.additionalProbingPaths = @('./Lang/') -} $runtimeConfig | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $runtimeConfigPath -Encoding utf8 $probeDeps | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath (Join-Path $packageRoot 'SatelliteResourceProbe.deps.json') -Encoding utf8 @@ -63,7 +60,7 @@ foreach ($startDirectory in @($packageRoot, $outsideRoot)) { $probeExit = $LASTEXITCODE $probeOutput $result = $probeOutput | ConvertFrom-Json - $shouldLocalize = $StandardLayout -or $startDirectory -eq $packageRoot + $shouldLocalize = $true if ($result.Localized -ne $shouldLocalize -or ($null -ne $result.SchedulerSatellite) -ne $shouldLocalize -or -not $result.UnrelatedRejected -or diff --git a/utils/SatelliteResourceProbe/SatelliteResourceProbe.csproj b/utils/SatelliteResourceProbe/SatelliteResourceProbe.csproj index 17a5eb3a..f286d5b2 100644 --- a/utils/SatelliteResourceProbe/SatelliteResourceProbe.csproj +++ b/utils/SatelliteResourceProbe/SatelliteResourceProbe.csproj @@ -1,6 +1,8 @@ Exe + + 1.0.0 net8.0-windows enable enable diff --git a/utils/post-build.py b/utils/post-build.py index 822b9792..a037593b 100644 --- a/utils/post-build.py +++ b/utils/post-build.py @@ -110,10 +110,20 @@ def is_reparse_point(path: Path) -> bool: newline="\n", ) -# Keep the SDK's /*.resources.dll layout and generated deps.json. -# .NET resolves these satellites relative to the application, regardless of -# the launch directory. Moving them into Lang required CWD-relative probing, -# which silently lost translations for logon tasks and outside-directory starts. +# Keep culture names and satellite identities intact beneath Lang. The managed +# resolver uses AppContext.BaseDirectory, never the current working directory. +# Move complete satellite directories so dependency translations travel too. +satellite_directories = sorted({path.parent for path in target_dir.glob("*/*.resources.dll")}) +for directory in satellite_directories: + if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", directory.name): + raise SystemExit(f"Invalid satellite culture directory: {directory.name}") + destination = target_dir / "Lang" / directory.name + if destination.exists(): + raise SystemExit(f"Satellite destination already exists: {destination}") +for directory in satellite_directories: + destination = target_dir / "Lang" / directory.name + destination.parent.mkdir(exist_ok=True) + directory.rename(destination) # Preserve the exact GitHub release channel in both portable and managed # packages. The numeric Windows file version cannot distinguish an RC from a diff --git a/utils/test-localization-package.py b/utils/test-localization-package.py index 4cc583ce..5e7e7de1 100644 --- a/utils/test-localization-package.py +++ b/utils/test-localization-package.py @@ -127,7 +127,7 @@ def test_installer_also_requires_xbox_persona_and_notice(self): "extras/XBOX-ONE-PERSONA-NOTICE.md"}.issubset( VALIDATOR.REQUIRED_PUBLISH_FILES)) - def test_actual_composition_preserves_standard_satellites_and_manifests(self): + def test_actual_composition_moves_satellites_to_lang_and_updates_manifests(self): with tempfile.TemporaryDirectory(prefix="ds4w-localization-package-") as temporary: root = Path(temporary) publish = root / "x64" / "Release" / "output" @@ -172,19 +172,23 @@ def test_actual_composition_preserves_standard_satellites_and_manifests(self): str(publish), str(REPOSITORY), "issue60-regression", ], cwd=root, check=True, capture_output=True, text=True) package = publish.parent / "DS4Windows" - self.assertFalse((package / "Lang").exists()) + self.assertTrue((package / "Lang").is_dir()) + self.assertFalse((package / "de").exists()) + self.assertFalse((package / "pt-BR").exists()) self.assertEqual(deps_text, (package / "DS4Windows.deps.json").read_text(encoding="utf-8")) VALIDATOR.validate_localization_package(package) owned = set((package / ".ds4windows-managed-files.txt").read_text(encoding="utf-8").splitlines()) for relative in fixture_paths: - self.assertEqual(contents[relative], (package / relative).read_bytes()) - self.assertIn(relative, owned) + packaged_relative = "Lang/" + relative if relative in SATELLITES else relative + self.assertEqual(contents[relative], (package / packaged_relative).read_bytes()) + self.assertIn(packaged_relative, owned) archive = publish.parent / "DS4Windows_issue60-regression_x64.zip" with zipfile.ZipFile(archive) as packaged: for relative in fixture_paths: - self.assertIn("DS4Windows/" + relative, packaged.namelist()) - self.assertEqual(contents[relative], packaged.read("DS4Windows/" + relative)) + packaged_relative = "Lang/" + relative if relative in SATELLITES else relative + self.assertIn("DS4Windows/" + packaged_relative, packaged.namelist()) + self.assertEqual(contents[relative], packaged.read("DS4Windows/" + packaged_relative)) broker = packaged.read("DS4Windows/extras/VIIPER-0.1.9-rc4.6.6-x64.exe") self.assertEqual(broker, packaged.read("DS4Windows/viiper.exe")) broker_hash = hashlib.sha256(broker).hexdigest() @@ -223,15 +227,16 @@ def test_actual_composition_preserves_standard_satellites_and_manifests(self): for entry in ET.parse(wix).getroot().iter("{http://wixtoolset.org/schemas/v4/wxs}File") } for relative in fixture_paths: - self.assertIn(relative, paths) - self.assertIn(relative, sources) + packaged_relative = "Lang/" + relative if relative in SATELLITES else relative + self.assertIn(packaged_relative, paths) + self.assertIn(packaged_relative, sources) for relative in PORTABLE_ONLY: self.assertNotIn(relative, paths) self.assertNotIn(relative, sources) # Validation examines real published paths, not only source text. - (package / SATELLITES[0]).unlink() - with self.assertRaisesRegex(SystemExit, "Missing standard-layout satellite"): + (package / "Lang" / SATELLITES[0]).unlink() + with self.assertRaisesRegex(SystemExit, "Missing Lang satellite"): VALIDATOR.validate_localization_package(package) def assert_rejected_publish_preserves_existing_output(self, collision, *, directory=False, reparse=False): diff --git a/utils/test-previous-msi-upgrade.ps1 b/utils/test-previous-msi-upgrade.ps1 index 32681cdf..a46bdfdb 100644 --- a/utils/test-previous-msi-upgrade.ps1 +++ b/utils/test-previous-msi-upgrade.ps1 @@ -14,7 +14,7 @@ $ErrorActionPreference = 'Stop' if ($env:GITHUB_ACTIONS -cne 'true' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:RUNNER_OS -cne 'Windows' -or - $env:GITHUB_REPOSITORY -cne 'hbashton/DS4Windows' -or + $env:GITHUB_REPOSITORY -cnotin @('hbashton/DS4Windows', 'Pav-Osmolski/DS4Windows') -or $env:GITHUB_RUN_ID -notmatch '^\d+$' -or [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or [string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) { diff --git a/utils/validate-installer.py b/utils/validate-installer.py index 21749fb8..6f8893e6 100644 --- a/utils/validate-installer.py +++ b/utils/validate-installer.py @@ -908,7 +908,7 @@ def main() -> int: def validate_localization_package(publish_root: Path) -> None: - """Keep both application and dependency satellites in .NET's standard layout.""" + """Keep application and dependency satellites together under Lang.""" runtime = json.loads( (publish_root / "DS4Windows.runtimeconfig.json").read_text(encoding="utf-8-sig") ) @@ -932,8 +932,8 @@ def validate_localization_package(publish_root: Path) -> None: raise SystemExit("Invalid satellite locale: " + repr(culture)) if PurePosixPath(asset).name != expected_name: raise SystemExit("Unexpected satellite assembly: " + asset) - if not (publish_root / culture / expected_name).is_file(): - raise SystemExit("Missing standard-layout satellite: " + culture + "/" + expected_name) + if not (publish_root / "Lang" / culture / expected_name).is_file(): + raise SystemExit("Missing Lang satellite: " + culture + "/" + expected_name) validated.add(owner) if validated != {"DS4Windows", "TaskScheduler"}: raise SystemExit("Package dependency metadata omits application or TaskScheduler satellites.")