From 94f1bf94dd01c88b503bd69d0ccb09409a90e2f7 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Wed, 5 Aug 2026 01:03:26 -0700 Subject: [PATCH] Share one acquire/release path across manual and auto modes (#53) Manual mode could not take the controller back from a running Steam. The tray toggle called ControllerManager::EnableGameMode() once and discarded the result, so a Blocked outcome -- Steam holding the exclusive write handle -- was simply dropped. The auto modes wrapped the same call in TryAcquireController(), which escalates a blocked claim to a device cycle via the elevated helper and retries on re-arrival. Only closing Steam freed the pad in manual mode. The two paths diverged because acquiring is asynchronous: a blocked claim fires a cycle through Task Scheduler and comes back seconds later as a WM_DEVICECHANGE, so the retry loop lives in timers and message handlers rather than on the stack. Each of those re-entry points has to re-answer "do we still want control?", and the code answered it by recomputing m_autoMode != Manual && WantControl(steamState) -- deriving intent from Steam. That works in the auto modes, where Steam's state is the intent. Manual mode had nothing to derive from: its intent lived in a menu click that was never recorded anywhere. WantControl() said as much, returning false for Manual with the comment "tray toggle decides" -- and the tray toggle's decision went unstored. Store it. m_wantControl replaces the derived guard at all three re-entry points (the IDT_ACQUIRE and IDT_WAKE_POLL timers, and the arrival branch of WM_DEVICECHANGE), leaving WantControl() with a single caller in ApplySteamState. The toggle sets it and calls the shared path, so manual mode gets the retry burst, device cycling, wake polling, and the failure balloon it never had. Release is unified the same way: ReleaseControl() lifts the yield branch out of ApplySteamState, substituting m_steamWatcher.GetState() for the SteamState parameter the manual caller has no access to. Manual toggle-off now also cycles the devnode, so a running Steam sees an arrival event and re-adopts the pad instead of leaving it unclaimed. Three follow-on details: - Manual toggle-on calls EnsureCycleTaskRegistered(), matching what IDM_MODE_GAME already does, so the one-time UAC prompt lands on a menu click rather than ambushing the first cycle. - SetAutoMode(Manual) adopts IsGameModeActive() rather than inheriting a stale m_wantControl from the auto mode being left. - The toggle's menu label tracks m_wantControl in manual mode, not the live handle state. Acquisition can spend several seconds cycling, and a second click during that window should cancel rather than start over. Both AUTO: log lines in the acquire path are now reachable from manual mode, so they are relabelled ACQUIRE: -- these are exactly the lines read when diagnosing a failed handoff, and the old prefix pointed away from it. Co-authored-by: Claude Opus 5 --- src/app/TrayApp.cpp | 87 ++++++++++++++++++++++++++++++--------------- src/app/TrayApp.h | 9 ++++- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 6f59af5..c08d6b0 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -167,10 +167,22 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_COMMAND: switch (LOWORD(wp)) { case IDM_TOGGLE: - if (m_controller->IsGameModeActive()) - m_controller->DisableGameMode(); - else - m_controller->EnableGameMode(); + // 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 { + // 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. + if (!IsProcessElevated()) + EnsureCycleTaskRegistered(); + m_wantControl = true; + m_acquireRetries = 0; + TryAcquireController(); + } break; case IDM_TRACKPAD: m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled()); @@ -255,19 +267,19 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_TIMER: if (wp == IDT_ACQUIRE) { KillTimer(m_hwnd, IDT_ACQUIRE); - if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) + 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_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) + if (m_wantControl) TryAcquireController(WAKE_PROBE_MS); } 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; - EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); + 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. " @@ -279,10 +291,9 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) { m_controller->OnDeviceChange(); // A controller arriving (plugged in, or re-arriving after a device - // cycle) should be acquired without a manual toggle when the - // active auto mode wants control right now. - if (wp == DBT_DEVICEARRIVAL && m_autoMode != AutoMode::Manual - && WantControl(m_steamWatcher.GetState())) + // cycle) should be acquired straight away whenever control is + // wanted — this is what wins the reopen race against Steam. + if (wp == DBT_DEVICEARRIVAL && m_wantControl) TryAcquireController(); } return TRUE; @@ -301,7 +312,12 @@ void TrayApp::SetAutoMode(AutoMode mode) { m_acquireRetries = 0; SaveSettings(); UpdateStartupRegistration(); - if (mode != AutoMode::Manual) + 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 ApplySteamState(m_steamWatcher.GetState()); } @@ -320,26 +336,35 @@ void TrayApp::ApplySteamState(SteamState state) { static_cast(state), WantControl(state) ? "take control" : "yield"); if (WantControl(state)) { + m_wantControl = true; m_acquireRetries = 0; TryAcquireController(); } else { - KillTimer(m_hwnd, IDT_ACQUIRE); - KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); - KillTimer(m_hwnd, IDT_WAKE_POLL); - m_acquireRetries = 0; - m_lastCycleTick = 0; - const bool hadControl = m_controller->IsGameModeActive(); - // 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 && state != SteamState::NoSteam) - RestartControllerDevices(); + ReleaseControl(); } } +// 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() { + KillTimer(m_hwnd, IDT_ACQUIRE); + KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT); + KillTimer(m_hwnd, IDT_WAKE_POLL); + m_wantControl = false; + m_acquireRetries = 0; + m_lastCycleTick = 0; + const bool hadControl = m_controller->IsGameModeActive(); + // 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 && m_steamWatcher.GetState() != SteamState::NoSteam) + RestartControllerDevices(); +} + void TrayApp::TryAcquireController(uint32_t stateWaitMs) { m_controller->OnDeviceChange(); auto outcome = m_controller->EnableGameMode(stateWaitMs); @@ -400,7 +425,7 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) { } ++m_acquireRetries; m_lastCycleTick = nowTick; - EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); + EventLog::Write("ACQUIRE: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries); m_controller->ReleaseDevices(); if (!RestartControllerDevices()) return; // helper unavailable — balloon shown // The cycle runs asynchronously via the helper task; the device-arrival @@ -777,8 +802,12 @@ void TrayApp::ShowContextMenu() { // 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); - std::wstring toggleLabel = gameModeOn ? L"Disable Steamless Mode" - : L"Enable Steamless Mode"; + // 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) diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 64af80d..6d970ef 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -52,11 +52,12 @@ private: void DeleteLegacyStartupTask(); void UpdateStartupRegistration(); - // Auto-mode plumbing. + // Control plumbing, shared by every mode. void SetAutoMode(AutoMode mode); bool WantControl(SteamState state) const; void ApplySteamState(SteamState state); void TryAcquireController(uint32_t stateWaitMs = 250); + void ReleaseControl(); bool RestartControllerDevices(); void ShowElevationBalloon(); @@ -66,6 +67,12 @@ private: HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; AutoMode m_autoMode = AutoMode::Manual; + // Do we want the controller right now? Acquiring is asynchronous — a + // blocked claim escalates to a device cycle that lands seconds later as a + // WM_DEVICECHANGE — so intent has to outlive the call that started it. + // Auto modes derive this from Steam's state; manual mode from the tray + // toggle, which is otherwise nowhere on record. + bool m_wantControl = false; int m_acquireRetries = 0; ULONGLONG m_lastCycleTick = 0; bool m_elevationBalloonShown = false;