GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
HiddenDesktop.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.ComponentModel;
20using System.Diagnostics;
21using System.Drawing;
22using System.Linq;
23using System.Runtime.ExceptionServices;
24using System.Runtime.InteropServices;
25using System.Threading;
26using System.Windows.Forms;
27using static GenerallyPositive.Enums;
28
30{
44 public static class HiddenDesktop
45 {
46 private const uint GenericAll = 0x10000000;
47 private const uint DesktopSwitchDesktop = 0x0100;
48
53 public const string DefaultName = "GPAL.Hidden";
54
56 internal const int DefaultPeekMs = 5_000;
57
58 // by name, in the order they were made, which is the order Peek walks them in
59 private static readonly Dictionary<string, IntPtr> desktops = new Dictionary<string, IntPtr>();
60 private static readonly object gate = new object();
61
62 // how many desktops have been handed out to browsers that did not name one
63 private static int unnamed = 0;
64
65 // the desktop the screen was on before a peek switched away from it, and the only handle that can put it
66 // back. zero when the screen is where the user left it
67 private static IntPtr cameFrom = IntPtr.Zero;
68
69 private delegate bool EnumDesktopWindowsProc(IntPtr hWnd, IntPtr lParam);
70
71 // the screen is not the process's to keep. a peek still running when the process ends, or when an
72 // exception takes the process down, hands the screen back on the way out rather than leaving somebody
73 // looking at a desktop with nothing on it to leave by
74 static HiddenDesktop()
75 {
76 AppDomain.CurrentDomain.ProcessExit += (sender, args) => Return();
77 AppDomain.CurrentDomain.UnhandledException += (sender, args) => Return();
78 }
79
80 [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
81 private static extern IntPtr CreateDesktop(string lpszDesktop, IntPtr lpszDevice, IntPtr pDevmode, int dwFlags, uint dwDesiredAccess, IntPtr lpsa);
82
83 [DllImport("user32.dll", SetLastError = true)]
84 private static extern bool SetThreadDesktop(IntPtr hDesktop);
85
86 [DllImport("user32.dll", SetLastError = true)]
87 private static extern bool CloseDesktop(IntPtr hDesktop);
88
89 [DllImport("user32.dll", SetLastError = true)]
90 private static extern bool EnumDesktopWindows(IntPtr hDesktop, EnumDesktopWindowsProc lpfn, IntPtr lParam);
91
92 [DllImport("user32.dll")]
93 private static extern bool IsWindowVisible(IntPtr hWnd);
94
95 [DllImport("user32.dll", SetLastError = true)]
96 private static extern bool SwitchDesktop(IntPtr hDesktop);
97
98 [DllImport("user32.dll", SetLastError = true)]
99 private static extern IntPtr OpenInputDesktop(uint dwFlags, bool fInherit, uint dwDesiredAccess);
100
101 [DllImport("user32.dll", SetLastError = true)]
102 private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
103
104 [DllImport("user32.dll", SetLastError = true)]
105 private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
106
113 internal static string StartupNameFor(string name)
114 {
115 return $@"WinSta0\{name}";
116 }
117
123 internal static string NextName()
124 {
125 return $"{DefaultName}.{Interlocked.Increment(ref unnamed)}";
126 }
127
129 internal static IReadOnlyList<string> Names
130 {
131 get
132 {
133 lock (gate)
134 return desktops.Keys.ToList();
135 }
136 }
137
144 internal static bool Ensure(string name)
145 {
146 bool retVal;
147
148 lock (gate)
149 {
150 if (false == desktops.ContainsKey(name))
151 {
152 IntPtr made = CreateDesktop(name, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero);
153
154 if (IntPtr.Zero == made)
155 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not make the desktop [{name}]", null, GPALObjectType.Browser,
156 new Win32Exception(Marshal.GetLastWin32Error()));
157 else
158 desktops[name] = made;
159 }
160
161 retVal = true == desktops.ContainsKey(name);
162 }
163
164 return retVal;
165 }
166
179 internal static T Launch<T>(string name, Func<T> launch)
180 {
181 T retVal = default(T);
182 ExceptionDispatchInfo failure = null;
183 IntPtr handle = HandleFor(name);
184
185 Thread launcher = new Thread(() =>
186 {
187 if (false == SetThreadDesktop(handle))
188 failure = ExceptionDispatchInfo.Capture(
189 new Win32Exception(Marshal.GetLastWin32Error(), $"Could not put the launching thread on [{name}]"));
190 else
191 {
192 // caught to be rethrown on the workflow's own thread, so a launch that failed fails where it
193 // was asked for rather than on a thread nobody is holding
194 try
195 {
196 retVal = launch();
197 }
198 catch (Exception ex)
199 {
200 failure = ExceptionDispatchInfo.Capture(ex);
201 }
202 }
203 });
204
205 launcher.IsBackground = true;
206 launcher.Start();
207 launcher.Join();
208
209 failure?.Throw();
210
211 return retVal;
212 }
213
220 internal static int WindowCount(string name)
221 {
222 int retVal = 0;
223 IntPtr handle = HandleFor(name);
224
225 if (IntPtr.Zero != handle)
226 EnumDesktopWindows(handle, (window, param) =>
227 {
228 if (true == IsWindowVisible(window))
229 retVal++;
230
231 return true;
232 }, IntPtr.Zero);
233
234 return retVal;
235 }
236
237 [DllImport("user32.dll", SetLastError = true)]
238 private static extern int GetWindowThreadProcessId(IntPtr window, out int processId);
239
240 [DllImport("user32.dll", SetLastError = true)]
241 private static extern bool PostMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam);
242
243 private const uint WM_CLOSE = 0x0010;
244
255 internal static bool CloseWindowsOf(string name, int processId)
256 {
257 bool retVal = false;
258 IntPtr handle = HandleFor(name);
259
260 if (IntPtr.Zero != handle)
261 EnumDesktopWindows(handle, (window, param) =>
262 {
263 GetWindowThreadProcessId(window, out int owner);
264
265 if (processId == owner && true == IsWindowVisible(window))
266 retVal = PostMessage(window, WM_CLOSE, IntPtr.Zero, IntPtr.Zero) || retVal;
267
268 return true;
269 }, IntPtr.Zero);
270
271 return retVal;
272 }
273
285 internal static IntPtr MainWindowOf(string name, int processId)
286 {
287 IntPtr retVal = IntPtr.Zero;
288 IntPtr handle = HandleFor(name);
289
290 if (IntPtr.Zero != handle)
291 EnumDesktopWindows(handle, (window, param) =>
292 {
293 GetWindowThreadProcessId(window, out int owner);
294
295 if (processId == owner && true == IsWindowVisible(window))
296 retVal = window;
297
298 return IntPtr.Zero == retVal;
299 }, IntPtr.Zero);
300
301 return retVal;
302 }
303
314 internal static List<IntPtr> WindowsOn(string name)
315 {
316 List<IntPtr> retVal = new List<IntPtr>();
317 IntPtr handle = HandleFor(name);
318
319 if (IntPtr.Zero != handle)
320 EnumDesktopWindows(handle, (window, param) =>
321 {
322 if (true == IsWindowVisible(window))
323 retVal.Add(window);
324
325 return true;
326 }, IntPtr.Zero);
327
328 return retVal;
329 }
330
343 internal static IntPtr WaitForWindowOn(string name, Func<string, bool> titleMatches, int waitTimeInSeconds)
344 {
345 IntPtr retVal = IntPtr.Zero;
346 IntPtr handle = HandleFor(name);
347
348 for (int second = 0; second < waitTimeInSeconds && IntPtr.Zero == retVal && IntPtr.Zero != handle; second++)
349 {
350 Thread.Sleep(1000);
351
352 EnumDesktopWindows(handle, (window, param) =>
353 {
354 if (true == IsWindowVisible(window) && true == titleMatches(GenerallyPositive.GPALForm.FormHelper.TitleOf(window)))
355 retVal = window;
356
357 return IntPtr.Zero == retVal;
358 }, IntPtr.Zero);
359 }
360
361 return retVal;
362 }
363
369 internal static int WindowCount()
370 {
371 return Names.Sum(name => WindowCount(name));
372 }
373
382 internal static bool HasWindows(string name, int timeoutInMs)
383 {
384 bool retVal = false;
385 DateTime giveUpAt = DateTime.Now.AddMilliseconds(timeoutInMs);
386
387 while (false == retVal && DateTime.Now < giveUpAt)
388 {
389 retVal = 0 < WindowCount(name);
390
391 if (false == retVal)
392 Thread.Sleep(250);
393 }
394
395 return retVal;
396 }
397
401 internal static bool IsShowing
402 {
403 get
404 {
405 lock (gate)
406 return IntPtr.Zero != cameFrom;
407 }
408 }
409
419 internal static bool Return()
420 {
421 bool retVal = false;
422 IntPtr back;
423
424 lock (gate)
425 {
426 back = cameFrom;
427 cameFrom = IntPtr.Zero;
428 }
429
430 if (IntPtr.Zero != back)
431 {
432 retVal = SwitchDesktop(back);
433 CloseDesktop(back);
434 }
435
436 return retVal;
437 }
438
458 internal static string Peek(int forMsEach)
459 {
460 List<KeyValuePair<string, IntPtr>> walk;
461
462 lock (gate)
463 walk = desktops.Where(desktop => 0 < WindowCount(desktop.Key)).ToList();
464
465 return Walk(walk, forMsEach, "there is nothing on any hidden desktop to look at");
466 }
467
475 internal static string PeekOne(string name, int forMs)
476 {
477 List<KeyValuePair<string, IntPtr>> walk;
478
479 lock (gate)
480 walk = desktops.Where(desktop => desktop.Key == name && 0 < WindowCount(desktop.Key)).ToList();
481
482 return Walk(walk, forMs, $"there is nothing on the hidden desktop [{name}] to look at");
483 }
484
492 private static string Walk(List<KeyValuePair<string, IntPtr>> walk, int forMsEach, string nothingToSee)
493 {
494 string retVal = null;
495
496 if (0 == walk.Count)
497 retVal = nothingToSee;
498 else if (true == IsShowing)
499 retVal = "the screen is already on a hidden desktop";
500 else
501 {
502 // the right to switch and nothing more. asking for everything on the desktop the user is on is
503 // refused for an ordinary process, and a handle we cannot get is a screen we cannot hand back.
504 // taken before the switch, because after it the input desktop is the one we moved to
505 IntPtr from = OpenInputDesktop(0, false, DesktopSwitchDesktop);
506
507 if (IntPtr.Zero == from)
508 retVal = $"no handle on the desktop you are on [{Marshal.GetLastWin32Error()}], so nothing was switched";
509 else
510 {
511 lock (gate)
512 cameFrom = from;
513
514 // started before the switch and outliving this process, because everything else here that
515 // hands the screen back needs this process to still be alive to do it
516 Process guard = DesktopGuard.Start(0 < forMsEach ? forMsEach * walk.Count : 0);
517
518 // the walk holds the screen, so it runs on a foreground thread: a process on its way out
519 // waits for the screen to be handed back rather than leaving somebody stranded
520 Thread viewer = new Thread(() =>
521 {
522 try
523 {
524 foreach (KeyValuePair<string, IntPtr> desktop in walk)
525 {
526 // the way out goes up on the desktop before the screen does, so it is already
527 // listening for Escape by the time anyone is looking at it
528 using (Escape escape = Escape.ShowOn(desktop.Value, desktop.Key, forMsEach))
529 {
530 if (false == SwitchDesktop(desktop.Value))
531 continue;
532
533 if (true == escape.WaitToLeave(forMsEach))
534 break;
535 }
536 }
537 }
538 finally
539 {
540 Return();
541 DesktopGuard.Stop(guard);
542 }
543 });
544
545 viewer.IsBackground = false;
546 viewer.Start();
547 }
548 }
549
550 if (null != retVal)
551 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Cannot look at the hidden desktops: {retVal}", null, GPALObjectType.Browser);
552
553 return retVal;
554 }
555
566 internal static void CloseAll()
567 {
568 // whoever is shutting us down is not the person looking at the screen, so the screen goes back first
569 Return();
570
571 lock (gate)
572 {
573 foreach (IntPtr handle in desktops.Values)
574 CloseDesktop(handle);
575
576 desktops.Clear();
577 }
578 }
579
596 internal static void CloseEverythingOn(string name)
597 {
598 foreach (IntPtr window in WindowsOn(name))
599 PostMessage(window, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
600
601 DateTime giveUpAt = DateTime.Now.AddMilliseconds(CLOSING_MS);
602
603 while (0 < WindowCount(name) && DateTime.Now < giveUpAt)
604 Thread.Sleep(BETWEEN_LOOKS_MS);
605
606 foreach (IntPtr window in WindowsOn(name))
607 {
608 GetWindowThreadProcessId(window, out int owner);
609
610 // ending a program that has already gone is not a failure, it is the thing that was wanted
611 try { Process.GetProcessById(owner).Kill(); }
612 catch (Exception) { }
613 }
614
615 Close(name);
616 }
617
618 // how long the programs on a desktop are given to close themselves before they are ended
619 private const int CLOSING_MS = 5000;
620
621 // how often the desktop is looked at while they go
622 private const int BETWEEN_LOOKS_MS = 200;
623
624 internal static void Close(string name)
625 {
626 lock (gate)
627 if (true == desktops.TryGetValue(name, out IntPtr handle))
628 {
629 CloseDesktop(handle);
630 desktops.Remove(name);
631 }
632 }
633
639 private static IntPtr HandleFor(string name)
640 {
641 IntPtr retVal;
642
643 lock (gate)
644 desktops.TryGetValue(name, out retVal);
645
646 return retVal;
647 }
648
664 private sealed class Escape : IDisposable
665 {
666 private const int HotKeyId = 0xE5CA;
667 private const uint VkEscape = 0x1B;
668 private const uint ModNoRepeat = 0x4000;
669
670 // how long a peek with no clock on it waits when the escape window never came up, because then
671 // there is nothing on the screen to leave by and the clock is all that is left
672 private const int BlindHoldInMs = 15_000;
673
674 // and how long it waits when the escape window did come up. never Timeout.Infinite: a peek with no
675 // clock on it still gets one, because a hot key that failed to register and a button behind a
676 // fullscreen browser are both ways for a window that exists to not be a way out
677 private const int MaxHoldInMs = 600_000;
678
679 private readonly ManualResetEvent leaving = new ManualResetEvent(false);
680 private readonly ManualResetEvent ready = new ManualResetEvent(false);
681 private readonly Thread thread;
682
683 private volatile EscapeForm form;
684 private volatile bool live;
685
686 private Escape(IntPtr desktop, string name, int forMs)
687 {
688 thread = new Thread(() =>
689 {
690 if (false == SetThreadDesktop(desktop))
691 ready.Set();
692 else
693 {
694 EscapeForm showing = new EscapeForm(name, Leave, 0 < forMs ? forMs : MaxHoldInMs);
695
696 showing.Shown += (sender, args) =>
697 {
698 live = true;
699 ready.Set();
700 };
701
702 form = showing;
703
704 System.Windows.Forms.Application.Run(showing);
705 }
706 });
707
708 thread.IsBackground = true;
709 thread.SetApartmentState(ApartmentState.STA);
710 thread.Start();
711 }
712
721 internal static Escape ShowOn(IntPtr desktop, string name, int forMs)
722 {
723 Escape retVal = new Escape(desktop, name, forMs);
724
725 retVal.ready.WaitOne(5_000);
726
727 if (false == retVal.live)
728 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No way out could be put on the hidden desktop [{name}], so the clock is the only way back",
729 null, GPALObjectType.Browser);
730
731 return retVal;
732 }
733
739 internal bool WaitToLeave(int forMs)
740 {
741 bool retVal;
742 int limit = 0 < forMs ? forMs : (true == live ? MaxHoldInMs : BlindHoldInMs);
743
744 retVal = leaving.WaitOne(limit);
745
746 return retVal;
747 }
748
750 public void Dispose()
751 {
752 EscapeForm closing = form;
753
754 if (null != closing && true == closing.IsHandleCreated)
755 closing.BeginInvoke(new Action(() => closing.Close()));
756
757 thread.Join(5_000);
758
759 leaving.Dispose();
760 ready.Dispose();
761 }
762
764 private void Leave()
765 {
766 leaving.Set();
767 }
768
774 private sealed class EscapeForm : Form
775 {
776 private const int WmHotKey = 0x0312;
777
778 private readonly Action leave;
779
780 // counts down to the screen going back on its own, once a second
781 private readonly System.Windows.Forms.Timer countdown = new System.Windows.Forms.Timer { Interval = 1_000 };
782
783 internal EscapeForm(string desktop, Action leave, int holdMs)
784 {
785 this.leave = leave;
786
787 Rectangle screen = Screen.PrimaryScreen.Bounds;
788
789 FormBorderStyle = FormBorderStyle.None;
790 StartPosition = FormStartPosition.Manual;
791 ShowInTaskbar = false;
792 TopMost = true;
793 BackColor = Color.FromArgb(24, 24, 24);
794 ForeColor = Color.White;
795 Padding = new Padding(6);
796 Size = new Size(300, 62);
797
798 // the bottom left corner, because the middle of the screen is where the browser being looked
799 // at is, and this is here to be found rather than to be read
800 Location = new Point(screen.Left + 12, screen.Bottom - Height - 12);
801 KeyPreview = true;
802
803 Button back = new Button
804 {
805 Text = "Return (Esc)",
806 Dock = DockStyle.Fill,
807 FlatStyle = FlatStyle.System,
808 Font = new Font("Segoe UI", 9F, FontStyle.Bold),
809 };
810
811 Label caption = new Label
812 {
813 Text = desktop,
814 Dock = DockStyle.Top,
815 Height = 18,
816 TextAlign = ContentAlignment.MiddleCenter,
817 Font = new Font("Segoe UI", 8F, FontStyle.Regular),
818 };
819
820 back.Click += (sender, args) => this.leave();
821
822 // the clock starts when the window is up, which is just before the screen switches to it
823 DateTime returnAt = DateTime.MinValue;
824
825 Shown += (sender, args) =>
826 {
827 returnAt = DateTime.Now.AddMilliseconds(holdMs);
828 caption.Text = CaptionFor(desktop, returnAt);
829 countdown.Start();
830 };
831
832 countdown.Tick += (sender, args) => caption.Text = CaptionFor(desktop, returnAt);
833
834 // the fill goes in before the dock, because docking runs back through the collection
835 Controls.Add(back);
836 Controls.Add(caption);
837
838 KeyDown += (sender, args) =>
839 {
840 if (Keys.Escape == args.KeyCode)
841 this.leave();
842 };
843 }
844
845 protected override void OnHandleCreated(EventArgs e)
846 {
847 base.OnHandleCreated(e);
848
849 // the hot key belongs to this thread, and this thread is on the hidden desktop, so Escape is
850 // only taken there and only for as long as this window is up
851 if (false == RegisterHotKey(Handle, HotKeyId, ModNoRepeat, VkEscape))
852 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Escape could not be taken on the hidden desktop, so the button is the way out",
853 null, GPALObjectType.Browser, new Win32Exception(Marshal.GetLastWin32Error()));
854 }
855
857 private static string CaptionFor(string desktop, DateTime returnAt)
858 {
859 TimeSpan left = returnAt - DateTime.Now;
860
861 if (TimeSpan.Zero > left)
862 left = TimeSpan.Zero;
863
864 return $"{desktop} returning in {(int)left.TotalMinutes}:{left.Seconds:00}";
865 }
866
867 protected override void OnHandleDestroyed(EventArgs e)
868 {
869 countdown.Stop();
870 countdown.Dispose();
871 UnregisterHotKey(Handle, HotKeyId);
872
873 base.OnHandleDestroyed(e);
874 }
875
876 protected override void WndProc(ref Message m)
877 {
878 if (WmHotKey == m.Msg && HotKeyId == m.WParam.ToInt32())
879 leave();
880
881 base.WndProc(ref m);
882 }
883 }
884 }
885 }
886
892 internal class GPALHiddenDesktop : IHiddenDesktop
893 {
894 internal GPALHiddenDesktop(string name)
895 {
896 Name = name;
897 }
898
900 public string Name { get; }
901
903 public int WindowCount => HiddenDesktop.WindowCount(Name);
904
907 public string Peek() => HiddenDesktop.PeekOne(Name, HiddenDesktop.DefaultPeekMs);
908
912 public string Peek(int forMs) => HiddenDesktop.PeekOne(Name, forMs);
913
915 public void Close() => HiddenDesktop.Close(Name);
916
921 public void CloseEverything() => HiddenDesktop.CloseEverythingOn(Name);
922 }
923
928 internal class GPALHiddenDesktops : IHiddenDesktops
929 {
931 public IReadOnlyList<string> Names => HiddenDesktop.Names;
932
939 public IHiddenDesktop this[string desktopName] => new GPALHiddenDesktop(desktopName);
940
942 public int WindowCount => HiddenDesktop.WindowCount();
943
945 public bool IsShowing => HiddenDesktop.IsShowing;
946
949 public string Peek() => HiddenDesktop.Peek(HiddenDesktop.DefaultPeekMs);
950
954 public string Peek(int forMsEach) => HiddenDesktop.Peek(forMsEach);
955
959 public string Peek(string desktopName) => HiddenDesktop.PeekOne(desktopName, HiddenDesktop.DefaultPeekMs);
960
963 public bool Return() => HiddenDesktop.Return();
964
966 public void CloseAll() => HiddenDesktop.CloseAll();
967
971 public void CloseEverything()
972 {
973 foreach (string name in HiddenDesktop.Names.ToList())
974 HiddenDesktop.CloseEverythingOn(name);
975 }
976 }
977}
One Win32 desktop object a browser is running on. Reached from the browser that is on it,...
Every hidden desktop this process has made, and the screen itself. Peek and Return move the screen,...