Skip to content

Commit 40cfb21

Browse files
hifihedgehogclaude
andcommitted
Add MIDI uninstall, HidHide/MIDI uninstall guards, settings auto-save
- MIDI Services uninstall via registry-cached WiX bootstrapper (silent) - Registry-based MIDI detection (no SDK loading) to prevent native crashes - HidHide uninstall guard: disabled when any device has HidHide enabled - MIDI uninstall guard: disabled when any MIDI slots exist - Settings auto-save: theme, polling, checkboxes trigger MarkDirty() - InputService double-stop protection via _stopped flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9eb1352 commit 40cfb21

File tree

6 files changed

+175
-11
lines changed

6 files changed

+175
-11
lines changed

PadForge.App/Common/DriverInstaller.cs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,11 +655,90 @@ private static async Task<string> FindMidiServicesDownloadUrl(HttpClient http)
655655
}
656656

657657
/// <summary>
658-
/// Checks whether Windows MIDI Services is available on this system.
658+
/// Uninstalls Windows MIDI Services by finding the cached WiX Burn bootstrapper
659+
/// via the registry UninstallString and launching it with /uninstall /quiet.
660+
/// The uninstaller is launched fire-and-forget because the MIDI Services SDK
661+
/// DLLs are loaded in-process — waiting for the uninstaller to finish would
662+
/// cause a native crash when the backing service is removed mid-session.
663+
/// </summary>
664+
public static void UninstallMidiServices()
665+
{
666+
string uninstallCmd = FindMidiServicesUninstallString();
667+
if (string.IsNullOrEmpty(uninstallCmd))
668+
throw new InvalidOperationException("Could not find Windows MIDI Services uninstall entry in registry.");
669+
670+
// UninstallString is e.g.: "C:\...\Setup.exe" /uninstall
671+
// Parse the quoted exe path and any existing arguments, then append /quiet.
672+
string exePath;
673+
string existingArgs = "";
674+
if (uninstallCmd.StartsWith('"'))
675+
{
676+
int closeQuote = uninstallCmd.IndexOf('"', 1);
677+
exePath = uninstallCmd.Substring(1, closeQuote - 1);
678+
existingArgs = uninstallCmd.Substring(closeQuote + 1).Trim();
679+
}
680+
else
681+
{
682+
int space = uninstallCmd.IndexOf(' ');
683+
exePath = space > 0 ? uninstallCmd.Substring(0, space) : uninstallCmd;
684+
if (space > 0) existingArgs = uninstallCmd.Substring(space + 1).Trim();
685+
}
686+
687+
var psi = new ProcessStartInfo
688+
{
689+
FileName = exePath,
690+
Arguments = $"{existingArgs} /quiet /norestart".Trim(),
691+
UseShellExecute = false,
692+
CreateNoWindow = true
693+
};
694+
using var proc = Process.Start(psi);
695+
proc?.WaitForExit(300_000);
696+
}
697+
698+
/// <summary>
699+
/// Searches the registry Uninstall keys for the Windows MIDI Services entry
700+
/// and returns its UninstallString value.
701+
/// </summary>
702+
private static string FindMidiServicesUninstallString()
703+
{
704+
var views = new[] { RegistryView.Registry64, RegistryView.Registry32 };
705+
706+
foreach (var view in views)
707+
{
708+
try
709+
{
710+
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
711+
using var uninstallKey = baseKey.OpenSubKey(
712+
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", false);
713+
if (uninstallKey == null) continue;
714+
715+
foreach (var subName in uninstallKey.GetSubKeyNames())
716+
{
717+
using var sub = uninstallKey.OpenSubKey(subName, false);
718+
var name = sub?.GetValue("DisplayName") as string;
719+
if (string.IsNullOrEmpty(name)) continue;
720+
721+
// Match the WiX Burn bootstrapper bundle entry, not the individual MSI components.
722+
if (name.Equals("Windows MIDI Services Runtime and Tools", StringComparison.OrdinalIgnoreCase))
723+
{
724+
return sub.GetValue("UninstallString") as string;
725+
}
726+
}
727+
}
728+
catch { }
729+
}
730+
731+
return null;
732+
}
733+
734+
/// <summary>
735+
/// Checks whether Windows MIDI Services is installed by looking for the
736+
/// registry uninstall entry. Does NOT load the SDK runtime — that would
737+
/// lock native DLLs in-process and prevent clean uninstallation.
659738
/// </summary>
660739
public static bool IsMidiServicesInstalled()
661740
{
662-
return MidiVirtualController.IsAvailable();
741+
return FindMidiServicesUninstallString() != null;
663742
}
664743

665744
// ─────────────────────────────────────────────

PadForge.App/Common/Input/MidiVirtualController.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,20 @@ public static void ResetAvailability()
329329
/// <summary>
330330
/// Shuts down the MIDI Services SDK initializer. Call on app exit.
331331
/// </summary>
332-
public static void Shutdown()
332+
/// <param name="skipDispose">
333+
/// When true, abandons the initializer without calling Dispose().
334+
/// Use before uninstalling MIDI Services — Dispose() calls into the
335+
/// runtime which triggers a native crash if the service is being removed.
336+
/// </param>
337+
public static void Shutdown(bool skipDispose = false)
333338
{
334339
if (_initializer != null)
335340
{
336-
_initializer.Dispose();
341+
if (!skipDispose)
342+
try { _initializer.Dispose(); } catch { }
337343
_initializer = null;
338344
}
345+
lock (_availLock) { _isAvailable = null; }
339346
}
340347
}
341348
}

