Files
SteamlessController/src/app/TrayApp.cpp
T
Arvin GandClaude Opus 5 527ee65257 Take the dock in a cycle of its own, after the controller
Riding the dock along in the acquire cycle had three costs. The helper
reports one verdict per run, so a dock that cycled cleanly stood in for a
slot that was vetoed and hid the checks that stop us cycling against our own
handle. Later attempts grew to the docks plus every slot, outrunning the 4 s
gap before "claiming anyway" reopens slots the helper had not reached yet.
And a dock that could not be claimed after the takeover was only logged.

The acquire cycle is slots only again, exactly as before. Once the
controller is ours, a dock Steam still holds is cycled on its own after
500 ms, when the helper run that won the slot has exited, and taken back by
a dock-only pounce, at most twice per takeover. Cycling the dock does not
interrupt the controller. The self-held release keeps held docks, since no
slot cycle includes them.

Tested on a puck: slot cycle and takeover as before, dock claimed on the
first dock cycle, no prompt when docking, Steam gets both back on exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:08:54 -07:00

2168 lines
104 KiB
C++

#include "TrayApp.h"
#include "ControllerManager.h"
#include "ControllerPlatform.h"
#include "DeviceRestart.h"
#include "EventLog.h"
#include "GameLibrary.h"
#include "GameProfiles.h"
#include "InputInjection.h"
#include "steam/SteamController.h"
#include "resource.h"
#include <shellapi.h>
#include <dbt.h>
#include <winreg.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <cwctype>
#include <string>
static TrayApp* g_app = nullptr;
static constexpr wchar_t WNDCLASS_NAME[] = L"SteamlessControllerTray";
static constexpr wchar_t CYCLE_TASK_NAME[] = L"SteamlessControllerDeviceCycle";
static constexpr wchar_t LEGACY_STARTUP_TASK_NAME[] = L"SteamlessController";
static constexpr wchar_t HELPER_EXE_NAME[] = L"SteamlessDeviceCycle.exe";
// Runs a console tool with no visible window; true when it exits 0.
static bool RunToolHidden(std::wstring cmdline) {
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi{};
if (!CreateProcessW(nullptr, cmdline.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
return false;
WaitForSingleObject(pi.hProcess, 15000);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return code == 0;
}
// Start a tool and walk away. RunToolHidden waits up to 15s for the exit code,
// which on the UI thread is a frozen tray menu — and the holder scan is exactly
// the kind of job that takes that long. Nothing here needs the exit code: the
// tool writes its findings to its own log.
// Ask the helper to cycle just the interface that has the controller in it.
// A receiver publishes one per slot and only one is ever occupied, so cycling
// all of them costs several times the downtime and gives Steam several
// arrivals to react to instead of one.
//
// Only on the first attempt. If narrowing did not get the controller back, the
// assumption behind it may be wrong — the controller could have moved slots —
// so later attempts write nothing and the helper falls back to cycling
// everything.
//
// One path per line, cycled in that order — the helper has always read the
// request that way.
static void RequestNarrowCycle(const std::vector<std::wstring>& paths) {
wchar_t local[MAX_PATH];
if (!GetEnvironmentVariableW(L"LOCALAPPDATA", local, MAX_PATH)) return;
const std::wstring file =
std::wstring(local) + L"\\SteamlessController\\cycle.request";
if (paths.empty()) { DeleteFileW(file.c_str()); return; }
FILE* f = nullptr;
if (_wfopen_s(&f, file.c_str(), L"w, ccs=UTF-8") != 0 || !f) return;
for (const auto& path : paths)
fwprintf(f, L"%ls\n", path.c_str());
fclose(f);
}
static bool LaunchToolDetached(std::wstring cmdline) {
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi{};
if (!CreateProcessW(nullptr, cmdline.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
return false;
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return true;
}
// Path of the helper exe, expected beside our own executable.
static std::wstring HelperPath() {
wchar_t exe[MAX_PATH];
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring path(exe);
const size_t slash = path.find_last_of(L'\\');
if (slash != std::wstring::npos) path.resize(slash + 1);
path += HELPER_EXE_NAME;
return path;
}
TrayApp::TrayApp() {
g_app = this;
}
TrayApp::~TrayApp() {
// Stop the watchers before anything else so their callbacks can't post to
// a window (or reference a controller) that is being torn down.
m_steamWatcher.Stop();
m_foregroundWatcher.Stop();
RemoveTrayIcon();
g_app = nullptr;
}
bool TrayApp::Init(HINSTANCE hInstance) {
m_hInstance = hInstance;
m_iconOff = LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ICON_OFF));
m_iconOn = LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ICON_ON));
m_iconShared = LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ICON_SHARED));
m_wmTaskbar = RegisterWindowMessageW(L"TaskbarCreated");
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = WNDCLASS_NAME;
if (!RegisterClassExW(&wc)) return false;
// Hidden top-level window so it receives broadcast shell notifications including TaskbarCreated when Explorer restarts.
m_hwnd = CreateWindowExW(0, WNDCLASS_NAME, L"SteamlessController",
0, 0, 0, 0, 0, nullptr, nullptr, hInstance, nullptr);
if (!m_hwnd) return false;
// Register for HID device arrival/removal notifications.
DEV_BROADCAST_DEVICEINTERFACE_W filter{};
filter.dbcc_size = sizeof(filter);
filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;
filter.dbcc_classguid = {0x4D1E55B2, 0xF16F, 0x11CF,
{0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30}};
RegisterDeviceNotificationW(m_hwnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE);
EventLog::Write("=== SteamlessController %s started ===", APP_VERSION_STR);
m_startTick = GetTickCount64();
QueryUnbiasedInterruptTime(&m_lastHeartbeatUnbiased);
m_controller = std::make_unique<ControllerManager>(
[this](bool connected, bool gameModeActive, bool sharedHandle, bool vigemMissing) {
UpdateTrayIcon(connected, gameModeActive, sharedHandle, vigemMissing);
});
auto raiseAlert = [this](const std::wstring& title, const std::wstring& text) {
{
std::lock_guard<std::mutex> lk(m_alertMutex);
m_alertTitle = title;
m_alertText = text;
}
PostMessageW(m_hwnd, WM_ALERT, 0, 0);
};
m_controller->SetAlertCallback(raiseAlert);
// Blocked input reaches the user the same way a dead controller does: it
// presents identically — nothing happens — and the cause is something only
// this app is in a position to name.
InputInjection::SetAlertCallback(raiseAlert);
LoadSettings();
// Converge the startup mechanism with the loaded mode — e.g. the first
// elevated run after enabling in-game mode migrates the Run key to the
// highest-privileges logon task (and back when the mode changes).
UpdateStartupRegistration();
AddTrayIcon();
UpdateTrayIcon(m_controller->IsConnected(), m_controller->IsGameModeActive(),
m_controller->IsGameModeShared(), false);
// The in-game mode needs the elevated cycle helper to take the controller
// back from a running Steam. Settle that here rather than on first use:
// the installer normally registers the task, but when it hasn't (running
// from a build folder, or after an uninstall) the only other trigger is
// the cycle itself — so the UAC prompt ambushed the user mid-game-launch.
// A no-op once the task exists, which is the common case. Must run after
// AddTrayIcon, since a declined prompt reports via the tray balloon.
if (m_autoMode == AutoMode::OffOnlyInGame)
EnsureCycleTaskRegistered();
// Deliberately last, and every part of that ordering is load-bearing: it
// reports through the tray balloon, which does nothing until AddTrayIcon has
// run; it honours the notification setting, which LoadSettings supplies; and
// it may need the elevated helper, so it goes after the task registration
// above rather than raising a second UAC prompt of its own.
//
// Nothing earlier needs the controller to exist. A device brought back here
// announces itself as an ordinary DBT_DEVICEARRIVAL once the message loop
// starts, and is picked up on that path like any other replug.
RecoverStrandedDevices();
// Runs for the life of the process in every mode. Nothing here depends on
// it, which is the point: it exists so that the log can tell "idle" from
// "stopped" without anyone having to reproduce the fault.
SetTimer(m_hwnd, IDT_HEARTBEAT, HEARTBEAT_MS, nullptr);
SetTimer(m_hwnd, IDT_STEAM_RECONCILE, STEAM_RECONCILE_MS, nullptr);
// Start watching for Steam regardless of auto mode — the state is applied
// only when auto mode is on, and having it warm makes toggling auto mode
// take effect instantly. The initial callback asserts the correct state
// at startup (which also self-heals after a previous crash).
m_steamWatcher.Start([this](SteamState state) {
PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast<WPARAM>(state), 0);
});
// Nothing switches profiles while the window is up, so a profile created
// for the game the user is already in has to be picked up when it closes.
m_remapWindow.SetOnClose([this] { RefreshActiveProfile(); });
// What Steam has installed, for matching a running game back to the app
// id its profile is keyed by.
RefreshSteamApps();
// Per-game profiles. Started unconditionally for the same reason as the
// Steam watcher: the callback decides whether anything applies, and a
// watcher that is already warm makes the first switch instant. Costs one
// hook and no thread — it only runs when the user changes applications.
if (!m_foregroundWatcher.Start([this](const ForegroundIdentity& id) {
OnForegroundChanged(id);
}))
EventLog::Write("PROFILE: foreground watcher could not start — "
"per-game profiles will not switch by themselves");
// Resolve what is in front right now. The watcher above only reports
// changes, so without this a user who launches us while already sitting
// in a game would be judged against the default profile until they next
// switched windows — and with a default that hands the controller back,
// that means no controller.
//
// Selection only, no control decision: the first Steam state is still in
// flight on the watcher thread, and deciding against the NoSteam it has
// not confirmed yet would be guessing.
SelectProfile(MatchProfile(ForegroundWatcher::Current()));
PushActiveProfile();
return true;
}
int TrayApp::Run() {
MSG msg;
BOOL ret;
while ((ret = GetMessageW(&msg, nullptr, 0, 0)) != 0) {
if (ret == -1) return -1;
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
return static_cast<int>(msg.wParam);
}
LRESULT CALLBACK TrayApp::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (g_app) return g_app->HandleMessage(hwnd, msg, wp, lp);
return DefWindowProcW(hwnd, msg, wp, lp);
}
LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == m_wmTaskbar) {
AddTrayIcon();
return 0;
}
switch (msg) {
case WM_TRAY:
if (LOWORD(lp) == NIN_BALLOONUSERCLICK) {
if (m_balloonAction == BalloonAction::EnableDisabledDevice)
EnableDisabledControllerDevice();
else if (m_balloonAction == BalloonAction::ViGEmDownload)
ShellExecuteW(nullptr, L"open", L"https://github.com/nefarius/ViGEmBus/releases/latest",
nullptr, nullptr, SW_SHOWNORMAL);
// BalloonAction::None: purely informational, nothing to open.
}
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
OpenRemapWindow();
else if (LOWORD(lp) == WM_RBUTTONUP || LOWORD(lp) == WM_LBUTTONUP)
ShowContextMenu();
return 0;
case WM_COMMAND:
switch (LOWORD(wp)) {
case IDM_TOGGLE:
// Only reachable in manual mode (grayed otherwise), so the click
// is the sole source of intent — record it and take the same
// acquire/release path the auto modes use. A second click while an
// acquisition is still cycling cancels it rather than restarting.
if (m_wantControl) ReleaseControl();
else EnableFromUser();
break;
case IDM_REMAP_BACK:
OpenRemapWindow();
break;
case IDM_MODE_MANUAL:
SetAutoMode(AutoMode::Manual);
break;
case IDM_MODE_STEAM:
SetAutoMode(AutoMode::OffWhileSteam);
break;
case IDM_MODE_GAME:
// Make sure the elevated helper task exists (one-time UAC when
// the installer did not register it) before the mode needs it.
EnsureCycleTaskRegistered();
SetAutoMode(AutoMode::OffOnlyInGame);
break;
case IDM_MODE_PROFILE:
// Same reason as the mode above: this one can be asked to take the
// controller while Steam is running it, and that needs the helper.
EnsureCycleTaskRegistered();
SetAutoMode(AutoMode::OffUnlessProfile);
// With nothing to match, this mode holds the controller for
// nothing at all. Said once, on the click, because a controller
// that has gone quiet and stays quiet is otherwise indisting-
// uishable from the app having broken.
if (m_gameProfiles.empty() && m_notificationsEnabled)
ShowAlertBalloon(L"No game profiles yet",
L"The controller stays in its own keyboard/mouse mode "
L"until you add a profile for a game in Customize Controls.",
BalloonAction::None, NIIF_INFO);
break;
case IDM_STARTUP:
m_startupEnabled = !m_startupEnabled;
UpdateStartupRegistration();
break;
case IDM_OPENLOG:
OpenEventLog();
break;
case IDM_NOTIFICATIONS:
m_notificationsEnabled = !m_notificationsEnabled;
EventLog::Write("USER: notifications %s",
m_notificationsEnabled ? "enabled" : "disabled");
SaveSettings();
break;
case IDM_ENABLE_DEVICE:
EnableDisabledControllerDevice();
break;
case IDM_EXIT: {
EventLog::Write("=== SteamlessController exiting (user request) ===");
m_controller->DisableGameMode();
// A held dock goes with the process, but Steam reopens one only on
// arrival — so without a cycle it would not hear about a controller
// set on the puck again until the puck was replugged.
const auto heldDocks = m_controller->HeldDockPaths();
if (!heldDocks.empty()) {
m_controller->ReleaseDocks();
if (m_steamWatcher.GetState() != SteamState::NoSteam) {
m_cycleRequestPaths = heldDocks;
RequestNarrowCycle(m_cycleRequestPaths);
RestartControllerDevices();
}
}
PostQuitMessage(0);
break;
}
}
return 0;
case WM_STEAMSTATE: {
const auto steamState = static_cast<SteamState>(wp);
ApplySteamState(steamState);
return 0;
}
case WM_CONTROLSTATE:
// Read here rather than carried in the message: by the time this
// arrives the state may have moved again, and the window wants the
// current answer, not the one that prompted the post.
m_remapWindow.SetControlState(m_controller->IsGameModeActive(),
m_autoMode == AutoMode::Manual);
return 0;
case WM_ALERT: {
if (!m_notificationsEnabled)
return 0; // the cause is still in the event log
std::wstring title, text;
{
std::lock_guard<std::mutex> lk(m_alertMutex);
title = m_alertTitle;
text = m_alertText;
}
if (!title.empty())
ShowAlertBalloon(title, text);
return 0;
}
case WM_TIMER:
if (wp == IDT_ACQUIRE) {
KillTimer(m_hwnd, IDT_ACQUIRE);
if (m_wantControl)
TryAcquireController();
} else if (wp == IDT_WAKE_POLL) {
KillTimer(m_hwnd, IDT_WAKE_POLL);
// Cheap probe — we are only asking whether anything woke up.
if (m_wantControl)
TryAcquireController(WAKE_PROBE_MS);
} else if (wp == IDT_RELEASE_GRACE) {
KillTimer(m_hwnd, IDT_RELEASE_GRACE);
// Re-asked rather than assumed: the grace period exists precisely
// so the answer can change while it runs, and coming back to the
// game is the case it is here for.
if (!WantControlNow()) ReleaseControl();
} else if (wp == IDT_GAME_LIVENESS) {
// The game holding the pad type has gone, so the type can follow
// the selected profile again — and the rebuild that costs is free
// now in a way it never is while the game is up.
if (!m_platformHold.StillRunning())
ReleasePlatformHold(L"the game exited");
} else if (wp == IDT_STEAM_RECONCILE) {
// The watcher announces an edge exactly once, through one
// PostMessage. If that message is lost the app keeps a stale view
// of Steam for the rest of the session — a game launches and the
// controller is never handed back. Compare and apply.
//
// Logged loudly on purpose: this should never fire, and if it does
// in the field the log says so rather than the user having to
// notice the controller behaving oddly.
const SteamState now = m_steamWatcher.GetState();
if (now != m_lastAppliedSteamState && m_autoMode != AutoMode::Manual) {
EventLog::Write("AUTO: steam state %d was never applied (last acted on %d) "
"— the change notification went missing; reconciling",
static_cast<int>(now),
static_cast<int>(m_lastAppliedSteamState));
ApplySteamState(now);
}
} else if (wp == IDT_CYCLE_WATCHDOG) {
// A cycle that never reported back. Every normal ending is an event
// that can go missing, and the flag is what keeps the device closed,
// so it must not be able to outlive the cycle that raised it.
EventLog::Write("CYCLE: no completion seen within %u ms — "
"assuming the cycle is over and reopening the device",
CYCLE_MAX_MS);
EndCycle("watchdog expired");
} else if (wp == IDT_DOCK_CYCLE) {
KillTimer(m_hwnd, IDT_DOCK_CYCLE);
CycleUnheldDocks();
} else if (wp == IDT_HEARTBEAT) {
// Periodic, so not killed — its absence is the signal.
WriteHeartbeat();
} else if (wp == IDT_ACQUIRE_VERDICT) {
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
// The last cycle's re-arrival had a grace period to land. If it
// did, the controller is ours and there is nothing to report.
if (m_controller->IsGameModeActive()) return 0;
// Cycling is over — either the budget is spent or a cycle proved it
// frees nothing. Exclusivity is not coming. Before calling it a
// failure, take the controller on a shared handle: on machines where
// the cycle never wins the reopen race that is the difference
// between a working controller and none at all (#79). The tray shows
// the shared icon so it is visible that Steam is still driving too.
if (m_wantControl &&
m_controller->EnableGameMode(250, /*allowShared=*/true)
== ControllerManager::GameModeOutcome::Enabled) {
EventLog::Write("ACQUIRE: exclusivity unavailable after %d cycle(s) — "
"taking the controller on a shared handle",
m_acquireRetries);
// Name whatever is holding the device, in the one case that
// most needs explaining: every cycle succeeded, PnP vetoed
// nothing, and the controller was still taken back before we
// could claim it (#79). The helper's own scan only runs on a
// veto, so that path records nothing here.
//
// Delegated to the helper rather than scanned in-process: the
// scanner abandons worker threads that wedge inside
// NtQueryObject, which is safe only because the helper exits
// moments later. Doing it here would leak them for the life of
// the tray session, and would stall the UI thread besides.
// Fire and forget — the answer lands in cycle.log.
const std::wstring helper = HelperPath();
if (!helper.empty()) {
EventLog::Write("ACQUIRE: asking the helper who holds the device "
"— see cycle.log for the FIND: lines");
LaunchToolDetached(L"\"" + helper + L"\" --find-holders");
}
RefreshActiveProfile();
return 0;
}
ReportAcquireFailure();
}
return 0;
case WM_DEVICECHANGE:
if (wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) {
// OnDeviceChange opens a handle on every interface it finds, and an
// open handle vetoes a device removal no matter who owns it. Calling
// it unconditionally meant a cycle we had just asked for spent its
// life fighting handles we reopened milliseconds after asking: a
// release cycle reopened all four interfaces 26ms in, then took
// 15.5s instead of about one, with a Kernel-PnP 225 naming this
// process as what stopped the removal. While our own cycle runs,
// watch it without touching it.
if (m_cycleInFlight)
TrackCycleProgress();
else
m_controller->OnDeviceChange();
// A controller arriving (plugged in, or re-arriving after a device
// cycle) should be acquired straight away whenever control is
// wanted — this is what wins the reopen race against Steam. Safe to
// call mid-cycle: it adopts a pounced handle, which is proof the
// cycle already delivered, and otherwise carries its own guard.
if (wp == DBT_DEVICEARRIVAL && m_wantControl)
TryAcquireController();
}
return TRUE;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
// Turning Steamless mode on at the user's request, from the tray toggle or
// from the remap window's banner. Both are the same intent and must take the
// same path — the window offering a shortcut that skipped the helper
// registration would move the UAC prompt to the first cycle, which is exactly
// where it ambushes people.
void TrayApp::EnableFromUser() {
// Taking the device back from a running Steam needs the elevated helper.
// Settle the one-time UAC prompt on this click instead of letting it
// ambush the first cycle. Registered even when already elevated: every
// cycle goes through the helper now, so an elevated run needs the task
// just as much.
EnsureCycleTaskRegistered();
m_wantControl = true;
m_acquireRetries = 0;
TryAcquireController();
}
void TrayApp::SetAutoMode(AutoMode mode) {
EventLog::Write("MODE: control mode set to %d "
"(0=manual 1=offWhileSteam 2=offOnlyInGame 3=offUnlessProfile)",
static_cast<int>(mode));
m_autoMode = mode;
m_acquireRetries = 0;
SaveSettings();
UpdateStartupRegistration();
if (mode == AutoMode::Manual)
// Manual takes its intent from the tray toggle. Adopt whatever is in
// effect right now rather than acquiring or releasing behind the
// user's back just because they changed mode.
m_wantControl = m_controller->IsGameModeActive();
else
EvaluateControl();
}
// ---------------------------------------------------------------------------
// Per-game profiles
// ---------------------------------------------------------------------------
// Case-insensitive whole-path comparison. Both sides are already full Win32
// paths — one from the shortcut the profile was created from, the other from
// QueryFullProcessImageNameW — so no canonicalisation is attempted here; a
// game reached through a substituted drive or a symlink is a known gap.
static bool SamePath(const std::wstring& a, const std::wstring& b) {
return !a.empty() && _wcsicmp(a.c_str(), b.c_str()) == 0;
}
std::wstring TrayApp::MatchProfile(const ForegroundIdentity& id) const {
static constexpr wchar_t kSteamPrefix[] = L"steam://rungameid/";
static constexpr size_t kSteamPrefixLen = ARRAYSIZE(kSteamPrefix) - 1;
static constexpr size_t kDirPrefixLen = 4; // "dir:"
// Resolved once rather than per profile: the lookup walks every known
// Steam install, and the answer cannot differ between profiles.
const std::wstring steamAppId = m_steamApps.AppIdForPath(id.exePath);
std::wstring lowerExe = id.exePath;
for (wchar_t& c : lowerExe) c = static_cast<wchar_t>(towlower(c));
// A "dir:" profile claims a whole install folder, so two of them can both
// match one process — a game installed inside another game's directory.
// The longer match is the more specific answer, which is the same rule
// SteamAppLocator::AppIdForPath applies within Steam's own libraries.
std::wstring bestDirId;
size_t bestDirLen = 0;
for (const auto& [profileId, profile] : m_gameProfiles) {
// An exact identity beats any directory, so these return straight
// away rather than competing on length: a profile naming this precise
// executable or package was made for this application and nothing
// else, while a directory only says the application lives there.
if (profileId.rfind(L"aumid:", 0) == 0) {
if (!id.aumid.empty()
&& _wcsicmp(profileId.c_str() + 6, id.aumid.c_str()) == 0)
return profileId;
} else if (profileId.rfind(kSteamPrefix, 0) == 0) {
if (!steamAppId.empty() && profileId.compare(kSteamPrefixLen,
std::wstring::npos,
steamAppId) == 0)
return profileId;
} else if (profileId.rfind(L"dir:", 0) == 0) {
if (lowerExe.empty()) continue;
std::wstring dir = profileId.substr(kDirPrefixLen);
for (wchar_t& c : dir) c = static_cast<wchar_t>(towlower(c));
// The trailing separator is what stops "...\Portal" matching an
// executable under "...\Portal 2".
if (!dir.empty() && dir.back() != L'\\') dir += L'\\';
if (dir.size() <= bestDirLen) continue; // cannot beat what we have
if (lowerExe.compare(0, dir.size(), dir) != 0) continue;
bestDirId = profileId;
bestDirLen = dir.size();
} else if (SamePath(profileId, id.exePath)) {
return profileId;
}
}
return bestDirId;
}
void TrayApp::RefreshSteamApps() {
m_steamApps.Refresh();
EventLog::Write("PROFILE: %zu installed Steam app(s) located for profile matching",
m_steamApps.Count());
}
const ControllerProfile& TrayApp::ActiveProfile() const {
if (!m_activeGameId.empty()) {
auto it = m_gameProfiles.find(m_activeGameId);
// A profile that follows the default resolves to it here rather than
// being copied anywhere, so editing the default reaches every game
// following it without touching a single stored profile.
if (it != m_gameProfiles.end() && !it->second.useDefaultMappings)
return it->second;
}
return m_defaultProfile;
}
ControllerProfile TrayApp::EffectiveProfile() const {
ControllerProfile profile = ActiveProfile();
// A held pad type outranks the selected profile's, and only the pad type.
// Everything else — every binding, every pad mode — is the foreground's to
// decide, so alt-tabbing to the desktop still gets the desktop's controls.
if (m_platformHold.IsHeld()) {
auto it = m_gameProfiles.find(m_platformHold.ProfileId());
if (it != m_gameProfiles.end()) {
// A profile that follows the default has no platform of its own,
// exactly as ActiveProfile resolves it.
profile.platform = it->second.useDefaultMappings
? m_defaultProfile.platform
: it->second.platform;
}
}
return profile;
}
void TrayApp::UpdatePlatformHold(const ForegroundIdentity& id) {
// Only a matched game holds anything. Landing on the desktop deliberately
// leaves the previous hold alone — that is the whole point, and what makes
// alt-tabbing free.
if (m_activeGameId.empty() || id.pid == 0) return;
// Same game AND the same run of it. The pid is part of the test because a
// game closed and reopened inside the poll interval is the same profile
// wearing a new process, and a hold left pinned to the old one would be
// dropped by the next liveness check for a game that is plainly running.
if (m_platformHold.IsHeld()
&& m_platformHold.ProfileId() == m_activeGameId
&& m_platformHold.Pid() == id.pid)
return;
// A different game takes over, and its pad type applies immediately. That
// rebuild is not one to defer: the user has just switched to the game that
// wants it.
m_platformHold.Hold(m_activeGameId, id.pid);
SetTimer(m_hwnd, IDT_GAME_LIVENESS, LIVENESS_POLL_MS, nullptr);
}
void TrayApp::ReleasePlatformHold(const wchar_t* why) {
if (!m_platformHold.IsHeld()) return;
EventLog::Write("PROFILE: released the pad type held for %ls (%ls)",
m_platformHold.ProfileId().c_str(), why);
m_platformHold.Clear();
KillTimer(m_hwnd, IDT_GAME_LIVENESS);
// Re-apply so the pad type goes back to following the selected profile.
// Safe here in a way it never is mid-session: whatever needed the old type
// is gone, so nothing is watching the pad it rebuilds.
if (WantControlNow()) PushActiveProfile();
}
bool TrayApp::SelectProfile(const std::wstring& gameId) {
if (gameId == m_activeGameId) return false;
// A profile deleted between resolving and selecting leaves nothing to
// switch to; the default is the right answer, not a stale id.
if (!gameId.empty() && m_gameProfiles.find(gameId) == m_gameProfiles.end())
return SelectProfile({});
m_activeGameId = gameId;
EventLog::Write("PROFILE: switched to %ls",
gameId.empty() ? L"the default profile" : gameId.c_str());
return true;
}
void TrayApp::PushActiveProfile() {
const ControllerProfile& profile = ActiveProfile();
const ControllerProfile applied = EffectiveProfile();
// The blend, not the selection: bindings from whatever is in front, pad
// type from whatever game is still running. See EffectiveProfile.
m_controller->SetProfile(applied);
// What actually reached the controller, which is not the same question as
// which profile matched — a profile set to follow the default matches, is
// reported as switched to, and then supplies none of its own bindings.
// That is the shape of "my custom bindings do nothing", and without this
// line a log shows a profile loading correctly while the user watches it
// have no effect.
//
// Only on a change: this is called on every foreground switch, and one
// line per alt-tab would bury everything else.
const bool following = !m_activeGameId.empty()
&& ActiveProfile().useDefaultMappings;
std::wstring applying = (m_activeGameId.empty() ? std::wstring(L"the default profile")
: m_activeGameId)
+ (applied.platform == ControllerPlatform::PlayStation
? L" [PlayStation]" : L" [Xbox]")
+ (following ? L" (following the default — its own bindings are"
L" not being used)"
: L"");
if (applying != m_lastAppliedDescription) {
m_lastAppliedDescription = applying;
EventLog::Write("PROFILE: applied %ls", applying.c_str());
}
if (m_activeGameId.empty()) return; // returning to the default is not news
// Announced only when the profile is actually going to run. Pushing
// happens before the control decision, so without this the balloon would
// promise a game's custom controls at the same moment we hand the
// controller back and provide none.
if (!WantControlNow()) return;
// Told once per game rather than on every activation. Alt-tabbing out of
// a game and back in reloads its profile, and a balloon on each round
// trip would be noise — the point is to confirm the profile was found at
// all, which the first one does.
if (m_activeGameId == m_toastedGameId) return;
m_toastedGameId = m_activeGameId;
if (!m_notificationsEnabled) return;
// Falls back to the id when the profile predates display names being
// stored; an exe path is poor prose but it still identifies the game.
const std::wstring& name = profile.displayName.empty() ? m_activeGameId
: profile.displayName;
ShowAlertBalloon(L"Custom controls loaded",
L"Custom controller profile for " + name + L" loaded.",
BalloonAction::None, NIIF_INFO);
}
void TrayApp::OnForegroundChanged(const ForegroundIdentity& id) {
// Deliberately not gated on the controller already being ours. With a
// default profile that can hand the controller back, this is the path
// that decides whether we hold the device at all, so it has to keep
// running precisely when we do not — otherwise nothing would ever notice
// the game that is supposed to take it back.
//
// The customization window edits one profile while another could be
// running underneath it. Switching out from under the user mid-edit
// would apply their changes to the wrong game.
if (m_remapWindow.IsOpen()) return;
const bool selectionChanged = SelectProfile(MatchProfile(id));
// A game coming to the front claims the pad type and keeps it until it
// exits. Landing on the desktop claims nothing and leaves the previous
// claim standing, which is what stops an alt-tab rebuilding the pad under
// a game that is still running.
//
// Asked before the early return below, because the hold follows a process
// while the selection follows a profile, and the two do not change
// together. A loader that hands off to the game it launched is the same
// profile wearing a new pid: the selection does not change, so the return
// fires, and a hold left on the old process is dropped the moment that
// process exits — rebuilding the pad under a game that has only just
// started.
UpdatePlatformHold(id);
if (!selectionChanged) return;
// Only push a profile we are actually going to run. Applying one changes
// the pad in place, but a profile whose platform differs rebuilds the
// virtual controller outright — so pushing the default on the way out of
// a game replugged the pad under the still-running game within a frame,
// five seconds before the grace period below had any say in it. Leaving
// whatever is live alone until the release really happens is what makes
// alt-tabbing back genuinely free.
if (WantControlNow()) PushActiveProfile();
EvaluateControl();
}
void TrayApp::RefreshActiveProfile() {
if (m_remapWindow.IsOpen()) return;
const ForegroundIdentity id = ForegroundWatcher::Current();
SelectProfile(MatchProfile(id));
// Catching up on the hold too. This runs at the moments nobody was
// listening — taking the controller back, closing the window that
// suppresses switching — and a game that has been in front the whole time
// should be holding the pad type by the end of it.
UpdatePlatformHold(id);
if (WantControlNow()) PushActiveProfile(); // see OnForegroundChanged
EvaluateControl();
}
bool TrayApp::SteamAllowsControl(SteamState state) const {
switch (m_autoMode) {
case AutoMode::OffWhileSteam: return state == SteamState::NoSteam;
case AutoMode::OffOnlyInGame: return state != SteamState::InGame;
default: return false; // Manual: tray toggle decides
}
}
// One question per mode, rather than one answer assembled from several
// settings that override each other — which is what makes the outcome
// predictable from the mode name alone.
bool TrayApp::WantControlNow() const {
switch (m_autoMode) {
case AutoMode::Manual:
// The tray toggle is the only authority here, and nothing else may
// turn the controller on behind the user's back.
return m_wantControl;
case AutoMode::OffUnlessProfile:
// A matched profile is the entire test. Deliberately asked without
// consulting Steam: the user made a profile for this game, which is
// them saying they want us driving it rather than Steam Input, and
// the acquire path already knows how to take the device from a live
// Steam. With nothing matched we want nothing, so this mode never
// contends with Steam except over games it was told to.
return !m_activeGameId.empty();
default:
// The Steam-yielding modes, unchanged: Steam's state is the whole
// answer, exactly as it has always been.
return SteamAllowsControl(m_steamWatcher.GetState());
}
}
void TrayApp::EvaluateControl() {
const bool want = WantControlNow();
EventLog::Write("CONTROL: want=%d (mode=%d steam=%d profile=%ls)",
want ? 1 : 0, static_cast<int>(m_autoMode),
static_cast<int>(m_steamWatcher.GetState()),
m_activeGameId.empty() ? L"none" : m_activeGameId.c_str());
if (want) {
// Alt-tabbed back before the grace period ran out — the release that
// was pending is simply cancelled, and the pad never went anywhere.
KillTimer(m_hwnd, IDT_RELEASE_GRACE);
// Already ours, or an acquisition is still in flight. Setting
// m_wantControl before TryAcquireController below is also what stops
// the RefreshActiveProfile call on its success path re-entering here.
if (m_wantControl) return;
m_wantControl = true;
m_acquireRetries = 0;
TryAcquireController();
return;
}
if (!m_wantControl) return;
// Steam turning up is the one release that cannot wait: the whole point
// of yielding is that Steam is about to open the device, and sitting on
// it for another few seconds is what it must not do.
//
// Only the Steam-yielding modes can be releasing for that reason.
// OffUnlessProfile releases because the game stopped being in front,
// which is precisely the case the grace period exists for — testing
// SteamAllowsControl there would find the `false` it returns for every
// mode it does not describe and make every release immediate.
const bool yieldingToSteam = (m_autoMode == AutoMode::OffWhileSteam
|| m_autoMode == AutoMode::OffOnlyInGame)
&& !SteamAllowsControl(m_steamWatcher.GetState());
if (yieldingToSteam) {
ReleaseControl();
return;
}
SetTimer(m_hwnd, IDT_RELEASE_GRACE, RELEASE_GRACE_MS, nullptr);
}
void TrayApp::ApplySteamState(SteamState state) {
// Recorded before the Manual check, so leaving Manual mode does not find a
// stale value and report a change that never actually went missing.
m_lastAppliedSteamState = state;
if (m_autoMode == AutoMode::Manual) return;
// The state only. What to do about it is EvaluateControl's to decide and
// to log, on the very next line — announcing a verdict here as well is
// how this line came to report a yield in a mode that never consults
// Steam and had in fact just taken the controller.
EventLog::Write("AUTO: steam state %d (0=none 1=idle 2=inGame)",
static_cast<int>(state));
EvaluateControl();
}
// Hand the controller back. Shared by the auto modes yielding to Steam and by
// the manual toggle being switched off — in both cases the user is done with
// us and Steam should be able to pick the pad straight back up.
void TrayApp::ReleaseControl() {
m_controller->StopPounce();
KillTimer(m_hwnd, IDT_ACQUIRE);
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
KillTimer(m_hwnd, IDT_WAKE_POLL);
KillTimer(m_hwnd, IDT_RELEASE_GRACE);
KillTimer(m_hwnd, IDT_DOCK_CYCLE);
m_wantControl = false;
m_acquireRetries = 0;
m_dockCycles = 0;
EndCycle("control was dropped", /*resync=*/false); // a release follows
m_lastCycleTick = 0;
// One driver notice per attempt, and letting go ends the attempt. Turning
// Steamless Mode back on is a fresh ask and deserves a fresh answer.
m_vigemBalloonShown = false;
// Deliberately leaves the selected profile alone. It used to be dropped
// here, on the grounds that a profile belongs to a controller we no
// longer have — but the selection is now what decides whether we take the
// controller in the first place, so clearing it here would erase the
// reason we are releasing and call straight back into this function. The
// foreground watcher keeps it current whether or not the device is ours,
// which is what that reset was standing in for.
const bool hadControl = m_controller->IsGameModeActive();
// Read while the slots are still up, as the acquire path does. The value
// outlives them either way — it is only ever assigned — but taking it here
// keeps the two paths asking the same question at the same point.
const std::wstring livePath = m_controller->LastLivePath();
// Released below with everything else, and Steam reopens a dock interface
// only on arrival, just as it does a slot — so a held dock is cycled too, or
// Steam would not hear about a controller set on the puck again until the
// puck was replugged. Docks can be held with no game mode: an acquire that
// cycles the slot lets go of the slot but keeps them.
const std::vector<std::wstring> heldDocks = m_controller->HeldDockPaths();
// Good citizen: restore lizard mode and close our HID handles so
// Steam can claim the controller without contention.
m_controller->ReleaseDevices();
// Steam only (re)opens controllers on device-arrival events. If it is
// already running and we held the device, it never saw one — cycle
// the device so Steam adopts it immediately.
if ((hadControl || !heldDocks.empty())
&& m_steamWatcher.GetState() != SteamState::NoSteam) {
// Logged because this cycle is otherwise invisible. The acquire path
// announces itself; this one used to not, which made a device left
// disabled by an interrupted release look like it came from nowhere.
EventLog::Write("RELEASE: cycling device so Steam sees an arrival");
// Narrowed to the interface that actually had the controller, for the
// same reason the acquire path narrows: a receiver publishes one per
// slot and only one is ever occupied, so cycling all four multiplies
// the downtime and hands Steam an arrival per interface to react to.
// What Steam has to see is an arrival on the interface it will read
// the controller from, and that is this one.
//
// Unconditional here, unlike the acquire path, which narrows only its
// first attempt so a wrong guess cannot keep costing it. A release has
// no second attempt to protect, and an empty path already falls back
// to cycling everything.
std::vector<std::wstring> request;
if (hadControl)
request = livePath.empty() ? SteamController::EnumerateAll()
: std::vector<std::wstring>{ livePath };
// After the slot: the controller is what Steam is waiting on.
request.insert(request.end(), heldDocks.begin(), heldDocks.end());
m_cycleRequestPaths = std::move(request);
RequestNarrowCycle(m_cycleRequestPaths);
RestartControllerDevices();
}
}
void TrayApp::TryAcquireController(uint32_t stateWaitMs) {
// Before the in-flight guard, not after. A pounced handle is proof the
// cycle has already delivered, and it is already open and exclusive, so
// there is nothing left to hold off for. Adopting after the guard meant
// sitting on a device we had already won for the whole CYCLE_MIN_GAP_MS.
if (m_controller->AdoptPounced())
EndCycle("a pounced handle was adopted");
// Do not open the device while a cycle we asked for is still running. The
// claim below opens handles, the cycle needs every handle gone to take the
// devnode down, and PnP does not care that the handle blocking it is ours —
// it vetoes the cycle just the same. Reported from the field as the app
// cycling the puck against a handle held by SteamlessController.exe itself,
// which left the vendor collections disabled.
//
// The arrival that ends a cycle clears this, so the reopen race against
// Steam is not slowed down; the tick is only a backstop for a cycle that
// never produces one.
if (m_cycleInFlight) {
if (GetTickCount64() - m_lastCycleTick < CYCLE_MIN_GAP_MS) {
// Once per cycle: a receiver publishes an interface per slot and
// every arrival re-enters here, so saying it each time would bury
// the log in repeats of a single fact.
if (!m_inFlightLogged) {
m_inFlightLogged = true;
EventLog::Write("ACQUIRE: cycle still in flight — not opening the device "
"(our own handle would veto it)");
}
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
return;
}
EventLog::Write("ACQUIRE: cycle produced no arrival within %llu ms — claiming anyway",
CYCLE_MIN_GAP_MS);
EndCycle("no arrival came, claiming anyway");
}
m_controller->ResetTiming();
const auto acquireStart = GetTickCount64();
m_controller->OnDeviceChange();
auto outcome = m_controller->EnableGameMode(stateWaitMs);
// A short burst of rapid retries, for the cases where the answer can change
// within milliseconds: a slot that has not started streaming yet, or a
// device still settling after an arrival.
//
// Not for a blocked claim. A held handle does not free itself, so retrying
// it ten times at 50ms intervals only delays the cycle that could actually
// free it: measured, 545ms of the 656ms a losing attempt took was this loop
// sleeping, against 0.2ms of actual claim work. Winning the device back
// after the cycle is the pounce thread's job now, and it does it without
// the UI thread being awake at all.
//
// A missing virtual pad is not a race with anybody, so the burst is called
// off the moment that is the answer. Left running it re-attempted eleven
// times in half a second and re-armed the driver notification on each
// pass — the toast storm reported in #68.
for (int i = 0; i < 10
&& outcome != ControllerManager::GameModeOutcome::Blocked
&& outcome != ControllerManager::GameModeOutcome::VirtualPadUnavailable
&& !m_controller->IsGameModeActive(); ++i) {
Sleep(50);
m_controller->OnDeviceChange();
outcome = m_controller->EnableGameMode(stateWaitMs);
}
{
// Where the attempt actually spent its time. Logged for every attempt,
// won or lost, because the interesting comparison is between the two.
const auto& t = m_controller->LastTiming();
EventLog::Write("ACQUIRE TIMING: total=%llums enumerate=%.1fms(x%d) open=%.1fms "
"claim=%.1fms probe=%.1fms -> %s",
GetTickCount64() - acquireStart, t.enumerateMs, t.enumerates,
t.openMs, t.claimMs, t.probeMs,
m_controller->IsGameModeActive() ? "won" : "lost");
}
if (m_controller->IsGameModeActive()) {
m_controller->StopPounce();
// Steam answers a controller set on the puck, which it cannot see is
// paired while we hold the slot, with a "pair to your Puck" prompt —
// and accepting it re-pairs the controller out from under us. Holding
// the dock interface keeps Steam from hearing about the dock at all.
// A dock Steam still holds is cycled away from it, shortly and on its
// own — see CycleUnheldDocks.
if (m_controller->ClaimDocks())
m_dockCycles = 0;
else if (m_dockCycles < MAX_DOCK_CYCLES)
SetTimer(m_hwnd, IDT_DOCK_CYCLE, DOCK_CYCLE_DELAY_MS, nullptr);
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
KillTimer(m_hwnd, IDT_WAKE_POLL);
m_acquireRetries = 0;
m_lastCycleTick = 0;
// The controller is ours again, and the user may have been sitting in
// a game the whole time it was not — no foreground change is coming
// to tell us, so ask.
RefreshActiveProfile();
return;
}
if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger
// The controller is fine; we just have nowhere to send its input. None of
// the machinery below applies — the retry counter, the cycling, and the
// holder scan all exist to get a HID handle away from another process,
// and no amount of restarting the puck installs a bus driver. Cycling
// here is exactly what disabled a reporter's HID collections in #65.
//
// So sit on a slow timer instead. The user is being told what to install,
// and this is what notices once they have.
if (outcome == ControllerManager::GameModeOutcome::VirtualPadUnavailable) {
// A verdict left pending by an earlier contested attempt would report
// this as Steam holding the controller, which it plainly is not.
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
SetTimer(m_hwnd, IDT_ACQUIRE, VIGEM_RETRY_MS, nullptr);
return;
}
// No slot is emitting state: the controller is off, asleep, or the
// receiver has no controller paired into it. Cycling cannot conjure one
// up, and doing it anyway restarts the receiver's devnodes (and can
// prompt for elevation) just because nothing is switched on.
//
// Poll instead of waiting for an event. A receiver keeps publishing all
// of its slot interfaces whether or not a controller is paired into one,
// so switching the controller on produces no WM_DEVICECHANGE at all —
// measured: 88 seconds of silence after the controller was turned on.
if (outcome == ControllerManager::GameModeOutcome::NoActiveController) {
SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_MS, nullptr);
return;
}
// A cycle is asynchronous — fired through Task Scheduler, and the helper
// pauses a second between disable and enable. Every device arrival it
// produces re-enters this function, so without a floor on the spacing we
// stack another cycle on top of one still in flight. Observed: game mode
// came up at :55.355 and our own second cycle ripped the device back out
// at :56.367. Wait for the one already running to finish instead.
const ULONGLONG nowTick = GetTickCount64();
if (m_lastCycleTick != 0 && nowTick - m_lastCycleTick < CYCLE_MIN_GAP_MS) {
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
return;
}
// The last cycle was blocked by a handle of our own. Another cycle would be
// blocked by the same thing, and each attempt risks leaving the devnode
// switched off, so let go instead of restarting hardware to escape
// ourselves. Releasing costs nothing if it turns out we held nothing.
if (m_acquireRetries > 0 && RefreshCycleStatus() && m_lastCycleStatus.selfHeld) {
EventLog::Write("ACQUIRE: the last cycle was vetoed by this app's own handle — "
"releasing it instead of cycling again");
// Docks are never in a slot's cycle, so none of that veto was theirs.
m_controller->ReleaseDevices(/*keepDocks=*/true);
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
return;
}
// A cycle that could not take the devnode down proves the next one will do
// exactly as much: nothing. Stop rather than burning the remaining attempts
// on a restart that is known to leave every handle alive — that cost 26
// seconds and three pointless device restarts before it was measured.
if (m_acquireRetries > 0 && LastCycleBrokeNothing()) {
EventLog::Write("ACQUIRE: last cycle invalidated no handles (%s) — not cycling again",
DeviceRestart::CycleKindName(m_lastCycleStatus.kind));
// Still let the verdict timer deliver the news: the last cycle's
// re-arrival can be in flight and may yet hand us the controller.
SetTimer(m_hwnd, IDT_ACQUIRE_VERDICT, ACQUIRE_VERDICT_MS, nullptr);
return;
}
// Steam holds a write handle, so our exclusive claim can't succeed while
// its handle lives. Cycle the device to invalidate it and retry on
// re-arrival (the timer is a fallback in case the arrival event is missed).
if (m_acquireRetries >= MAX_ACQUIRE_CYCLES) {
// Don't announce failure yet. The last cycle's re-arrival can still be
// in flight — on a four-interface receiver the round trip outran the
// retry timer, so the app cried failure a second before succeeding.
// Let the grace timer deliver the verdict instead.
SetTimer(m_hwnd, IDT_ACQUIRE_VERDICT, ACQUIRE_VERDICT_MS, nullptr);
return;
}
++m_acquireRetries;
m_lastCycleTick = nowTick;
EventLog::Write("ACQUIRE: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries);
// First attempt narrows the cycle to the occupied slot; later ones do not,
// so a wrong guess cannot keep costing us.
//
// Slots only. Docks are taken in a cycle of their own once the controller
// is ours — see CycleUnheldDocks.
m_cycleRequestPaths.clear();
if (m_acquireRetries == 1 && !m_controller->LastLivePath().empty())
m_cycleRequestPaths.push_back(m_controller->LastLivePath());
RequestNarrowCycle(m_cycleRequestPaths);
// Armed before the release so the paths are still known, and before the
// cycle so the thread is already spinning when the device comes back.
m_controller->BeginPounce();
// Docks we hold are not in this cycle, so holding on to them vetoes
// nothing — and letting go would hand Steam the chance to reopen them.
m_controller->ReleaseDevices(/*keepDocks=*/true);
if (!RestartControllerDevices()) {
m_controller->StopPounce();
return;
} // helper unavailable — balloon shown
// The cycle runs asynchronously via the helper task; the device-arrival
// notification drives the claim, with this timer as the fallback. It must
// outlast a cycle: the helper waits a second between disable and enable,
// then a multi-slot receiver has to re-enumerate every interface.
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
}
// Steam keeps its dock handle for as long as it runs, so a dock that could not
// be claimed outright is taken the way a slot is: cycle it and pounce.
//
// In a cycle of its own, after the controller is ours, rather than inside the
// acquire cycle. The helper reports one verdict per run, and a dock that
// cycled cleanly would stand in for a slot that did not — blinding the checks
// that stop us cycling against our own handle. It also keeps the acquire
// cycle as short as it was. Cycling a dock does not interrupt the controller,
// which carries on streaming on its slot.
void TrayApp::CycleUnheldDocks() {
if (!m_wantControl || !m_controller->IsGameModeActive()) return;
if (m_cycleInFlight) {
SetTimer(m_hwnd, IDT_DOCK_CYCLE, DOCK_CYCLE_DELAY_MS, nullptr);
return;
}
// Steam may have let go by now, and a cycle is not free.
if (m_controller->ClaimDocks()) {
m_dockCycles = 0;
return;
}
const auto docks = m_controller->UnheldDockPaths();
if (docks.empty() || m_dockCycles >= MAX_DOCK_CYCLES) return;
++m_dockCycles;
EventLog::Write("DOCK: held by another process — cycling it (attempt %d)", m_dockCycles);
m_cycleRequestPaths = docks;
RequestNarrowCycle(m_cycleRequestPaths);
m_controller->BeginDockPounce();
if (!RestartControllerDevices())
m_controller->StopPounce();
}
// Pick up the verdict of the cycle we last started. It runs in the elevated
// helper, so the answer comes back through a file — and anything stamped
// before our cycle began belongs to an earlier attempt and says nothing about
// this one.
bool TrayApp::RefreshCycleStatus() {
if (m_lastCycleTick == 0) return false;
DeviceRestart::CycleResult status;
if (!DeviceRestart::ReadCycleStatus(status)) return false;
// Ticks are milliseconds since boot, so a file left by a previous boot is
// not comparable to ours. One stamped in the future has to be from a
// longer-running earlier session — the ordinary case after a reboot, and
// one that would otherwise pass the staleness test below.
if (status.ticks > GetTickCount64()) return false;
if (status.ticks < m_lastCycleTick) return false;
m_lastCycleStatus = status;
return true;
}
bool TrayApp::LastCycleBrokeNothing() {
return RefreshCycleStatus() && m_lastCycleStatus.BrokeNoHandles();
}
// Explain the failure in terms of what actually happened. The app used to
// blame Steam unconditionally, which was wrong whenever some other process
// was sitting on the device — the case that is impossible to diagnose from
// the outside and the most likely one to leave a user stuck.
void TrayApp::ReportAcquireFailure() {
RefreshCycleStatus();
const DeviceRestart::CycleResult& st = m_lastCycleStatus;
// Everything the scan learned goes in this log, not just the conclusion:
// the unfiltered holder list and how much of the system the scan actually
// covered. This is the log "Open Event Log" opens, so it is the one that
// reaches a maintainer when a user reports a controller they cannot take
// back — and by then the process holding it is long gone.
if (!st.scanInfo.empty())
EventLog::Write("ACQUIRE: holder scan %ls", st.scanInfo.c_str());
if (!st.holdersAll.empty())
EventLog::Write("ACQUIRE: processes holding the device open: %ls "
"(steam.exe and SteamlessController.exe are expected here)",
st.holdersAll.c_str());
else if (!st.scanInfo.empty())
EventLog::Write("ACQUIRE: no process holding the device could be identified "
"— a protected process, or the scan did not reach it");
if (st.HeldByAnotherProcess()) {
EventLog::Write("ACQUIRE: giving up — another process holds the device "
"(veto=%s holders=%ls)",
DeviceRestart::VetoTypeName(st.vetoType),
st.holders.empty() ? L"(none identified)" : st.holders.c_str());
if (!m_notificationsEnabled) return;
std::wstring text;
// A replug is worth offering: Windows will not take the device down
// while a handle is held, at this node or any parent, but pulling the
// cable is a surprise removal that no software veto can stop.
if (!st.holders.empty())
text = st.holders
+ L" has the controller open, and Windows will not restart the "
L"device while it is held. Close it, or unplug and replug the "
L"controller.";
else if (st.VetoNamesTheHolder())
text = L"\"" + st.vetoName
+ L"\" has the controller open, and Windows will not restart the "
L"device while it is held. Close it, or unplug and replug the "
L"controller.";
else
text = L"Another program has the controller open, and Windows will not "
L"restart the device while it is held. Close any other controller "
L"tools, or unplug and replug the controller.";
ShowAlertBalloon(L"Could not take the controller", text);
return;
}
if (st.ticks != 0 && st.BrokeNoHandles()) {
EventLog::Write("ACQUIRE: giving up — the cycle never took the device down (%s err=%lu)",
DeviceRestart::CycleKindName(st.kind), st.error);
if (m_notificationsEnabled)
ShowAlertBalloon(L"Could not take the controller",
L"Windows would not restart the controller device, so it "
L"could not be taken from Steam. Unplugging and replugging "
L"it will hand it over.");
return;
}
EventLog::Write("ACQUIRE: giving up after %d device cycles", MAX_ACQUIRE_CYCLES);
if (m_notificationsEnabled)
ShowAlertBalloon(L"Could not take the controller",
L"Steam is holding the controller and did not release it. "
L"Closing Steam will hand it back.");
}
bool TrayApp::RunPendingRecovery() {
if (IsProcessElevated()) {
std::wstring report;
const int recovered = DeviceRestart::ReconcilePendingDisables(&report);
EventLog::Write("RECOVER: %ls (%d devnode(s) re-enabled)",
report.empty() ? L"nothing was still disabled" : report.c_str(),
recovered);
return true;
}
// Enabling a devnode needs elevation, so this has to go through the helper.
// Its scheduled task takes no arguments — schtasks cannot pass any — so it
// runs the ordinary entry point, which reconciles first and then cycles.
// Both are wanted here: the device comes back, and it comes back presented
// as a fresh arrival that Steam and we can race for as usual.
//
// Asynchronous: schtasks returns once the task has been started, not once
// the helper has finished, so the device reappears on its own a moment later
// as a normal arrival rather than before this returns.
return RestartControllerDevices();
}
// A silent log is ambiguous. Nothing is written while the app sits idle with
// nothing changing — AUTO lines only fire on Steam state *changes* — and
// nothing is written while it is wedged or dead either. So a gap means either
// "healthy, with nothing to say" or "stopped working", and after the fact there
// is no way to tell which. That ambiguity cost a real investigation: 33 hours
// of silence that read as a hang and was very likely an idle app.
//
// Driven by the window timer deliberately. A background thread would only prove
// the process still exists; this proves the message loop is still being pumped,
// which is what "not hung" means here — the acquire and release paths both run
// on it, and both can sit inside SetupAPI for seconds at a time.
void TrayApp::WriteHeartbeat() {
ULONGLONG unbiased = 0;
QueryUnbiasedInterruptTime(&unbiased);
const ULONGLONG sinceMs = m_lastHeartbeatUnbiased
? (unbiased - m_lastHeartbeatUnbiased) / 10000ULL
: 0;
m_lastHeartbeatUnbiased = unbiased;
// Unbiased time does not advance while the machine is suspended, so a beat
// that is late by this measure was genuinely blocked rather than closed in
// a laptop lid. Worth its own line: a stall that recovers leaves no other
// trace, and the beat before a gap is the last sign of life anyone gets.
if (sinceMs > HEARTBEAT_MS + HEARTBEAT_SLACK_MS)
EventLog::Write("HEARTBEAT: the message loop was blocked for %llu s "
"(beat due every %u s)",
sinceMs / 1000, HEARTBEAT_MS / 1000);
const ULONGLONG upMs = GetTickCount64() - m_startTick;
EventLog::Write("HEARTBEAT: alive uptime=%lluh%02llum controller=%s gameMode=%s "
"steam=%d (0=none 1=idle 2=inGame) mode=%d wantControl=%d",
upMs / 3600000ULL, (upMs / 60000ULL) % 60ULL,
m_controller->IsConnected() ? "connected" : "absent",
m_controller->IsGameModeActive() ? "on" : "off",
static_cast<int>(m_steamWatcher.GetState()),
static_cast<int>(m_autoMode),
m_wantControl ? 1 : 0);
}
// Undo a disable an earlier run committed to the registry but never reversed.
// Cheap and silent in the normal case: with no pending record this is one
// failed file open.
void TrayApp::RecoverStrandedDevices() {
if (!DeviceRestart::HasPendingDisable()) {
// No record of our own, but the device can still be switched off — by
// Device Manager, by regedit, by pnputil, by a tool that hides HID
// devices. Not something to undo silently: it may well be deliberate.
// Naming it is the useful part, because from here it is otherwise
// indistinguishable from no controller being plugged in at all.
m_disabledDeviceNode = DeviceRestart::FindDisabledControllerNode();
if (!m_disabledDeviceNode.empty()) {
EventLog::Write("STARTUP: a controller devnode is disabled in Windows, so it "
"publishes no interfaces and cannot be seen here or by Steam: "
"%ls", m_disabledDeviceNode.c_str());
EventLog::Write("STARTUP: re-enable it from the tray menu, in Device Manager, "
"or with: pnputil /enable-device \"%ls\"",
m_disabledDeviceNode.c_str());
if (m_notificationsEnabled)
ShowAlertBalloon(L"Controller is disabled in Windows",
L"Windows has this controller's device switched off, so "
L"nothing can see it — including Steam. Click here to "
L"re-enable it.",
BalloonAction::EnableDisabledDevice);
}
return;
}
EventLog::Write("RECOVER: a previous run was interrupted mid-cycle and left the "
"controller's devnode disabled — putting it back");
if (!RunPendingRecovery())
EventLog::Write("RECOVER: the elevated helper could not be run — the controller "
"stays disabled until it is re-enabled in Device Manager "
"(or: pnputil /enable-device \"<instance id>\")");
}
void TrayApp::EnableDisabledControllerDevice() {
// Re-resolve rather than trusting what the menu or balloon was built from:
// minutes may have passed, and the user may already have fixed it by hand.
const std::wstring node = DeviceRestart::FindDisabledControllerNode();
m_disabledDeviceNode = node;
if (node.empty()) {
EventLog::Write("USER: asked to re-enable the controller device, but no controller "
"devnode is disabled");
return;
}
EventLog::Write("USER: re-enabling disabled controller devnode %ls", node.c_str());
// Hand it to the same mechanism that repairs an interrupted cycle rather
// than growing a second way to switch a devnode on: recorded as pending, it
// is undone by exactly the path that has already been exercised. The parent
// is left blank — this node is known to be the disabled one, so there is
// nothing to guess at.
DeviceRestart::ArmPendingDisable(node, L"");
if (!RunPendingRecovery()) {
EventLog::Write("USER: could not run the elevated helper to re-enable the device");
if (m_notificationsEnabled)
ShowAlertBalloon(L"Could not re-enable the controller",
L"Windows would not let the device be switched back on from "
L"here. Re-enable it in Device Manager, under Human Interface "
L"Devices.");
return;
}
m_disabledDeviceNode.clear();
}
bool TrayApp::RestartControllerDevices() {
// Every cycle starts here, so the "do not reopen while this runs" flag is
// raised here rather than at each call site — the recovery cycle can be
// vetoed by our own handle exactly as readily as the acquire one, and the
// tick it is measured against has to move with it.
m_cycleInFlight = true;
m_inFlightLogged = false;
m_lastCycleTick = GetTickCount64();
// What this cycle owes us before it counts as finished. A narrowing request
// names the one occupied interface; without one the helper takes down
// everything, so everything has to come back. Consumed here, so a caller
// that sets nothing gets a full cycle rather than the last caller's guess.
m_cyclePaths = m_cycleRequestPaths.empty() ? SteamController::EnumerateAll()
: m_cycleRequestPaths;
m_cycleRequestPaths.clear();
m_cycleGone.assign(m_cyclePaths.size(), false);
// Backstop. Everything that ends a cycle normally is an event that might not
// arrive, and the flag staying up means the controller is never opened again.
SetTimer(m_hwnd, IDT_CYCLE_WATCHDOG, CYCLE_MAX_MS, nullptr);
// Always through the helper, elevated or not. Cycling in this process is
// possible when elevated, but it is synchronous: four interfaces at a
// second apiece means five seconds with no message pump, so every
// WM_DEVICECHANGE the cycle produces sits in the queue until it finishes.
// The first interface is back and taken by Steam long before we drain
// those messages and claim, which is how an elevated run loses a race the
// helper path wins (#79) — and it is the tray menu failing to paint that
// users report as the app hanging mid-cycle.
//
// The helper is a separate process, so the cycle runs while this one keeps
// pumping and the arrival drives the claim the moment it lands. Keeping one
// path also keeps the two from drifting: the shortcut had diverged into
// different timing and different logging (it wrote no cycle.log at all).
//
// Fire the elevated helper via its scheduled task. Starting
// a task the user authored needs no elevation, so no UAC prompt here.
std::wstring run = L"schtasks.exe /Run /TN \"";
run += CYCLE_TASK_NAME;
run += L"\"";
if (RunToolHidden(run)) return true; // asynchronous — flag stays raised
// Task missing (copied exes without installing?) — register it with a
// one-time UAC prompt, then retry.
if (EnsureCycleTaskRegistered() && RunToolHidden(std::move(run)))
return true;
// No cycle was started, so nothing is running that our handle could veto.
EndCycle("the helper could not be started");
return false;
}
// Watch a cycle we asked for without opening anything.
//
// The old test for "the cycle has delivered" was ControllerManager::IsConnected,
// which is true whenever any Steam Controller interface enumerates. A receiver
// publishes four and a narrowed cycle takes down one, so the surviving three
// held that true for the whole cycle and the guard came down on the first
// arrival of anything HID-shaped — including the device we were cycling, before
// it had even gone. What actually ends a cycle is the interfaces it took down
// coming back, so that is what this waits for.
//
// Enumeration is the right instrument for it: HidDevice::Enumerate opens each
// candidate with a zero access mask and closes it before moving on, so unlike
// OpenSlot it leaves nothing behind that PnP could trip over.
void TrayApp::TrackCycleProgress() {
if (m_cyclePaths.empty()) { // nothing recorded to wait on — do not hang on it
EndCycle("no interfaces were recorded to wait for");
return;
}
auto live = SteamController::EnumerateAll();
const auto docks = SteamController::EnumerateDocks();
live.insert(live.end(), docks.begin(), docks.end());
// Present having never gone is some other device's arrival landing before
// ours went down. Present having gone is that interface done.
//
// Per interface rather than for the set: the helper cycles them one after
// another, so the set reads as all back in the gap between the first
// returning and the next going down — and the resync that ends the cycle
// would then reopen the next one ahead of its turn and veto it.
bool done = true;
for (size_t i = 0; i < m_cyclePaths.size(); ++i) {
const bool present =
std::find(live.begin(), live.end(), m_cyclePaths[i]) != live.end();
if (!present) m_cycleGone[i] = true; // the helper has taken this one down
if (!present || !m_cycleGone[i]) done = false;
}
if (done)
EndCycle("cycled interfaces are back");
}
// One way out, so no path can leave the flag raised: that would hold off every
// reopen from here on and leave the tray reporting no controller until restart.
//
// resync is false for the caller that is about to let the device go anyway.
// Reopening it there would be the very veto this all exists to prevent, only
// now against a cycle nobody is waiting on.
void TrayApp::EndCycle(const char* why, bool resync) {
KillTimer(m_hwnd, IDT_CYCLE_WATCHDOG);
if (!m_cycleInFlight) return;
m_cycleInFlight = false;
m_cycleGone.clear();
m_cyclePaths.clear();
if (m_lastCycleTick != 0)
EventLog::Write("CYCLE: done after %llu ms (%s)",
GetTickCount64() - m_lastCycleTick, why);
// Pick up whatever the cycle left behind — the slot list has been frozen
// since the flag went up, so nothing else has looked at the device since.
if (resync)
m_controller->OnDeviceChange();
}
void TrayApp::ShowElevationBalloon() {
if (m_elevationBalloonShown) return;
m_elevationBalloonShown = true;
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
wcscpy_s(nid.szInfoTitle, L"One-time setup needed");
wcscpy_s(nid.szInfo,
L"Approve the administrator prompt to enable in-game controller "
L"handoff (select the mode again to retry).");
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
void TrayApp::OpenRemapWindow() {
// The moment a user is most likely to have installed something since the
// app started, and the moment a stale list would matter most — profiles
// created here are matched against it.
//
// Left on the UI thread, unlike the picker's own enumeration below. It is
// a handful of small text files and measures in tens of milliseconds, and
// m_steamApps is read by MatchProfile on this thread every time the
// foreground changes — so moving it would trade an unnoticeable delay for
// a data race.
RefreshSteamApps();
// The banner's enable button. Same path as the tray toggle, deliberately.
m_remapWindow.SetOnRequestEnable([this] { EnableFromUser(); });
// Whatever the state is right now, so the window does not open showing the
// last thing that happened to change.
m_remapWindow.SetControlState(m_controller->IsGameModeActive(),
m_autoMode == AutoMode::Manual);
// The window fills its own picker, on a background thread: finding every
// installed application runs to well over a second on an ordinary machine
// — several, on a cold cache — and doing it here meant the window did not
// appear at all until it finished.
m_remapWindow.Open(
m_hInstance,
m_controller.get(),
m_defaultProfile,
m_gameProfiles,
[this](const std::wstring& gameId, const ControllerProfile& profile) {
if (gameId.empty()) {
m_defaultProfile = profile;
SaveSettings();
} else {
m_gameProfiles[gameId] = profile;
GameProfiles::Save(m_gameProfiles);
}
// Editing whichever profile is live should be visible straight
// away. Asked unconditionally rather than by comparing ids,
// because the live profile may not be the edited one but may be
// following it — only ActiveProfile knows that, and for anything
// genuinely unrelated this resolves to what is already loaded.
PushActiveProfile();
},
[this](const std::wstring& gameId) {
m_gameProfiles.erase(gameId);
GameProfiles::Save(m_gameProfiles);
EventLog::Write("PROFILE: deleted %ls", gameId.c_str());
// A deleted profile stops deciding anything, including the pad
// type it was holding — which would otherwise outlive it for as
// long as its game kept running, with nothing left to explain it.
if (m_platformHold.IsHeld() && m_platformHold.ProfileId() == gameId)
ReleasePlatformHold(L"its profile was deleted");
// No control decision here, for the same reason applying makes
// none: the window is open and may have a row listening for a
// button, and dropping the controller under it would end that
// mid-capture. ActiveProfile already resolves a now-missing id to
// the default, and closing the window re-evaluates — which is
// what lets go of a controller this profile was holding.
PushActiveProfile();
});
}
void TrayApp::AddTrayIcon() {
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_TRAY;
nid.hIcon = m_iconOff;
wcscpy_s(nid.szTip, L"Steamless Controller");
Shell_NotifyIconW(NIM_ADD, &nid);
nid.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIconW(NIM_SETVERSION, &nid);
}
void TrayApp::RemoveTrayIcon() {
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
Shell_NotifyIconW(NIM_DELETE, &nid);
}
void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool sharedHandle,
bool padUnavailable) {
// The remap window dims and explains itself when nothing is being driven,
// so it has to hear about this from wherever the state actually changes.
PostMessageW(m_hwnd, WM_CONTROLSTATE, 0, 0);
if (padUnavailable) ShowViGEmBalloon();
// Cleared only by a pad that actually came up, not by any notification
// that happens to carry false. The acquire path notifies repeatedly within
// a single attempt, so clearing on false re-armed the balloon between
// consecutive failures and turned one attempt into a burst of toasts.
// ReleaseControl clears it too, so a deliberate re-enable says it again.
else if (gameModeActive) m_vigemBalloonShown = false;
// Sharing is a distinct running state, not a degraded one: game mode works,
// but another process is on the same controller and can fight us for it.
// Worth its own icon so a user chasing odd input knows to look at Steam.
const bool shared = gameModeActive && sharedHandle;
// Fall through rather than returning early — the icon and tooltip still need
// to track the controller while the driver is unavailable.
const wchar_t* tip = shared ? L"Steamless Controller — Steamless Mode ON (sharing with Steam)"
: gameModeActive ? L"Steamless Controller — Steamless Mode ON"
: padUnavailable ? L"Steamless Controller — no virtual gamepad bus"
: connected ? L"Steamless Controller — Connected (Steamless Mode OFF)"
: L"Steamless Controller — No controller found";
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_TIP | NIF_ICON;
nid.hIcon = shared ? m_iconShared
: gameModeActive ? m_iconOn
: m_iconOff;
wcscpy_s(nid.szTip, tip);
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& text,
BalloonAction action, DWORD infoFlags) {
m_balloonAction = action;
// Every balloon the app raises, recorded where it is raised. Notifications
// are the one thing this app does that leaves no other trace, so a report
// of seeing one twice was otherwise unanswerable — the log could show a
// profile loading once and say nothing about how many times Windows drew
// the toast. Two lines here means we asked twice; one means Windows drew
// it twice, and those want completely different fixes.
EventLog::Write("BALLOON: %ls — %ls", title.c_str(), text.c_str());
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = infoFlags;
wcsncpy_s(nid.szInfoTitle, title.c_str(), _TRUNCATE);
wcsncpy_s(nid.szInfo, text.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
void TrayApp::OpenEventLog() {
// Touch the log so the file exists even on a fresh install.
EventLog::Write("USER: opened event log");
// Open with Notepad explicitly — .log has no guaranteed file association.
ShellExecuteW(nullptr, L"open", L"notepad.exe",
EventLog::FilePath().c_str(), nullptr, SW_SHOWNORMAL);
}
// Latched: every failed game-mode enable calls back into UpdateTrayIcon, which
// during an acquire-retry loop is several times a second — without this the user
// gets a wall of identical balloons. UpdateTrayIcon clears the latch once the
// driver is reachable again, so a genuine mid-session disappearance still warns.
void TrayApp::ShowViGEmBalloon() {
if (!m_notificationsEnabled) return;
if (m_vigemBalloonShown) return;
m_vigemBalloonShown = true;
// Two different problems wear the same failure internally, and the advice
// for one is wrong for the other. Telling somebody who already has a bus
// to go install ViGEmBus is what #65 spent three weeks on, so say which
// case this is — the manager established it by enumerating, not by
// guessing from an error code.
//
// Missing is by far the likelier of the two: the one confirmed way to get
// here is an installer that skipped ViGEmBus (#68). The other branch names
// no culprit on purpose — a bus that is present and unusable has several
// possible causes and we have never seen one in the field.
const bool missing = m_controller->LastPadDriverMissing();
m_balloonAction = missing ? BalloonAction::ViGEmDownload : BalloonAction::None;
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
wcscpy_s(nid.szInfoTitle, missing ? L"Driver required" : L"Virtual gamepad unavailable");
wcscpy_s(nid.szInfo,
missing
? L"ViGEmBus is unavailable — not installed, or disabled in Device "
L"Manager under System devices. Click here to download it."
: L"A virtual gamepad bus is present but would not accept a "
L"controller. It may be disabled, or a version this app cannot "
L"talk to. The event log has the details.");
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
static constexpr wchar_t REG_KEY[] = L"Software\\SteamlessController";
static constexpr wchar_t REG_RUN_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
static constexpr wchar_t APP_NAME[] = L"SteamlessController";
bool TrayApp::RunKeyExists() const {
HKEY key;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_READ, &key) != ERROR_SUCCESS)
return false;
bool exists = RegQueryValueExW(key, APP_NAME, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS;
RegCloseKey(key);
return exists;
}
void TrayApp::SetRunKey(bool enabled) {
HKEY key;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_WRITE, &key) != ERROR_SUCCESS)
return;
if (enabled) {
wchar_t path[MAX_PATH];
GetModuleFileNameW(nullptr, path, MAX_PATH);
RegSetValueExW(key, APP_NAME, 0, REG_SZ,
reinterpret_cast<const BYTE*>(path),
static_cast<DWORD>((wcslen(path) + 1) * sizeof(wchar_t)));
} else {
RegDeleteValueW(key, APP_NAME);
}
RegCloseKey(key);
}
bool TrayApp::IsProcessElevated() {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
return false;
TOKEN_ELEVATION elev{};
DWORD size = sizeof(elev);
const bool ok = GetTokenInformation(token, TokenElevation, &elev, sizeof(elev), &size) != 0;
CloseHandle(token);
return ok && elev.TokenIsElevated != 0;
}
// The executable the cycle task is currently registered to run, or empty when
// the task is missing or unreadable.
static std::string RegisteredTaskCommand() {
wchar_t tempDir[MAX_PATH];
if (!GetTempPathW(MAX_PATH, tempDir)) return {};
const std::wstring xmlPath = std::wstring(tempDir) + L"SteamlessTaskQuery.xml";
// Via cmd.exe for the redirect. The XML declares encoding="UTF-16", but
// redirected output is single-byte — read it narrow, don't "fix" this.
std::wstring cmd = L"cmd.exe /c schtasks.exe /Query /TN \"";
cmd += CYCLE_TASK_NAME;
cmd += L"\" /XML > \"" + xmlPath + L"\"";
const bool queried = RunToolHidden(std::move(cmd));
std::string xml;
if (queried) {
if (FILE* f = nullptr; _wfopen_s(&f, xmlPath.c_str(), L"rb") == 0 && f) {
char buf[1024];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0)
xml.append(buf, n);
fclose(f);
}
}
DeleteFileW(xmlPath.c_str());
if (xml.empty()) return {};
const size_t open = xml.find("<Command>");
if (open == std::string::npos) return {};
const size_t start = open + strlen("<Command>");
const size_t close = xml.find("</Command>", start);
if (close == std::string::npos) return {};
std::string cmdPath = xml.substr(start, close - start);
const size_t first = cmdPath.find_first_not_of(" \t\r\n");
const size_t last = cmdPath.find_last_not_of(" \t\r\n");
if (first == std::string::npos) return {};
return cmdPath.substr(first, last - first + 1);
}
// True when the registered command is this build's helper. Compared against
// both codepage renderings of our path, since which one the console redirect
// used is not worth guessing at.
static bool RegisteredCommandMatches(const std::string& registered,
const std::wstring& helper) {
if (registered.empty()) return false;
for (UINT cp : { CP_ACP, CP_OEMCP }) {
const int n = WideCharToMultiByte(cp, 0, helper.c_str(), -1,
nullptr, 0, nullptr, nullptr);
if (n <= 0) continue;
std::string narrow(static_cast<size_t>(n) - 1, '\0');
WideCharToMultiByte(cp, 0, helper.c_str(), -1, narrow.data(), n, nullptr, nullptr);
if (_stricmp(narrow.c_str(), registered.c_str()) == 0) return true;
}
return false;
}
bool TrayApp::EnsureCycleTaskRegistered() {
// Registered AND pointing at this build's helper? Existence alone is not
// enough: a task left by an older or relocated install still "runs" —
// schtasks reports success — while executing a helper that may be gone or
// predate current device support, so the cycle silently does nothing and
// the controller is never reclaimed. That failure is invisible from the
// app's side, so verify the path rather than just the task.
{
const std::string registered = RegisteredTaskCommand();
if (RegisteredCommandMatches(registered, HelperPath()))
return true;
if (!registered.empty())
EventLog::Write("CYCLE TASK: registered helper is stale, re-registering");
}
// One-time UAC prompt: the helper registers its own on-demand task.
SHELLEXECUTEINFOW sei{};
sei.cbSize = sizeof(sei);
sei.fMask = SEE_MASK_NOCLOSEPROCESS;
const std::wstring helper = HelperPath();
sei.lpVerb = L"runas";
sei.lpFile = helper.c_str();
sei.lpParameters = L"--register";
sei.nShow = SW_HIDE;
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
// UAC declined — in-game mode degrades: we can't cycle the device
// away from a running Steam, but takeover when Steam exits still works.
ShowElevationBalloon();
return false;
}
WaitForSingleObject(sei.hProcess, 30000);
DWORD code = 1;
GetExitCodeProcess(sei.hProcess, &code);
CloseHandle(sei.hProcess);
if (code != 0) ShowElevationBalloon();
return code == 0;
}
void TrayApp::DeleteLegacyStartupTask() {
// A pre-release build registered an elevated logon task for the tray app
// itself; running elevated at logon resurrects the UIPI cursor-freeze
// bug, so remove it best-effort (also done by the helper's --register).
std::wstring cmd = L"schtasks.exe /Delete /F /TN \"";
cmd += LEGACY_STARTUP_TASK_NAME;
cmd += L"\"";
RunToolHidden(std::move(cmd));
}
void TrayApp::UpdateStartupRegistration() {
// Startup always uses the HKCU Run key — the tray app never runs
// elevated, so the Run key works unconditionally.
if (m_startupMechanism == 2) DeleteLegacyStartupTask();
const int desired = m_startupEnabled ? 1 : 0;
SetRunKey(desired == 1);
m_startupMechanism = desired;
SaveSettings();
}
void TrayApp::LoadSettings() {
HKEY key;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_KEY, 0, KEY_READ, &key) != ERROR_SUCCESS)
return;
auto readDw = [&](const wchar_t* name, DWORD def) -> DWORD {
DWORD val = 0, size = sizeof(val);
if (RegQueryValueExW(key, name, nullptr, nullptr,
reinterpret_cast<LPBYTE>(&val), &size) == ERROR_SUCCESS)
return val;
return def;
};
// 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame, 3 = OffUnlessProfile
// (old builds stored 0/1). Anything unrecognised falls back to Manual,
// which is the mode that touches nothing on its own.
const DWORD mode = readDw(L"AutoSteamMode", 0);
m_autoMode = mode == 3 ? AutoMode::OffUnlessProfile
: mode == 2 ? AutoMode::OffOnlyInGame
: mode == 1 ? AutoMode::OffWhileSteam
: AutoMode::Manual;
const bool isPlayStation = readDw(L"ControllerPlatform", 0) != 0;
// Existing installs keep exactly the bindings they had — the new
// click-by-default only applies to fresh installs, which never reach here
// and so take the BackButtonConfig defaults.
const DWORD unbound = BackButtonBinding::FromAction(BackButtonAction::None).Pack();
ControllerProfile profile;
profile.platform = isPlayStation ? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox;
profile.back.l4 = BackButtonBinding::Unpack(readDw(L"BackBtnL4", unbound));
profile.back.l5 = BackButtonBinding::Unpack(readDw(L"BackBtnL5", unbound));
profile.back.r4 = BackButtonBinding::Unpack(readDw(L"BackBtnR4", unbound));
profile.back.r5 = BackButtonBinding::Unpack(readDw(L"BackBtnR5", unbound));
// Migrate the retired "Back Buttons as Mouse Click" toggle into real
// bindings. The toggle used to hijack L4/R4 for a left click and silently
// discard whatever they were bound to, so only fill in the paddles that
// have no binding of their own — an explicit mapping was the user's intent
// all along and starts working again now that nothing overrides it.
const bool migrateBackMouse = readDw(L"BackButtons", 0) != 0;
if (migrateBackMouse) {
const auto leftClick = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton);
if (profile.back.l4.IsAction(BackButtonAction::None)) profile.back.l4 = leftClick;
if (profile.back.r4.IsAction(BackButtonAction::None)) profile.back.r4 = leftClick;
}
// Per-pad settings. Installs that predate them carry the two retired
// toggles instead — one pad, mouse or nothing — which is expressible in
// the new shape exactly: the chosen pad becomes a pointer whose click is
// the left button it was always hardwired to, and the other pad stays
// unclaimed. "PadsMigrated" marks the conversion done, so a later choice
// of "no pad at all" is not overwritten on the next launch by the stale
// toggles this reads.
const bool migratePads = readDw(L"PadsMigrated", 0) == 0;
if (migratePads) {
// A pad the old build was not using for the mouse still reached the
// game as a DS4 touchpad on PlayStation, and did nothing at all on
// Xbox. Both are now explicit modes, so the migration has to name the
// right one rather than let them share a default.
//
// Assigned whole rather than field by field: ControllerProfile's
// defaults describe a fresh install, and an upgrade is not one. Left
// partially overwritten, the pad that was not driving the mouse would
// pick up the fresh-install click binding it never had.
const TrackpadMode idle = isPlayStation ? TrackpadMode::DS4Touchpad
: TrackpadMode::None;
profile.leftPad = TrackpadSettings{ .mode = idle };
profile.rightPad = TrackpadSettings{ .mode = idle };
if (readDw(L"TrackpadMouse", 0) != 0) {
auto& pad = readDw(L"UseLeftTrackpad", 0) != 0 ? profile.leftPad
: profile.rightPad;
pad.mode = TrackpadMode::MousePointer;
pad.click = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton);
}
} else {
// Both pads carry the same twelve settings under different prefixes.
// The direction and touch values are absent from anything written
// before the directional modes existed, and their defaults here match
// TrackpadSettings' own so those profiles read back as an
// unconfigured directional pad rather than a silently unbound one.
auto readPad = [&](const wchar_t* prefix, TrackpadSettings& pad) {
auto name = [&](const wchar_t* suffix) { return std::wstring(prefix) + suffix; };
auto binding = [&](const wchar_t* suffix, BackButtonAction def) {
return BackButtonBinding::Unpack(
readDw(name(suffix).c_str(),
BackButtonBinding::FromAction(def).Pack()));
};
pad.mode = TrackpadModeFromDword(readDw(name(L"Mode").c_str(), 0));
pad.click = binding(L"Click", BackButtonAction::None);
pad.scrollDir = ScrollDirectionFromDword(readDw(name(L"ScrollDir").c_str(), 0));
pad.scrollSpeed = ScrollSpeedFromDword(readDw(name(L"ScrollSpeed").c_str(), 0));
pad.touch = binding(L"Touch", BackButtonAction::None);
pad.up = binding(L"Up", BackButtonAction::DPadUp);
pad.down = binding(L"Down", BackButtonAction::DPadDown);
pad.left = binding(L"Left", BackButtonAction::DPadLeft);
pad.right = binding(L"Right", BackButtonAction::DPadRight);
pad.diagonals = DiagonalModeFromDword(readDw(name(L"Diagonals").c_str(), 0));
};
readPad(L"LeftPad", profile.leftPad);
readPad(L"RightPad", profile.rightPad);
}
m_defaultProfile = profile;
m_activeGameId.clear();
m_controller->SetProfile(m_defaultProfile);
m_notificationsEnabled = readDw(L"ShowNotifications", 1) != 0;
// Startup mechanism: 0 none, 1 Run key, 2 elevated task. Migrate installs
// that predate the setting by probing the Run key they would have used.
m_startupMechanism = static_cast<int>(readDw(L"StartupMechanism",
RunKeyExists() ? 1 : 0));
m_startupEnabled = m_startupMechanism != 0;
RegCloseKey(key);
m_gameProfiles = GameProfiles::Load();
// Persist whatever was migrated and drop the retired values, so each
// fill-in above runs exactly once and a later choice of "Off" is not
// resurrected on the next launch.
if (migrateBackMouse || migratePads) {
SaveSettings();
HKEY writeKey;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_KEY, 0, KEY_SET_VALUE, &writeKey) == ERROR_SUCCESS) {
if (migrateBackMouse) RegDeleteValueW(writeKey, L"BackButtons");
if (migratePads) {
RegDeleteValueW(writeKey, L"TrackpadMouse");
RegDeleteValueW(writeKey, L"UseLeftTrackpad");
}
RegCloseKey(writeKey);
}
}
}
void TrayApp::SaveSettings() {
HKEY key;
if (RegCreateKeyExW(HKEY_CURRENT_USER, REG_KEY, 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr,
&key, nullptr) != ERROR_SUCCESS)
return;
auto writeDw = [&](const wchar_t* name, DWORD val) {
RegSetValueExW(key, name, 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&val), sizeof(val));
};
writeDw(L"AutoSteamMode", static_cast<DWORD>(m_autoMode));
writeDw(L"StartupMechanism", static_cast<DWORD>(m_startupMechanism));
writeDw(L"ShowNotifications", m_notificationsEnabled ? 1 : 0);
// Always the default profile, never ControllerManager's live one: a game
// profile can be active here, and persisting that would overwrite the
// user's own settings with a per-game override.
const auto& profile = m_defaultProfile;
writeDw(L"ControllerPlatform",
profile.platform == ControllerPlatform::PlayStation ? 1 : 0);
writeDw(L"BackBtnL4", profile.back.l4.Pack());
writeDw(L"BackBtnL5", profile.back.l5.Pack());
writeDw(L"BackBtnR4", profile.back.r4.Pack());
writeDw(L"BackBtnR5", profile.back.r5.Pack());
auto writePad = [&](const wchar_t* prefix, const TrackpadSettings& pad) {
auto name = [&](const wchar_t* suffix) { return std::wstring(prefix) + suffix; };
writeDw(name(L"Mode").c_str(), static_cast<DWORD>(pad.mode));
writeDw(name(L"Click").c_str(), pad.click.Pack());
writeDw(name(L"ScrollDir").c_str(), static_cast<DWORD>(pad.scrollDir));
writeDw(name(L"ScrollSpeed").c_str(), pad.scrollSpeed);
writeDw(name(L"Touch").c_str(), pad.touch.Pack());
writeDw(name(L"Up").c_str(), pad.up.Pack());
writeDw(name(L"Down").c_str(), pad.down.Pack());
writeDw(name(L"Left").c_str(), pad.left.Pack());
writeDw(name(L"Right").c_str(), pad.right.Pack());
writeDw(name(L"Diagonals").c_str(), static_cast<DWORD>(pad.diagonals));
};
writePad(L"LeftPad", profile.leftPad);
writePad(L"RightPad", profile.rightPad);
// Written last and unconditionally: its presence is what tells the next
// launch the per-pad values above are authoritative.
writeDw(L"PadsMigrated", 1);
RegCloseKey(key);
}
void TrayApp::ShowContextMenu() {
// Re-enumerate before reading state off the manager. Releasing the
// controller clears its slot list and only a device event refills it, so
// after a manual disable the menu grayed out its own toggle as "(no
// controller detected)" and stayed that way — the controller was sitting
// right there, and only restarting the app brought the toggle back (#68).
//
// Not while a cycle we asked for is running, though: this opens handles,
// and an open handle is what vetoes a cycle, ours included.
if (!m_cycleInFlight)
m_controller->OnDeviceChange();
bool connected = m_controller->IsConnected();
bool gameModeOn = m_controller->IsGameModeActive();
bool startupOn = m_startupEnabled;
HMENU menu = CreatePopupMenu();
// Only present when it applies, and first when it does: with the devnode
// switched off nothing else on this menu can do anything useful, and every
// other entry will be reporting "no controller detected" for a reason the
// user has no way to guess at. Re-resolved on each open so it disappears
// once the device is back, however it got there.
m_disabledDeviceNode = DeviceRestart::FindDisabledControllerNode();
if (!m_disabledDeviceNode.empty()) {
AppendMenuW(menu, MF_STRING, IDM_ENABLE_DEVICE,
L"Re-enable Controller Device (disabled in Windows)");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
}
// Manual toggle is disabled while an auto mode owns the decision — when
// grayed, the label itself says why.
const bool manual = (m_autoMode == AutoMode::Manual);
// In manual mode the label tracks intent, not the current handle state:
// acquiring can spend several seconds cycling the device, and during that
// window the toggle's job is to offer a cancel.
const bool toggleOn = manual ? m_wantControl : gameModeOn;
std::wstring toggleLabel = toggleOn ? L"Disable Steamless Mode"
: L"Enable Steamless Mode";
if (!connected)
toggleLabel += L" (no controller detected)";
else if (!manual)
toggleLabel += L" (managed by Auto Mode)";
UINT toggleFlags = MF_STRING | ((connected && manual) ? MF_ENABLED : MF_GRAYED);
AppendMenuW(menu, toggleFlags, IDM_TOGGLE, toggleLabel.c_str());
HMENU modeMenu = CreatePopupMenu();
AppendMenuW(modeMenu, MF_STRING | (manual ? MF_CHECKED : MF_UNCHECKED),
IDM_MODE_MANUAL, L"Manual");
AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffWhileSteam ? MF_CHECKED : MF_UNCHECKED),
IDM_MODE_STEAM, L"Auto - Off while Steam running");
AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffOnlyInGame ? MF_CHECKED : MF_UNCHECKED),
IDM_MODE_GAME, L"Auto - Off ONLY while in Steam game");
AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffUnlessProfile ? MF_CHECKED : MF_UNCHECKED),
IDM_MODE_PROFILE, L"Auto - Off unless a game profile is running");
AppendMenuW(menu, MF_STRING | MF_POPUP,
reinterpret_cast<UINT_PTR>(modeMenu), L"Control Mode");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
// Everything about how the controller behaves — trackpads, paddles, and
// which kind of pad the game sees — is configured in one window now, per
// profile. This menu used to carry three separate entries for pieces of
// that, none of which could be set per game.
AppendMenuW(menu, MF_STRING, IDM_REMAP_BACK, L"Customize Controls...");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING | (startupOn ? MF_CHECKED : MF_UNCHECKED),
IDM_STARTUP, L"Start with Windows");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, IDM_OPENLOG, L"Open Event Log");
AppendMenuW(menu, MF_STRING | (m_notificationsEnabled ? MF_CHECKED : MF_UNCHECKED),
IDM_NOTIFICATIONS, L"Show Notifications");
AppendMenuW(menu, MF_STRING, IDM_EXIT, L"Exit");
// SetForegroundWindow is required for the menu to dismiss on click-away.
SetForegroundWindow(m_hwnd);
POINT pt;
GetCursorPos(&pt);
TrackPopupMenu(menu, TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_RIGHTBUTTON,
pt.x, pt.y, 0, m_hwnd, nullptr);
DestroyMenu(menu);
}