diff --git a/AGENTS.md b/AGENTS.md index ecf53da..b7b8652 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,10 @@ These are the working rules for agents in this repo. Staypaw is a Windows tray a Staypaw watches the keyboard. People run it only if they can trust it, so never break these, not even in dev tooling that ships. -- **No code in other processes.** No DLL injection, no `CreateRemoteThread`, no hook DLLs loaded into other apps. Use only out-of-context WinEvent hooks, Raw Input and low-level hooks (`WH_KEYBOARD_LL`, `WH_MOUSE_LL`), which all run in Staypaw's own process. Work laptops ban injection, and anti-cheat flags it. -- **Never touch a game's process.** Identify a window's app with `PROCESS_QUERY_LIMITED_INFORMATION` and nothing more: no memory reads and no input sent to it. Held keys aimed at a game are dropped, never replayed. Game terms of service forbid tools that read or drive the client, and replayed keys are fake input. +- **No code in other processes.** No DLL injection, no `CreateRemoteThread`, no hook DLLs loaded into other apps. Use only out-of-context WinEvent hooks, Raw Input and low-level hooks (`WH_KEYBOARD_LL`, `WH_MOUSE_LL`), which all run in Staypaw's own process. A test fails on any other hook type passed to `SetWindowsHookEx`. Work laptops ban injection, and anti-cheat flags it. +- **Never touch a game's process.** Identify a window's app with `PROCESS_QUERY_LIMITED_INFORMATION` and nothing more: no memory reads and no input sent to it. Held keys aimed at a game are dropped, never replayed. A game is the protected app, an exe that Windows' Game Bar lists, or an app Staypaw can't name, because anti-cheat can hide even a process's name. Game terms of service forbid tools that read or drive the client, and replayed keys are fake input. One exception, the owner's call: to finish a restore that left no window in front, Staypaw's thread shares the input of your window's thread for about a millisecond, even a game's (see `ROADMAP.md`). - **No networking code.** No `HttpClient`, sockets, updater or telemetry, and no package that brings them in. A test fails on any `System.Net` use in Staypaw's assemblies. A keyboard watcher that talks to the network can't be trusted. -- **Never record which key was pressed.** The log and memory keep only the time of a keypress. The one exception is held keys (M3): kept in memory until they're delivered or dropped, and never written anywhere. Anything more makes Staypaw a keylogger. +- **Never record which key was pressed.** The log and memory keep only the time of a keypress. The one exception is held keys (M3): kept in memory until they're delivered or dropped, and never written anywhere. Never trace or log a `HeldKey`; it has no `ToString` beyond its type name for that reason. Anything more makes Staypaw a keylogger. - **Run unelevated.** Don't set `requireAdministrator` in the manifest. An elevated mode is a Later item with its own decision. - **Code ported from NoFocusSteal keeps its MIT notice** in `THIRD-PARTY-NOTICES.md`. The license requires it. @@ -25,6 +25,7 @@ Staypaw watches the keyboard. People run it only if they can trust it, so never - `Staypaw.Core` holds the watcher and the rules and never references Avalonia; a test enforces it. Rules take the event and the time as inputs and call no Win32, so tests run without real windows. - Win32 calls go through CsWin32 (`NativeMethods.txt`), not hand-written `DllImport`. The generated code is typed and NativeAOT-safe. - NativeAOT must stay free of warnings: no reflection-based serialization. Use source generators. +- The keyboard hook runs on a thread of its own, and its callback only compares and copies. Every key on the system waits for it, and Windows quietly removes a hook that answers too slowly. ## Brand @@ -54,8 +55,8 @@ One meaning per term. Use these words in code, docs and commits. ## Commands - `dotnet build` builds everything. `dotnet test` runs the tests. -- `tests/Staypaw.Tests/bin/Release/net10.0-windows/Staypaw.Tests.exe -explicit only` runs the explicit tests, which steal focus on the real desktop. Run them only when the owner isn't typing. -- `tools/Staypaw.StealTest` takes focus the way misbehaving apps do: `Staypaw.StealTest.exe [alt|attach|both] [repeatMs]`. It prints how many keys reached it. +- `tests/Staypaw.Tests/bin/Release/net10.0-windows/Staypaw.Tests.exe -explicit only` runs the explicit tests, which steal focus on the real desktop, and one types an unused key (0x97) into its own test windows. Run them only when the owner isn't typing. +- `tools/Staypaw.StealTest` takes focus the way misbehaving apps do: `Staypaw.StealTest.exe [alt|attach|both] [repeatMs]`. It also steals whenever its thread gets `WM_APP`, and it prints how many keys reached it. - `dotnet publish src/Staypaw.Desktop -c Release -r win-x64` builds the native exe. With Visual Studio 18, put `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer` on `PATH` first: its `VsDevCmd.bat` calls `vswhere.exe` without a path, and the error text breaks the NativeAOT link step ("'vswhere.exe' is not recognized"). - `dotnet run scripts/make-icons.cs` regenerates `docs/brand/staypaw.ico` and `app-icon-256.png` from `app-icon.svg`. - `uv run --no-project --with fonttools --with uharfbuzz python -I scripts/make_lockup.py docs/brand` rebuilds the lockups. Its header says where to get the font. diff --git a/README.md b/README.md index 7564dbc..14d8af7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Windows lets apps pull focus to themselves: a chat popup, an installer, a browse Calla, a calico cat-eared homebody with warm white hair, slow-blinks with her arms folded, as Staypaw does when all is quiet. -> **Status:** in development. Staypaw already logs every steal and protects the app you pick, but there's no release to install yet. See [ROADMAP.md](ROADMAP.md). Until Staypaw ships, use one of the tools under [Alternatives](#alternatives). +> **Status:** in development. Staypaw already logs every steal, protects the app you pick and holds stray keys, but there's no release to install yet. See [ROADMAP.md](ROADMAP.md). Until Staypaw ships, use one of the tools under [Alternatives](#alternatives).
@@ -31,7 +31,7 @@ Planned for 1.0: - **Protects an app you pick.** Nothing takes focus from it unless you click or Alt+Tab away, even while you're idle. - **Keeps focus where you're typing.** -- **Holds stray keys.** A key you press during a steal never reaches the app that stole focus. +- **Holds stray keys.** A key you press during a steal never reaches the app that stole focus. It goes to your window once focus is back, or is dropped if that window is a game. - **Tells you which app did it,** in a log you can open. - **Installs signed,** through winget. @@ -39,9 +39,9 @@ Planned for 1.0: Staypaw watches your keyboard, so it has to be easy to trust. -- **Nothing injected.** Staypaw never loads code into another app, and never touches a game's process. +- **Nothing injected.** Staypaw never loads code into another app, and never touches a game's process. Its keyboard hook is the low-level kind, which runs inside Staypaw itself. - **No network.** There is no networking code, no updater and no telemetry. -- **No keys stored.** It keeps the time of each keypress, never the key. Keys held during a steal stay in memory for a moment, then go to your window or are dropped. Nothing about them is written to disk. +- **No keys stored.** It keeps the time of each keypress, never the key. Keys held during a steal stay in memory for a moment, then go to your window or are dropped. They never go to a game. Nothing about them is written to disk. - **Open source,** so you can check all of this instead of trusting it. ## Alternatives diff --git a/ROADMAP.md b/ROADMAP.md index e64dc52..b19479a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,6 +50,15 @@ What the design must protect. Everything else gets ordinary good design. The tray menu costs as much as the window: the first thing Avalonia draws starts its renderer, which isn't released until Staypaw quits. The welcome pays at first launch what the first tray click pays anyway. This gives up M1's idle figure as the whole story: it holds only until the menu or the window first opens. +## Decisions (2026-10-24) + +- **Held keys are replayed into your window, never into a game.** A window counts as a game when it's the protected app, an exe that Windows' Game Bar lists (read from the registry, so the game itself is never touched), or an app Staypaw can't name, since anti-cheat can hide even a process's name. Replayed keys are input the game didn't get from you. This gives up the keys typed in a steal's first milliseconds when the protected app isn't a game, and leaves a game that Windows doesn't list, and that you didn't protect, open to replayed keys. +- **Keys wait until Staypaw has judged a focus change and finished any restore.** A `WH_KEYBOARD_LL` hook on a thread of its own catches them. Every key on the system waits for that hook, and Windows quietly removes one that answers too slowly, so it can't share the UI thread, which may be busy drawing. Keys held for a change that stands go to the new window only if it was your switch; otherwise they're dropped, never sent to a stealer. This gives up keys typed in the first milliseconds of a change Staypaw lets stand, such as a link you opened. +- **A hold that makes no progress for 500 ms drops its keys and lets the keyboard go,** so a stuck watcher or a bug can't take the keyboard. This gives up those keys. +- **A restore calls `SetForegroundWindow` alone, without NoFocusSteal's `BringWindowToTop`.** Its extra activation could leave no window in front: in a steal test that typed without pause, about 40 of 1,000 keys reached no window, and each steal logged two `Restored` rows and counted twice toward the fight limit. `SetForegroundWindow` raises the window anyway. This gives up NoFocusSteal's exact restore. +- **A restore that leaves no window in front is finished from your window's thread.** After a steal, `SetForegroundWindow` can return true and still leave no window in front, for good: your keys go nowhere and the stealer can't even steal again. A plain retry never fixed it. While it lasts, for up to 500 ms, the 15 ms check retries with Staypaw's thread sharing the input of your window's thread (`AttachThreadInput`), which finished 17 of 17 such restores. It's the one time Staypaw shares a game's input state: for about a millisecond, with no process handle, no memory read and no input sent. A hung window is skipped, since it would hang Staypaw too. This gives up keeping Staypaw's input apart from a protected game's at all times. +- **Keys already in your window when a stealer takes focus can't be held.** A stealer that joins your window's input with `AttachThreadInput` and then takes focus gets the keys your window hasn't read yet, or they're lost when it lets go. The hook let them through rightly, because your window had focus: tagged keys put each one 0–3 ms before the steal. So the key test goes quiet for 30 ms before each steal it asks for, and checks the keys typed after it. This gives up keys typed in the last milliseconds before a steal, and a test of them. + ## M0: Placeholder (as soon as possible) - [x] Add `LICENSE` (Apache-2.0), `README.md`, `ROADMAP.md` and `AGENTS.md`. @@ -93,12 +102,14 @@ What the design must protect. Everything else gets ordinary good design. ## M3: Hold stray keys -- [ ] A `WH_KEYBOARD_LL` hook in Staypaw's own process that holds keys typed between a steal and the restore. -- [ ] Deliver held keys to your window after the restore, and drop them when that window is a protected game. -- [ ] Scan the published exe with Microsoft Defender, since a global keyboard hook can look like a keylogger. +- [x] A `WH_KEYBOARD_LL` hook in Staypaw's own process that holds keys typed between a steal and the restore. +- [x] Deliver held keys to your window after the restore, and drop them when that window is a protected game. +- [x] Scan the published exe with Microsoft Defender, since a global keyboard hook can look like a keylogger. **Done when:** the steal test counts 0 keys reaching the stealer, and a Defender scan of the published exe is clean. +**Done 2026-10-24:** `StealTests` passed three runs in a row on the real desktop. In each, 45 keys typed during 15 steals were held and replayed, the stealer got none, and your window got all 120. With key holding turned off, the stealer got 36 and the test failed. A Defender custom scan of the published exe (signatures 1.461.184.0) found no threats. The exe is 17,896,448 bytes, 29 KB more than before M3. Idle in the tray, a build from before the last fixes used 40.8 MB working set and 65.1 MB private, against 40.9 and 64.4 before M3. `GuardrailTests` fails on any hook that isn't low-level; a deliberate `WH_KEYBOARD` failed it. + ## M4: 1.0 - [ ] A Velopack per-user Setup and a portable zip, built by a release workflow. diff --git a/src/Staypaw.Core/FocusWatcher.cs b/src/Staypaw.Core/FocusWatcher.cs index 0768140..2f16e09 100644 --- a/src/Staypaw.Core/FocusWatcher.cs +++ b/src/Staypaw.Core/FocusWatcher.cs @@ -34,8 +34,14 @@ public sealed class FocusWatcher : IDisposable // A virtual-key code no program uses, for the fallback in ForceForeground. private const ushort NoOpKey = 0xE8; + // Tags on the keys Staypaw sends, so its own hook lets them through. The last replayed key marks the batch landed. + private const nuint ReplayTag = 0x53545901; + private const nuint LastReplayTag = 0x53545902; + private const nuint NoOpTag = 0x53545903; + private readonly FocusLog _log; private readonly InputTracker _input = new(); + private readonly KeyHolder _held = new(); private readonly Queue<(WindowInfo Window, int EventTime, int LastInputInfo)> _pending = new(); private readonly uint _ownPid = (uint)Environment.ProcessId; private readonly WINEVENTPROC _onForeground; @@ -48,7 +54,17 @@ public sealed class FocusWatcher : IDisposable private HWINEVENTHOOK _foregroundHook; private HWINEVENTHOOK _focusHook; private nuint _pollTimer; + private KeyboardHook? _keyboard; private HWND _lastSeenForeground; + private int _restoreRetryUntil; + + // The foreground window once the latest judgement, and any restore, finished. The keyboard hook's thread holds + // keys typed while any other window has focus, including during the judging. + private volatile nint _judged; + + // The window the latest judgement left you in: the one restored, or the one you switched to. Null after a change + // that stood without being yours. + private HWND _yours; private WindowInfo? _tracked; private bool _draining; private int? _lastBogusLogged; @@ -73,6 +89,9 @@ internal FocusWatcher(FocusLog log) public static string LogPath => FocusLog.DefaultPath; + /// For tests that stand in for you typing: Raw Input never reports keys a program sends. + internal InputTracker Input => _input; + /// The exe name of the app whose windows keep focus even while you're idle, or null for none. public string? ProtectedApp { get; set; } @@ -108,6 +127,9 @@ public void Start() } _lastSeenForeground = foreground; + _judged = foreground; + _yours = foreground; + _keyboard = new KeyboardHook(OnKey); _foregroundHook = PInvoke.SetWinEventHook(PInvoke.EVENT_SYSTEM_FOREGROUND, PInvoke.EVENT_SYSTEM_FOREGROUND, HMODULE.Null, _onForeground, 0, 0, PInvoke.WINEVENT_OUTOFCONTEXT); @@ -136,22 +158,38 @@ public void Dispose() } _foregroundHook = _focusHook = HWINEVENTHOOK.Null; + _keyboard?.Dispose(); + _keyboard = null; _input.Dispose(); } private void CheckForeground() { var foreground = PInvoke.GetForegroundWindow(); - if (foreground.IsNull || foreground == _lastSeenForeground) + + // A restore can leave no window in front, and then no event comes to retry it. + if (foreground.IsNull && _lastSeenForeground.IsNull && _tracked is not null && !WindowInfo.IsGone(_tracked.Hwnd) + && Classifier.Elapsed(_restoreRetryUntil, Environment.TickCount) > 0 && ForceForeground(_tracked.Hwnd)) { - return; + foreground = _tracked.Hwnd; } - _lastSeenForeground = foreground; - if (_tracked is null || foreground != _tracked.Hwnd) + if (!foreground.IsNull && foreground != _lastSeenForeground) { - Enqueue(foreground, Environment.TickCount); + _lastSeenForeground = foreground; + if (_tracked is null || foreground != _tracked.Hwnd) + { + Enqueue(foreground, Environment.TickCount); + } } + + // Back in the window you were in with no change to judge, as when Windows hands focus back by itself. + if (_tracked is not null && foreground == _tracked.Hwnd && foreground != _judged) + { + _judged = _yours = foreground; + } + + SettleHeldKeys(); } private void Enqueue(HWND hwnd, int time) @@ -194,6 +232,8 @@ private void Enqueue(HWND hwnd, int time) { _draining = false; } + + SettleHeldKeys(); } private void Evaluate(WindowInfo next, int eventTime, int lastInputInfo) @@ -201,6 +241,7 @@ private void Evaluate(WindowInfo next, int eventTime, int lastInputInfo) var previous = _tracked; if (previous is not null && next.Hwnd == previous.Hwnd) { + _judged = _yours = next.Hwnd; return; } @@ -208,8 +249,8 @@ private void Evaluate(WindowInfo next, int eventTime, int lastInputInfo) // Input that Raw Input didn't report (touch, pen, some accessibility tools) still moves the system-wide // last-input time. Compare against input seen around that moment, not the latest input: mouse moves after - // the switch mustn't hide a click we never saw. - int? unseen = lastInputInfo != 0 && !_input.SawInputNear(lastInputInfo) ? lastInputInfo : null; + // the switch mustn't hide a click we never saw. Keys Staypaw held moved it too. + int? unseen = lastInputInfo != 0 && !_input.SawInputNear(lastInputInfo) && !_held.HeldNear(lastInputInfo) ? lastInputInfo : null; var change = new FocusChange { @@ -243,6 +284,21 @@ private void Evaluate(WindowInfo next, int eventTime, int lastInputInfo) && Classifier.ShouldRestore(classification, change, IsProtected(previous), repeatStealer) && Restore(previous, next, eventTime, ref note); + if (previous is not null && (restored || PInvoke.GetForegroundWindow() == previous.Hwnd)) + { + // A restore still pending leaves _judged alone, so keys keep waiting for the retry. + _yours = previous.Hwnd; + if (PInvoke.GetForegroundWindow() == previous.Hwnd) + { + _judged = previous.Hwnd; + } + } + else + { + _judged = next.Hwnd; + _yours = classification.Verdict == Verdict.Yours ? next.Hwnd : HWND.Null; + } + if (classification.Verdict == Verdict.Steal && !next.IsBogus) { _lastStole[key] = eventTime; @@ -302,8 +358,10 @@ private bool Restore(WindowInfo previous, WindowInfo stealer, int eventTime, ref } // Often the switch just hasn't registered yet. Keep your window as the one you're in and look at the - // foreground again on the next check, which retries if the stealer is still there. + // foreground again on the next check, which retries if the stealer is still there, or if no window is, + // for as long as keys typed meanwhile are held. _lastSeenForeground = HWND.Null; + _restoreRetryUntil = unchecked(Environment.TickCount + KeyHolder.LimitMs); } else if (alreadyBack && stealer.IsBogus) { @@ -341,34 +399,39 @@ private static bool TakeFocusBack(WindowInfo previous, WindowInfo stealer, out b private static unsafe bool ForceForeground(HWND target) { + // Sharing input state with the thread that owns the foreground lets us move it. With no window in front, a + // switch to another thread's window can stall for good though SetForegroundWindow says it worked, so share + // the target's instead and the switch happens at once. Not a hung one's, which would hang Staypaw too. var foreground = PInvoke.GetForegroundWindow(); - var foregroundThread = foreground.IsNull ? 0 : PInvoke.GetWindowThreadProcessId(foreground); + var inputOwner = !foreground.IsNull ? foreground : PInvoke.IsHungAppWindow(target) ? HWND.Null : target; + var inputThread = inputOwner.IsNull ? 0 : PInvoke.GetWindowThreadProcessId(inputOwner); var me = PInvoke.GetCurrentThreadId(); - - // Sharing input state with the thread that owns the foreground lets us move it. - var attached = foregroundThread != 0 && foregroundThread != me && PInvoke.AttachThreadInput(me, foregroundThread, true); + var attached = inputThread != 0 && inputThread != me && PInvoke.AttachThreadInput(me, inputThread, true); try { - PInvoke.BringWindowToTop(target); + // No BringWindowToTop as well: SetForegroundWindow raises the window anyway, and the extra activation + // could leave no window in front and lose the keys typed meanwhile. PInvoke.SetForegroundWindow(target); } finally { if (attached) { - PInvoke.AttachThreadInput(me, foregroundThread, false); + PInvoke.AttachThreadInput(me, inputThread, false); } } - if (PInvoke.GetForegroundWindow() == target) + var front = PInvoke.GetForegroundWindow(); + if (front == target || foreground.IsNull) { - return true; + return front == target; } // Fallback: Windows lets the process that produced the latest input set the foreground. Send a key no program // uses, then try again. It goes to the stealer, which has focus, never to the window being restored. var down = new INPUT { type = INPUT_TYPE.INPUT_KEYBOARD }; down.Anonymous.ki.wVk = (VIRTUAL_KEY)NoOpKey; + down.Anonymous.ki.dwExtraInfo = NoOpTag; var up = down; up.Anonymous.ki.dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP; PInvoke.SendInput([down, up], sizeof(INPUT)); @@ -393,6 +456,92 @@ private static unsafe void Flash(WindowInfo stealer) PInvoke.FlashWindowEx(in info); } + // On the hook's thread, for every key on the system, so it only compares and copies. + private bool OnKey(KBDLLHOOKSTRUCT e) + { + if (e.dwExtraInfo == NoOpTag) + { + return false; + } + + var key = new HeldKey((ushort)e.vkCode, (ushort)e.scanCode, + (e.flags & KBDLLHOOKSTRUCT_FLAGS.LLKHF_UP) != 0, (e.flags & KBDLLHOOKSTRUCT_FLAGS.LLKHF_EXTENDED) != 0); + nint foreground = PInvoke.GetForegroundWindow(); + if (e.dwExtraInfo is not (ReplayTag or LastReplayTag)) + { + return _held.Offer(key, foreground, _judged, unchecked((int)e.time)); + } + + var again = _held.OnReplayed(key, e.dwExtraInfo == LastReplayTag, foreground, _judged, unchecked((int)e.time), out var next); + Replay(next); + return again; + } + + /// Once Staypaw has judged where focus is, replays held keys into your window or drops them. + private void SettleHeldKeys() + { + if (!_held.Holding) + { + return; + } + + var foreground = PInvoke.GetForegroundWindow(); + var settled = foreground == _judged; + var deliver = settled && !foreground.IsNull && foreground == _yours && !IsGame(foreground); + Replay(_held.Settle(settled, deliver, Environment.TickCount)); + } + + // Replayed keys are input a game didn't get from you, which game terms of service forbid. + private bool IsGame(HWND hwnd) + { + var window = WindowInfo.Capture(hwnd); + return IsProtected(window) || window.MayBeGame(); + } + + private unsafe void Replay(HeldKey[]? keys) + { + if (keys is null) + { + return; + } + + var inputs = new INPUT[keys.Length]; + for (var i = 0; i < keys.Length; i++) + { + var key = keys[i]; + inputs[i].type = INPUT_TYPE.INPUT_KEYBOARD; + ref var ki = ref inputs[i].Anonymous.ki; + ki.wScan = key.Scan; + ki.dwExtraInfo = i == keys.Length - 1 ? LastReplayTag : ReplayTag; + + // A character sent by an input method or a tool rather than a key; the scan field holds the character. + if (key.Vk == (ushort)VIRTUAL_KEY.VK_PACKET) + { + ki.dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_UNICODE; + } + else + { + ki.wVk = (VIRTUAL_KEY)key.Vk; + } + + if (key.Up) + { + ki.dwFlags |= KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP; + } + + if (key.Extended) + { + ki.dwFlags |= KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY; + } + } + + // Windows refuses input aimed at an administrator app, and refused keys never come back through the hook. + if (PInvoke.SendInput(inputs, sizeof(INPUT)) != inputs.Length) + { + _held.Drop(); + } + } + /// Records a restore and returns true if the app now fights back so often that Staypaw backs off. private bool CountRestore(string key, int now) { diff --git a/src/Staypaw.Core/KeyHolder.cs b/src/Staypaw.Core/KeyHolder.cs new file mode 100644 index 0000000..ed6ed9e --- /dev/null +++ b/src/Staypaw.Core/KeyHolder.cs @@ -0,0 +1,196 @@ +namespace Staypaw.Core; + +/// +/// One key event as the low-level keyboard hook reports it. Held keys are the trust contract's one exception: kept in +/// memory until they're replayed or dropped, and never written anywhere. A plain struct, so its ToString is only the +/// type name and a trace line can't leak a key. +/// +internal readonly struct HeldKey(ushort vk, ushort scan, bool up, bool extended) +{ + public ushort Vk { get; } = vk; + public ushort Scan { get; } = scan; + public bool Up { get; } = up; + public bool Extended { get; } = extended; +} + +/// +/// Holds keys typed while focus is on a window Staypaw hasn't judged yet, then hands them back to replay into your +/// window, or drops them. Windows are passed in as handles and times as tick counts; it calls no Win32. The hook's +/// thread and the watcher's both call it, so every change happens under one lock. +/// +internal sealed class KeyHolder +{ + // A restore takes a few milliseconds. A hold this old means the watcher is busy or losing a fight, and then your + // keyboard matters more than the keys. + public const int LimitMs = 500; + + private readonly Lock _gate = new(); + private readonly List _keys = []; + private readonly HashSet _downs = []; + private volatile bool _holding; + private bool _replaying; + private int _since; + private int _heldAgain; + private nint _gaveUpOn; + private volatile int _lastHeldAt; + + /// True from the first held key until the keys are replayed or dropped. Safe to read without the lock. + public bool Holding => _holding; + + /// + /// True if a key was held around , in the window + /// uses. Windows counts a held key as input though Raw Input never reports it, so it would pass for touch or pen. + /// + public bool HeldNear(int time) => Classifier.Elapsed(_lastHeldAt, time) is >= -150 and <= 60; + + /// + /// True to hold a key you typed: focus is on , and the watcher last saw + /// , so a move it hasn't judged may be a steal. + /// + public bool Offer(HeldKey key, nint foreground, nint judged, int now) + { + lock (_gate) + { + if (_holding && Classifier.Elapsed(now, _since) > LimitMs) + { + _gaveUpOn = foreground; + End(); + } + + if (!_holding) + { + if (foreground == 0 || foreground == judged || foreground == _gaveUpOn) + { + return false; + } + + _holding = true; + _since = now; + _gaveUpOn = 0; + } + + // A key-up whose key-down went through before the hold passes too: dropping it would leave the key down. + if (key.Up && !_downs.Contains(key.Vk)) + { + return false; + } + + if (!key.Up) + { + _downs.Add(key.Vk); + } + + _keys.Add(key); + _lastHeldAt = now; + return true; + } + } + + /// + /// After the watcher judged a change: the keys to replay into the foreground window if , + /// otherwise null, and they're dropped. Until , a change is unjudged or a restore is + /// pending, so the keys wait, up to the limit. + /// + public HeldKey[]? Settle(bool settled, bool deliver, int now) + { + lock (_gate) + { + if (!_holding) + { + return null; + } + + if (Classifier.Elapsed(now, _since) > LimitMs) + { + End(); + return null; + } + + if (_replaying || !settled) + { + return null; + } + + if (!deliver || _keys.Count == 0) + { + End(); + return null; + } + + return TakeBatch(now); + } + } + + /// + /// One of Staypaw's replayed keys came back through the hook. True to hold it again, because focus moved before it + /// landed. After the batch's last key, is any keys typed meanwhile, to replay in turn. + /// + public bool OnReplayed(HeldKey key, bool last, nint foreground, nint judged, int now, out HeldKey[]? next) + { + next = null; + lock (_gate) + { + if (!_replaying) + { + return false; + } + + // Ahead of keys typed during the replay, which came after it. + var again = foreground != 0 && foreground != judged; + if (again) + { + _keys.Insert(_heldAgain++, key); + _lastHeldAt = now; + } + + if (last) + { + _replaying = false; + if (_heldAgain > 0) + { + _since = now; + } + else if (_keys.Count > 0) + { + next = TakeBatch(now); + } + else + { + End(); + } + } + + return again; + } + } + + /// Drops the held keys, for a replay Windows refused. + public void Drop() + { + lock (_gate) + { + End(); + } + } + + private HeldKey[] TakeBatch(int now) + { + var batch = _keys.ToArray(); + _keys.Clear(); + _heldAgain = 0; + _replaying = true; + + // The limit is for a hold that's stuck; one that's replaying is making progress. + _since = now; + return batch; + } + + private void End() + { + _keys.Clear(); + _downs.Clear(); + _heldAgain = 0; + _replaying = false; + _holding = false; + } +} diff --git a/src/Staypaw.Core/KeyboardHook.cs b/src/Staypaw.Core/KeyboardHook.cs new file mode 100644 index 0000000..408e863 --- /dev/null +++ b/src/Staypaw.Core/KeyboardHook.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Staypaw.Core; + +/// +/// A low-level keyboard hook (WH_KEYBOARD_LL) on a thread of its own. Windows calls it inside this process, so +/// nothing is injected anywhere. Every key on the system waits for its answer, and Windows quietly removes a hook +/// that answers too slowly, so it gets a thread that does nothing else: the UI thread can be busy drawing. +/// +internal sealed class KeyboardHook : IDisposable +{ + private readonly Func _hold; + private readonly HOOKPROC _proc; + private readonly Thread _thread; + private uint _threadId; + + /// Returns true to keep the key from reaching any window. + public KeyboardHook(Func hold) + { + _hold = hold; + _proc = OnKey; + using var ready = new ManualResetEventSlim(); + _thread = new Thread(() => Run(ready)) { IsBackground = true, Name = "Staypaw keyboard hook" }; + _thread.Start(); + ready.Wait(); + } + + public bool Installed { get; private set; } + + public void Dispose() + { + if (_threadId != 0) + { + PInvoke.PostThreadMessage(_threadId, PInvoke.WM_QUIT, 0, 0); + } + + _thread.Join(); + } + + private unsafe void Run(ManualResetEventSlim ready) + { + using var hook = PInvoke.SetWindowsHookEx(WINDOWS_HOOK_ID.WH_KEYBOARD_LL, _proc, PInvoke.GetModuleHandle((string?)null), 0); + Installed = !hook.IsInvalid; + _threadId = PInvoke.GetCurrentThreadId(); + ready.Set(); + if (!Installed) + { + Trace.WriteLine("Staypaw: keyboard hook failed: " + System.Runtime.InteropServices.Marshal.GetLastPInvokeError()); + return; + } + + // Windows calls the hook from inside GetMessage; no message ever arrives but WM_QUIT. + MSG msg; + while (PInvoke.GetMessage(&msg, HWND.Null, 0, 0) > 0) + { + } + } + + private unsafe LRESULT OnKey(int code, WPARAM wParam, LPARAM lParam) + { + if (code >= 0) + { + try + { + if (_hold(*(KBDLLHOOKSTRUCT*)lParam.Value)) + { + return new LRESULT(1); + } + } + catch (Exception ex) + { + // A bug must never take the keyboard: let the key through. + Trace.WriteLine("Staypaw: keyboard hook failed: " + ex); + } + } + + return PInvoke.CallNextHookEx(HHOOK.Null, code, wParam, lParam); + } +} diff --git a/src/Staypaw.Core/NativeMethods.txt b/src/Staypaw.Core/NativeMethods.txt index 8d26891..4e5fceb 100644 --- a/src/Staypaw.Core/NativeMethods.txt +++ b/src/Staypaw.Core/NativeMethods.txt @@ -1,5 +1,5 @@ AttachThreadInput -BringWindowToTop +CallNextHookEx CloseHandle CreateWindowEx DefWindowProc @@ -18,6 +18,7 @@ GetCurrentThreadId GetCursorPos GetForegroundWindow GetLastInputInfo +GetMessage GetMessageTime GetModuleHandle GetProcessTimes @@ -26,13 +27,16 @@ GetTokenInformation GetWindowText GetWindowThreadProcessId HWND_MESSAGE +IsHungAppWindow IsIconic IsWindow IsWindowVisible +KBDLLHOOKSTRUCT KillTimer OpenProcess OpenProcessToken PeekMessage +PostThreadMessage QueryFullProcessImageName RAWINPUT RegisterClassEx @@ -42,11 +46,14 @@ RID_DEVICE_INFO_TYPE SendInput SetForegroundWindow SetTimer +SetWindowsHookEx SetWinEventHook SystemParametersInfo TOKEN_ELEVATION +UnhookWindowsHookEx UnhookWinEvent UnregisterClass WindowFromPoint WINEVENT_OUTOFCONTEXT WM_INPUT +WM_QUIT diff --git a/src/Staypaw.Core/WindowInfo.cs b/src/Staypaw.Core/WindowInfo.cs index 27efce8..0fc49a0 100644 --- a/src/Staypaw.Core/WindowInfo.cs +++ b/src/Staypaw.Core/WindowInfo.cs @@ -1,3 +1,4 @@ +using Microsoft.Win32; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.Graphics.Dwm; @@ -113,6 +114,31 @@ public static unsafe bool IsGone(HWND hwnd) return PInvoke.DwmGetWindowAttribute(hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAKED, &cloaked, sizeof(int)).Succeeded && cloaked != 0; } + /// + /// True if Windows' Game Bar lists this exe as a game, or if the exe couldn't be named at all: anti-cheat shields + /// some games even from PROCESS_QUERY_LIMITED_INFORMATION. The list is read from the registry, so the game + /// itself is never touched. Matched by file name, so a game moved since Windows listed it still counts. + /// + public bool MayBeGame() + { + if (ExePath.Length == 0) + { + return true; + } + + using var games = Registry.CurrentUser.OpenSubKey(@"System\GameConfigStore\Children"); + foreach (var id in games?.GetSubKeyNames() ?? []) + { + using var game = games!.OpenSubKey(id); + if (game?.GetValue("MatchedExeFullPath") is string path && Path.GetFileName(path).Equals(ExeName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static unsafe bool IsElevated(HANDLE process) { // A token we aren't allowed to query belongs to a more privileged process, which is what matters here. diff --git a/tests/Staypaw.Tests/GuardrailTests.cs b/tests/Staypaw.Tests/GuardrailTests.cs index 90e4b31..dfe1542 100644 --- a/tests/Staypaw.Tests/GuardrailTests.cs +++ b/tests/Staypaw.Tests/GuardrailTests.cs @@ -1,13 +1,35 @@ using System.Reflection; +using System.Text.RegularExpressions; namespace Staypaw.Tests; -/// Structural rules from AGENTS.md: the core has no UI, and nothing uses the network. +/// Structural rules from AGENTS.md: the core has no UI, nothing uses the network, and every hook stays in +/// Staypaw's own process. public class GuardrailTests { private static readonly Assembly Core = Assembly.Load("Staypaw.Core"); private static readonly Assembly Desktop = Assembly.Load("Staypaw"); + // Only the low-level hooks run in the process that sets them; every other system-wide hook loads into other apps. + [Fact] + public void Every_Windows_hook_is_low_level() + { + var root = AppContext.BaseDirectory; + while (!File.Exists(Path.Combine(root, "AGENTS.md"))) + { + root = Path.GetDirectoryName(root.TrimEnd(Path.DirectorySeparatorChar))!; + } + + var hooks = HookTypes(Directory.EnumerateFiles(Path.Combine(root, "src"), "*.cs", SearchOption.AllDirectories).Select(File.ReadAllText)); + + Assert.NotEmpty(hooks); + Assert.All(hooks, hook => Assert.EndsWith("_LL", hook, StringComparison.Ordinal)); + } + + [Fact] + public void The_check_finds_a_hook_that_loads_into_other_apps() => + Assert.Equal(["WH_KEYBOARD"], HookTypes(["PInvoke.SetWindowsHookEx(WINDOWS_HOOK_ID.WH_KEYBOARD, proc, module, 0);"])); + [Fact] public void Core_does_not_reference_Avalonia() => Assert.Empty(References(Core, "Avalonia")); @@ -26,6 +48,10 @@ public void No_Staypaw_assembly_uses_the_network() public void The_check_finds_network_references() => Assert.NotEmpty(References(Assembly.Load("System.Net.Http"), "System.Net")); + private static string[] HookTypes(IEnumerable sources) => + [.. sources.SelectMany(source => Regex.Matches(source, @"SetWindowsHookEx\(\s*(?:WINDOWS_HOOK_ID\.)?(\w+)")) + .Select(match => match.Groups[1].Value)]; + private static string[] References(Assembly assembly, string prefix) => [.. assembly.GetReferencedAssemblies() .Select(name => name.Name!) diff --git a/tests/Staypaw.Tests/KeyHolderTests.cs b/tests/Staypaw.Tests/KeyHolderTests.cs new file mode 100644 index 0000000..bbcaf25 --- /dev/null +++ b/tests/Staypaw.Tests/KeyHolderTests.cs @@ -0,0 +1,178 @@ +using Staypaw.Core; + +namespace Staypaw.Tests; + +// Windows are plain numbers here: 1 is the window Staypaw last saw, 2 a stealer, 0 none. +public class KeyHolderTests +{ + private const int T0 = 1000; + private static readonly HeldKey ADown = new(0x41, 0x1E, up: false, extended: false); + private static readonly HeldKey AUp = new(0x41, 0x1E, up: true, extended: false); + private static readonly HeldKey BDown = new(0x42, 0x30, up: false, extended: false); + private static readonly HeldKey ShiftUp = new(0x10, 0x2A, up: true, extended: false); + + [Fact] + public void Keys_pass_while_focus_is_where_Staypaw_last_saw_it() + { + var holder = new KeyHolder(); + + Assert.False(holder.Offer(ADown, foreground: 1, judged: 1, T0)); + Assert.False(holder.Offer(ADown, foreground: 0, judged: 1, T0)); + Assert.False(holder.Holding); + } + + [Fact] + public void Keys_wait_once_focus_moves_and_come_back_in_order_for_your_window() + { + var holder = new KeyHolder(); + + Assert.True(holder.Offer(ADown, foreground: 2, judged: 1, T0)); + Assert.True(holder.Offer(AUp, foreground: 2, judged: 1, T0 + 5)); + + Assert.Equal([ADown, AUp], holder.Settle(settled: true, deliver: true, T0 + 10)); + } + + [Fact] + public void A_key_up_whose_key_down_went_through_before_the_hold_passes() + { + var holder = new KeyHolder(); + + Assert.False(holder.Offer(ShiftUp, foreground: 2, judged: 1, T0)); + Assert.True(holder.Offer(BDown, foreground: 2, judged: 1, T0 + 5)); + + Assert.Equal([BDown], holder.Settle(settled: true, deliver: true, T0 + 10)); + } + + [Fact] + public void Keys_for_a_window_that_is_not_yours_are_dropped() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + + Assert.Null(holder.Settle(settled: true, deliver: false, T0 + 10)); + Assert.False(holder.Holding); + Assert.False(holder.Offer(AUp, foreground: 2, judged: 2, T0 + 20)); + } + + [Fact] + public void Keys_wait_while_a_restore_is_still_pending() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 0, T0); + + Assert.Null(holder.Settle(settled: false, deliver: true, T0 + 100)); + Assert.True(holder.Holding); + Assert.Equal([ADown], holder.Settle(settled: true, deliver: true, T0 + 150)); + } + + [Fact] + public void A_hold_past_the_limit_is_dropped_and_not_restarted_for_the_same_window() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + + Assert.False(holder.Offer(BDown, foreground: 2, judged: 1, T0 + KeyHolder.LimitMs + 1)); + Assert.False(holder.Holding); + Assert.True(holder.Offer(BDown, foreground: 3, judged: 1, T0 + KeyHolder.LimitMs + 2)); + } + + [Fact] + public void Settling_past_the_limit_drops_the_keys() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 0, T0); + + Assert.Null(holder.Settle(settled: false, deliver: true, T0 + KeyHolder.LimitMs + 1)); + Assert.False(holder.Holding); + } + + [Fact] + public void Keys_typed_during_a_replay_follow_it() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + Assert.Equal([ADown], holder.Settle(settled: true, deliver: true, T0 + 10)); + + Assert.True(holder.Offer(BDown, foreground: 1, judged: 1, T0 + 11)); + Assert.False(holder.OnReplayed(ADown, last: true, foreground: 1, judged: 1, T0 + 12, out var next)); + Assert.Equal([BDown], next); + + Assert.False(holder.OnReplayed(BDown, last: true, foreground: 1, judged: 1, T0 + 13, out next)); + Assert.Null(next); + Assert.False(holder.Holding); + } + + [Fact] + public void The_limit_restarts_with_each_replay() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + holder.Settle(settled: true, deliver: true, T0 + 400); + holder.Offer(BDown, foreground: 1, judged: 1, T0 + 450); + holder.OnReplayed(ADown, last: true, foreground: 1, judged: 1, T0 + 460, out _); + + Assert.True(holder.Offer(AUp, foreground: 1, judged: 1, T0 + KeyHolder.LimitMs + 100)); + } + + [Fact] + public void A_replayed_key_is_held_again_if_focus_moved_before_it_landed() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + holder.Offer(AUp, foreground: 2, judged: 1, T0 + 1); + holder.Settle(settled: true, deliver: true, T0 + 10); + holder.Offer(BDown, foreground: 1, judged: 1, T0 + 11); + + Assert.True(holder.OnReplayed(ADown, last: false, foreground: 3, judged: 1, T0 + 12, out _)); + Assert.True(holder.OnReplayed(AUp, last: true, foreground: 3, judged: 1, T0 + 12, out var next)); + Assert.Null(next); + + Assert.True(holder.Holding); + Assert.Equal([ADown, AUp, BDown], holder.Settle(settled: true, deliver: true, T0 + 20)); + } + + [Fact] + public void A_replay_that_lands_after_the_hold_ended_passes() + { + var holder = new KeyHolder(); + + Assert.False(holder.OnReplayed(ADown, last: true, foreground: 2, judged: 1, T0, out var next)); + Assert.Null(next); + } + + // Windows counts a held key as input though Raw Input never reports it, which would look like touch or pen. + [Fact] + public void A_held_key_counts_as_input_Staypaw_saw() + { + var holder = new KeyHolder(); + Assert.False(holder.Offer(ADown, foreground: 1, judged: 1, T0)); + Assert.False(holder.HeldNear(T0)); + + holder.Offer(BDown, foreground: 2, judged: 1, T0 + 1000); + + Assert.True(holder.HeldNear(T0 + 1000)); + Assert.False(holder.HeldNear(T0 + 2000)); + } + + [Fact] + public void A_replayed_key_held_again_counts_as_input_Staypaw_saw() + { + var holder = new KeyHolder(); + holder.Offer(ADown, foreground: 2, judged: 1, T0); + holder.Settle(settled: true, deliver: true, T0 + 10); + + holder.OnReplayed(ADown, last: true, foreground: 3, judged: 1, T0 + 1000, out _); + + Assert.True(holder.HeldNear(T0 + 1000)); + } + + [Fact] + public void A_hold_with_nothing_in_it_ends_without_a_replay() + { + var holder = new KeyHolder(); + holder.Offer(ShiftUp, foreground: 2, judged: 1, T0); + + Assert.Null(holder.Settle(settled: true, deliver: true, T0 + 10)); + Assert.False(holder.Holding); + } +} diff --git a/tests/Staypaw.Tests/StealTests.cs b/tests/Staypaw.Tests/StealTests.cs index d6fa6ad..7396b57 100644 --- a/tests/Staypaw.Tests/StealTests.cs +++ b/tests/Staypaw.Tests/StealTests.cs @@ -2,6 +2,7 @@ using Staypaw.Core; using Windows.Win32; using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; namespace Staypaw.Tests; @@ -10,6 +11,113 @@ namespace Staypaw.Tests; // Each steal waits 11 s so it lands outside the launch grace after your last click or key. public class StealTests { + // A key no program uses, so a key that goes astray does nothing. + private const ushort TestKey = 0x97; + + // WM_APP: the steal test steals again when its thread gets it. + private const uint StealNow = 0x8000; + + private static readonly string StealTestExe = Path.Combine( + AppContext.BaseDirectory.Replace(Path.Combine("tests", "Staypaw.Tests"), Path.Combine("tools", "Staypaw.StealTest"), StringComparison.Ordinal), + "Staypaw.StealTest.exe"); + + // Two steal-test windows: one takes focus at 1 s and stands for your window, the other steals it at 11 s and then + // whenever the test asks. Keys are typed only while one of them has focus, so none reach your own windows. + [Fact(Explicit = true)] + public unsafe void Keys_typed_during_steals_land_in_your_window_and_never_in_the_stealer() + { + var log = Path.Combine(Path.GetTempPath(), $"staypaw-keys-{Guid.NewGuid():N}.csv"); + using var watcher = new FocusWatcher(new FocusLog(log)); + watcher.Start(); + using var yours = StartStealTest("1 attach"); + using var thief = StartStealTest("11 attach"); + + var down = new INPUT { type = INPUT_TYPE.INPUT_KEYBOARD }; + down.Anonymous.ki.wVk = (VIRTUAL_KEY)TestKey; + var up = down; + up.Anonymous.ki.dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP; + int typed = 0, typedWhileStolen = 0; + string[] rows; + + // Raw Input never reports keys a program sends, so the watcher is told about each one: you're typing. + void Type() + { + var pid = ForegroundPid(); + watcher.Input.OnKey(TestKey, down: true, Environment.TickCount); + if ((pid == yours.Id || pid == thief.Id) && PInvoke.SendInput([down, up], sizeof(INPUT)) == 2) + { + typed++; + typedWhileStolen += pid == thief.Id ? 1 : 0; + } + } + + try + { + // Your window's own arrival is a steal that stands, unless you're really typing somewhere. + Assert.True(WaitFor(() => ForegroundPid() == yours.Id, pump: true, 10000), + "the steal-test window never kept focus; were you typing?"); + + // The stealer's first steal must find you typing to be undone. No key is sent, so none can meet it. + var until = Environment.TickCount64 + 15000; + while (thief.MainWindowHandle == 0 && Environment.TickCount64 < until) + { + watcher.Input.OnKey(TestKey, down: true, Environment.TickCount); + Pump(TimeSpan.FromMilliseconds(10)); + thief.Refresh(); + } + + watcher.Input.OnKey(TestKey, down: true, Environment.TickCount); + Pump(TimeSpan.FromMilliseconds(500)); + var thiefThread = PInvoke.GetWindowThreadProcessId((HWND)thief.MainWindowHandle); + for (var steal = 0; steal < 15; steal++) + { + for (var i = 0; i < 5; i++) + { + Type(); + Pump(TimeSpan.FromMilliseconds(10)); + } + + // A key still unread in your window when a stealer joins its input (AttachThreadInput) and takes + // focus goes to the stealer, or nowhere, and no hook can hold it back. So the keyboard goes quiet + // before each steal, and only keys typed after one are tested. + Pump(TimeSpan.FromMilliseconds(30)); + PInvoke.PostThreadMessage(thiefThread, StealNow, 0, 0); + + // Without pumping, Staypaw can't judge the steal before these keys arrive. + if (WaitFor(() => ForegroundPid() == thief.Id, pump: false, 1000)) + { + Type(); + Type(); + Type(); + } + + WaitFor(() => ForegroundPid() == yours.Id, pump: true, 1000); + + // About 400 ms between steals, as from a stealer on a timer. Much faster, and Staypaw stops fighting + // the app for a minute. + Pump(TimeSpan.FromMilliseconds(250)); + } + + // A window handles WM_CLOSE ahead of keys still in its queue, so let the last held keys land first. + Pump(TimeSpan.FromSeconds(1)); + } + finally + { + Close(yours); + Close(thief); + rows = File.ReadAllLines(log); + File.Delete(log); + } + + var thiefKeys = thief.StandardOutput.ReadToEnd().Trim(); + var yoursKeys = yours.StandardOutput.ReadToEnd().Trim(); + var context = $"{typed} typed, {typedWhileStolen} while stolen; the stealer got {thiefKeys}, yours {yoursKeys}. The log:\n{string.Join('\n', rows)}"; + Assert.True(typedWhileStolen > 0, "no key was typed while the stealer had focus, so nothing was tested: " + context); + Assert.True(thiefKeys == "keys=0", context); + Assert.True(yoursKeys == $"keys={typed}", context); + TestContext.Current.TestOutputHelper?.WriteLine($"{typed} keys typed, {typedWhileStolen} of them while the stealer had focus"); + } + [Fact(Explicit = true)] public void A_scripted_steal_shows_up_as_a_log_row() { @@ -30,15 +138,11 @@ public void A_scripted_steal_from_the_protected_app_is_restored() private static string[] RunSteal(bool protectForeground, out bool foregroundKept) { var log = Path.Combine(Path.GetTempPath(), $"staypaw-steal-{Guid.NewGuid():N}.csv"); - var thiefExe = Path.Combine( - AppContext.BaseDirectory.Replace(Path.Combine("tests", "Staypaw.Tests"), Path.Combine("tools", "Staypaw.StealTest"), StringComparison.Ordinal), - "Staypaw.StealTest.exe"); - var foreground = PInvoke.GetForegroundWindow(); using var watcher = new FocusWatcher(new FocusLog(log)); watcher.Start(); watcher.ProtectedApp = protectForeground ? watcher.CurrentApp : null; - using var thief = Process.Start(new ProcessStartInfo(thiefExe, "11 attach") { UseShellExecute = false, CreateNoWindow = true })!; + using var thief = Process.Start(new ProcessStartInfo(StealTestExe, "11 attach") { UseShellExecute = false, CreateNoWindow = true })!; try { Pump(TimeSpan.FromSeconds(13)); @@ -54,6 +158,48 @@ private static string[] RunSteal(bool protectForeground, out bool foregroundKept return rows; } + private static int ForegroundPid() + { + PInvoke.GetWindowThreadProcessId(PInvoke.GetForegroundWindow(), out var pid); + return (int)pid; + } + + private static bool WaitFor(Func done, bool pump, int timeoutMs) + { + var until = Environment.TickCount64 + timeoutMs; + while (!done()) + { + if (Environment.TickCount64 > until) + { + return false; + } + + if (pump) + { + Pump(TimeSpan.FromMilliseconds(5)); + } + else + { + Thread.Sleep(1); + } + } + + return true; + } + + private static Process StartStealTest(string args) => + Process.Start(new ProcessStartInfo(StealTestExe, args) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true })!; + + // Closing its window makes the steal test print its count and exit. + private static void Close(Process stealTest) + { + stealTest.Refresh(); + if (!stealTest.CloseMainWindow() || !stealTest.WaitForExit(5000)) + { + stealTest.Kill(); + } + } + // The watcher's hooks and timer deliver through this thread's message queue. private static unsafe void Pump(TimeSpan duration) { diff --git a/tools/Staypaw.StealTest/Program.cs b/tools/Staypaw.StealTest/Program.cs index 9fba2d8..ccfcd3e 100644 --- a/tools/Staypaw.StealTest/Program.cs +++ b/tools/Staypaw.StealTest/Program.cs @@ -9,7 +9,8 @@ // // Usage: Staypaw.StealTest [alt|attach|both] [repeatMs] // Waits, then forces its window into the foreground with the tricks misbehaving apps use. With repeatMs it keeps -// grabbing focus. It closes after 20 seconds and prints how many keys reached it. +// grabbing focus. It also steals whenever its thread gets WM_APP, so a test can pick the moment. It closes after +// 20 seconds, or when its window is closed, and prints how many keys reached it. var delaySeconds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 3; var method = args.Length > 1 ? args[1] : "both"; var repeatMs = args.Length > 2 ? int.Parse(args[2], CultureInfo.InvariantCulture) : 0; @@ -29,7 +30,16 @@ Padding = new Padding(16), Text = "This window just took focus.\r\n\r\nIt counts the keys that reach it and closes itself in 20 seconds.", }); -form.KeyDown += (_, _) => keys++; +form.KeyDown += (_, e) => +{ + // 0xE8 is the unused key Staypaw sends a stealer to win back the foreground, never one you typed. + if (e.KeyValue != 0xE8) + { + keys++; + } +}; +form.FormClosed += (_, _) => Application.Exit(); +Application.AddMessageFilter(new StealOnRequest(() => Steal((HWND)form.Handle, method))); var delay = new Timer { Interval = Math.Max(1, delaySeconds) * 1000 }; delay.Tick += (_, _) => @@ -76,3 +86,19 @@ static void Steal(HWND hwnd, string method) } } } + +sealed class StealOnRequest(Action steal) : IMessageFilter +{ + private const int WmApp = 0x8000; + + public bool PreFilterMessage(ref Message m) + { + if (m.HWnd != 0 || m.Msg != WmApp) + { + return false; + } + + steal(); + return true; + } +}