PadForge.App/MainWindow.xaml.cs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,25 @@ public MainWindow()
8383
return true;
8484
return false;
8585
};
86+
_viewModel.Settings.HasAnyMidiSlots = () =>
87+
{
88+
for (int i = 0; i < InputManager.MaxPads; i++)
89+
if (SettingsManager.SlotCreated[i] &&
90+
_viewModel.Pads[i].OutputType == VirtualControllerType.Midi)
91+
return true;
92+
return false;
93+
};
94+
_viewModel.Settings.HasAnyHidHideDevices = () =>
95+
{
96+
var devices = SettingsManager.UserDevices;
97+
if (devices == null) return false;
98+
lock (devices.SyncRoot)
99+
{
100+
foreach (var ud in devices.Items)
101+
if (ud.HidHideEnabled) return true;
102+
}
103+
return false;
104+
};
86105

87106
// Wire engine start/stop commands.
88107
_viewModel.StartEngineRequested += (s, e) => _inputService.Start();
@@ -116,11 +135,23 @@ public MainWindow()
116135
_viewModel.Settings.LoadProfileRequested += OnLoadProfile;
117136
_viewModel.Settings.RevertToDefaultRequested += OnRevertToDefault;
118137

119-
// Apply registry Run key when Start at Login is toggled.
138+
// Persist Settings VM changes (theme, polling, checkboxes) and handle login toggle.
120139
_viewModel.Settings.PropertyChanged += (s, e) =>
121140
{
122141
if (e.PropertyName == nameof(SettingsViewModel.StartAtLogin))
123142
Common.StartupHelper.SetStartupEnabled(_viewModel.Settings.StartAtLogin);
143+
144+
if (e.PropertyName is nameof(SettingsViewModel.SelectedThemeIndex)
145+
or nameof(SettingsViewModel.AutoStartEngine)
146+
or nameof(SettingsViewModel.MinimizeToTray)
147+
or nameof(SettingsViewModel.StartMinimized)
148+
or nameof(SettingsViewModel.StartAtLogin)
149+
or nameof(SettingsViewModel.EnablePollingOnFocusLoss)
150+
or nameof(SettingsViewModel.PollingRateMs)
151+
or nameof(SettingsViewModel.EnableInputHiding)
152+
or nameof(SettingsViewModel.Use2DControllerView)
153+
or nameof(SettingsViewModel.EnableAutoProfileSwitching))
154+
_settingsService.MarkDirty();
124155
};
125156

126157
// Persist DSU / web controller server settings on change (Dashboard VM).
@@ -151,7 +182,7 @@ or nameof(DashboardViewModel.WebControllerPort))
151182
_viewModel.Settings.UninstallVJoyRequested += async (s, e) => await RunDriverOperationAsync(
152183
"Uninstalling vJoy…", DriverInstaller.UninstallVJoy, OnVJoyDriverChanged);
153184

