using System.Runtime.Versioning;
using RioJoy.Core;
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
using RioJoy.Core.Output;
using RioJoy.Core.Overlay;
using RioJoy.Core.Profiles;
using RioJoy.Core.Serial;
using RioJoy.Overlay;
namespace RioJoy.Tray;
///
/// Owns the active RIO connection and drives the three-state auto-switch: it
/// opens/closes the serial link and assembles a for the
/// active profile, releasing the COM port when a native game runs or nothing
/// matches (see docs/PLAN.md §Profiles). Manual override from the tray takes
/// precedence over the watcher.
///
/// The coordinator starts dormant and only takes the serial port when a
/// profiled game is in the foreground (via the watcher) or while a profile is being
/// 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;
private readonly Func _transportFactory;
private bool _auto = true;
private bool _editorSession;
// While editing, real keyboard/mouse + joystick run through these gates (off until
// the editor's "send output" toggle opens them).
private GatedInputSink? _editorInput;
private GatedJoystickSink? _editorJoystick;
// The active connection (null when dormant).
private IRioTransport? _transport;
private RioSerialLink? _link;
private RioRuntime? _runtime;
private CancellationTokenSource? _cts;
private IDisposable? _joystick;
private string? _activeProfileName;
public RioCoordinator(Func config, Func? transportFactory = null)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
}
/// Raised (with a short status string) whenever the active state changes.
public event Action? StatusChanged;
/// Current status line for the tray.
public string Status { get; private set; } = "Dormant";
/// Whether a profile is currently being edited (port held for the editor).
public bool IsEditing => _editorSession;
/// The name of the active profile, or null when dormant.
public string? ActiveProfileName => _activeProfileName;
/// Whether the auto-switch watcher controls the active profile.
public bool IsAuto => _auto;
/// The currently running runtime, for manual RIO-command triggers.
public RioRuntime? Runtime => _runtime;
/// Apply a watcher decision (ignored while a manual override is in effect).
public void ApplyDecision(SwitchDecision decision)
{
if (!_auto)
return;
switch (decision.Mode)
{
case SwitchMode.Activate when decision.Profile is not null:
Activate(decision.Profile);
break;
case SwitchMode.Yield:
GoDormant("Dormant — native game has the port");
break;
default:
GoDormant("Idle");
break;
}
}
/// Hand control back to the auto-switch watcher.
public void SetAuto()
{
_auto = true;
SetStatus("Auto");
}
/// Manually activate a profile; disables auto until .
public void SetManualProfile(RioProfile profile)
{
_auto = false;
Activate(profile);
}
/// Manually go dormant (release the port); disables auto.
public void SetManualDormant()
{
_auto = false;
GoDormant("Dormant (manual)");
}
///
/// Acquire the RIO for while its editor is open. The
/// editor takes precedence over the watcher (so the port stays put while editing);
/// call when the editor closes.
///
public void BeginEditorSession(RioProfile profile)
{
_editorSession = true;
_auto = false; // editor override beats the auto-switch watcher
Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick
}
///
/// Open or close the editor's output gates: when enabled, RIO button presses
/// drive real keyboard/mouse and the virtual joystick; when disabled they don't.
/// No-op outside an editor session.
///
public void SetEditorOutputs(bool enabled)
{
if (_editorInput is null || _editorJoystick is null)
return;
_editorInput.Enabled = enabled;
_editorJoystick.Enabled = enabled;
if (_activeProfileName is not null)
SetStatus($"Editing: {_activeProfileName} — outputs {(enabled ? "ON (sending to PC)" : "off")}");
}
/// Release the editor's port hold and hand control back to the watcher.
public void EndEditorSession()
{
_editorSession = false;
_auto = true;
GoDormant("Dormant"); // the next watcher poll re-activates if a profiled game is foreground
}
// Pick the best available virtual-joystick sink and record it for disposal: the
// signed ViGEmBus Xbox 360 pad first, then the (test-signed) RioGamepad HID
// feeder, then a no-op when neither driver is present.
private IJoystickSink CreateJoystickSink(out string note)
{
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
{
_joystick = vigem;
note = string.Empty;
return vigem!;
}
if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
{
_joystick = feeder;
note = string.Empty;
return feeder!;
}
_joystick = null;
note = " [no joystick driver]";
return new NullJoystickSink();
}
// routeInput=false holds the RIO link (and lamp feedback) but sends keyboard/
// mouse/joystick output to no-op sinks — used for the editor's live button check.
private void Activate(RioProfile profile, bool routeInput = true)
{
if (_activeProfileName == profile.Name && _runtime is not null && !_editorSession)
return; // already running this profile (editor sessions always re-arm)
Teardown();
AppConfig config = _config();
string port = profile.RioComPort ?? config.DefaultRioComPort;
try
{
// Keyboard/mouse + joystick are gated off while editing so the RIO can be
// exercised without injecting input (the editor toggle opens the gates);
// lamp feedback over serial still applies.
IInputSink input;
IJoystickSink joystick;
string note;
IJoystickSink realJoystick = CreateJoystickSink(out string joystickNote);
if (!routeInput)
{
// Keyboard/mouse + joystick are gated off while editing so the RIO can
// be exercised without injecting input (the editor toggle opens them).
_editorInput = new GatedInputSink(new SendInputSink());
_editorJoystick = new GatedJoystickSink(realJoystick);
input = _editorInput;
joystick = _editorJoystick;
note = " [editing — outputs off]";
}
else
{
input = new SendInputSink();
joystick = realJoystick;
note = joystickNote;
}
_transport = _transportFactory(port);
_link = new RioSerialLink(_transport);
_runtime = new RioRuntime(
_link,
profile.ToInputMap(),
input,
joystick,
new AxisCalibrator(profile.Calibration));
_runtime.EchoAllLamps = !routeInput; // editor: light any pressed button on the RIO
_cts = new CancellationTokenSource();
_ = _link.RunAsync(_cts.Token);
_runtime.Start();
_activeProfileName = profile.Name;
SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}");
}
catch (Exception ex)
{
Teardown();
SetStatus($"Error opening {port}: {ex.Message}");
return;
}
if (routeInput)
ApplyWallpaper(profile);
}
///
/// Generate the profile's cockpit wallpaper from the configured overlay template
/// and apply it. Best-effort and opt-in (only when
/// is set), so a missing template or render failure never breaks activation.
///
private void ApplyWallpaper(RioProfile profile)
{
string? templatePath = _config().OverlayTemplatePath;
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
return;
try
{
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
string templateDir = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".";
string outDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"RIOJoy", "wallpapers");
string outPath = Path.Combine(outDir, SafeFileName(profile.Name) + ".png");
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
profile.WallpaperPath = outPath;
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
private void GoDormant(string status)
{
Teardown();
SetStatus(status);
}
private void Teardown()
{
_runtime?.Dispose();
_runtime = null;
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
_link = null;
_joystick?.Dispose(); // closes the HID feeder handle
_joystick = null;
_editorInput = null;
_editorJoystick = null;
_transport?.Dispose(); // releases the COM port
_transport = null;
_activeProfileName = null;
}
private void SetStatus(string status)
{
Status = status;
StatusChanged?.Invoke(status);
}
public void Dispose() => Teardown();
}