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
242 internal static int WindowCount()
243 {
244 return Names.Sum(name => WindowCount(name));
245 }
246
255 internal static bool HasWindows(string name, int timeoutInMs)
256 {
257 bool retVal = false;
258 DateTime giveUpAt = DateTime.Now.AddMilliseconds(timeoutInMs);
259
260 while (false == retVal && DateTime.Now < giveUpAt)
261 {
262 retVal = 0 < WindowCount(name);
263
264 if (false == retVal)
265 Thread.Sleep(250);
266 }
267
268 return retVal;
269 }
270
274 internal static bool IsShowing
275 {
276 get
277 {
278 lock (gate)
279 return IntPtr.Zero != cameFrom;
280 }
281 }
282
292 internal static bool Return()
293 {
294 bool retVal = false;
295 IntPtr back;
296
297 lock (gate)
298 {
299 back = cameFrom;
300 cameFrom = IntPtr.Zero;
301 }
302
303 if (IntPtr.Zero != back)
304 {
305 retVal = SwitchDesktop(back);
306 CloseDesktop(back);
307 }
308
309 return retVal;
310 }
311
330 internal static string Peek(int forMsEach)
331 {
332 List<KeyValuePair<string, IntPtr>> walk;
333
334 lock (gate)
335 walk = desktops.Where(desktop => 0 < WindowCount(desktop.Key)).ToList();
336
337 return Walk(walk, forMsEach, "there is nothing on any hidden desktop to look at");
338 }
339
347 internal static string PeekOne(string name, int forMs)
348 {
349 List<KeyValuePair<string, IntPtr>> walk;
350
351 lock (gate)
352 walk = desktops.Where(desktop => desktop.Key == name && 0 < WindowCount(desktop.Key)).ToList();
353
354 return Walk(walk, forMs, $"there is nothing on the hidden desktop [{name}] to look at");
355 }
356
364 private static string Walk(List<KeyValuePair<string, IntPtr>> walk, int forMsEach, string nothingToSee)
365 {
366 string retVal = null;
367
368 if (0 == walk.Count)
369 retVal = nothingToSee;
370 else if (true == IsShowing)
371 retVal = "the screen is already on a hidden desktop";
372 else
373 {
374 // the right to switch and nothing more. asking for everything on the desktop the user is on is
375 // refused for an ordinary process, and a handle we cannot get is a screen we cannot hand back.
376 // taken before the switch, because after it the input desktop is the one we moved to
377 IntPtr from = OpenInputDesktop(0, false, DesktopSwitchDesktop);
378
379 if (IntPtr.Zero == from)
380 retVal = $"no handle on the desktop you are on [{Marshal.GetLastWin32Error()}], so nothing was switched";
381 else
382 {
383 lock (gate)
384 cameFrom = from;
385
386 // started before the switch and outliving this process, because everything else here that
387 // hands the screen back needs this process to still be alive to do it
388 Process guard = DesktopGuard.Start(0 < forMsEach ? forMsEach * walk.Count : 0);
389
390 // the walk holds the screen, so it runs on a foreground thread: a process on its way out
391 // waits for the screen to be handed back rather than leaving somebody stranded
392 Thread viewer = new Thread(() =>
393 {
394 try
395 {
396 foreach (KeyValuePair<string, IntPtr> desktop in walk)
397 {
398 // the way out goes up on the desktop before the screen does, so it is already
399 // listening for Escape by the time anyone is looking at it
400 using (Escape escape = Escape.ShowOn(desktop.Value, desktop.Key))
401 {
402 if (false == SwitchDesktop(desktop.Value))
403 continue;
404
405 if (true == escape.WaitToLeave(forMsEach))
406 break;
407 }
408 }
409 }
410 finally
411 {
412 Return();
413 DesktopGuard.Stop(guard);
414 }
415 });
416
417 viewer.IsBackground = false;
418 viewer.Start();
419 }
420 }
421
422 if (null != retVal)
423 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Cannot look at the hidden desktops: {retVal}", null, GPALObjectType.Browser);
424
425 return retVal;
426 }
427
438 internal static void CloseAll()
439 {
440 // whoever is shutting us down is not the person looking at the screen, so the screen goes back first
441 Return();
442
443 lock (gate)
444 {
445 foreach (IntPtr handle in desktops.Values)
446 CloseDesktop(handle);
447
448 desktops.Clear();
449 }
450 }
451
457 internal static void Close(string name)
458 {
459 lock (gate)
460 if (true == desktops.TryGetValue(name, out IntPtr handle))
461 {
462 CloseDesktop(handle);
463 desktops.Remove(name);
464 }
465 }
466
472 private static IntPtr HandleFor(string name)
473 {
474 IntPtr retVal;
475
476 lock (gate)
477 desktops.TryGetValue(name, out retVal);
478
479 return retVal;
480 }
481
496 private sealed class Escape : IDisposable
497 {
498 private const int HotKeyId = 0xE5CA;
499 private const uint VkEscape = 0x1B;
500 private const uint ModNoRepeat = 0x4000;
501
502 // how long a peek with no clock on it waits when the escape window never came up, because then
503 // there is nothing on the screen to leave by and the clock is all that is left
504 private const int BlindHoldInMs = 15_000;
505
506 // and how long it waits when the escape window did come up. never Timeout.Infinite: a peek with no
507 // clock on it still gets one, because a hot key that failed to register and a button behind a
508 // fullscreen browser are both ways for a window that exists to not be a way out
509 private const int MaxHoldInMs = 600_000;
510
511 private readonly ManualResetEvent leaving = new ManualResetEvent(false);
512 private readonly ManualResetEvent ready = new ManualResetEvent(false);
513 private readonly Thread thread;
514
515 private volatile EscapeForm form;
516 private volatile bool live;
517
518 private Escape(IntPtr desktop, string name)
519 {
520 thread = new Thread(() =>
521 {
522 if (false == SetThreadDesktop(desktop))
523 ready.Set();
524 else
525 {
526 EscapeForm showing = new EscapeForm(name, Leave);
527
528 showing.Shown += (sender, args) =>
529 {
530 live = true;
531 ready.Set();
532 };
533
534 form = showing;
535
536 System.Windows.Forms.Application.Run(showing);
537 }
538 });
539
540 thread.IsBackground = true;
541 thread.SetApartmentState(ApartmentState.STA);
542 thread.Start();
543 }
544
552 internal static Escape ShowOn(IntPtr desktop, string name)
553 {
554 Escape retVal = new Escape(desktop, name);
555
556 retVal.ready.WaitOne(5_000);
557
558 if (false == retVal.live)
559 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No way out could be put on the hidden desktop [{name}], so the clock is the only way back",
560 null, GPALObjectType.Browser);
561
562 return retVal;
563 }
564
570 internal bool WaitToLeave(int forMs)
571 {
572 bool retVal;
573 int limit = 0 < forMs ? forMs : (true == live ? MaxHoldInMs : BlindHoldInMs);
574
575 retVal = leaving.WaitOne(limit);
576
577 return retVal;
578 }
579
581 public void Dispose()
582 {
583 EscapeForm closing = form;
584
585 if (null != closing && true == closing.IsHandleCreated)
586 closing.BeginInvoke(new Action(() => closing.Close()));
587
588 thread.Join(5_000);
589
590 leaving.Dispose();
591 ready.Dispose();
592 }
593
595 private void Leave()
596 {
597 leaving.Set();
598 }
599
605 private sealed class EscapeForm : Form
606 {
607 private const int WmHotKey = 0x0312;
608
609 private readonly Action leave;
610
611 internal EscapeForm(string desktop, Action leave)
612 {
613 this.leave = leave;
614
615 Rectangle screen = Screen.PrimaryScreen.Bounds;
616
617 FormBorderStyle = FormBorderStyle.None;
618 StartPosition = FormStartPosition.Manual;
619 ShowInTaskbar = false;
620 TopMost = true;
621 BackColor = Color.FromArgb(24, 24, 24);
622 ForeColor = Color.White;
623 Padding = new Padding(6);
624 Size = new Size(300, 62);
625
626 // the bottom left corner, because the middle of the screen is where the browser being looked
627 // at is, and this is here to be found rather than to be read
628 Location = new Point(screen.Left + 12, screen.Bottom - Height - 12);
629 KeyPreview = true;
630
631 Button back = new Button
632 {
633 Text = "Return (Esc)",
634 Dock = DockStyle.Fill,
635 FlatStyle = FlatStyle.System,
636 Font = new Font("Segoe UI", 9F, FontStyle.Bold),
637 };
638
639 Label caption = new Label
640 {
641 Text = desktop,
642 Dock = DockStyle.Top,
643 Height = 18,
644 TextAlign = ContentAlignment.MiddleCenter,
645 Font = new Font("Segoe UI", 8F, FontStyle.Regular),
646 };
647
648 back.Click += (sender, args) => this.leave();
649
650 // the fill goes in before the dock, because docking runs back through the collection
651 Controls.Add(back);
652 Controls.Add(caption);
653
654 KeyDown += (sender, args) =>
655 {
656 if (Keys.Escape == args.KeyCode)
657 this.leave();
658 };
659 }
660
661 protected override void OnHandleCreated(EventArgs e)
662 {
663 base.OnHandleCreated(e);
664
665 // the hot key belongs to this thread, and this thread is on the hidden desktop, so Escape is
666 // only taken there and only for as long as this window is up
667 if (false == RegisterHotKey(Handle, HotKeyId, ModNoRepeat, VkEscape))
668 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Escape could not be taken on the hidden desktop, so the button is the way out",
669 null, GPALObjectType.Browser, new Win32Exception(Marshal.GetLastWin32Error()));
670 }
671
672 protected override void OnHandleDestroyed(EventArgs e)
673 {
674 UnregisterHotKey(Handle, HotKeyId);
675
676 base.OnHandleDestroyed(e);
677 }
678
679 protected override void WndProc(ref Message m)
680 {
681 if (WmHotKey == m.Msg && HotKeyId == m.WParam.ToInt32())
682 leave();
683
684 base.WndProc(ref m);
685 }
686 }
687 }
688 }
689
695 internal class GPALHiddenDesktop : IHiddenDesktop
696 {
697 internal GPALHiddenDesktop(string name)
698 {
699 Name = name;
700 }
701
703 public string Name { get; }
704
706 public int WindowCount => HiddenDesktop.WindowCount(Name);
707
710 public string Peek() => HiddenDesktop.PeekOne(Name, HiddenDesktop.DefaultPeekMs);
711
715 public string Peek(int forMs) => HiddenDesktop.PeekOne(Name, forMs);
716
718 public void Close() => HiddenDesktop.Close(Name);
719 }
720
725 internal class GPALHiddenDesktops : IHiddenDesktops
726 {
728 public IReadOnlyList<string> Names => HiddenDesktop.Names;
729
736 public IHiddenDesktop this[string desktopName] => new GPALHiddenDesktop(desktopName);
737
739 public int WindowCount => HiddenDesktop.WindowCount();
740
742 public bool IsShowing => HiddenDesktop.IsShowing;
743
746 public string Peek() => HiddenDesktop.Peek(HiddenDesktop.DefaultPeekMs);
747
751 public string Peek(int forMsEach) => HiddenDesktop.Peek(forMsEach);
752
756 public string Peek(string desktopName) => HiddenDesktop.PeekOne(desktopName, HiddenDesktop.DefaultPeekMs);
757
760 public bool Return() => HiddenDesktop.Return();
761
763 public void CloseAll() => HiddenDesktop.CloseAll();
764 }
765}
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,...