Files
SteamlessController/src/app/InputInjection.h
T
Dylan DeverillandClaude Opus 5 09fe805119 Tell the user when Windows is discarding the input this app injects
Users reported the trackpad going dead after a takeover — haptics still
firing, cursor not moving — and recovering the moment focus moved to
another window or the window in front was closed. Haptics are HID writes
and the virtual pad is ViGEm, so both keep working regardless; SendInput
was the one stage of the pipeline that could fail and leave no trace.

Reproduced on this machine with an elevated Device Manager in front:

  - 137 px of movement injected, cursor pinned at (910,781) for 5s
  - foreground mmc.exe at High integrity, this process at Medium
  - SendInput returned success for every one of those events

That last point decides the shape of the fix. UIPI is documented as
failing the call with ERROR_ACCESS_DENIED, but on Windows 11 26200 the
call reports success and the input is dropped afterwards, so no
return-value check can see it. The condition is only observable by asking,
before injecting, whether the foreground window outranks this process.

So that is what Send now does, on every event:

  - one check covers cursor movement, trackpad clicks, paddle buttons and
    keys, none of which can be judged from their own result. A blocked
    paddle press previously produced no evidence anywhere
  - the foreground lookup is cached against the window it describes;
    injection happens at report rate, foreground changes do not
  - the state changing is what gets logged, not the events: one line
    naming the blocker, one when it stops
  - the tray balloon says which application is blocking input and that
    switching away from it restores the controller, rate-limited to one
    per blocker per minute so alt-tabbing cannot spam it
  - the check never gates the event. It observes and reports; if it is
    ever wrong about a system, the input goes out exactly as before

A foreground process whose token cannot be read is deliberately not
named. From Low integrity, OpenProcess succeeds while OpenProcessToken
fails with ERROR_ACCESS_DENIED, and protected processes refuse it too —
so an unreadable level means unknown, not blocked. Guessing would put a
wrong application's name in front of the user; the snapshot records
"unreadable" instead.

Backing this up, for the causes that produce no error and no integrity
mismatch: injected movement is checked against the cursor actually
moving, which is what catches a cursor clip a game left behind or a hook
filtering injected events. It stays quiet whenever a blocker is already
known, so it cannot contradict the better explanation. A refused call is
still logged with a dropped-event count and a recovery line, as a cheap
catch-all for systems where the call does fail.

The read loop also logs the shape of the first state report it gets.
TrackpadMouse requires 30 bytes where the haptics need 28, so a short
report would produce working haptics and a dead cursor — the same symptom
from a completely different cause, and not one that can be told apart
from a user's description. This machine reports 0x42 at 54 bytes over
USB, which rules it out here but not on transports we have less evidence
for.

EventLog::Write is annotated _Printf_format_string_ so a mismatched
specifier is caught by /analyze. It is not enforced in the normal build,
but these lines are read long after the fact by someone trying to explain
a fault they cannot reproduce, and the paths that log least often are
exactly the ones no test run exercises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:49:06 -07:00

59 lines
3.0 KiB
C++

#pragma once
#include <Windows.h>
#include <functional>
#include <string>
// Synthetic input, with the reasons it can fail recorded.
//
// Every key, mouse button and cursor move this app produces leaves through
// SendInput, and SendInput is the one stage of the pipeline Windows can refuse
// without any other symptom: haptics and the virtual pad are HID writes and
// keep working regardless. A refused injection therefore looks exactly like
// "the trackpad stopped moving the mouse, but the pad still buzzes", with
// nothing in the log to say why.
//
// The refusal that matters is UIPI: a process may not inject input while the
// foreground window belongs to a process at a higher integrity level, so an
// elevated game, launcher or tool in the foreground silently swallows
// everything this app sends until focus moves elsewhere or that window closes.
// This app never runs elevated (see SteamlessDeviceCycle) precisely so that it
// is not the one doing the blocking, which leaves it on the receiving end.
//
// The refusal is not visible in SendInput's result: on Windows 11 the call
// reports success and the event is discarded afterwards, so the only reliable
// way to know is to ask, before injecting, whether the foreground window
// outranks this process. Send does that on every event and reports the state
// changing — which covers cursor movement, trackpad clicks, paddle buttons and
// keys alike, none of which can be checked from their own return value.
//
// Injection can also be accepted and still do nothing for reasons that are not
// UIPI — a game that left the cursor clipped, or a low-level hook dropping
// injected events — so movement is checked against the cursor actually moving
// as well.
namespace InputInjection {
// How the app tells the user something is wrong (the tray balloon). Called from
// a controller read thread, so the callback must marshal to the UI thread — the
// tray's existing alert path already does.
using AlertFn = std::function<void(const std::wstring& title,
const std::wstring& text)>;
void SetAlertCallback(AlertFn fn);
// SendInput for a single event. 'what' is a short tag naming the caller
// ("trackpad-move", "trackpad-click", "paddle-mouse", "key") so a log line can
// say what was lost. Returns whether Windows accepted the event — which is not
// the same as the event arriving; see above.
bool Send(const INPUT& input, const char* what);
// Everything that decides whether injection can land: the foreground window's
// process and integrity level, ours, any cursor clip, and whether the cursor
// can be read at all. Logged when game mode is taken, and whenever injection
// is refused. 'reason' leads the line.
void LogEnvironment(const char* reason);
// Injection was accepted but the cursor did not move. 'travelPx' is how much
// movement was sent while the cursor sat at 'at'. Rate-limited like refusals.
void LogCursorNotMoving(long travelPx, POINT at);
} // namespace InputInjection