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 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Fable 5 committed 2026-07-07 14:32:14 -07:00
1 parent 224e6d4cd7
commit 5cd54be8d0
16 files changed
+1027 -21

No files matched your search

+14
View File
@@ -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
)
+8 -1
View File
@@ -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
+110 -6
View File
@@ -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<bool> 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<int>(buf[1])) {
EventLog::Write("BATTERY: %u%% (chargeState=%u)", buf[1], buf[2]);
slot->lastBatteryPercent = static_cast<int>(buf[1]);
}
}
continue;
}
+14
View File
@@ -11,6 +11,9 @@
class ControllerManager {
public:
using StateChangedFn = std::function<void(bool connected, bool gameModeActive, bool vigemMissing)>;
// 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<void(const std::wstring& title, const std::wstring& text)>;
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<std::unique_ptr<Slot>> m_slots;
bool m_trackpadMouseEnabled = false;
bool m_useLeftTrackpad = false;
+51
View File
@@ -0,0 +1,51 @@
#include "DeviceRestart.h"
#include <SetupAPI.h>
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;
}
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <Windows.h>
#include <string>
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);
}
+78
View File
@@ -0,0 +1,78 @@
#include "EventLog.h"
#include <Windows.h>
#include <cstdarg>
#include <cstdio>
#include <mutex>
#include <share.h>
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<std::mutex> 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
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <string>
// 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();
}
+100
View File
@@ -0,0 +1,100 @@
#include "SteamWatcher.h"
#include <Windows.h>
#include <TlHelp32.h>
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<int>(now) > static_cast<int>(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);
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <atomic>
#include <functional>
#include <thread>
// 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<void(SteamState)>;
~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<bool> m_running{false};
std::atomic<SteamState> m_state{SteamState::NoSteam};
};
+365 -8
View File
@@ -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 <shellapi.h>
#include <dbt.h>
#include <winreg.h>
#include <string>
static TrayApp* g_app = nullptr;
static constexpr wchar_t WNDCLASS_NAME[] = L"SteamlessControllerTray";
static constexpr wchar_t CYCLE_TASK_NAME[] = L"SteamlessControllerDeviceCycle";
static constexpr wchar_t LEGACY_STARTUP_TASK_NAME[] = L"SteamlessController";
static constexpr wchar_t HELPER_EXE_NAME[] = L"SteamlessDeviceCycle.exe";
// Runs a console tool with no visible window; true when it exits 0.
static bool RunToolHidden(std::wstring cmdline) {
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi{};
if (!CreateProcessW(nullptr, cmdline.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
return false;
WaitForSingleObject(pi.hProcess, 15000);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return code == 0;
}
// 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<ControllerManager>(
[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<std::mutex> 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<WPARAM>(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<SteamState>(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<std::mutex> lk(m_alertMutex);
title = m_alertTitle;
text = m_alertText;
}
if (!title.empty())
ShowAlertBalloon(title, text);
return 0;
}
case WM_TIMER:
if (wp == IDT_ACQUIRE) {
KillTimer(m_hwnd, IDT_ACQUIRE);
if (m_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<int>(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<int>(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<BackButtonAction>(readDw(L"BackBtnR5", static_cast<DWORD>(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<int>(readDw(L"StartupMechanism",
RunKeyExists() ? 1 : 0));
m_startupEnabled = m_startupMechanism != 0;
RegCloseKey(key);
}
@@ -277,6 +610,9 @@ void TrayApp::SaveSettings() {
reinterpret_cast<const BYTE*>(&val), sizeof(val));
};
writeDw(L"AutoSteamMode", static_cast<DWORD>(m_autoMode));
writeDw(L"StartupMechanism", static_cast<DWORD>(m_startupMechanism));
writeDw(L"ShowNotifications", m_notificationsEnabled ? 1 : 0);
writeDw(L"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<UINT_PTR>(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.
+53 -2
View File
@@ -1,10 +1,21 @@
#pragma once
#include <Windows.h>
#include <memory>
#include <mutex>
#include <string>
#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<ControllerManager> 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;
};
+5 -3
View File
@@ -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;
}
+131
View File
@@ -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 <Windows.h>
#include <string>
#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"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\r\n";
xml += L"<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\r\n";
xml += L" <RegistrationInfo>\r\n";
xml += L" <Description>Restarts the Steam Controller device on demand for SteamlessController.</Description>\r\n";
xml += L" </RegistrationInfo>\r\n";
xml += L" <Principals>\r\n";
xml += L" <Principal id=\"Author\">\r\n";
xml += L" <LogonType>InteractiveToken</LogonType>\r\n";
xml += L" <RunLevel>HighestAvailable</RunLevel>\r\n";
xml += L" </Principal>\r\n";
xml += L" </Principals>\r\n";
xml += L" <Settings>\r\n";
xml += L" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\r\n";
xml += L" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\r\n";
xml += L" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\r\n";
xml += L" <AllowStartOnDemand>true</AllowStartOnDemand>\r\n";
xml += L" <Enabled>true</Enabled>\r\n";
xml += L" <ExecutionTimeLimit>PT1M</ExecutionTimeLimit>\r\n";
xml += L" </Settings>\r\n";
xml += L" <Actions Context=\"Author\">\r\n";
xml += L" <Exec><Command>";
xml += exe;
xml += L"</Command></Exec>\r\n";
xml += L" </Actions>\r\n";
xml += L"</Task>\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<DWORD>(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();
}
+16 -1
View File
@@ -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();
+8
View File
@@ -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);