Files
SteamlessController/src/app/TrayApp.cpp
T
Dylan DeverillandClaude Sonnet 5 19e5735504 Let Manual mode enable itself on launch, off by default for upgrades (#107)
People kept updating control mappings that did nothing, because Steamless
mode was off and nothing said so until the in-window banner landed. The
banner helps once you're looking at the window, but the fix people actually
asked for is not needing to remember to flip the tray toggle every session.

Manual mode now has its own "Enable on Launch" item, checked by default only
for fresh installs — LoadSettings already returns before touching any member
when the registry key doesn't exist at all, so that fork was free. An
existing install keeps today's off-until-toggled behavior unless the user
opts in, since flipping it under them wasn't part of the ask. Hidden outside
Manual mode: the auto modes never make an "at launch" decision, they
resolve control from Steam/game state continuously, so there is nothing for
the item to mean there.

Routes through EnableFromUser() at the end of Init(), after the profile is
pushed — the same path the tray toggle and the remap window's banner button
already share, so a fresh install's first acquire carries the right bindings
and goes through the same elevated-helper registration as a manual click.

Bumped to 1.23; this is the last change queued before packaging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 00:03:51 -07:00

2195 lines
105 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();
// Manual mode only — the auto modes decide for themselves once the Steam
// watcher's first callback lands. Last, so the profile above is already
// pushed and the acquire that follows carries the right bindings from the
// first frame.
if (m_autoMode == AutoMode::Manual && m_enableOnLaunch)
EnableFromUser();
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_ENABLE_ON_LAUNCH:
m_enableOnLaunch = !m_enableOnLaunch;
SaveSettings();
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;
// Default false here, not true: this branch only runs once the registry
// key already exists, i.e. the app has run before — including on builds
// that predate this setting, which never wrote it. Only a fresh install
// (LoadSettings returning above before this line is reached) gets the
// in-class default of true.
m_enableOnLaunch = readDw(L"EnableOnLaunch", 0) != 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);
writeDw(L"EnableOnLaunch", m_enableOnLaunch ? 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");
// Meaningless outside Manual mode — the auto modes never make an "at
// launch" decision, they resolve control from Steam/game state on every
// evaluation — so it stays off the menu entirely there rather than
// sitting grayed out with nothing to explain.
if (manual)
AppendMenuW(menu, MF_STRING | (m_enableOnLaunch ? MF_CHECKED : MF_UNCHECKED),
IDM_ENABLE_ON_LAUNCH, L"Enable on Launch");
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);
}