#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 #include #include #include #include #include #include #include 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& 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( [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 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(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(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(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 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(now), static_cast(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(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(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(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(m_autoMode), static_cast(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(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 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 request; if (hadControl) request = livePath.empty() ? SteamController::EnumerateAll() : std::vector{ 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(m_steamWatcher.GetState()), static_cast(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 \"\")"); } 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(path), static_cast((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(""); if (open == std::string::npos) return {}; const size_t start = open + strlen(""); const size_t close = xml.find("", 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(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(&val), &size) == ERROR_SUCCESS) return val; return def; }; // 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame, 3 = OffUnlessProfile // (old builds stored 0/1). Anything unrecognised falls back to Manual, // which is the mode that touches nothing on its own. const DWORD mode = readDw(L"AutoSteamMode", 0); m_autoMode = mode == 3 ? AutoMode::OffUnlessProfile : mode == 2 ? AutoMode::OffOnlyInGame : mode == 1 ? AutoMode::OffWhileSteam : AutoMode::Manual; const bool isPlayStation = readDw(L"ControllerPlatform", 0) != 0; // Existing installs keep exactly the bindings they had — the new // click-by-default only applies to fresh installs, which never reach here // and so take the BackButtonConfig defaults. const DWORD unbound = BackButtonBinding::FromAction(BackButtonAction::None).Pack(); ControllerProfile profile; profile.platform = isPlayStation ? ControllerPlatform::PlayStation : ControllerPlatform::Xbox; profile.back.l4 = BackButtonBinding::Unpack(readDw(L"BackBtnL4", unbound)); profile.back.l5 = BackButtonBinding::Unpack(readDw(L"BackBtnL5", unbound)); profile.back.r4 = BackButtonBinding::Unpack(readDw(L"BackBtnR4", unbound)); profile.back.r5 = BackButtonBinding::Unpack(readDw(L"BackBtnR5", unbound)); // Migrate the retired "Back Buttons as Mouse Click" toggle into real // bindings. The toggle used to hijack L4/R4 for a left click and silently // discard whatever they were bound to, so only fill in the paddles that // have no binding of their own — an explicit mapping was the user's intent // all along and starts working again now that nothing overrides it. const bool migrateBackMouse = readDw(L"BackButtons", 0) != 0; if (migrateBackMouse) { const auto leftClick = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton); if (profile.back.l4.IsAction(BackButtonAction::None)) profile.back.l4 = leftClick; if (profile.back.r4.IsAction(BackButtonAction::None)) profile.back.r4 = leftClick; } // Per-pad settings. Installs that predate them carry the two retired // toggles instead — one pad, mouse or nothing — which is expressible in // the new shape exactly: the chosen pad becomes a pointer whose click is // the left button it was always hardwired to, and the other pad stays // unclaimed. "PadsMigrated" marks the conversion done, so a later choice // of "no pad at all" is not overwritten on the next launch by the stale // toggles this reads. const bool migratePads = readDw(L"PadsMigrated", 0) == 0; if (migratePads) { // A pad the old build was not using for the mouse still reached the // game as a DS4 touchpad on PlayStation, and did nothing at all on // Xbox. Both are now explicit modes, so the migration has to name the // right one rather than let them share a default. // // Assigned whole rather than field by field: ControllerProfile's // defaults describe a fresh install, and an upgrade is not one. Left // partially overwritten, the pad that was not driving the mouse would // pick up the fresh-install click binding it never had. const TrackpadMode idle = isPlayStation ? TrackpadMode::DS4Touchpad : TrackpadMode::None; profile.leftPad = TrackpadSettings{ .mode = idle }; profile.rightPad = TrackpadSettings{ .mode = idle }; if (readDw(L"TrackpadMouse", 0) != 0) { auto& pad = readDw(L"UseLeftTrackpad", 0) != 0 ? profile.leftPad : profile.rightPad; pad.mode = TrackpadMode::MousePointer; pad.click = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton); } } else { // Both pads carry the same twelve settings under different prefixes. // The direction and touch values are absent from anything written // before the directional modes existed, and their defaults here match // TrackpadSettings' own so those profiles read back as an // unconfigured directional pad rather than a silently unbound one. auto readPad = [&](const wchar_t* prefix, TrackpadSettings& pad) { auto name = [&](const wchar_t* suffix) { return std::wstring(prefix) + suffix; }; auto binding = [&](const wchar_t* suffix, BackButtonAction def) { return BackButtonBinding::Unpack( readDw(name(suffix).c_str(), BackButtonBinding::FromAction(def).Pack())); }; pad.mode = TrackpadModeFromDword(readDw(name(L"Mode").c_str(), 0)); pad.click = binding(L"Click", BackButtonAction::None); pad.scrollDir = ScrollDirectionFromDword(readDw(name(L"ScrollDir").c_str(), 0)); pad.scrollSpeed = ScrollSpeedFromDword(readDw(name(L"ScrollSpeed").c_str(), 0)); pad.touch = binding(L"Touch", BackButtonAction::None); pad.up = binding(L"Up", BackButtonAction::DPadUp); pad.down = binding(L"Down", BackButtonAction::DPadDown); pad.left = binding(L"Left", BackButtonAction::DPadLeft); pad.right = binding(L"Right", BackButtonAction::DPadRight); pad.diagonals = DiagonalModeFromDword(readDw(name(L"Diagonals").c_str(), 0)); }; readPad(L"LeftPad", profile.leftPad); readPad(L"RightPad", profile.rightPad); } m_defaultProfile = profile; m_activeGameId.clear(); m_controller->SetProfile(m_defaultProfile); m_notificationsEnabled = readDw(L"ShowNotifications", 1) != 0; // Startup mechanism: 0 none, 1 Run key, 2 elevated task. Migrate installs // that predate the setting by probing the Run key they would have used. m_startupMechanism = static_cast(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(&val), sizeof(val)); }; writeDw(L"AutoSteamMode", static_cast(m_autoMode)); writeDw(L"StartupMechanism", static_cast(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(pad.mode)); writeDw(name(L"Click").c_str(), pad.click.Pack()); writeDw(name(L"ScrollDir").c_str(), static_cast(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(pad.diagonals)); }; writePad(L"LeftPad", profile.leftPad); writePad(L"RightPad", profile.rightPad); // Written last and unconditionally: its presence is what tells the next // launch the per-pad values above are authoritative. writeDw(L"PadsMigrated", 1); RegCloseKey(key); } void TrayApp::ShowContextMenu() { // Re-enumerate before reading state off the manager. Releasing the // controller clears its slot list and only a device event refills it, so // after a manual disable the menu grayed out its own toggle as "(no // controller detected)" and stayed that way — the controller was sitting // right there, and only restarting the app brought the toggle back (#68). // // Not while a cycle we asked for is running, though: this opens handles, // and an open handle is what vetoes a cycle, ours included. if (!m_cycleInFlight) m_controller->OnDeviceChange(); bool connected = m_controller->IsConnected(); bool gameModeOn = m_controller->IsGameModeActive(); bool startupOn = m_startupEnabled; HMENU menu = CreatePopupMenu(); // Only present when it applies, and first when it does: with the devnode // switched off nothing else on this menu can do anything useful, and every // other entry will be reporting "no controller detected" for a reason the // user has no way to guess at. Re-resolved on each open so it disappears // once the device is back, however it got there. m_disabledDeviceNode = DeviceRestart::FindDisabledControllerNode(); if (!m_disabledDeviceNode.empty()) { AppendMenuW(menu, MF_STRING, IDM_ENABLE_DEVICE, L"Re-enable Controller Device (disabled in Windows)"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); } // Manual toggle is disabled while an auto mode owns the decision — when // grayed, the label itself says why. const bool manual = (m_autoMode == AutoMode::Manual); // In manual mode the label tracks intent, not the current handle state: // acquiring can spend several seconds cycling the device, and during that // window the toggle's job is to offer a cancel. const bool toggleOn = manual ? m_wantControl : gameModeOn; std::wstring toggleLabel = toggleOn ? L"Disable Steamless Mode" : L"Enable Steamless Mode"; if (!connected) toggleLabel += L" (no controller detected)"; else if (!manual) toggleLabel += L" (managed by Auto Mode)"; UINT toggleFlags = MF_STRING | ((connected && manual) ? MF_ENABLED : MF_GRAYED); AppendMenuW(menu, toggleFlags, IDM_TOGGLE, toggleLabel.c_str()); HMENU modeMenu = CreatePopupMenu(); AppendMenuW(modeMenu, MF_STRING | (manual ? MF_CHECKED : MF_UNCHECKED), IDM_MODE_MANUAL, L"Manual"); AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffWhileSteam ? MF_CHECKED : MF_UNCHECKED), IDM_MODE_STEAM, L"Auto - Off while Steam running"); AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffOnlyInGame ? MF_CHECKED : MF_UNCHECKED), IDM_MODE_GAME, L"Auto - Off ONLY while in Steam game"); AppendMenuW(modeMenu, MF_STRING | (m_autoMode == AutoMode::OffUnlessProfile ? MF_CHECKED : MF_UNCHECKED), IDM_MODE_PROFILE, L"Auto - Off unless a game profile is running"); AppendMenuW(menu, MF_STRING | MF_POPUP, reinterpret_cast(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); }