154-
// Wire MIDI Services install command.
185+
// Wire MIDI Services install/uninstall commands.
155186
_viewModel.Settings.InstallMidiServicesRequested += async (s, e) =>
156187
{
157188
_viewModel.StatusText = "Downloading Windows MIDI Services…";
@@ -176,6 +207,15 @@ or nameof(DashboardViewModel.WebControllerPort))
176207
RefreshMidiServicesStatus();
177208
}
178209
};
210+
_viewModel.Settings.UninstallMidiServicesRequested += async (s, e) =>
211+
{
212+
// The uninstall guard prevents this when MIDI slots are active, so the
213+
// SDK runtime won't be loaded in-process. Safe to wait for the uninstaller.
214+
// Abandon the initializer just in case (e.g. IsAvailable was called elsewhere).
215+
Common.Input.MidiVirtualController.Shutdown(skipDispose: true);
216+
await RunDriverOperationAsync(
217+
"Uninstalling Windows MIDI Services…", DriverInstaller.UninstallMidiServices, RefreshMidiServicesStatus);
218+
};
179219

180220
// Wire device service events (assign to slot, hide, etc.).
181221
_deviceService.WireEvents();
@@ -191,6 +231,7 @@ or nameof(DashboardViewModel.WebControllerPort))
191231
_deviceService.DeviceHidingStateChanged += (s, e) =>
192232
{
193233
_inputService.ApplyDeviceHiding();
234+
_viewModel.Settings.RefreshDriverGuards();
194235
};
195236

196237
// After assigning a device to a slot, navigate to that controller page.
@@ -1873,7 +1914,7 @@ private bool HasAnyControllerTypeCapacity()
18731914
return xboxCount < SettingsManager.MaxXbox360Slots
18741915
|| ds4Count < SettingsManager.MaxDS4Slots
18751916
|| vjoyCount < SettingsManager.MaxVJoySlots
1876-
|| (Common.Input.MidiVirtualController.IsAvailable() && midiCount < SettingsManager.MaxMidiSlots);
1917+
|| (DriverInstaller.IsMidiServicesInstalled() && midiCount < SettingsManager.MaxMidiSlots);
18771918
}
18781919

18791920
private void ShowControllerTypePopup(UIElement anchor, PlacementMode placement = PlacementMode.Right)
@@ -2078,7 +2119,7 @@ private void ShowControllerTypePopup(UIElement anchor, PlacementMode placement =
20782119
Stretch = System.Windows.Media.Stretch.Uniform
20792120
};
20802121
midiPopupPath.SetResourceReference(System.Windows.Shapes.Shape.FillProperty, "SystemControlForegroundBaseHighBrush");
2081-
bool midiAvailable = Common.Input.MidiVirtualController.IsAvailable();
2122+
bool midiAvailable = DriverInstaller.IsMidiServicesInstalled();
20822123
bool midiAtCapacity = midiCount >= SettingsManager.MaxMidiSlots;
20832124
bool midiDisabled = !midiAvailable || midiAtCapacity;
20842125
string midiTooltip = !midiAvailable ? "MIDI (requires Windows MIDI Services)"

PadForge.App/Services/InputService.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ public void Start()
122122
if (_inputManager != null)
123123
return; // Already running.
124124

125+
_stopped = false;
126+
125127
// Remove stale ViGEm USB device nodes left over from previous sessions
126128
// (e.g., app crash without Dispose, or old builds that didn't call Dispose).
127129
// Must run BEFORE SDL initialization so stale nodes aren't enumerated.
@@ -213,8 +215,13 @@ public void Start()
213215
/// <summary>
214216
/// Stops the UI timer and engine, releases resources.
215217
/// </summary>
218+
private bool _stopped;
219+
216220
public void Stop(bool preserveVJoyNodes = false)
217221
{
222+
if (_stopped) return;
223+
_stopped = true;
224+
218225
// Stop UI timer.
219226
if (_uiTimer != null)
220227
{
@@ -2772,7 +2779,7 @@ public void Dispose()
27722779
if (_disposed)
27732780
return;
27742781

2775-
Stop();
2782+
try { Stop(); } catch { /* Best effort on shutdown */ }
27762783
_disposed = true;
27772784
}
27782785
}

