From fe87c79f55060f7bdf98fec02b3cb688ace46298 Mon Sep 17 00:00:00 2001 From: Cyd Date: Tue, 30 Jun 2026 12:34:47 -0500 Subject: [PATCH] net48 port (test branch): retarget all projects to .NET Framework 4.8 Retargets RioJoy.Core/Overlay/Tray + tests from net8.0-windows to net48 so the app can be tested as a framework-dependent build (relies on the in-box .NET Framework 4.8 on Windows 10/11). Builds clean; all 241 tests pass. Polyfills (no behavior change): - PolySharp source generator for init/records/Index/Range/required members. - System.Memory, System.Text.Json, Microsoft.Bcl.HashCode, System.Threading.Channels (tests) NuGet packages. - Compat/Net48Polyfills.cs: GetValueOrDefault, KeyValuePair.Deconstruct, Math.Clamp; tests/TestPolyfills.cs: Task.WaitAsync. Source adjustments for APIs absent on net48: - ArgumentNullException/ArgumentException.ThrowIf* inlined to manual guards. - Convert.ToHexString, Encoding.Latin1, Environment.ProcessPath, ApplicationConfiguration.Initialize, Enum.GetNames/GetValues, string.StartsWith(char), string.Split(char, opts), TextBox.PlaceholderText, PeriodicTimer, Memory-based Stream Read/WriteAsync, array range-slicing. - Dropped [SupportedOSPlatform] hints (net48 is single-platform). deploy/build-package.ps1: publish framework-dependent (no self-contained). Co-Authored-By: Claude Opus 4.8 --- deploy/build-package.ps1 | 8 +-- src/RioJoy.Core/Calibration/AxisCalibrator.cs | 2 +- src/RioJoy.Core/Compat/Net48Polyfills.cs | 52 +++++++++++++++++++ src/RioJoy.Core/Editing/SheetLayout.cs | 2 +- src/RioJoy.Core/Hid/RioHidReport.cs | 2 +- src/RioJoy.Core/Mapping/RioInputMap.cs | 2 +- .../Output/HidFeederJoystickSink.cs | 2 - src/RioJoy.Core/Output/SendInputSink.cs | 2 - src/RioJoy.Core/Output/ViGEmJoystickSink.cs | 6 +-- src/RioJoy.Core/Overlay/FontFitter.cs | 2 +- src/RioJoy.Core/Overlay/GoobieDataImporter.cs | 4 +- .../Overlay/OverlayLayoutEngine.cs | 8 +-- .../Overlay/OverlayTemplateStore.cs | 8 +-- src/RioJoy.Core/Plasma/PlasmaCommands.cs | 7 +-- src/RioJoy.Core/Profiles/AutoSwitch.cs | 4 +- src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs | 14 ++++- src/RioJoy.Core/Profiles/ConfigStore.cs | 8 +-- src/RioJoy.Core/Profiles/IniFile.cs | 6 +-- src/RioJoy.Core/Profiles/RioIniImporter.cs | 2 +- src/RioJoy.Core/Protocol/PacketParser.cs | 2 +- src/RioJoy.Core/Protocol/RioPacket.cs | 2 +- src/RioJoy.Core/RioJoy.Core.csproj | 15 ++++-- src/RioJoy.Core/RioRuntime.cs | 4 +- src/RioJoy.Core/Serial/SerialPortTransport.cs | 20 +++++-- .../ProfileWallpaperGenerator.cs | 6 +-- src/RioJoy.Overlay/RioJoy.Overlay.csproj | 7 ++- src/RioJoy.Overlay/SkiaOverlayRenderer.cs | 10 ++-- src/RioJoy.Tray/Editor/PanelCanvas.cs | 2 - src/RioJoy.Tray/Editor/PanelView.cs | 2 - src/RioJoy.Tray/Editor/ProfileEditorForm.cs | 14 ++--- src/RioJoy.Tray/Os/AutoStartManager.cs | 4 +- .../Os/ForegroundProcessProvider.cs | 2 - src/RioJoy.Tray/Program.cs | 5 +- src/RioJoy.Tray/RioCoordinator.cs | 2 - src/RioJoy.Tray/RioJoy.Tray.csproj | 10 +++- src/RioJoy.Tray/TrayApplicationContext.cs | 2 - src/RioJoy.Tray/WallpaperApplier.cs | 4 +- .../Overlay/SkiaOverlayRendererTests.cs | 2 +- .../Protocol/PacketBuilderTests.cs | 4 +- .../RioJoy.Core.Tests.csproj | 8 ++- .../RioJoy.Core.Tests/Serial/FakeTransport.cs | 2 +- tests/RioJoy.Core.Tests/TestPolyfills.cs | 21 ++++++++ 42 files changed, 198 insertions(+), 93 deletions(-) create mode 100644 src/RioJoy.Core/Compat/Net48Polyfills.cs create mode 100644 tests/RioJoy.Core.Tests/TestPolyfills.cs diff --git a/deploy/build-package.ps1 b/deploy/build-package.ps1 index a716aed..a0fb481 100644 --- a/deploy/build-package.ps1 +++ b/deploy/build-package.ps1 @@ -47,12 +47,12 @@ try { $pkgDir = Join-Path $staging 'RIOJoy' New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null - # 1. Publish the tray app into riojoy\app (self-contained win-x64 — no .NET on target). + # 1. Publish the tray app into riojoy\app (net48, framework-dependent — relies on + # the in-box .NET Framework 4.8 present on every Windows 10/11 machine). $appOut = Join-Path $pkgDir 'app' - Write-Host "Publishing RioJoy.Tray ($Configuration, self-contained win-x64)..." + Write-Host "Publishing RioJoy.Tray ($Configuration, net48 framework-dependent)..." & dotnet publish (Join-Path $repo 'src\RioJoy.Tray\RioJoy.Tray.csproj') ` - -c $Configuration -r win-x64 --self-contained true ` - -p:PublishSingleFile=false -p:DebugType=none ` + -c $Configuration -p:DebugType=none ` -o $appOut | Out-Null if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' } diff --git a/src/RioJoy.Core/Calibration/AxisCalibrator.cs b/src/RioJoy.Core/Calibration/AxisCalibrator.cs index 14387fb..d285c9c 100644 --- a/src/RioJoy.Core/Calibration/AxisCalibrator.cs +++ b/src/RioJoy.Core/Calibration/AxisCalibrator.cs @@ -68,7 +68,7 @@ public sealed class AxisCalibrator Clamp(x), Clamp(y), Clamp(z), Clamp(rx), Clamp(ry), Clamp(rz)); } - private static int Clamp(int v) => Math.Clamp(v, 0, AxisOutputs.Max); + private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max); private int Throttle(int throttle) { diff --git a/src/RioJoy.Core/Compat/Net48Polyfills.cs b/src/RioJoy.Core/Compat/Net48Polyfills.cs new file mode 100644 index 0000000..1f11890 --- /dev/null +++ b/src/RioJoy.Core/Compat/Net48Polyfills.cs @@ -0,0 +1,52 @@ +// net48 compatibility shims. On net8 these APIs are in the BCL; net48 lacks them. +// (Language-feature polyfills — init/records/Index/Range/required — come from the +// PolySharp source generator; this file covers runtime helpers that PolySharp +// cannot supply because they live on existing BCL types.) + +namespace System.Collections.Generic +{ + public static class Net48CollectionExtensions + { + /// + /// Polyfill for CollectionExtensions.GetValueOrDefault (netstandard2.1+), + /// absent on net48. Returns the value for or + /// default(TValue) when the key is missing. + /// + public static TValue? GetValueOrDefault( + this IReadOnlyDictionary dictionary, TKey key) + { + if (dictionary is null) throw new ArgumentNullException(nameof(dictionary)); + return dictionary.TryGetValue(key, out var value) ? value : default; + } + + /// + /// Polyfill for KeyValuePair<,>.Deconstruct (netstandard2.1+), absent on + /// net48 — enables foreach (var (k, v) in dictionary). + /// + public static void Deconstruct( + this KeyValuePair pair, out TKey key, out TValue value) + { + key = pair.Key; + value = pair.Value; + } + } +} + +namespace RioJoy.Core.Compat +{ + internal static class Net48Math + { + /// + /// Polyfill for Math.Clamp (netstandard2.1+), absent on net48. Returns + /// bounded to [, ]. + /// + public static T Clamp(T value, T min, T max) where T : IComparable + { + if (min.CompareTo(max) > 0) + throw new ArgumentException($"{nameof(min)} ({min}) cannot be greater than {nameof(max)} ({max})."); + if (value.CompareTo(min) < 0) return min; + if (value.CompareTo(max) > 0) return max; + return value; + } + } +} diff --git a/src/RioJoy.Core/Editing/SheetLayout.cs b/src/RioJoy.Core/Editing/SheetLayout.cs index 1f70f16..2e77367 100644 --- a/src/RioJoy.Core/Editing/SheetLayout.cs +++ b/src/RioJoy.Core/Editing/SheetLayout.cs @@ -68,7 +68,7 @@ public static class SheetLayout /// public static IReadOnlyList Parse(string csv, int maxTextLength = 40) { - ArgumentNullException.ThrowIfNull(csv); + if (csv is null) throw new ArgumentNullException(nameof(csv)); string[] lines = csv.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); var cells = new List(); diff --git a/src/RioJoy.Core/Hid/RioHidReport.cs b/src/RioJoy.Core/Hid/RioHidReport.cs index 4a5a376..b0df9fe 100644 --- a/src/RioJoy.Core/Hid/RioHidReport.cs +++ b/src/RioJoy.Core/Hid/RioHidReport.cs @@ -52,7 +52,7 @@ public sealed class RioHidReport /// Set an axis value (clamped to 0..), little-endian. public void SetAxis(JoyAxis axis, int value) { - ushort v = (ushort)Math.Clamp(value, 0, AxisMax); + ushort v = (ushort)RioJoy.Core.Compat.Net48Math.Clamp(value, 0, AxisMax); int offset = (int)axis * 2; _buffer[offset] = (byte)(v & 0xFF); _buffer[offset + 1] = (byte)(v >> 8); diff --git a/src/RioJoy.Core/Mapping/RioInputMap.cs b/src/RioJoy.Core/Mapping/RioInputMap.cs index 06290e8..7a3d4cd 100644 --- a/src/RioJoy.Core/Mapping/RioInputMap.cs +++ b/src/RioJoy.Core/Mapping/RioInputMap.cs @@ -16,7 +16,7 @@ public sealed class RioInputMap /// Create a map from raw 16-bit words indexed by address. public RioInputMap(IReadOnlyDictionary entries) { - ArgumentNullException.ThrowIfNull(entries); + if (entries is null) throw new ArgumentNullException(nameof(entries)); foreach ((int address, ushort raw) in entries) this[address] = new RioMapEntry(raw); } diff --git a/src/RioJoy.Core/Output/HidFeederJoystickSink.cs b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs index f515f6b..a22e91e 100644 --- a/src/RioJoy.Core/Output/HidFeederJoystickSink.cs +++ b/src/RioJoy.Core/Output/HidFeederJoystickSink.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using System.Runtime.Versioning; using Microsoft.Win32.SafeHandles; using RioJoy.Core.Calibration; using RioJoy.Core.Hid; @@ -14,7 +13,6 @@ namespace RioJoy.Core.Output; /// DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT). Replaces /// once the driver is installed (Phase 1/3b). /// -[SupportedOSPlatform("windows")] public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable { // Must match driver/RioGamepad/Public.h. diff --git a/src/RioJoy.Core/Output/SendInputSink.cs b/src/RioJoy.Core/Output/SendInputSink.cs index 33f1b26..0b745a9 100644 --- a/src/RioJoy.Core/Output/SendInputSink.cs +++ b/src/RioJoy.Core/Output/SendInputSink.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using System.Runtime.Versioning; using RioJoy.Core.Mapping; using MouseButtonId = RioJoy.Core.Mapping.MouseButton; @@ -12,7 +11,6 @@ namespace RioJoy.Core.Output; /// scancodes (most do) see the keypress. Targets the interactive session, which /// is why RIOJoy is a tray app rather than a service (see docs/PLAN.md risks). /// -[SupportedOSPlatform("windows")] public sealed class SendInputSink : IInputSink { public void KeyDown(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: false); diff --git a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs index a40849d..5d64aaf 100644 --- a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs +++ b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using Nefarius.ViGEm.Client; using Nefarius.ViGEm.Client.Targets; using Nefarius.ViGEm.Client.Targets.Xbox360; @@ -14,7 +13,6 @@ namespace RioJoy.Core.Output; /// triggers, and the D-pad for the hat. RIO joystick buttons beyond /// are dropped (map those to the keyboard). /// -[SupportedOSPlatform("windows")] public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable { // RIO joystick button (1-based) -> Xbox 360 button, in a natural order. @@ -115,11 +113,11 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable // RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767. private static short ToThumb(int value) => - (short)Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue); + (short)RioJoy.Core.Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue); // RIO axis 0..32766 -> Xbox trigger byte 0..255. private static byte ToTrigger(int value) => - (byte)Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255); + (byte)RioJoy.Core.Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255); public void Dispose() { diff --git a/src/RioJoy.Core/Overlay/FontFitter.cs b/src/RioJoy.Core/Overlay/FontFitter.cs index 5723ec7..22ebc96 100644 --- a/src/RioJoy.Core/Overlay/FontFitter.cs +++ b/src/RioJoy.Core/Overlay/FontFitter.cs @@ -27,7 +27,7 @@ public static class FontFitter public static double BestFitSize( string text, string fontFamily, double width, double height, ITextMeasurer measurer) { - ArgumentNullException.ThrowIfNull(measurer); + if (measurer is null) throw new ArgumentNullException(nameof(measurer)); // State mirrors the Scheme named-let: (fontsize last-extents last-fontsize adjust). double fontsize = 6; // minimum possible fontsize (the loop's seed) diff --git a/src/RioJoy.Core/Overlay/GoobieDataImporter.cs b/src/RioJoy.Core/Overlay/GoobieDataImporter.cs index fbad471..342879d 100644 --- a/src/RioJoy.Core/Overlay/GoobieDataImporter.cs +++ b/src/RioJoy.Core/Overlay/GoobieDataImporter.cs @@ -26,7 +26,7 @@ public static class GoobieDataImporter /// Parse the data file text into fields + rows. public static Sheet Parse(string text) { - ArgumentNullException.ThrowIfNull(text); + if (text is null) throw new ArgumentNullException(nameof(text)); List> lists = ReadLists(text); if (lists.Count == 0) @@ -53,7 +53,7 @@ public static class GoobieDataImporter /// public static IReadOnlyDictionary LabelsForRow(Sheet sheet, int rowIndex = 0) { - ArgumentNullException.ThrowIfNull(sheet); + if (sheet is null) throw new ArgumentNullException(nameof(sheet)); if (rowIndex < 0 || rowIndex >= sheet.Rows.Count) throw new ArgumentOutOfRangeException(nameof(rowIndex)); diff --git a/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs b/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs index b2949ae..0d774f8 100644 --- a/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs +++ b/src/RioJoy.Core/Overlay/OverlayLayoutEngine.cs @@ -25,8 +25,8 @@ public sealed class OverlayLayoutEngine IReadOnlyDictionary labels, OverlayLayoutOptions? options = null) { - ArgumentNullException.ThrowIfNull(template); - ArgumentNullException.ThrowIfNull(labels); + if (template is null) throw new ArgumentNullException(nameof(template)); + if (labels is null) throw new ArgumentNullException(nameof(labels)); options ??= new OverlayLayoutOptions(); var placed = new List(template.Regions.Count); @@ -41,8 +41,8 @@ public sealed class OverlayLayoutEngine /// Lay out a single label within a region. public PlacedLabel Place(OverlayRegion region, string text, OverlayLayoutOptions? options = null) { - ArgumentNullException.ThrowIfNull(region); - ArgumentException.ThrowIfNullOrEmpty(text); + if (region is null) throw new ArgumentNullException(nameof(region)); + if (string.IsNullOrEmpty(text)) throw new ArgumentException("Value cannot be null or empty.", nameof(text)); options ??= new OverlayLayoutOptions(); double rotation = region.RotationDegrees ?? 0; diff --git a/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs b/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs index d7ed400..720e0ab 100644 --- a/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs +++ b/src/RioJoy.Core/Overlay/OverlayTemplateStore.cs @@ -20,20 +20,20 @@ public static class OverlayTemplateStore public static string Serialize(OverlayTemplate template) { - ArgumentNullException.ThrowIfNull(template); + if (template is null) throw new ArgumentNullException(nameof(template)); return JsonSerializer.Serialize(template, Options); } public static OverlayTemplate Deserialize(string json) { - ArgumentException.ThrowIfNullOrWhiteSpace(json); + if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json)); return JsonSerializer.Deserialize(json, Options) ?? throw new JsonException("Overlay template JSON deserialized to null."); } public static void Save(OverlayTemplate template, string path) { - ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); string? dir = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); @@ -42,7 +42,7 @@ public static class OverlayTemplateStore public static OverlayTemplate Load(string path) { - ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); return Deserialize(File.ReadAllText(path)); } } diff --git a/src/RioJoy.Core/Plasma/PlasmaCommands.cs b/src/RioJoy.Core/Plasma/PlasmaCommands.cs index 4f3fe0c..ead3704 100644 --- a/src/RioJoy.Core/Plasma/PlasmaCommands.cs +++ b/src/RioJoy.Core/Plasma/PlasmaCommands.cs @@ -48,8 +48,9 @@ public static class PlasmaCommands /// Encode display text as raw bytes (Latin-1, one byte per char). public static byte[] Text(string text) { - ArgumentNullException.ThrowIfNull(text); - return Encoding.Latin1.GetBytes(text); + if (text is null) throw new ArgumentNullException(nameof(text)); + // net48 has no Encoding.Latin1; codepage 28591 is ISO-8859-1 (Latin-1). + return Encoding.GetEncoding(28591).GetBytes(text); } /// @@ -72,7 +73,7 @@ public static class PlasmaCommands public static (byte x, byte y, byte font, int length) ResolvePosText( string text, byte x, byte y, byte font) { - ArgumentNullException.ThrowIfNull(text); + if (text is null) throw new ArgumentNullException(nameof(text)); int len = text.Length; if (font != 2) // not the Score font diff --git a/src/RioJoy.Core/Profiles/AutoSwitch.cs b/src/RioJoy.Core/Profiles/AutoSwitch.cs index e5b7da3..67bd2fd 100644 --- a/src/RioJoy.Core/Profiles/AutoSwitch.cs +++ b/src/RioJoy.Core/Profiles/AutoSwitch.cs @@ -48,7 +48,7 @@ public static class AutoSwitchResolver /// Normalize an executable name for matching: basename, no ".exe", lower-case. public static string Normalize(string executable) { - ArgumentNullException.ThrowIfNull(executable); + if (executable is null) throw new ArgumentNullException(nameof(executable)); string name = executable.Trim(); // Strip any path (handle both separators regardless of OS). int slash = name.LastIndexOfAny(new[] { '/', '\\' }); @@ -68,7 +68,7 @@ public static class AutoSwitchResolver /// public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable) { - ArgumentNullException.ThrowIfNull(config); + if (config is null) throw new ArgumentNullException(nameof(config)); if (!string.IsNullOrWhiteSpace(foregroundExecutable)) { diff --git a/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs index a37c96c..7c481f4 100644 --- a/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs +++ b/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs @@ -56,9 +56,19 @@ public sealed class AutoSwitchWatcher /// Poll on until cancelled. public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken) { - using var timer = new PeriodicTimer(interval); + // net48 has no PeriodicTimer; poll on a Task.Delay loop instead. Poll(); - while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } Poll(); + } } } diff --git a/src/RioJoy.Core/Profiles/ConfigStore.cs b/src/RioJoy.Core/Profiles/ConfigStore.cs index 99052cb..a3f903b 100644 --- a/src/RioJoy.Core/Profiles/ConfigStore.cs +++ b/src/RioJoy.Core/Profiles/ConfigStore.cs @@ -18,13 +18,13 @@ public static class ConfigStore public static string Serialize(AppConfig config) { - ArgumentNullException.ThrowIfNull(config); + if (config is null) throw new ArgumentNullException(nameof(config)); return JsonSerializer.Serialize(config, Options); } public static AppConfig Deserialize(string json) { - ArgumentException.ThrowIfNullOrWhiteSpace(json); + if (string.IsNullOrWhiteSpace(json)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(json)); return JsonSerializer.Deserialize(json, Options) ?? throw new JsonException("Config JSON deserialized to null."); } @@ -32,7 +32,7 @@ public static class ConfigStore /// Save the config to (creating directories). public static void Save(AppConfig config, string path) { - ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); string? dir = Path.GetDirectoryName(Path.GetFullPath(path)); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); @@ -45,7 +45,7 @@ public static class ConfigStore /// public static AppConfig Load(string path) { - ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); return File.Exists(path) ? Deserialize(File.ReadAllText(path)) : new AppConfig(); } } diff --git a/src/RioJoy.Core/Profiles/IniFile.cs b/src/RioJoy.Core/Profiles/IniFile.cs index 49b38c0..2160715 100644 --- a/src/RioJoy.Core/Profiles/IniFile.cs +++ b/src/RioJoy.Core/Profiles/IniFile.cs @@ -11,7 +11,7 @@ public sealed class IniFile public static IniFile Parse(string text) { - ArgumentNullException.ThrowIfNull(text); + if (text is null) throw new ArgumentNullException(nameof(text)); var ini = new IniFile(); var current = new Dictionary(StringComparer.OrdinalIgnoreCase); ini._sections[string.Empty] = current; @@ -68,9 +68,9 @@ public sealed class IniFile /// Parse an integer value that may be hex (0x…) or decimal. public static int ParseInt(string value) { - ArgumentNullException.ThrowIfNull(value); + if (value is null) throw new ArgumentNullException(nameof(value)); string v = value.Trim(); - bool neg = v.StartsWith('-'); + bool neg = v.StartsWith("-", StringComparison.Ordinal); if (neg) v = v[1..]; int magnitude = v.StartsWith("0x", StringComparison.OrdinalIgnoreCase) diff --git a/src/RioJoy.Core/Profiles/RioIniImporter.cs b/src/RioJoy.Core/Profiles/RioIniImporter.cs index b6b88aa..6b7487b 100644 --- a/src/RioJoy.Core/Profiles/RioIniImporter.cs +++ b/src/RioJoy.Core/Profiles/RioIniImporter.cs @@ -15,7 +15,7 @@ public static class RioIniImporter /// Parse into a profile named . public static RioProfile Import(string iniText, string name = "Imported") { - ArgumentNullException.ThrowIfNull(iniText); + if (iniText is null) throw new ArgumentNullException(nameof(iniText)); IniFile ini = IniFile.Parse(iniText); var profile = new RioProfile diff --git a/src/RioJoy.Core/Protocol/PacketParser.cs b/src/RioJoy.Core/Protocol/PacketParser.cs index 5faae99..9dbc7ed 100644 --- a/src/RioJoy.Core/Protocol/PacketParser.cs +++ b/src/RioJoy.Core/Protocol/PacketParser.cs @@ -97,7 +97,7 @@ public sealed class PacketParser /// public void Feed(ReadOnlySpan data, Action onEvent) { - ArgumentNullException.ThrowIfNull(onEvent); + if (onEvent is null) throw new ArgumentNullException(nameof(onEvent)); foreach (byte ch in data) { if (Feed(ch, out RioRxEvent ev)) diff --git a/src/RioJoy.Core/Protocol/RioPacket.cs b/src/RioJoy.Core/Protocol/RioPacket.cs index 60adc2d..8be56fd 100644 --- a/src/RioJoy.Core/Protocol/RioPacket.cs +++ b/src/RioJoy.Core/Protocol/RioPacket.cs @@ -30,7 +30,7 @@ public readonly struct RioPacket public override string ToString() { - var hex = Convert.ToHexString(Payload.Span); + var hex = BitConverter.ToString(Payload.ToArray()).Replace("-", string.Empty); return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]"; } } diff --git a/src/RioJoy.Core/RioJoy.Core.csproj b/src/RioJoy.Core/RioJoy.Core.csproj index b2e5387..ffb09f8 100644 --- a/src/RioJoy.Core/RioJoy.Core.csproj +++ b/src/RioJoy.Core/RioJoy.Core.csproj @@ -1,10 +1,12 @@ - - net8.0-windows + + net48 x64 + x64 enable enable latest @@ -13,6 +15,13 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs index 867a3ee..fb1d42a 100644 --- a/src/RioJoy.Core/RioRuntime.cs +++ b/src/RioJoy.Core/RioRuntime.cs @@ -30,8 +30,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable AxisCalibrator? calibrator = null) { _link = link ?? throw new ArgumentNullException(nameof(link)); - ArgumentNullException.ThrowIfNull(map); - ArgumentNullException.ThrowIfNull(input); + if (map is null) throw new ArgumentNullException(nameof(map)); + if (input is null) throw new ArgumentNullException(nameof(input)); _joystick = joystick ?? throw new ArgumentNullException(nameof(joystick)); _calibrator = calibrator ?? new AxisCalibrator(); diff --git a/src/RioJoy.Core/Serial/SerialPortTransport.cs b/src/RioJoy.Core/Serial/SerialPortTransport.cs index e808671..bb2107a 100644 --- a/src/RioJoy.Core/Serial/SerialPortTransport.cs +++ b/src/RioJoy.Core/Serial/SerialPortTransport.cs @@ -22,7 +22,7 @@ public sealed class SerialPortTransport : IRioTransport public SerialPortTransport(string portName) { - ArgumentException.ThrowIfNullOrWhiteSpace(portName); + if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(portName)); _port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One) { @@ -44,11 +44,21 @@ public sealed class SerialPortTransport : IRioTransport public string Description => $"{_port.PortName} @ {BaudRate} 8N1"; - public ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) => - _stream.ReadAsync(buffer, cancellationToken); + // net48's Stream has no Memory-based ReadAsync/WriteAsync overloads, so bridge + // through a pooled array and copy into/out of the caller's Memory. + public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) + { + byte[] tmp = new byte[buffer.Length]; + int read = await _stream.ReadAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false); + new ReadOnlySpan(tmp, 0, read).CopyTo(buffer.Span); + return read; + } - public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => - _stream.WriteAsync(data, cancellationToken); + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + byte[] tmp = data.ToArray(); + await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false); + } public void Dispose() { diff --git a/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs b/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs index 7491c53..9079442 100644 --- a/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs +++ b/src/RioJoy.Overlay/ProfileWallpaperGenerator.cs @@ -32,9 +32,9 @@ public sealed class ProfileWallpaperGenerator string outputPath, OverlayLayoutOptions? options = null) { - ArgumentNullException.ThrowIfNull(template); - ArgumentNullException.ThrowIfNull(labels); - ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + if (template is null) throw new ArgumentNullException(nameof(template)); + if (labels is null) throw new ArgumentNullException(nameof(labels)); + if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputPath)); if (string.IsNullOrWhiteSpace(template.BaseImagePath)) throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set."); diff --git a/src/RioJoy.Overlay/RioJoy.Overlay.csproj b/src/RioJoy.Overlay/RioJoy.Overlay.csproj index 90aa8c6..ac3dd16 100644 --- a/src/RioJoy.Overlay/RioJoy.Overlay.csproj +++ b/src/RioJoy.Overlay/RioJoy.Overlay.csproj @@ -1,8 +1,9 @@ - net8.0-windows + net48 x64 + x64 enable enable latest @@ -12,6 +13,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/RioJoy.Overlay/SkiaOverlayRenderer.cs b/src/RioJoy.Overlay/SkiaOverlayRenderer.cs index 0cc07fc..f20be11 100644 --- a/src/RioJoy.Overlay/SkiaOverlayRenderer.cs +++ b/src/RioJoy.Overlay/SkiaOverlayRenderer.cs @@ -35,9 +35,9 @@ public sealed class SkiaOverlayRenderer SKBitmap baseImage, OverlayLayoutOptions? options = null) { - ArgumentNullException.ThrowIfNull(template); - ArgumentNullException.ThrowIfNull(labels); - ArgumentNullException.ThrowIfNull(baseImage); + if (template is null) throw new ArgumentNullException(nameof(template)); + if (labels is null) throw new ArgumentNullException(nameof(labels)); + if (baseImage is null) throw new ArgumentNullException(nameof(baseImage)); options ??= new OverlayLayoutOptions(); var output = baseImage.Copy(); @@ -114,8 +114,8 @@ public sealed class SkiaOverlayRenderer string outputPath, OverlayLayoutOptions? options = null) { - ArgumentNullException.ThrowIfNull(template); - ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + if (template is null) throw new ArgumentNullException(nameof(template)); + if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputPath)); if (string.IsNullOrWhiteSpace(template.BaseImagePath)) throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set."); diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs index 65021b5..42e07ba 100644 --- a/src/RioJoy.Tray/Editor/PanelCanvas.cs +++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using RioJoy.Core.Editing; namespace RioJoy.Tray.Editor; @@ -8,7 +7,6 @@ namespace RioJoy.Tray.Editor; /// ) and raises when a button /// is clicked. Sized to the full panel so the host scrolls it. /// -[SupportedOSPlatform("windows")] internal sealed class PanelCanvas : Control { private static readonly IReadOnlyList AllButtons = CockpitPanel.Buttons(); diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs index c90513c..edf414a 100644 --- a/src/RioJoy.Tray/Editor/PanelView.cs +++ b/src/RioJoy.Tray/Editor/PanelView.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using RioJoy.Core.Editing; namespace RioJoy.Tray.Editor; @@ -28,7 +27,6 @@ public sealed record CalibrationCell(JoyStickSetting Setting, string Label, int /// shaded by whether the address is assigned (Off vs Dim); keypads are neutral /// (no lamps). Shared by and offline rendering. /// -[SupportedOSPlatform("windows")] public static class PanelView { public const int CellW = 66; diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs index ba45ced..d5ab933 100644 --- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs +++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using System.Text; using RioJoy.Core.Editing; using RioJoy.Core.Output; @@ -17,7 +16,6 @@ namespace RioJoy.Tray.Editor; /// wallpaper region b-XX) and (the iRIO /// word); Save raises . /// -[SupportedOSPlatform("windows")] public sealed class ProfileEditorForm : Form { private static readonly Dictionary GroupOf = @@ -30,7 +28,7 @@ public sealed class ProfileEditorForm : Form private readonly PanelCanvas _canvas = new(); private readonly TextBox _nameBox = new() { Location = new Point(66, 12), Width = 234 }; - private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234, PlaceholderText = "game.exe, other.exe — auto-switch" }; + private readonly TextBox _matchBox = new() { Location = new Point(66, 40), Width = 234 }; private readonly Label _info = new() { AutoSize = true, Location = new Point(12, 68), MaximumSize = new Size(310, 0) }; private readonly TextBox _labelBox = new() { Location = new Point(70, 100), Width = 230 }; private readonly ComboBox _kindBox = new() { Location = new Point(70, 134), Width = 150, DropDownStyle = ComboBoxStyle.DropDownList }; @@ -116,7 +114,7 @@ public sealed class ProfileEditorForm : Form StartPosition = FormStartPosition.CenterScreen; MinimumSize = new Size(900, 500); - _kindBox.Items.AddRange(Enum.GetNames()); + _kindBox.Items.AddRange(Enum.GetNames(typeof(RioRouteKind))); _kindBox.SelectedIndexChanged += OnKindChanged; _canvas.LabelProvider = a => _profile.OverlayLabels.TryGetValue(RegionFor(a), out string? t) ? t : null; @@ -271,11 +269,11 @@ public sealed class ProfileEditorForm : Form _valueCombo.Items.Add(new ValueItem(h.ToString(), (byte)(int)h)); break; case RioRouteKind.Mouse: - foreach (MouseAction m in Enum.GetValues()) + foreach (MouseAction m in (MouseAction[])Enum.GetValues(typeof(MouseAction))) _valueCombo.Items.Add(new ValueItem(m.ToString(), (byte)m)); break; case RioRouteKind.RioCommand: - foreach (RioCommandCode c in Enum.GetValues()) + foreach (RioCommandCode c in (RioCommandCode[])Enum.GetValues(typeof(RioCommandCode))) _valueCombo.Items.Add(new ValueItem(c.ToString(), (byte)c)); break; } @@ -349,7 +347,9 @@ public sealed class ProfileEditorForm : Form // The foreground executables that auto-activate this profile (comma-separated). _profile.MatchExecutables = _matchBox.Text - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) .ToList(); ApplyToCell(); diff --git a/src/RioJoy.Tray/Os/AutoStartManager.cs b/src/RioJoy.Tray/Os/AutoStartManager.cs index eb9ee65..7289698 100644 --- a/src/RioJoy.Tray/Os/AutoStartManager.cs +++ b/src/RioJoy.Tray/Os/AutoStartManager.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using Microsoft.Win32; namespace RioJoy.Tray.Os; @@ -8,7 +7,6 @@ namespace RioJoy.Tray.Os; /// Run registry key. Per-user (HKCU) needs no elevation and matches the /// tray app running in the interactive session. /// -[SupportedOSPlatform("windows")] public static class AutoStartManager { private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; @@ -24,7 +22,7 @@ public static class AutoStartManager { using RegistryKey key = Registry.CurrentUser.CreateSubKey(RunKey); if (enabled) - key.SetValue(ValueName, $"\"{Environment.ProcessPath}\""); + key.SetValue(ValueName, $"\"{Application.ExecutablePath}\""); else key.DeleteValue(ValueName, throwOnMissingValue: false); } diff --git a/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs b/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs index 3f04e65..c402006 100644 --- a/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs +++ b/src/RioJoy.Tray/Os/ForegroundProcessProvider.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Runtime.Versioning; using RioJoy.Core.Profiles; namespace RioJoy.Tray.Os; @@ -10,7 +9,6 @@ namespace RioJoy.Tray.Os; /// foreground window's owning process and returns its executable file name. Used /// by the auto-switch watcher to detect native games vs. supported titles. /// -[SupportedOSPlatform("windows")] public sealed class ForegroundProcessProvider : IForegroundProcessProvider { public string? GetForegroundExecutable() diff --git a/src/RioJoy.Tray/Program.cs b/src/RioJoy.Tray/Program.cs index 9f77fc3..eb34f5e 100644 --- a/src/RioJoy.Tray/Program.cs +++ b/src/RioJoy.Tray/Program.cs @@ -20,7 +20,10 @@ internal static class Program if (!createdNew) return; // another RIOJoy is already running in this session - ApplicationConfiguration.Initialize(); + // net48 has no source-generated ApplicationConfiguration.Initialize(); + // do the equivalent setup directly. + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); Application.Run(new TrayApplicationContext()); } } diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs index 4ce6da1..035a0ce 100644 --- a/src/RioJoy.Tray/RioCoordinator.cs +++ b/src/RioJoy.Tray/RioCoordinator.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using RioJoy.Core; using RioJoy.Core.Calibration; using RioJoy.Core.Mapping; @@ -22,7 +21,6 @@ namespace RioJoy.Tray; /// edited (). This keeps the COM port free the rest /// of the time so the native games can open it without clashing. /// -[SupportedOSPlatform("windows")] public sealed class RioCoordinator : IDisposable { private readonly Func _config; diff --git a/src/RioJoy.Tray/RioJoy.Tray.csproj b/src/RioJoy.Tray/RioJoy.Tray.csproj index a5f1f5c..4bcbb12 100644 --- a/src/RioJoy.Tray/RioJoy.Tray.csproj +++ b/src/RioJoy.Tray/RioJoy.Tray.csproj @@ -2,8 +2,9 @@ WinExe - net8.0-windows + net48 x64 + x64 enable true enable @@ -16,4 +17,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 82f66b1..ac9204b 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -1,4 +1,3 @@ -using System.Runtime.Versioning; using RioJoy.Core; using RioJoy.Core.Mapping; using RioJoy.Core.Profiles; @@ -14,7 +13,6 @@ namespace RioJoy.Tray; /// auto-switch watcher is polled on a UI timer so menu/status updates stay on the /// UI thread. /// -[SupportedOSPlatform("windows")] internal sealed class TrayApplicationContext : ApplicationContext { private static readonly string ConfigPath = diff --git a/src/RioJoy.Tray/WallpaperApplier.cs b/src/RioJoy.Tray/WallpaperApplier.cs index 024f9fb..9370f48 100644 --- a/src/RioJoy.Tray/WallpaperApplier.cs +++ b/src/RioJoy.Tray/WallpaperApplier.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using System.Runtime.Versioning; namespace RioJoy.Tray; @@ -14,7 +13,6 @@ namespace RioJoy.Tray; /// a user-visible system setting, so it is only invoked on an explicit profile /// activation, never speculatively. /// -[SupportedOSPlatform("windows")] public static class WallpaperApplier { private const int SPI_SETDESKWALLPAPER = 0x0014; @@ -27,7 +25,7 @@ public static class WallpaperApplier /// public static bool Apply(string imagePath) { - ArgumentException.ThrowIfNullOrWhiteSpace(imagePath); + if (string.IsNullOrWhiteSpace(imagePath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(imagePath)); string full = Path.GetFullPath(imagePath); if (!File.Exists(full)) throw new FileNotFoundException("Wallpaper image not found.", full); diff --git a/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs b/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs index 89db36b..87dc9f5 100644 --- a/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs +++ b/tests/RioJoy.Core.Tests/Overlay/SkiaOverlayRendererTests.cs @@ -77,6 +77,6 @@ public class SkiaOverlayRendererTests byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary { ["x"] = "HI" }, baseImg); Assert.True(png.Length > 8); - Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png[..8]); + Assert.Equal(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }, png.AsSpan(0, 8).ToArray()); } } diff --git a/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs b/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs index 273d2f0..709a583 100644 --- a/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs +++ b/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs @@ -41,7 +41,7 @@ public class PacketBuilderTests byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload); Assert.Equal((byte)RioCommand.AnalogReply, packet[0]); - Assert.Equal(payload, packet[1..^1]); - Assert.Equal(RioChecksum.Compute(packet[..^1]), packet[^1]); + Assert.Equal(payload, packet.AsSpan(1, packet.Length - 2).ToArray()); + Assert.Equal(RioChecksum.Compute(packet.AsSpan(0, packet.Length - 1)), packet[^1]); } } diff --git a/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj index 650566e..fdbe434 100644 --- a/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj +++ b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj @@ -1,8 +1,9 @@ - net8.0-windows + net48 x64 + x64 enable enable latest @@ -13,6 +14,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs b/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs index e820fd8..c86865e 100644 --- a/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs +++ b/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs @@ -40,7 +40,7 @@ internal sealed class FakeTransport : IRioTransport public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { _writes.Writer.TryWrite(data.ToArray()); - return ValueTask.CompletedTask; + return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task } /// Read the next outbound write, failing if none arrives in time. diff --git a/tests/RioJoy.Core.Tests/TestPolyfills.cs b/tests/RioJoy.Core.Tests/TestPolyfills.cs new file mode 100644 index 0000000..0eca058 --- /dev/null +++ b/tests/RioJoy.Core.Tests/TestPolyfills.cs @@ -0,0 +1,21 @@ +namespace RioJoy.Core.Tests; + +/// +/// net48 test-only polyfills. Visible to all tests via enclosing-namespace scope. +/// +internal static class TaskTestExtensions +{ + /// + /// Polyfill for Task<T>.WaitAsync(TimeSpan) (net6+), absent on net48. + /// Throws if the task does not complete in time. + /// + public static async Task WaitAsync(this Task task, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(); + Task completed = await Task.WhenAny(task, Task.Delay(timeout, cts.Token)).ConfigureAwait(false); + if (completed != task) + throw new TimeoutException($"Task did not complete within {timeout}."); + cts.Cancel(); // stop the delay timer + return await task.ConfigureAwait(false); + } +}