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>
This commit is contained in:
Cyd
2026-07-19 22:15:20 -05:00
co-authored by Claude Fable 5
parent 30c1a85445
commit 46961d0cd1
3 changed files with 80 additions and 3 deletions
+9 -2
View File
@@ -254,8 +254,15 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
generates + applies the wallpaper on profile activation when
`AppConfig.OverlayTemplatePath` is set (best-effort, off by default, never breaks
activation). The live `SystemParametersInfo` apply changes a user setting, so it
is gated behind config and not exercised by tests. ⏳ Optional: restore the prior
wallpaper when going dormant.
is gated behind config and not exercised by tests. **Restore-on-dormant — done ✅:**
`RioCoordinator` captures the user's own wallpaper (`WallpaperApplier.GetCurrent`)
the first time it overrides it, and puts it back (`WallpaperApplier.Restore`) on
every `GoDormant` and on `Dispose` — so idle/native-game/exit return the desktop
to what the user had. Capture is once-per-override (switching between cockpit
profiles never records a cockpit wallpaper as the "previous"); only engages when
`OverlayTemplatePath` is set. Known gap: a hard crash between apply and restore
leaves the cockpit wallpaper (SPIF_UPDATEINIFILE persists it) — a future
crash-recovery could persist the saved path to config.
- **Wallpaper maker — done ✅.** `RioJoy.Tray/Editor/WallpaperMakerForm` (tray →
"Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline:
it renders the profile's wallpaper live on the template base image, outlines all
+39 -1
View File
@@ -44,6 +44,11 @@ public sealed class RioCoordinator : IDisposable
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));
@@ -257,6 +262,7 @@ public sealed class RioCoordinator : IDisposable
return;
try
{
CaptureWallpaperOnce();
WallpaperApplier.Apply(profile.WallpaperPath!);
}
catch (Exception ex)
@@ -280,6 +286,7 @@ public sealed class RioCoordinator : IDisposable
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
profile.WallpaperPath = outPath;
CaptureWallpaperOnce();
WallpaperApplier.Apply(outPath);
}
catch (Exception ex)
@@ -289,6 +296,32 @@ public sealed class RioCoordinator : IDisposable
#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())
@@ -299,6 +332,7 @@ public sealed class RioCoordinator : IDisposable
private void GoDormant(string status)
{
Teardown();
RestoreWallpaper(); // put the user's own desktop back
SetStatus(status);
}
@@ -330,5 +364,9 @@ public sealed class RioCoordinator : IDisposable
StatusChanged?.Invoke(status);
}
public void Dispose() => Teardown();
public void Dispose()
{
Teardown();
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
}
}
+32
View File
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using System.Text;
namespace RioJoy.Tray;
@@ -16,8 +17,10 @@ namespace RioJoy.Tray;
public static class WallpaperApplier
{
private const int SPI_SETDESKWALLPAPER = 0x0014;
private const int SPI_GETDESKWALLPAPER = 0x0073;
private const int SPIF_UPDATEINIFILE = 0x01; // persist across logon
private const int SPIF_SENDCHANGE = 0x02; // broadcast WM_SETTINGCHANGE
private const int MaxPath = 260;
/// <summary>
/// Apply <paramref name="imagePath"/> as the desktop wallpaper. Returns true on
@@ -50,7 +53,36 @@ public static class WallpaperApplier
SPI_SETDESKWALLPAPER, 0, full, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
}
/// <summary>
/// Read the desktop wallpaper Windows currently has set (empty string when none
/// is set — a solid-color desktop). Capture this before <see cref="Apply"/>
/// overrides it, so <see cref="Restore"/> can put it back when going dormant.
/// </summary>
public static string GetCurrent()
{
var buffer = new StringBuilder(MaxPath);
bool ok = SystemParametersInfo(SPI_GETDESKWALLPAPER, buffer.Capacity, buffer, 0);
return ok ? buffer.ToString() : string.Empty;
}
/// <summary>
/// Restore a wallpaper previously read by <see cref="GetCurrent"/>. Unlike
/// <see cref="Apply"/> this does not require the file to exist and accepts an
/// empty path (which clears the wallpaper to the solid desktop color) — the
/// value came straight from Windows, so it is passed back verbatim. Returns the
/// API result; callers treat it as best-effort.
/// </summary>
public static bool Restore(string? previous)
{
return SystemParametersInfo(
SPI_SETDESKWALLPAPER, 0, previous ?? string.Empty, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SystemParametersInfo(int uAction, int uParam, StringBuilder lpvParam, int fuWinIni);
}