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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<,>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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<T>.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user