Files
riojoy/src/RioJoy.Tray/RioCoordinator.cs
T
CydandClaude Fable 5 46961d0cd1 Wallpaper: restore the user's desktop when going dormant
The wallpaper maker/runtime overrides the desktop with the cockpit
wallpaper on profile activation but never put the user's own back.
Now RioCoordinator captures the current wallpaper (SPI_GETDESKWALLPAPER,
via new WallpaperApplier.GetCurrent) the first time it overrides it, and
restores it (WallpaperApplier.Restore) on every GoDormant and on Dispose
— so going idle, a native game taking the port, or app exit returns the
desktop to what the user had. Capture is once-per-override so switching
between cockpit profiles keeps the real previous wallpaper; only engages
when OverlayTemplatePath is set. Builds clean on net48 + net40.

Known gap (documented in PLAN.md): a hard crash between apply and restore
leaves the cockpit wallpaper, since SPIF_UPDATEINIFILE persists it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:15:20 -05:00

373 lines
13 KiB
C#

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;
#if !NET40
using RioJoy.Overlay; // SkiaSharp wallpaper generation — modern flavor only
#endif
namespace RioJoy.Tray;
/// <summary>
/// Owns the active RIO connection and drives the three-state auto-switch: it
/// opens/closes the serial link and assembles a <see cref="RioRuntime"/> 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 <b>dormant</b> and only takes the serial port when a
/// profiled game is in the foreground (via the watcher) or while a profile is being
/// 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>
public sealed class RioCoordinator : IDisposable
{
private readonly Func<AppConfig> _config;
private readonly Func<string, IRioTransport> _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;
// The user's own desktop wallpaper, captured the first time we override it with
// a cockpit wallpaper. null = we are not currently overriding (nothing to
// restore). "" is a valid captured value (the user had no wallpaper).
private string? _savedWallpaper;
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_transportFactory = transportFactory
?? (port => new SerialPortTransport(port, _config().RioBaudRate));
}
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
public event Action<string>? StatusChanged;
/// <summary>Current status line for the tray.</summary>
public string Status { get; private set; } = "Dormant";
/// <summary>Whether a profile is currently being edited (port held for the editor).</summary>
public bool IsEditing => _editorSession;
/// <summary>The name of the active profile, or null when dormant.</summary>
public string? ActiveProfileName => _activeProfileName;
/// <summary>Whether the auto-switch watcher controls the active profile.</summary>
public bool IsAuto => _auto;
/// <summary>The currently running runtime, for manual RIO-command triggers.</summary>
public RioRuntime? Runtime => _runtime;
/// <summary>Apply a watcher decision (ignored while a manual override is in effect).</summary>
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;
}
}
/// <summary>Hand control back to the auto-switch watcher.</summary>
public void SetAuto()
{
_auto = true;
SetStatus("Auto");
}
/// <summary>Manually activate a profile; disables auto until <see cref="SetAuto"/>.</summary>
public void SetManualProfile(RioProfile profile)
{
_auto = false;
Activate(profile);
}
/// <summary>Manually go dormant (release the port); disables auto.</summary>
public void SetManualDormant()
{
_auto = false;
GoDormant("Dormant (manual)");
}
/// <summary>
/// Acquire the RIO for <paramref name="profile"/> while its editor is open. The
/// editor takes precedence over the watcher (so the port stays put while editing);
/// call <see cref="EndEditorSession"/> when the editor closes.
/// </summary>
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
}
/// <summary>
/// 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.
/// </summary>
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")}");
}
/// <summary>Release the editor's port hold and hand control back to the watcher.</summary>
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 !NET40 // ViGEmBus is Win10+; on XP the HID feeder drives RioGamepadXP.sys
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
{
_joystick = vigem;
note = string.Empty;
return vigem!;
}
#endif
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, new RioSerialLinkOptions
{
AnalogPollInterval = TimeSpan.FromMilliseconds(
Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config
});
_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);
}
/// <summary>
/// Generate the profile's cockpit wallpaper from the configured overlay template
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
/// is set), so a missing template or render failure never breaks activation.
/// </summary>
private void ApplyWallpaper(RioProfile profile)
{
#if NET40
// XP flavor: no SkiaSharp, so no generation — apply the profile's
// pre-rendered wallpaper (authored on a modern machine) if it exists.
if (string.IsNullOrWhiteSpace(profile.WallpaperPath) || !File.Exists(profile.WallpaperPath))
return;
try
{
CaptureWallpaperOnce();
WallpaperApplier.Apply(profile.WallpaperPath!);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
#else
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;
CaptureWallpaperOnce();
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
{
SetStatus($"{Status} [wallpaper: {ex.Message}]");
}
#endif
}
/// <summary>
/// Remember the user's current desktop wallpaper the first time we override it,
/// so it can be restored when we go dormant. No-op once captured (so switching
/// between cockpit profiles never records a cockpit wallpaper as the "previous").
/// </summary>
private void CaptureWallpaperOnce()
{
if (_savedWallpaper != null)
return;
try { _savedWallpaper = WallpaperApplier.GetCurrent(); }
catch { _savedWallpaper = string.Empty; } // best-effort; empty just clears on restore
}
/// <summary>
/// Put the user's pre-activation wallpaper back, if we overrode it. Called when
/// going dormant and on shutdown; best-effort and idempotent.
/// </summary>
private void RestoreWallpaper()
{
if (_savedWallpaper == null)
return; // never overrode — leave the desktop alone
try { WallpaperApplier.Restore(_savedWallpaper); }
catch { /* best-effort — never block going dormant */ }
_savedWallpaper = null;
}
private static string SafeFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
private void GoDormant(string status)
{
Teardown();
RestoreWallpaper(); // put the user's own desktop back
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();
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
}
}