Skip to content

Commit 398729f

Browse files
committed
v0.0.3: Stability, crash recovery, multi-version COM, edge cases
Stability fixes (confirmed by triple-model code review): - Fix COM object leaks: ReleaseComObject on all transient IVirtualDesktop, IApplicationView, IServiceProvider10 objects with try/finally - Fix Process object leaks: using var on all Process.GetProcessById calls - LL hook safety: SendMessageTimeout replaces SendMessage (100ms, SMTO_ABORTIFHUNG), removed Trace.WriteLine from hook callback - Explorer restart: ClearAll() releases stale COM refs before reinit - TOCTOU: try/catch ObjectDisposedException on BeginInvoke Crash recovery: - TrackerPersistence: JSON state at %LOCALAPPDATA% with thread-safe file lock - RecoverOrphanedDesktops() on startup removes desktops from previous crash - Desktop naming with [MVD] prefix for Task View identification Multi-version COM adapters: - Split IVirtualDesktopManagerInternal into 24H2 and Pre24H2 vtable layouts - DesktopManagerAdapter factory with build-number selection + smoke test fallback - App now works on all Windows 11 versions (21H2 through 24H2+) Edge cases: - Admin-elevated windows: detect via token query, show balloon tip - In-flight guard: HashSet prevents re-entrant Toggle on same hwnd - Maximize animation: 250ms delay after desktop switch for visible animation - PostMessage/WM_SYSCOMMAND/SC_MAXIMIZE added to NativeMethods
1 parent 1251183 commit 398729f

10 files changed

Lines changed: 561 additions & 91 deletions

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,19 @@ The app runs in the system tray. Right-click the tray icon for options:
2121

2222
## How It Works
2323

24-
1. **Maximize** — creates a new virtual desktop, moves the window there, switches to it, and maximizes the window. The desktop is named after the process.
24+
1. **Maximize** — creates a new virtual desktop, moves the window there, switches to it, and maximizes the window. The desktop is named `[MVD] ProcessName` so you can identify it in Task View.
2525
2. **Auto-restore on un-maximize** — if you restore/un-maximize a tracked window, it's automatically sent back to its original desktop and the temp desktop is cleaned up.
2626
3. **Auto-restore on close** — closing a tracked window triggers the same cleanup.
2727
4. **Toggle** — pressing the hotkey on an already-tracked window restores it.
28+
5. **Crash recovery** — if the app is killed or crashes, orphaned desktops are automatically cleaned up on next launch.
2829

2930
## Requirements
3031

31-
- **Windows 11 24H2** or later
32+
- **Windows 11** (any version — 21H2 through 24H2+)
3233
- Self-contained — no .NET installation required
3334

35+
The app auto-detects your Windows build and selects the correct COM vtable layout. If a future Windows update breaks things, the app enters degraded mode and checks for updates automatically.
36+
3437
### Shift+Click Compatibility
3538

