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<T>/GetValues<T>,
  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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-30 12:34:47 -05:00
co-authored by Claude Opus 4.8
parent 67b56559f7
commit fe87c79f55
42 changed files with 198 additions and 93 deletions
+4 -4
View File
@@ -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.' }
@@ -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)
{
+52
View File
@@ -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
{
/// <summary>
/// Polyfill for CollectionExtensions.GetValueOrDefault (netstandard2.1+),
/// absent on net48. Returns the value for <paramref name="key"/> or
/// default(TValue) when the key is missing.
/// </summary>
public static TValue? GetValueOrDefault<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)
{
if (dictionary is null) throw new ArgumentNullException(nameof(dictionary));
return dictionary.TryGetValue(key, out var value) ? value : default;
}
/// <summary>
/// Polyfill for KeyValuePair&lt;,&gt;.Deconstruct (netstandard2.1+), absent on
/// net48 — enables <c>foreach (var (k, v) in dictionary)</c>.
/// </summary>
public static void Deconstruct<TKey, TValue>(
this KeyValuePair<TKey, TValue> pair, out TKey key, out TValue value)
{
key = pair.Key;
value = pair.Value;
}
}
}
namespace RioJoy.Core.Compat
{
internal static class Net48Math
{
/// <summary>
/// Polyfill for Math.Clamp (netstandard2.1+), absent on net48. Returns
/// <paramref name="value"/> bounded to [<paramref name="min"/>, <paramref name="max"/>].
/// </summary>
public static T Clamp<T>(T value, T min, T max) where T : IComparable<T>
{
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;
}
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ public static class SheetLayout
/// </summary>
public static IReadOnlyList<SheetCell> 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<SheetCell>();
+1 -1
View File
@@ -52,7 +52,7 @@ public sealed class RioHidReport
/// <summary>Set an axis value (clamped to 0..<see cref="AxisMax"/>), little-endian.</summary>
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);
+1 -1
View File
@@ -16,7 +16,7 @@ public sealed class RioInputMap
/// <summary>Create a map from raw 16-bit words indexed by address.</summary>
public RioInputMap(IReadOnlyDictionary<int, ushort> 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);
}
@@ -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;
/// <c>DeviceIoControl(IOCTL_RIO_SUBMIT_REPORT)</c>. Replaces
/// <see cref="NullJoystickSink"/> once the driver is installed (Phase 1/3b).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class HidFeederJoystickSink : IJoystickSink, IDisposable
{
// Must match driver/RioGamepad/Public.h.
-2
View File
@@ -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).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class SendInputSink : IInputSink
{
public void KeyDown(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: false);
+2 -4
View File
@@ -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
/// <see cref="MappableButtonCount"/> are dropped (map those to the keyboard).
/// </summary>
[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()
{
+1 -1
View File
@@ -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)
@@ -26,7 +26,7 @@ public static class GoobieDataImporter
/// <summary>Parse the data file text into fields + rows.</summary>
public static Sheet Parse(string text)
{
ArgumentNullException.ThrowIfNull(text);
if (text is null) throw new ArgumentNullException(nameof(text));
List<List<string>> lists = ReadLists(text);
if (lists.Count == 0)
@@ -53,7 +53,7 @@ public static class GoobieDataImporter
/// </summary>
public static IReadOnlyDictionary<string, string> 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));
@@ -25,8 +25,8 @@ public sealed class OverlayLayoutEngine
IReadOnlyDictionary<string, string> 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<PlacedLabel>(template.Regions.Count);
@@ -41,8 +41,8 @@ public sealed class OverlayLayoutEngine
/// <summary>Lay out a single label within a region.</summary>
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;
@@ -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<OverlayTemplate>(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));
}
}
+4 -3
View File
@@ -48,8 +48,9 @@ public static class PlasmaCommands
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
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);
}
/// <summary>
@@ -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
+2 -2
View File
@@ -48,7 +48,7 @@ public static class AutoSwitchResolver
/// <summary>Normalize an executable name for matching: basename, no ".exe", lower-case.</summary>
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
/// </summary>
public static SwitchDecision Resolve(AppConfig config, string? foregroundExecutable)
{
ArgumentNullException.ThrowIfNull(config);
if (config is null) throw new ArgumentNullException(nameof(config));
if (!string.IsNullOrWhiteSpace(foregroundExecutable))
{
+12 -2
View File
@@ -56,9 +56,19 @@ public sealed class AutoSwitchWatcher
/// <summary>Poll on <paramref name="interval"/> until cancelled.</summary>
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();
}
}
}
+4 -4
View File
@@ -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<AppConfig>(json, Options)
?? throw new JsonException("Config JSON deserialized to null.");
}
@@ -32,7 +32,7 @@ public static class ConfigStore
/// <summary>Save the config to <paramref name="path"/> (creating directories).</summary>
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
/// </summary>
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();
}
}
+3 -3
View File
@@ -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<string, string>(StringComparer.OrdinalIgnoreCase);
ini._sections[string.Empty] = current;
@@ -68,9 +68,9 @@ public sealed class IniFile
/// <summary>Parse an integer value that may be hex (<c>0x…</c>) or decimal.</summary>
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)
+1 -1
View File
@@ -15,7 +15,7 @@ public static class RioIniImporter
/// <summary>Parse <paramref name="iniText"/> into a profile named <paramref name="name"/>.</summary>
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
+1 -1
View File
@@ -97,7 +97,7 @@ public sealed class PacketParser
/// </summary>
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
{
ArgumentNullException.ThrowIfNull(onEvent);
if (onEvent is null) throw new ArgumentNullException(nameof(onEvent));
foreach (byte ch in data)
{
if (Feed(ch, out RioRxEvent ev))
+1 -1
View File
@@ -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}]";
}
}
+12 -3
View File
@@ -1,10 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net8.0 LTS. Windows-flavored TFM: this library uses Win32 P/Invoke
(SendInput, SystemParametersInfo) and System.IO.Ports. -->
<TargetFramework>net8.0-windows</TargetFramework>
<!-- net48 test branch. Uses Win32 P/Invoke (SendInput, SystemParametersInfo)
and System.IO.Ports. Span/records/init/Index are polyfilled (System.Memory
+ PolySharp); System.Text.Json comes from NuGet (not in the net48 BCL). -->
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@@ -13,6 +15,13 @@
<ItemGroup>
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.256" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+2 -2
View File
@@ -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();
+15 -5
View File
@@ -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<int> ReadAsync(Memory<byte> 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<byte>.
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
byte[] tmp = new byte[buffer.Length];
int read = await _stream.ReadAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
new ReadOnlySpan<byte>(tmp, 0, read).CopyTo(buffer.Span);
return read;
}
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
_stream.WriteAsync(data, cancellationToken);
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
byte[] tmp = data.ToArray();
await _stream.WriteAsync(tmp, 0, tmp.Length, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
{
@@ -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.");
+6 -1
View File
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@@ -12,6 +13,10 @@
<!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. -->
<PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
+5 -5
View File
@@ -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.");
-2
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using RioJoy.Core.Editing;
namespace RioJoy.Tray.Editor;
@@ -8,7 +7,6 @@ namespace RioJoy.Tray.Editor;
/// <see cref="PanelView"/>) and raises <see cref="AddressSelected"/> when a button
/// is clicked. Sized to the full panel so the host scrolls it.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class PanelCanvas : Control
{
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitPanel.Buttons();
-2
View File
@@ -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 <see cref="PanelCanvas"/> and offline rendering.
/// </summary>
[SupportedOSPlatform("windows")]
public static class PanelView
{
public const int CellW = 66;
+7 -7
View File
@@ -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 <c>b-XX</c>) and <see cref="RioProfile.Buttons"/> (the iRIO
/// word); <c>Save</c> raises <see cref="Saved"/>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ProfileEditorForm : Form
{
private static readonly Dictionary<int, string> 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<RioRouteKind>());
_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<MouseAction>())
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<RioCommandCode>())
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();
+1 -3
View File
@@ -1,4 +1,3 @@
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace RioJoy.Tray.Os;
@@ -8,7 +7,6 @@ namespace RioJoy.Tray.Os;
/// <c>Run</c> registry key. Per-user (HKCU) needs no elevation and matches the
/// tray app running in the interactive session.
/// </summary>
[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);
}
@@ -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.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ForegroundProcessProvider : IForegroundProcessProvider
{
public string? GetForegroundExecutable()
+4 -1
View File
@@ -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());
}
}
-2
View File
@@ -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 (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest
/// of the time so the native games can open it without clashing.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class RioCoordinator : IDisposable
{
private readonly Func<AppConfig> _config;
+9 -1
View File
@@ -2,8 +2,9 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
@@ -16,4 +17,11 @@
<ProjectReference Include="..\RioJoy.Overlay\RioJoy.Overlay.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -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.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class TrayApplicationContext : ApplicationContext
{
private static readonly string ConfigPath =
+1 -3
View File
@@ -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.</para>
/// </summary>
[SupportedOSPlatform("windows")]
public static class WallpaperApplier
{
private const int SPI_SETDESKWALLPAPER = 0x0014;
@@ -27,7 +25,7 @@ public static class WallpaperApplier
/// </summary>
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);
@@ -77,6 +77,6 @@ public class SkiaOverlayRendererTests
byte[] png = new SkiaOverlayRenderer().RenderToPng(template, new Dictionary<string, string> { ["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());
}
}
@@ -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]);
}
}
@@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@@ -13,6 +14,11 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -40,7 +40,7 @@ internal sealed class FakeTransport : IRioTransport
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
_writes.Writer.TryWrite(data.ToArray());
return ValueTask.CompletedTask;
return default; // net48 has no ValueTask.CompletedTask; default(ValueTask) is the completed task
}
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
+21
View File
@@ -0,0 +1,21 @@
namespace RioJoy.Core.Tests;
/// <summary>
/// net48 test-only polyfills. Visible to all tests via enclosing-namespace scope.
/// </summary>
internal static class TaskTestExtensions
{
/// <summary>
/// Polyfill for Task&lt;T&gt;.WaitAsync(TimeSpan) (net6+), absent on net48.
/// Throws <see cref="TimeoutException"/> if the task does not complete in time.
/// </summary>
public static async Task<T> WaitAsync<T>(this Task<T> 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);
}
}