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(); }