Files
riojoy/src/RioJoy.Core/Profiles/AutoSwitchWatcher.cs
T
CydandClaude Fable 5 1ff0b16015 Phase 8A (2/2): RioJoy.Core multi-targets net48 + net40 (Windows XP flavor)
- TargetFrameworks net48;net40. net48 keeps x64 + ViGEm + System.IO.Ports
  package; net40 adds Microsoft.Bcl.Async + System.ValueTuple and uses the
  in-box SerialPort.
- Compat/TaskCompat bridges Task.Run/Delay/WhenAny/WhenAll (TaskEx on
  net40) and SemaphoreSlim.WaitAsync (net40 blocks briefly - trivial at
  9600 baud).
- IReadOnlyList/IReadOnlyDictionary -> IList/IDictionary throughout
  (net40 predates the IReadOnly* interfaces and the Bcl backport cannot
  make arrays implement them).
- HashCode.Combine replaced with a manual combine (Bcl.HashCode has no
  net40 build); Marshal.SizeOf<T> -> typeof form; ViGEmJoystickSink
  gated #if !NET40. HidFeederJoystickSink stays on both flavors - it
  will drive RioGamepadXP.sys on XP via the same contract.

Both TFMs build; 275 tests green on net48.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:41:58 -05:00

75 lines
2.8 KiB
C#

namespace RioJoy.Core.Profiles;
/// <summary>Supplies the current foreground application's executable name.</summary>
public interface IForegroundProcessProvider
{
/// <summary>The foreground executable name (e.g. "game.exe"), or null if unknown.</summary>
string? GetForegroundExecutable();
}
/// <summary>
/// Polls the foreground process and resolves the three-state auto-switch decision,
/// raising <see cref="DecisionChanged"/> only when the decision actually changes.
/// The polling cadence is driven by the host; <see cref="Poll"/> performs one
/// resolution so the logic is deterministic and unit-testable.
/// </summary>
public sealed class AutoSwitchWatcher
{
private readonly IForegroundProcessProvider _provider;
private readonly Func<AppConfig> _configAccessor;
private SwitchDecision? _last;
public AutoSwitchWatcher(IForegroundProcessProvider provider, Func<AppConfig> configAccessor)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
_configAccessor = configAccessor ?? throw new ArgumentNullException(nameof(configAccessor));
}
/// <summary>Raised when the resolved decision differs from the previous one.</summary>
public event Action<SwitchDecision>? DecisionChanged;
/// <summary>The most recently resolved decision (null until the first <see cref="Poll"/>).</summary>
public SwitchDecision? Current => _last;
/// <summary>
/// Forget the last decision so the next <see cref="Poll"/> re-raises
/// <see cref="DecisionChanged"/> even if the resolved decision is unchanged. Used
/// after a manual/editor override to re-sync the coordinator with the foreground app.
/// </summary>
public void Reset() => _last = null;
/// <summary>Resolve once; raise <see cref="DecisionChanged"/> if it changed. Returns the decision.</summary>
public SwitchDecision Poll()
{
string? exe = _provider.GetForegroundExecutable();
SwitchDecision decision = AutoSwitchResolver.Resolve(_configAccessor(), exe);
if (_last is null || !_last.Value.Equals(decision))
{
_last = decision;
DecisionChanged?.Invoke(decision);
}
return decision;
}
/// <summary>Poll on <paramref name="interval"/> until cancelled.</summary>
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
{
// net48 has no PeriodicTimer; poll on a Task.Delay loop instead.
Poll();
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Compat.TaskCompat.Delay(interval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
Poll();
}
}
}