3639
Shift+Click works wherever Windows 11 Snap Layouts works — apps that correctly handle `WM_NCHITTEST` and return `HTMAXBUTTON`. This includes:
@@ -68,12 +71,14 @@ src/MaximizeToVirtualDesktop/
6871
├── VirtualDesktopService.cs COM wrapper, 4 operations, Explorer restart recovery
6972
├── WindowMonitor.cs SetWinEventHook for close/un-maximize detection
7073
├── MaximizeButtonHook.cs WH_MOUSE_LL for Shift+Click on maximize button
74+
├── TrackerPersistence.cs JSON crash recovery in %LOCALAPPDATA%
7175
└── Interop/
7276
├── NativeMethods.cs P/Invoke declarations
73-
└── VirtualDesktopCom.cs Vendored COM interfaces (from MScholtes/VirtualDesktop)
77+
├── VirtualDesktopCom.cs Vendored COM interfaces (from MScholtes/VirtualDesktop)
78+
└── DesktopManagerAdapter.cs Multi-version COM vtable adapter
7479
```
7580

76-
**Zero NuGet dependencies.** COM interop declarations are vendored from [MScholtes/VirtualDesktop](https://github.com/MScholtes/VirtualDesktop) (MIT license, actively maintained).
81+
**Zero NuGet dependencies.** COM interop declarations are vendored from [MScholtes/VirtualDesktop](https://github.com/MScholtes/VirtualDesktop) (MIT license, actively maintained). The app ships two vtable layouts (pre-24H2 and 24H2+) and auto-selects the correct one with a smoke test fallback.
7782

7883
## Design Principles
7984

@@ -84,7 +89,7 @@ src/MaximizeToVirtualDesktop/
8489
## Known Limitations
8590

8691
- **Elevated windows** — cannot move windows running as Administrator from a non-elevated instance.
87-
- **App crash** — if the app crashes, temporary desktops may remain. They're named after the process for easy identification.
92+
- **App crash** — if the app crashes, temporary desktops are cleaned up automatically on next launch. They're prefixed with `[MVD]` in Task View for easy manual identification.
8893

8994
## The Virtual Desktop GUID Problem
9095

@@ -97,8 +102,9 @@ This is the single biggest fragility in this app. When it breaks, the app shows
97102
### How to update the GUIDs
98103

99104
1. Check [MScholtes/VirtualDesktop](https://github.com/MScholtes/VirtualDesktop) — Markus Scholtes maintains per-build interface files (e.g., `VirtualDesktop11-24H2.cs`) and typically updates within days of a new Windows build. Huge thanks to him for doing this thankless work for the entire community.
100-
2. Copy the updated GUIDs into `src/MaximizeToVirtualDesktop/Interop/VirtualDesktopCom.cs`
101-
3. The fragile GUIDs are on these interfaces:
105+
2. If it's a **vtable change** (new/removed methods, same GUIDs), add a new adapter class in `DesktopManagerAdapter.cs` and a new COM interface in `VirtualDesktopCom.cs`.
106+
3. If it's a **GUID change**, update the GUIDs on the affected interfaces.
107+
4. The fragile GUIDs are on these interfaces:
102108

103109
| Interface | What it does | Stable? |
104110
|-----------|-------------|---------|

src/MaximizeToVirtualDesktop/FullScreenManager.cs

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics;
2+
using System.Runtime.InteropServices;
23
using MaximizeToVirtualDesktop.Interop;
34

45
namespace MaximizeToVirtualDesktop;
@@ -11,6 +12,12 @@ internal sealed class FullScreenManager
1112
{
1213
private readonly VirtualDesktopService _vds;
1314
private readonly FullScreenTracker _tracker;
15+
private readonly HashSet<IntPtr> _inFlight = new();
16+
17+
/// <summary>
18+
/// Callback to show a notification balloon (set by TrayApplication).
19+
/// </summary>
20+
public Action<string, string>? ShowBalloon { get; set; }
1421

1522
public FullScreenManager(VirtualDesktopService vds, FullScreenTracker tracker)
1623
{
@@ -29,13 +36,26 @@ public void Toggle(IntPtr hwnd)
2936
return;
3037
}
3138

32-
if (_tracker.IsTracked(hwnd))
39+
if (!_inFlight.Add(hwnd))
3340
{
34-
Restore(hwnd);
41+
Trace.WriteLine($"FullScreenManager: hwnd {hwnd} already in-flight, ignoring.");
42+
return;
3543
}
36-
else
44+
45+
try
3746
{
38-
MaximizeToDesktop(hwnd);
47+
if (_tracker.IsTracked(hwnd))
48+
{
49+
Restore(hwnd);
50+
}
51+
else
52+
{
53+
MaximizeToDesktop(hwnd);
54+
}
55+
}
56+
finally
57+
{
58+
_inFlight.Remove(hwnd);
3959
}
4060
}
4161

@@ -81,14 +101,15 @@ public void MaximizeToDesktop(IntPtr hwnd)
81101
}
82102

83103
// 3. Name the desktop after the window title (or process name as fallback)
104+
string? processName = null;
84105
try
85106
{
86107
NativeMethods.GetWindowThreadProcessId(hwnd, out int processId);
87-
var process = Process.GetProcessById(processId);
88-
var name = !string.IsNullOrWhiteSpace(process.MainWindowTitle)
108+
using var process = Process.GetProcessById(processId);
109+
processName = !string.IsNullOrWhiteSpace(process.MainWindowTitle)
89110
? process.MainWindowTitle
90111
: process.ProcessName;
91-
_vds.SetDesktopName(tempDesktop, name);
112+
_vds.SetDesktopName(tempDesktop, $"[MVD] {processName}");
92113
}
93114
catch
94115
{
@@ -100,6 +121,7 @@ public void MaximizeToDesktop(IntPtr hwnd)
100121
{
101122
Trace.WriteLine("FullScreenManager: Failed to move window, rolling back desktop creation.");
102123
_vds.RemoveDesktop(tempDesktop);
124+
Marshal.ReleaseComObject(tempDesktop);
103125
return;
104126
}
105127

@@ -109,17 +131,36 @@ public void MaximizeToDesktop(IntPtr hwnd)
109131
// Rollback: move window back, remove desktop
110132
Trace.WriteLine("FullScreenManager: Failed to switch desktop, rolling back.");
111133
var origDesktop = _vds.FindDesktop(originalDesktopId.Value);
112-
if (origDesktop != null) _vds.MoveWindowToDesktop(hwnd, origDesktop);
134+
try
135+
{
136+
if (origDesktop != null) _vds.MoveWindowToDesktop(hwnd, origDesktop);
137+
}
138+
finally
139+
{
140+
if (origDesktop != null) Marshal.ReleaseComObject(origDesktop);
141+
}
113142
_vds.RemoveDesktop(tempDesktop);
143+
Marshal.ReleaseComObject(tempDesktop);
114144
return;
115145
}
116146

117-
// 6. Maximize the window
118-
NativeMethods.ShowWindow(hwnd, NativeMethods.SW_MAXIMIZE);
147+
// 6. Maximize the window — delay lets desktop switch animation finish first
148+
bool elevated = NativeMethods.IsWindowElevated(hwnd);
149+
if (elevated)
150+
{
151+
Trace.WriteLine("FullScreenManager: Window is elevated, cannot maximize via UIPI.");
152+
ShowBalloon?.Invoke("Elevated Window",
153+
"Window was moved to a new desktop but could not be maximized (it's running as Administrator). Press Win+↑ to maximize it.");
154+
}
155+
else
156+
{
157+
Thread.Sleep(250);
158+
NativeMethods.ShowWindow(hwnd, NativeMethods.SW_MAXIMIZE);
159+
}
119160
NativeMethods.SetForegroundWindow(hwnd);
120161

121162
// 7. Track it
122-
_tracker.Track(hwnd, originalDesktopId.Value, tempDesktop, originalPlacement);
163+
_tracker.Track(hwnd, originalDesktopId.Value, tempDesktopId.Value, tempDesktop, processName, originalPlacement);
123164

124165
Trace.WriteLine($"FullScreenManager: Successfully maximized {hwnd} to desktop {tempDesktopId}");
125166
}
@@ -149,32 +190,30 @@ public void Restore(IntPtr hwnd)
149190
NativeMethods.SetWindowPlacement(hwnd, ref placement);
150191
}
151192

152-
// 2. Move window back to original desktop
153-
if (windowStillExists)
193+
// 2. Move window back to original desktop and switch back
194+
var origDesktop = _vds.FindDesktop(entry.OriginalDesktopId);
195+
try
154196
{
155-
var origDesktop = _vds.FindDesktop(entry.OriginalDesktopId);
156197
if (origDesktop != null)
157198
{
158-
_vds.MoveWindowToDesktop(hwnd, origDesktop);
199+
if (windowStillExists) _vds.MoveWindowToDesktop(hwnd, origDesktop);
200+
_vds.SwitchToDesktop(origDesktop);
159201
}
160202
else
161203
{
162-
// Original desktop was removed by user — leave window on current desktop
163204
Trace.WriteLine("FullScreenManager: Original desktop no longer exists, leaving window on current.");
164205
}
165206
}
166-
167-
// 3. Switch back to original desktop
168-
var origDesktopForSwitch = _vds.FindDesktop(entry.OriginalDesktopId);
169-
if (origDesktopForSwitch != null)
207+
finally
170208
{
171-
_vds.SwitchToDesktop(origDesktopForSwitch);
209+
if (origDesktop != null) Marshal.ReleaseComObject(origDesktop);
172210
}
173211

174-
// 4. Remove temp desktop (tolerates failure — user may have already removed it)
212+
// 3. Remove temp desktop and release its COM reference
175213
_vds.RemoveDesktop(entry.TempDesktop);
214+
Marshal.ReleaseComObject(entry.TempDesktop);
176215

177-
// 5. Set focus on the restored window
216+
// 4. Set focus on the restored window
178217
if (windowStillExists)
179218
{
180219
NativeMethods.SetForegroundWindow(hwnd);
@@ -195,13 +234,18 @@ public void HandleWindowDestroyed(IntPtr hwnd)
195234

196235
// Switch back to original desktop first
197236
var origDesktop = _vds.FindDesktop(entry.OriginalDesktopId);
198-
if (origDesktop != null)
237+
try
238+
{
239+
if (origDesktop != null) _vds.SwitchToDesktop(origDesktop);
240+
}
241+
finally
199242
{
200-
_vds.SwitchToDesktop(origDesktop);
243+
if (origDesktop != null) Marshal.ReleaseComObject(origDesktop);
201244
}
202245

203-
// Then remove the temp desktop
246+
// Then remove the temp desktop and release its COM reference
204247
_vds.RemoveDesktop(entry.TempDesktop);
248+
Marshal.ReleaseComObject(entry.TempDesktop);
205249
}
206250

207251
/// <summary>

src/MaximizeToVirtualDesktop/FullScreenTracker.cs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
using System.Diagnostics;
2+
using System.Runtime.InteropServices;
23
using MaximizeToVirtualDesktop.Interop;
34

45
namespace MaximizeToVirtualDesktop;
56

67
internal sealed record TrackingEntry(
78
IntPtr Hwnd,
89
Guid OriginalDesktopId,
10+
Guid TempDesktopId,
911
IVirtualDesktop TempDesktop,
12+
string? ProcessName,
1013
NativeMethods.WINDOWPLACEMENT OriginalPlacement);
1114

1215
/// <summary>
@@ -29,27 +32,54 @@ public bool IsTracked(IntPtr hwnd)
2932
lock (_lock) return _entries.GetValueOrDefault(hwnd);
3033
}
3134

32-
public void Track(IntPtr hwnd, Guid originalDesktopId, IVirtualDesktop tempDesktop,
35+
public void Track(IntPtr hwnd, Guid originalDesktopId, Guid tempDesktopId,
36+
IVirtualDesktop tempDesktop, string? processName,
3337
NativeMethods.WINDOWPLACEMENT originalPlacement)
3438
{
3539
lock (_lock)
3640
{
37-
_entries[hwnd] = new TrackingEntry(hwnd, originalDesktopId, tempDesktop, originalPlacement);
41+
_entries[hwnd] = new TrackingEntry(hwnd, originalDesktopId, tempDesktopId,
42+
tempDesktop, processName, originalPlacement);
3843
Trace.WriteLine($"FullScreenTracker: Now tracking {hwnd} (total: {_entries.Count})");
3944
}
45+
PersistToDisk();
4046
}
4147

4248
public TrackingEntry? Untrack(IntPtr hwnd)
4349
{
50+
TrackingEntry? entry;
4451
lock (_lock)
4552
{
46-
if (_entries.Remove(hwnd, out var entry))
53+
if (_entries.Remove(hwnd, out entry))
4754
{
4855
Trace.WriteLine($"FullScreenTracker: Untracked {hwnd} (total: {_entries.Count})");
49-
return entry;
5056
}
51-
return null;
57+
else
58+
{
59+
return null;
60+
}
61+
}
62+
PersistToDisk();
63+
return entry;
64+
}
65+
66+
/// <summary>
67+
/// Clear all entries, releasing COM references. Called when Explorer restarts
68+
/// (Windows destroys all virtual desktops, making our tracked refs stale).
69+
/// </summary>
70+
public void ClearAll()
71+
{
72+
lock (_lock)
73+
{
74+
foreach (var entry in _entries.Values)
75+
{
76+
try { Marshal.ReleaseComObject(entry.TempDesktop); } catch { }
77+
}
78+
var count = _entries.Count;
79+
_entries.Clear();
80+
Trace.WriteLine($"FullScreenTracker: Cleared {count} stale entries (Explorer restart).");
5281
}
82+
TrackerPersistence.Delete();
5383
}
5484

5585
/// <summary>Returns all tracked entries (snapshot).</summary>
@@ -73,4 +103,15 @@ public int Count
73103
{
74104
get { lock (_lock) return _entries.Count; }
75105
}
106+
107+
private void PersistToDisk()
108+
{
109+
List<TrackerPersistence.PersistedEntry> snapshot;
110+
lock (_lock)
111+
{
112+
snapshot = _entries.Values.Select(e =>
113+
new TrackerPersistence.PersistedEntry(e.TempDesktopId, e.ProcessName, DateTime.UtcNow)).ToList();
114+
}
115+
TrackerPersistence.Save(snapshot);
116+
}
76117
}

0 commit comments

Comments
 (0)