mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-22 14:42:16 -05:00
The 2026 controller's triggers are smooth analog travel with no physical second stage, so a trigger set to Dual Stage holds a full-press binding once it is pulled past a threshold. The analog value still reaches the game untouched, so a game sees the pull and the extra input together. The threshold is software on the analog value, with hysteresis: it presses at 95% and lets go at 80% by default, which is what measurement showed the firmware's own full-pull bit does. Both triggers share one code path and the point is adjustable. The left trigger's firmware bit turned up at buf[5] bit 3 and is named BTN_LT_FULL; nothing here reads it, but it is now documented where the right one is. The binding is an ordinary BackButtonBinding, so it can be a gamepad button, a key combo or a mouse button, and goes out through the same paddle dispatch and virtual-pad paths. It is released if the mode is switched off mid-hold. There is no actuator in a trigger, so the click is a pad click plus a short grip buzz on the trigger's side. It is queued to the rumble thread rather than written from the read loop, because a Bluetooth write can block for seconds. The buzz merges with whatever rumble a game is already asking for instead of cancelling it, and a click is dropped if the previous one on that side is still playing. Customize Controls gains a group per trigger: a mode select, a live meter with draggable press and release markers, a Full Press rebind row and a click-feel select. Settings persist per profile, and profiles written before this existed read back as a plain analog trigger. The settings page's script and styles each go in a literal of their own, because the neighbouring ones are within a kilobyte of the compiler's limit on a single string. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2208 lines
106 KiB
C++
2208 lines
106 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);
|
|
}
|
|
|
|
// Outside the migration branch above: the triggers have nothing to migrate,
|
|
// and an install that predates them has no such values, which reads back as
|
|
// Standard — analog only, as it always was.
|
|
{
|
|
auto readTriggerDw = [&](const wchar_t* name, uint32_t def) -> uint32_t {
|
|
return readDw(name, def);
|
|
};
|
|
profile.leftTrigger = ReadTriggerSettings(L"LeftTrigger", readTriggerDw);
|
|
profile.rightTrigger = ReadTriggerSettings(L"RightTrigger", readTriggerDw);
|
|
}
|
|
|
|
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);
|
|
WriteTriggerSettings(L"LeftTrigger", profile.leftTrigger, writeDw);
|
|
WriteTriggerSettings(L"RightTrigger", profile.rightTrigger, writeDw);
|
|
// 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);
|
|
}
|