PadForge.App/ViewModels/SettingsViewModel.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ public string HidHideVersion
141141
public RelayCommand UninstallHidHideCommand =>
142142
_uninstallHidHideCommand ??= new RelayCommand(
143143
() => UninstallHidHideRequested?.Invoke(this, EventArgs.Empty),
144-
() => _isHidHideInstalled);
144+
() => _isHidHideInstalled && !HasAnyHidHideDevices());
145145

146146
/// <summary>Raised when the user requests HidHide installation.</summary>
147147
public event EventHandler InstallHidHideRequested;
@@ -220,6 +220,7 @@ public bool IsMidiServicesInstalled
220220
{
221221
OnPropertyChanged(nameof(MidiServicesStatusText));
222222
_installMidiServicesCommand?.NotifyCanExecuteChanged();
223+
_uninstallMidiServicesCommand?.NotifyCanExecuteChanged();
223224
}
224225
}
225226
}
@@ -244,9 +245,20 @@ public string MidiServicesVersion
244245
() => InstallMidiServicesRequested?.Invoke(this, EventArgs.Empty),
245246
() => !_isMidiServicesInstalled);
246247

248+
private RelayCommand _uninstallMidiServicesCommand;
249+
250+
/// <summary>Command to uninstall Windows MIDI Services.</summary>
251+
public RelayCommand UninstallMidiServicesCommand =>
252+
_uninstallMidiServicesCommand ??= new RelayCommand(
253+
() => UninstallMidiServicesRequested?.Invoke(this, EventArgs.Empty),
254+
() => _isMidiServicesInstalled && !HasAnyMidiSlots());
255+
247256
/// <summary>Raised when the user requests MIDI Services installation.</summary>
248257
public event EventHandler InstallMidiServicesRequested;
249258

259+
/// <summary>Raised when the user requests MIDI Services uninstallation.</summary>
260+
public event EventHandler UninstallMidiServicesRequested;
261+
250262
// ─────────────────────────────────────────────
251263
// Driver uninstall guards
252264
// ─────────────────────────────────────────────
@@ -263,6 +275,18 @@ public string MidiServicesVersion
263275
/// </summary>
264276
internal Func<bool> HasAnyVJoySlots { get; set; } = () => false;
265277

278+
/// <summary>
279+
/// Set by MainWindow to provide slot-type queries for uninstall guards.
280+
/// Returns true if any created slot uses MIDI.
281+
/// </summary>
282+
internal Func<bool> HasAnyMidiSlots { get; set; } = () => false;
283+
284+
/// <summary>
285+
/// Set by MainWindow to provide device-state queries for uninstall guards.
286+
/// Returns true if any device has HidHide enabled.
287+
/// </summary>
288+
internal Func<bool> HasAnyHidHideDevices { get; set; } = () => false;
289+
266290
/// <summary>
267291
/// Re-evaluates uninstall button CanExecute state.
268292
/// Call after slot creation/deletion/type changes.
@@ -271,6 +295,8 @@ public void RefreshDriverGuards()
271295
{
272296
_uninstallViGEmCommand?.NotifyCanExecuteChanged();
273297
_uninstallVJoyCommand?.NotifyCanExecuteChanged();
298+
_uninstallHidHideCommand?.NotifyCanExecuteChanged();
299+
_uninstallMidiServicesCommand?.NotifyCanExecuteChanged();
274300
}
275301

276302
// ─────────────────────────────────────────────

PadForge.App/Views/SettingsPage.xaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@
235235
VerticalAlignment="Center">
236236
<Button Content="Install"
237237
Command="{Binding InstallMidiServicesCommand}"
238-
Visibility="{Binding IsMidiServicesInstalled, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}"/>
238+
Visibility="{Binding IsMidiServicesInstalled, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}"
239+
Margin="0,0,8,0"/>
240+
<Button Content="Uninstall"
241+
Command="{Binding UninstallMidiServicesCommand}"
242+
Visibility="{Binding IsMidiServicesInstalled, Converter={StaticResource BoolToVisibilityConverter}}"/>
239243
</StackPanel>
240244
</Grid>
241245
</StackPanel>

0 commit comments

Comments
 (0)