From 5cd54be8d003da1f5f467ad5dcc3373f7b55024a Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Tue, 7 Jul 2026 14:32:14 -0700 Subject: [PATCH] Add Steam-aware auto control modes, device-cycle helper, and diagnostics Control modes (tray 'Control Mode' submenu, persisted): - Manual: tray toggle only (grayed labels now explain why when disabled) - Auto - Off while Steam running: yield the controller whenever steam.exe runs; reclaim ~6s after it exits (debounced against Steam self-restarts) - Auto - Off ONLY while in Steam game: yield only while RunningAppID != 0 SteamWatcher polls the Steam process and RunningAppID every 2s with asymmetric debounce: transitions toward more Steam activity are reported immediately (yield fast), transitions away must hold ~6s. Taking the device from a running Steam requires invalidating its HID write handle. A new elevated helper (SteamlessDeviceCycle.exe) restarts the controller device node (unplug/replug equivalent) on demand via a highest-privileges scheduled task, registered by the installer or a one-time UAC prompt. The tray app itself NEVER runs elevated: an elevated foreground window blocks unelevated SendInput via UIPI, which froze Steam Input's desktop cursor whenever the tray menu was open. The same cycle mechanism hands the device to an already-running Steam on game launch (Steam only opens controllers on arrival events). Crash safety: an unhandled-exception filter best-effort restores lizard mode without taking locks; the firmware's own revert timeout remains the final fallback. ClaimExclusive now restores shared access on failure instead of leaving the device handle closed. Diagnostics for field disconnect reports: - Event log at %LOCALAPPDATA%\SteamlessController\events.log (512KB rotation, read-shareable): connect/disconnect causes, game mode transitions with failure reasons, battery changes, stalls, keepalive failures, auto-mode decisions. 'Open Event Log' in the tray menu. - Silent-stall detection: no reports for 4s while enumerated (battery death, sleep, wireless drop) is now detected, logged, and alerted with the last battery reading. - Disconnect/stall alert balloons, suppressed for intentional handoffs, with a 'Disconnect Notifications' toggle for users with odd setups. Startup reverts to the plain HKCU Run key (the elevated-logon-task variant from an interim build is migrated back and cleaned up). Installer ships and registers the helper and unregisters it on uninstall; version 1.8. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 14 ++ resources/InnoInstallerScript.iss | 9 +- src/app/ControllerManager.cpp | 116 +++++++++- src/app/ControllerManager.h | 14 ++ src/app/DeviceRestart.cpp | 51 ++++ src/app/DeviceRestart.h | 16 ++ src/app/EventLog.cpp | 78 +++++++ src/app/EventLog.h | 17 ++ src/app/SteamWatcher.cpp | 100 ++++++++ src/app/SteamWatcher.h | 41 ++++ src/app/TrayApp.cpp | 373 +++++++++++++++++++++++++++++- src/app/TrayApp.h | 55 ++++- src/app/main.cpp | 8 +- src/helper/DeviceCycleHelper.cpp | 131 +++++++++++ src/steam/SteamController.cpp | 17 +- src/steam/SteamController.h | 8 + 16 files changed, 1027 insertions(+), 21 deletions(-) create mode 100644 src/app/DeviceRestart.cpp create mode 100644 src/app/DeviceRestart.h create mode 100644 src/app/EventLog.cpp create mode 100644 src/app/EventLog.h create mode 100644 src/app/SteamWatcher.cpp create mode 100644 src/app/SteamWatcher.h create mode 100644 src/helper/DeviceCycleHelper.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f068d44..5ae0eda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,17 @@ if (MSVC) target_compile_options(RawControllerProbe PRIVATE /W4 /utf-8) endif() +# Elevated on-demand helper — restarts the controller device node so the tray +# app (which never runs elevated) can take the device from a running Steam. +add_executable(SteamlessDeviceCycle WIN32 + src/helper/DeviceCycleHelper.cpp + src/app/DeviceRestart.cpp +) +target_link_libraries(SteamlessDeviceCycle PRIVATE SteamController) +if (MSVC) + target_compile_options(SteamlessDeviceCycle PRIVATE /W4 /WX /utf-8) +endif() + # HapticTester — local-only interactive haptic tuning tool (source is gitignored) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/probe/HapticTester.cpp") add_executable(HapticTester src/probe/HapticTester.cpp) @@ -79,6 +90,9 @@ add_executable(SteamlessController WIN32 src/app/ControllerManager.cpp src/app/VirtualController.cpp src/app/TrackpadMouse.cpp + src/app/SteamWatcher.cpp + src/app/DeviceRestart.cpp + src/app/EventLog.cpp src/app/RemapWindow.cpp resources/app.rc ) diff --git a/resources/InnoInstallerScript.iss b/resources/InnoInstallerScript.iss index b55d1ad..3b6d2a2 100644 --- a/resources/InnoInstallerScript.iss +++ b/resources/InnoInstallerScript.iss @@ -3,7 +3,7 @@ ; Non-commercial use only #define MyAppName "SteamlessController" -#define MyAppVersion "1.7" +#define MyAppVersion "1.8" #define ViGEmSetup "ViGEmBus_1.22.0_x64_x86_arm64.exe" #define MyAppPublisher "Dylan Deverill" #define MyAppURL "https://github.com/ddeverill/SteamlessController" @@ -47,6 +47,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ [Files] Source: "build\release\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "build\release\Release\SteamlessDeviceCycle.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "resources\{#ViGEmSetup}"; DestDir: "{tmp}"; Flags: deleteafterinstall [Icons] @@ -56,5 +57,11 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de [Run] ; ViGEmBus uses MSI-style silent switches; /install is rejected by the 1.22.0 bootstrapper. Filename: "{tmp}\{#ViGEmSetup}"; Parameters: "/qn /norestart"; StatusMsg: "Installing ViGEmBus driver..."; Flags: waituntilterminated +; Register the elevated on-demand device-cycle task while the installer is +; already elevated, so users never see a UAC prompt from the app itself. +Filename: "{app}\SteamlessDeviceCycle.exe"; Parameters: "--register"; StatusMsg: "Registering controller helper task..."; Flags: waituntilterminated runhidden Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent +[UninstallRun] +Filename: "{app}\SteamlessDeviceCycle.exe"; Parameters: "--unregister"; RunOnceId: "RemoveCycleTask"; Flags: waituntilterminated runhidden + diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index fb388a7..ed00e15 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -1,4 +1,5 @@ #include "ControllerManager.h" +#include "EventLog.h" #include "VirtualController.h" #include "TrackpadMouse.h" #include "steam/SteamController.h" @@ -23,6 +24,7 @@ struct ControllerManager::Slot { std::thread readThread; std::atomic readRunning{false}; bool gameModeActive = false; + int lastBatteryPercent = -1; // -1 = never reported // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -138,6 +140,35 @@ static bool DetectCapture(const uint8_t* cur, const uint8_t* prev, return false; } +// --------------------------------------------------------------------------- +// Crash-path lizard restore +// +// If the process dies with the controller in game mode, the user is left +// without input until the firmware's own lizard revert timeout kicks in +// (several seconds). The unhandled-exception filter shortens that window by +// best-effort sending the lizard restore reports on the way down. It must not +// take locks or join threads — another thread may be wedged holding them. +// --------------------------------------------------------------------------- + +static ControllerManager* g_crashRestoreInstance = nullptr; + +static LONG WINAPI CrashRestoreFilter(EXCEPTION_POINTERS*) { + if (g_crashRestoreInstance) + g_crashRestoreInstance->EmergencyRestoreAll(); + // Let Windows Error Reporting / debuggers see the crash as usual. + return EXCEPTION_CONTINUE_SEARCH; +} + +void ControllerManager::EmergencyRestoreAll() noexcept { + for (auto& slot : m_slots) { + if (!slot || !slot->sc) continue; + // Stop the read loop cooperatively (no join) so its keepalive can't + // re-clear the mappings we are about to restore. + slot->readRunning = false; + slot->sc->EmergencyLizardRestore(); + } +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -145,10 +176,13 @@ static bool DetectCapture(const uint8_t* cur, const uint8_t* prev, ControllerManager::ControllerManager(StateChangedFn onStateChanged) : m_onStateChanged(std::move(onStateChanged)) { + g_crashRestoreInstance = this; + SetUnhandledExceptionFilter(CrashRestoreFilter); SyncDevices(); } ControllerManager::~ControllerManager() { + g_crashRestoreInstance = nullptr; for (auto& slot : m_slots) { // Stop the read loop and virtual controller first (same as DisableGameModeSlot // but without the gameModeActive guard — we always want to restore lizard mode). @@ -189,6 +223,8 @@ void ControllerManager::DisableGameMode() { } void ControllerManager::ReleaseDevices() { + if (!m_slots.empty()) + EventLog::Write("RELEASE: closing all device handles (intentional handoff)"); // Disable game mode first — enables lizard mode and tears down ViGEm while // we still hold write access to the device. DisableGameMode(); @@ -259,6 +295,17 @@ void ControllerManager::SyncDevices() { bool alive = std::any_of(livePaths.begin(), livePaths.end(), [&](const auto& p) { return p == (*it)->path; }); if (!alive) { + EventLog::Write("DISCONNECT: OS removed device (gameMode=%d, lastBattery=%d%%) %ls", + (*it)->gameModeActive ? 1 : 0, + (*it)->lastBatteryPercent, (*it)->path.c_str()); + // Alert only when the controller was in active use; deliberate + // unplugs while idle (and our own device cycles, which release + // slots before cycling) stay quiet. + if ((*it)->gameModeActive && m_alertFn) + m_alertFn(L"Controller disconnected", + L"The USB device was removed. Check the cable or dongle — " + L"if this happens randomly, Windows USB power saving may be " + L"suspending the port."); DisableGameModeSlot(**it); it = m_slots.erase(it); } else { @@ -299,8 +346,13 @@ void ControllerManager::OpenSlot(const std::wstring& path) { void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (slot.gameModeActive) return; - if (!slot.sc->ClaimExclusive()) return; + if (!slot.sc->ClaimExclusive()) { + EventLog::Write("GAMEMODE: exclusive claim failed (another process holds a write handle) %ls", + slot.path.c_str()); + return; + } if (!slot.sc->DisableLizardMode()) { + EventLog::Write("GAMEMODE: DisableLizardMode failed %ls", slot.path.c_str()); slot.sc->ReleaseToShared(); return; } @@ -311,6 +363,8 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { if (sc->IsOpen()) sc->SetRumble(largeMotor, smallMotor); }); if (!slot.vc->IsValid()) { + EventLog::Write("GAMEMODE: ViGEm virtual controller failed (driverMissing=%d)", + slot.vc->IsDriverMissing() ? 1 : 0); if (slot.vc->IsDriverMissing()) vigemMissingOut = true; slot.vc.reset(); slot.sc->EnableLizardMode(); @@ -321,6 +375,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { slot.sc->SetImuEnabled(true); slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad); + EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); slot.gameModeActive = true; slot.trackpad.Reset(); slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled); @@ -331,6 +386,7 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { void ControllerManager::DisableGameModeSlot(Slot& slot) { if (!slot.gameModeActive) return; + EventLog::Write("GAMEMODE: disabled %ls", slot.path.c_str()); StopReadLoop(slot); if (slot.sc->IsOpen()) slot.sc->SetRumble(0, 0); @@ -361,7 +417,14 @@ void ControllerManager::ReadLoop(Slot* slot) { uint8_t buf[64]; uint8_t prevBuf[64] = {}; bool hasPrev = false; - auto lastKeepalive = std::chrono::steady_clock::now(); + auto lastKeepalive = std::chrono::steady_clock::now(); + auto lastReport = std::chrono::steady_clock::now(); + bool stalled = false; + bool keepaliveFailing = false; + + // No reports for this long while the device is still enumerated means the + // controller died silently: battery empty, auto-sleep, or wireless drop. + static constexpr auto kStallThreshold = std::chrono::seconds(4); while (slot->readRunning) { // Keepalive — the firmware silently reverts to lizard mode (and its @@ -372,16 +435,57 @@ void ControllerManager::ReadLoop(Slot* slot) { const auto nowKa = std::chrono::steady_clock::now(); if (nowKa - lastKeepalive >= std::chrono::seconds(2)) { lastKeepalive = nowKa; - slot->sc->SendKeepalive(); + const bool ok = slot->sc->SendKeepalive(); + if (!ok && !keepaliveFailing) { + keepaliveFailing = true; + EventLog::Write("KEEPALIVE: send failed (device write error)"); + } else if (ok && keepaliveFailing) { + keepaliveFailing = false; + EventLog::Write("KEEPALIVE: recovered"); + } } size_t n = slot->sc->ReadReport(buf, sizeof(buf), /*timeoutMs=*/32); - if (n == 0) continue; + if (n == 0) { + if (!stalled + && std::chrono::steady_clock::now() - lastReport > kStallThreshold) { + stalled = true; + EventLog::Write("STALL: no reports for 4s (lastBattery=%d%%) %ls", + slot->lastBatteryPercent, slot->path.c_str()); + if (m_alertFn) { + wchar_t text[256]; + if (slot->lastBatteryPercent >= 0) + swprintf_s(text, + L"No input received for several seconds — the battery may " + L"be dead, the controller may have gone to sleep, or the " + L"wireless signal dropped. Last battery report: %d%%.", + slot->lastBatteryPercent); + else + swprintf_s(text, + L"No input received for several seconds — the battery may " + L"be dead, the controller may have gone to sleep, or the " + L"wireless signal dropped."); + m_alertFn(L"Controller not responding", text); + } + } + continue; + } + lastReport = std::chrono::steady_clock::now(); + if (stalled) { + stalled = false; + EventLog::Write("STALL: reports resumed"); + } // Battery status — update DS4 battery level; no further processing needed. if (buf[0] == SteamController::REPORT_BATTERY_STATUS) { - if (n >= 3 && slot->vc) - slot->vc->SetBatteryState(buf[1], buf[2]); + if (n >= 3) { + if (slot->vc) + slot->vc->SetBatteryState(buf[1], buf[2]); + if (slot->lastBatteryPercent != static_cast(buf[1])) { + EventLog::Write("BATTERY: %u%% (chargeState=%u)", buf[1], buf[2]); + slot->lastBatteryPercent = static_cast(buf[1]); + } + } continue; } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 25fdc6b..0205541 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -11,6 +11,9 @@ class ControllerManager { public: using StateChangedFn = std::function; + // User-facing alert (unexpected disconnect, stall). May fire from a read + // thread — the receiver must marshal to its own thread (e.g. PostMessage). + using AlertFn = std::function; explicit ControllerManager(StateChangedFn onStateChanged); ~ControllerManager(); @@ -19,12 +22,22 @@ public: void OnDeviceChange(); + // Set once right after construction, before any game mode is enabled — + // read loops copy it without locking. + void SetAlertCallback(AlertFn fn) { m_alertFn = std::move(fn); } + void EnableGameMode(); void DisableGameMode(); // Disables game mode then closes all device handles so another process // (e.g. Steam) can claim the controller. Safe to call when already disabled. void ReleaseDevices(); + // Best-effort lizard restore for crash paths — called from the unhandled- + // exception filter installed by the constructor. Takes no locks and joins + // no threads. The firmware's own revert timeout remains the final + // fallback if this fails or the process dies harder than a C++ exception. + void EmergencyRestoreAll() noexcept; + void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); void SetBackButtonConfig(const BackButtonConfig& cfg); @@ -57,6 +70,7 @@ private: void NotifyStateChanged(bool vigemMissing = false); StateChangedFn m_onStateChanged; + AlertFn m_alertFn; std::vector> m_slots; bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; diff --git a/src/app/DeviceRestart.cpp b/src/app/DeviceRestart.cpp new file mode 100644 index 0000000..9213f44 --- /dev/null +++ b/src/app/DeviceRestart.cpp @@ -0,0 +1,51 @@ +#include "DeviceRestart.h" +#include + +namespace DeviceRestart { + +bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { + if (errorOut) *errorOut = ERROR_SUCCESS; + + HDEVINFO devs = SetupDiCreateDeviceInfoList(nullptr, nullptr); + if (devs == INVALID_HANDLE_VALUE) { + if (errorOut) *errorOut = GetLastError(); + return false; + } + + bool ok = false; + SP_DEVICE_INTERFACE_DATA ifData{}; + ifData.cbSize = sizeof(ifData); + if (SetupDiOpenDeviceInterfaceW(devs, interfacePath.c_str(), 0, &ifData)) { + // Query with no output buffer purely to resolve the owning devnode — + // the call fails with ERROR_INSUFFICIENT_BUFFER but still fills devInfo. + SP_DEVINFO_DATA devInfo{}; + devInfo.cbSize = sizeof(devInfo); + SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &devInfo); + + if (devInfo.DevInst != 0) { + SP_PROPCHANGE_PARAMS pc{}; + pc.ClassInstallHeader.cbSize = sizeof(SP_CLASSINSTALL_HEADER); + pc.ClassInstallHeader.InstallFunction = DIF_PROPERTYCHANGE; + pc.StateChange = DICS_PROPCHANGE; // restart + pc.Scope = DICS_FLAG_GLOBAL; + pc.HwProfile = 0; + + if (SetupDiSetClassInstallParamsW(devs, &devInfo, + &pc.ClassInstallHeader, sizeof(pc)) + && SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, devs, &devInfo)) { + ok = true; + } else if (errorOut) { + *errorOut = GetLastError(); + } + } else if (errorOut) { + *errorOut = GetLastError(); + } + } else if (errorOut) { + *errorOut = GetLastError(); + } + + SetupDiDestroyDeviceInfoList(devs); + return ok; +} + +} diff --git a/src/app/DeviceRestart.h b/src/app/DeviceRestart.h new file mode 100644 index 0000000..066db7b --- /dev/null +++ b/src/app/DeviceRestart.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include + +namespace DeviceRestart { + +// Restarts the PnP device node backing an HID interface path — equivalent to +// unplugging and replugging the device. Every process's open handles to it +// (including Steam's) are invalidated, and the re-arrival becomes an open +// race that whoever reacts to the arrival notification first wins. +// +// Requires administrator rights; fails with ERROR_ACCESS_DENIED in errorOut +// when the process is not elevated. +bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut = nullptr); + +} diff --git a/src/app/EventLog.cpp b/src/app/EventLog.cpp new file mode 100644 index 0000000..01e0a0f --- /dev/null +++ b/src/app/EventLog.cpp @@ -0,0 +1,78 @@ +#include "EventLog.h" +#include +#include +#include +#include +#include + +namespace { + +constexpr long kMaxLogBytes = 512 * 1024; + +std::mutex g_mutex; +FILE* g_file = nullptr; + +std::wstring LogDir() { + wchar_t buf[MAX_PATH]; + const DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return L""; + std::wstring dir(buf); + dir += L"\\SteamlessController"; + return dir; +} + +std::wstring LogPath() { return LogDir() + L"\\events.log"; } +std::wstring OldLogPath() { return LogDir() + L"\\events.old.log"; } + +// Called with g_mutex held. +void RotateLocked() { + if (g_file) { + fclose(g_file); + g_file = nullptr; + } + MoveFileExW(LogPath().c_str(), OldLogPath().c_str(), MOVEFILE_REPLACE_EXISTING); +} + +// Called with g_mutex held. +bool OpenLocked() { + if (g_file) return true; + const std::wstring dir = LogDir(); + if (dir.empty()) return false; + CreateDirectoryW(dir.c_str(), nullptr); + // _SH_DENYWR: others may read the log (Notepad via "Open Event Log", + // support copying it) while we hold it open for append. + g_file = _wfsopen(LogPath().c_str(), L"a", _SH_DENYWR); + return g_file != nullptr; +} + +} // namespace + +namespace EventLog { + +void Write(const char* fmt, ...) { + std::lock_guard lk(g_mutex); + if (!OpenLocked()) return; + + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(g_file, "%04u-%02u-%02u %02u:%02u:%02u.%03u ", + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds); + + va_list ap; + va_start(ap, fmt); + vfprintf(g_file, fmt, ap); + va_end(ap); + + fputc('\n', g_file); + fflush(g_file); + + if (ftell(g_file) > kMaxLogBytes) + RotateLocked(); // next Write reopens a fresh file +} + +std::wstring FilePath() { + return LogPath(); +} + +} // namespace EventLog diff --git a/src/app/EventLog.h b/src/app/EventLog.h new file mode 100644 index 0000000..a5cee8f --- /dev/null +++ b/src/app/EventLog.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// Persistent diagnostic event log for support and field debugging: device +// connect/disconnect causes, mode transitions, battery readings, stalls. +// Written to %LOCALAPPDATA%\SteamlessController\events.log and rotated to +// events.old.log at ~512 KB, so it can run forever without growing unbounded. +// +// Thread-safe. printf-style formatting; use %ls for wide strings (paths). +namespace EventLog { + +void Write(const char* fmt, ...); + +// Full path of the current log file (for "Open Event Log" in the tray menu). +std::wstring FilePath(); + +} diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp new file mode 100644 index 0000000..92a8bad --- /dev/null +++ b/src/app/SteamWatcher.cpp @@ -0,0 +1,100 @@ +#include "SteamWatcher.h" +#include +#include + +static constexpr int POLL_INTERVAL_MS = 2000; +static constexpr int POLL_SLICE_MS = 100; // wake often for fast Stop() +static constexpr int LESS_STEAM_POLL_COUNT = 3; // ~6s stable before we take over + +static bool IsSteamProcessRunning() { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return false; + + PROCESSENTRY32W pe{}; + pe.dwSize = sizeof(pe); + bool found = false; + if (Process32FirstW(snap, &pe)) { + do { + if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { + found = true; + break; + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + return found; +} + +// Steam publishes the app id of the running game here and resets it to 0 on +// exit. Covers Steam games and non-Steam shortcuts launched through Steam; +// games launched entirely outside Steam are invisible to it (acceptable — we +// keep the controller, and the manual toggle remains the escape hatch). +static bool IsGameRunning() { + DWORD appId = 0; + DWORD size = sizeof(appId); + if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"RunningAppID", + RRF_RT_REG_DWORD, nullptr, &appId, &size) != ERROR_SUCCESS) + return false; + return appId != 0; +} + +SteamState SteamWatcher::Detect() { + if (!IsSteamProcessRunning()) return SteamState::NoSteam; + return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; +} + +void SteamWatcher::Start(SteamStateFn onChange) { + Stop(); + m_onChange = std::move(onChange); + m_running = true; + m_thread = std::thread(&SteamWatcher::PollLoop, this); +} + +void SteamWatcher::Stop() { + if (m_running.exchange(false) && m_thread.joinable()) + m_thread.join(); +} + +void SteamWatcher::PollLoop() { + SteamState reported = Detect(); + m_state = reported; + if (m_onChange) m_onChange(reported); + + SteamState pending = reported; + int pendingPolls = 0; + + while (m_running.load()) { + for (int waited = 0; waited < POLL_INTERVAL_MS && m_running.load(); + waited += POLL_SLICE_MS) + Sleep(POLL_SLICE_MS); + if (!m_running.load()) break; + + const SteamState now = Detect(); + if (now == reported) { + pendingPolls = 0; + continue; + } + + if (static_cast(now) > static_cast(reported)) { + // More Steam activity (Steam appeared / game launched) — report + // immediately so the controller is yielded before Steam needs it. + reported = now; + pendingPolls = 0; + m_state = reported; + if (m_onChange) m_onChange(reported); + } else { + // Less Steam activity (game quit / Steam gone) — must hold for + // several consecutive polls so a Steam self-restart or game + // relaunch doesn't cause ownership flapping. + if (now != pending) { + pending = now; + pendingPolls = 1; + } else if (++pendingPolls >= LESS_STEAM_POLL_COUNT) { + reported = now; + pendingPolls = 0; + m_state = reported; + if (m_onChange) m_onChange(reported); + } + } + } +} diff --git a/src/app/SteamWatcher.h b/src/app/SteamWatcher.h new file mode 100644 index 0000000..29496b1 --- /dev/null +++ b/src/app/SteamWatcher.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +// What Steam is doing right now, as far as controller ownership is concerned. +// Ordered by "how much Steam is going on" — transitions to a HIGHER value are +// reported immediately (yield the controller fast), transitions to a LOWER +// value are debounced (don't grab it during a Steam restart or game relaunch). +enum class SteamState { + NoSteam = 0, // steam.exe not running + SteamIdle = 1, // Steam running, no game active (RunningAppID == 0) + InGame = 2, // Steam running a game +}; + +// Watches the Steam process and its RunningAppID registry value, reporting +// debounced state transitions from a 2-second polling thread. +class SteamWatcher { +public: + using SteamStateFn = std::function; + + ~SteamWatcher() { Stop(); } + + // Starts the polling thread. Fires the callback once immediately with the + // current state (so startup can assert the right mode), then on every + // debounced transition. The callback runs on the watcher thread — marshal + // to the UI thread (e.g. PostMessage) before touching UI or controller state. + void Start(SteamStateFn onChange); + void Stop(); + + SteamState GetState() const { return m_state.load(); } + +private: + void PollLoop(); + static SteamState Detect(); + + SteamStateFn m_onChange; + std::thread m_thread; + std::atomic m_running{false}; + std::atomic m_state{SteamState::NoSteam}; +}; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 78225ef..f47aabe 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -1,20 +1,60 @@ #include "TrayApp.h" #include "ControllerManager.h" #include "ControllerPlatform.h" +#include "DeviceRestart.h" +#include "EventLog.h" +#include "steam/SteamController.h" #include "resource.h" #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; +} + +// 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 watcher before anything else so its callback can't post to a + // window (or reference a controller) that is being torn down. + m_steamWatcher.Stop(); RemoveTrayIcon(); g_app = nullptr; } @@ -45,14 +85,37 @@ bool TrayApp::Init(HINSTANCE hInstance) { {0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30}}; RegisterDeviceNotificationW(m_hwnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE); + EventLog::Write("=== SteamlessController started ==="); + m_controller = std::make_unique( [this](bool connected, bool gameModeActive, bool vigemMissing) { UpdateTrayIcon(connected, gameModeActive, vigemMissing); }); + m_controller->SetAlertCallback( + [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); + }); 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(), false); + + // 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); + }); return true; } @@ -120,19 +183,76 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { m_controller->SetControllerPlatform(ControllerPlatform::PlayStation); SaveSettings(); 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 didn't register it) before the mode needs it. + if (!IsProcessElevated()) + EnsureCycleTaskRegistered(); + SetAutoMode(AutoMode::OffOnlyInGame); + break; case IDM_STARTUP: - SetStartupEnabled(!IsStartupEnabled()); + m_startupEnabled = !m_startupEnabled; + UpdateStartupRegistration(); + break; + case IDM_OPENLOG: + OpenEventLog(); + break; + case IDM_NOTIFICATIONS: + m_notificationsEnabled = !m_notificationsEnabled; + EventLog::Write("USER: disconnect notifications %s", + m_notificationsEnabled ? "enabled" : "disabled"); + SaveSettings(); break; case IDM_EXIT: + EventLog::Write("=== SteamlessController exiting (user request) ==="); m_controller->DisableGameMode(); PostQuitMessage(0); break; } return 0; + case WM_STEAMSTATE: + ApplySteamState(static_cast(wp)); + 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_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState())) + TryAcquireController(); + } + return 0; + case WM_DEVICECHANGE: - if (wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) + 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())) + TryAcquireController(); + } return TRUE; case WM_DESTROY: @@ -142,6 +262,123 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return DefWindowProcW(hwnd, msg, wp, lp); } +void TrayApp::SetAutoMode(AutoMode mode) { + EventLog::Write("MODE: control mode set to %d (0=manual 1=offWhileSteam 2=offOnlyInGame)", + static_cast(mode)); + m_autoMode = mode; + m_acquireRetries = 0; + SaveSettings(); + UpdateStartupRegistration(); + if (mode != AutoMode::Manual) + ApplySteamState(m_steamWatcher.GetState()); +} + +bool TrayApp::WantControl(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 + } +} + +void TrayApp::ApplySteamState(SteamState state) { + if (m_autoMode == AutoMode::Manual) return; + + EventLog::Write("AUTO: steam state %d (0=none 1=idle 2=inGame) -> %s", + static_cast(state), + WantControl(state) ? "take control" : "yield"); + if (WantControl(state)) { + m_acquireRetries = 0; + TryAcquireController(); + } else { + KillTimer(m_hwnd, IDT_ACQUIRE); + m_acquireRetries = 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(); + } +} + +void TrayApp::TryAcquireController() { + m_controller->OnDeviceChange(); + m_controller->EnableGameMode(); + + // A short burst of rapid retries: right after a device cycle we race + // Steam's re-enumeration for the exclusive open, and starting the claim + // the instant the device arrives is what wins that race. + for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) { + Sleep(50); + m_controller->OnDeviceChange(); + m_controller->EnableGameMode(); + } + if (m_controller->IsGameModeActive()) { + m_acquireRetries = 0; + return; + } + if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger + + // 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) { + EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); + return; + } + ++m_acquireRetries; + EventLog::Write("AUTO: 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 + // notification drives the claim, with this timer as the fallback. + SetTimer(m_hwnd, IDT_ACQUIRE, 2500, nullptr); +} + +bool TrayApp::RestartControllerDevices() { + // On the rare elevated run, cycle directly. + if (IsProcessElevated()) { + bool anyRestarted = false; + for (const auto& path : SteamController::EnumerateAll()) + if (DeviceRestart::RestartInterfaceDevice(path)) + anyRestarted = true; + return anyRestarted; + } + + // Normal path: 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; + + // Task missing (copied exes without installing?) — register it with a + // one-time UAC prompt, then retry. + if (!EnsureCycleTaskRegistered()) return false; + return RunToolHidden(std::move(run)); +} + +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() { m_remapWindow.Open( m_hInstance, @@ -192,6 +429,26 @@ void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMiss Shell_NotifyIconW(NIM_MODIFY, &nid); } +void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& text) { + NOTIFYICONDATAW nid{}; + nid.cbSize = sizeof(nid); + nid.hWnd = m_hwnd; + nid.uID = TRAY_UID; + nid.uFlags = NIF_INFO; + nid.dwInfoFlags = NIIF_WARNING; + 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); +} + void TrayApp::ShowViGEmBalloon() { NOTIFYICONDATAW nid{}; nid.cbSize = sizeof(nid); @@ -208,7 +465,7 @@ 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::IsStartupEnabled() const { +bool TrayApp::RunKeyExists() const { HKEY key; if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_READ, &key) != ERROR_SUCCESS) return false; @@ -217,7 +474,7 @@ bool TrayApp::IsStartupEnabled() const { return exists; } -void TrayApp::SetStartupEnabled(bool enabled) { +void TrayApp::SetRunKey(bool enabled) { HKEY key; if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_WRITE, &key) != ERROR_SUCCESS) return; @@ -235,6 +492,69 @@ void TrayApp::SetStartupEnabled(bool enabled) { 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; +} + +bool TrayApp::EnsureCycleTaskRegistered() { + // Already registered? (The installer normally does this.) + { + std::wstring query = L"schtasks.exe /Query /TN \""; + query += CYCLE_TASK_NAME; + query += L"\""; + if (RunToolHidden(std::move(query))) return true; + } + + // 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) @@ -248,6 +568,11 @@ void TrayApp::LoadSettings() { return def; }; + // 0 = Manual, 1 = OffWhileSteam, 2 = OffOnlyInGame (old builds stored 0/1). + const DWORD mode = readDw(L"AutoSteamMode", 0); + m_autoMode = mode == 2 ? AutoMode::OffOnlyInGame + : mode == 1 ? AutoMode::OffWhileSteam + : AutoMode::Manual; m_controller->SetTrackpadMouseEnabled(readDw(L"TrackpadMouse", 0) != 0); m_controller->SetBackButtonsEnabled (readDw(L"BackButtons", 0) != 0); m_controller->SetUseLeftTrackpad (readDw(L"UseLeftTrackpad", 0) != 0); @@ -262,6 +587,14 @@ void TrayApp::LoadSettings() { cfg.r5 = static_cast(readDw(L"BackBtnR5", static_cast(BackButtonAction::None))); m_controller->SetBackButtonConfig(cfg); + 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; + RegCloseKey(key); } @@ -277,6 +610,9 @@ void TrayApp::SaveSettings() { 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"TrackpadMouse", m_controller->IsTrackpadMouseEnabled() ? 1 : 0); writeDw(L"BackButtons", m_controller->IsBackButtonsEnabled() ? 1 : 0); writeDw(L"UseLeftTrackpad", m_controller->IsUseLeftTrackpad() ? 1 : 0); @@ -297,13 +633,31 @@ void TrayApp::ShowContextMenu() { bool trackpadOn = m_controller->IsTrackpadMouseEnabled(); bool backMouseOn = m_controller->IsBackButtonsEnabled(); bool leftPad = m_controller->IsUseLeftTrackpad(); - bool startupOn = IsStartupEnabled(); + bool startupOn = m_startupEnabled; HMENU menu = CreatePopupMenu(); - UINT toggleFlags = MF_STRING | (connected ? MF_ENABLED : MF_GRAYED); - AppendMenuW(menu, toggleFlags, IDM_TOGGLE, - gameModeOn ? L"Disable Steamless Mode" : L"Enable Steamless Mode"); + // 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"; + 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(menu, MF_STRING | MF_POPUP, + reinterpret_cast(modeMenu), L"Control Mode"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); @@ -335,6 +689,9 @@ void TrayApp::ShowContextMenu() { IDM_STARTUP, L"Start with Windows"); AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, IDM_OPENLOG, L"Open Event Log"); + AppendMenuW(menu, MF_STRING | (m_notificationsEnabled ? MF_CHECKED : MF_UNCHECKED), + IDM_NOTIFICATIONS, L"Disconnect Notifications"); AppendMenuW(menu, MF_STRING, IDM_EXIT, L"Exit"); // SetForegroundWindow is required for the menu to dismiss on click-away. diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index a224460..e809717 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -1,10 +1,21 @@ #pragma once #include #include +#include +#include #include "RemapWindow.h" +#include "SteamWatcher.h" class ControllerManager; +// Who decides when SteamlessController takes over the physical controller. +enum class AutoMode { + Manual = 0, // tray toggle only + OffWhileSteam = 1, // yield whenever steam.exe is running + OffOnlyInGame = 2, // yield only while a game is running (needs admin to + // wrest the device from a running Steam) +}; + class TrayApp { public: TrayApp(); @@ -21,19 +32,50 @@ private: void RemoveTrayIcon(); void UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing = false); void ShowViGEmBalloon(); + void ShowAlertBalloon(const std::wstring& title, const std::wstring& text); + void OpenEventLog(); void ShowContextMenu(); void LoadSettings(); void SaveSettings(); void OpenRemapWindow(); - bool IsStartupEnabled() const; - void SetStartupEnabled(bool enabled); + + // The tray app never runs elevated (an elevated foreground window blocks + // unelevated SendInput — Steam Input's desktop cursor — via UIPI). + // Privileged work (cycling the device node) is delegated to the + // SteamlessDeviceCycle helper, registered as an on-demand highest- + // privileges scheduled task by the installer or, failing that, by a + // one-time UAC prompt here. + static bool IsProcessElevated(); + bool EnsureCycleTaskRegistered(); + bool RunKeyExists() const; + void SetRunKey(bool enabled); + void DeleteLegacyStartupTask(); + void UpdateStartupRegistration(); + + // Auto-mode plumbing. + void SetAutoMode(AutoMode mode); + bool WantControl(SteamState state) const; + void ApplySteamState(SteamState state); + void TryAcquireController(); + bool RestartControllerDevices(); + void ShowElevationBalloon(); HWND m_hwnd = nullptr; HINSTANCE m_hInstance = nullptr; UINT m_wmTaskbar = 0; HICON m_iconOff = nullptr; HICON m_iconOn = nullptr; + AutoMode m_autoMode = AutoMode::Manual; + int m_acquireRetries = 0; + bool m_elevationBalloonShown = false; + bool m_startupEnabled = false; + int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task + bool m_notificationsEnabled = true; // disconnect/stall balloons + std::mutex m_alertMutex; // guards the two alert strings + std::wstring m_alertTitle; // set on read threads, + std::wstring m_alertText; // consumed on WM_ALERT std::unique_ptr m_controller; + SteamWatcher m_steamWatcher; RemapWindow m_remapWindow; static constexpr UINT IDM_TOGGLE = 1001; @@ -45,6 +87,15 @@ private: static constexpr UINT IDM_BACKBUTTONS = 1007; static constexpr UINT IDM_PLATFORM_XBOX = 1008; static constexpr UINT IDM_PLATFORM_PS = 1009; + static constexpr UINT IDM_MODE_MANUAL = 1010; + static constexpr UINT IDM_MODE_STEAM = 1011; + static constexpr UINT IDM_MODE_GAME = 1012; + static constexpr UINT IDM_OPENLOG = 1013; + static constexpr UINT IDM_NOTIFICATIONS = 1014; static constexpr UINT WM_TRAY = WM_APP + 1; + static constexpr UINT WM_STEAMSTATE = WM_APP + 2; + static constexpr UINT WM_ALERT = WM_APP + 3; static constexpr UINT TRAY_UID = 1; + static constexpr UINT_PTR IDT_ACQUIRE = 1; + static constexpr int MAX_ACQUIRE_CYCLES = 3; }; diff --git a/src/app/main.cpp b/src/app/main.cpp index 0a03c06..0d491b4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -7,9 +7,11 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int) { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // Prevent multiple instances. - HANDLE mutex = CreateMutexW(nullptr, TRUE, L"SteamlessController_SingleInstance"); - if (!mutex || GetLastError() == ERROR_ALREADY_EXISTS) { - if (mutex) CloseHandle(mutex); + HANDLE mutex = CreateMutexW(nullptr, FALSE, L"SteamlessController_SingleInstance"); + if (!mutex) return 0; + const DWORD wait = WaitForSingleObject(mutex, 0); + if (wait != WAIT_OBJECT_0 && wait != WAIT_ABANDONED) { + CloseHandle(mutex); return 0; } diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp new file mode 100644 index 0000000..98a1322 --- /dev/null +++ b/src/helper/DeviceCycleHelper.cpp @@ -0,0 +1,131 @@ +// SteamlessDeviceCycle — tiny elevated on-demand helper. +// +// Restarts the SC2026 device nodes (equivalent to unplug/replug) so HID +// handles held by other processes — Steam's — are invalidated and the tray +// app can win the re-open race. Kept separate from the tray app so the app +// itself never runs elevated: an elevated foreground window blocks synthetic +// input from unelevated processes (UIPI), which froze Steam Input's desktop +// cursor whenever the elevated tray menu was open. +// +// Usage: +// SteamlessDeviceCycle.exe cycle the controller devices +// SteamlessDeviceCycle.exe --register create the on-demand scheduled +// task pointing at this exe (admin) +// SteamlessDeviceCycle.exe --unregister delete the task (admin) +// +// The task has no triggers — it only runs when the tray app starts it via +// Task Scheduler, which requires no elevation for tasks the user authored. + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include "app/DeviceRestart.h" +#include "steam/SteamController.h" + +static constexpr wchar_t CYCLE_TASK_NAME[] = L"SteamlessControllerDeviceCycle"; +// Startup task name used briefly by a pre-release build that ran the tray app +// elevated at logon; removed during --register so it can't resurrect the bug. +static constexpr wchar_t LEGACY_STARTUP_TASK_NAME[] = L"SteamlessController"; + +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; +} + +static int CycleDevices() { + bool any = false; + for (const auto& path : SteamController::EnumerateAll()) + if (DeviceRestart::RestartInterfaceDevice(path)) + any = true; + return any ? 0 : 1; +} + +static int RegisterTask() { + wchar_t exe[MAX_PATH]; + GetModuleFileNameW(nullptr, exe, MAX_PATH); + + // Trigger-less task definition: on-demand only, highest privileges. + // Registered from XML because the schtasks command line cannot express + // "no trigger" (and its /SD date parsing is locale-dependent). + std::wstring xml; + xml += L"\r\n"; + xml += L"\r\n"; + xml += L" \r\n"; + xml += L" Restarts the Steam Controller device on demand for SteamlessController.\r\n"; + xml += L" \r\n"; + xml += L" \r\n"; + xml += L" \r\n"; + xml += L" InteractiveToken\r\n"; + xml += L" HighestAvailable\r\n"; + xml += L" \r\n"; + xml += L" \r\n"; + xml += L" \r\n"; + xml += L" IgnoreNew\r\n"; + xml += L" false\r\n"; + xml += L" false\r\n"; + xml += L" true\r\n"; + xml += L" true\r\n"; + xml += L" PT1M\r\n"; + xml += L" \r\n"; + xml += L" \r\n"; + xml += L" "; + xml += exe; + xml += L"\r\n"; + xml += L" \r\n"; + xml += L"\r\n"; + + wchar_t tempDir[MAX_PATH]; + if (!GetTempPathW(MAX_PATH, tempDir)) return 1; + std::wstring xmlPath = std::wstring(tempDir) + L"SteamlessCycleTask.xml"; + + HANDLE f = CreateFileW(xmlPath.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) return 1; + const wchar_t bom = 0xFEFF; + DWORD written = 0; + WriteFile(f, &bom, sizeof(bom), &written, nullptr); + WriteFile(f, xml.c_str(), + static_cast(xml.size() * sizeof(wchar_t)), &written, nullptr); + CloseHandle(f); + + std::wstring cmd = L"schtasks.exe /Create /F /TN \""; + cmd += CYCLE_TASK_NAME; + cmd += L"\" /XML \""; + cmd += xmlPath; + cmd += L"\""; + const bool ok = RunToolHidden(std::move(cmd)); + DeleteFileW(xmlPath.c_str()); + + // Best-effort cleanup of the pre-release elevated startup task. + std::wstring legacy = L"schtasks.exe /Delete /F /TN \""; + legacy += LEGACY_STARTUP_TASK_NAME; + legacy += L"\""; + RunToolHidden(std::move(legacy)); + + return ok ? 0 : 1; +} + +static int UnregisterTask() { + std::wstring cmd = L"schtasks.exe /Delete /F /TN \""; + cmd += CYCLE_TASK_NAME; + cmd += L"\""; + return RunToolHidden(std::move(cmd)) ? 0 : 1; +} + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR cmdLine, int) { + if (cmdLine && wcsstr(cmdLine, L"--register")) return RegisterTask(); + if (cmdLine && wcsstr(cmdLine, L"--unregister")) return UnregisterTask(); + return CycleDevices(); +} diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 1a2a421..920c7c2 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -90,7 +90,13 @@ void SteamController::Close() { bool SteamController::ClaimExclusive() { // The read thread must already be stopped before calling Reopen — the // caller (EnableGameModeSlot) calls this before starting the read loop. - return m_device.Reopen(FILE_SHARE_READ); + if (m_device.Reopen(FILE_SHARE_READ)) + return true; + // Another process (Steam) holds a write handle. Reopen already closed our + // old handle, so restore shared access — otherwise the device would be + // left closed and unusable for idle tracking and later retries. + m_device.Reopen(FILE_SHARE_READ | FILE_SHARE_WRITE); + return false; } void SteamController::ReleaseToShared() { @@ -160,6 +166,15 @@ bool SteamController::SendKeepalive() { return m_device.SendFeatureReport(buf, sizeof(buf)); } +void SteamController::EmergencyLizardRestore() noexcept { + if (!m_device.IsOpen()) return; + uint8_t buf[64]; + BuildCmd(buf, CMD_SET_DEFAULT_MAPPINGS); + m_device.SendFeatureReport(buf, sizeof(buf)); + BuildCmd(buf, CMD_LOAD_DEFAULT_SETTINGS); + m_device.SendFeatureReport(buf, sizeof(buf)); +} + bool SteamController::EnableLizardMode() { if (m_running.exchange(false) && m_rumbleThread.joinable()) m_rumbleThread.join(); diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index be6c445..e11b2f2 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -184,6 +184,14 @@ public: // every couple of seconds while game mode is active, like hid-steam does. bool SendKeepalive(); + // Crash-context lizard restore: sends the default-mappings and + // default-settings feature reports WITHOUT taking locks or joining + // threads, so it is safe from an unhandled-exception filter where another + // thread may hold m_writeMutex forever. Racing a concurrent writer is + // acceptable — if this write is lost, the firmware's own revert timeout + // restores lizard mode a few seconds after the process dies. + void EmergencyLizardRestore() noexcept; + // Fire a single haptic event on one trackpad. // strongClick = true → physical-click sensation; false → light touch-down tick. void PulseTrackpadHaptic(bool left, bool strongClick);