mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-21 22:22:16 -05:00
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:
1 parent
224e6d4cd7
commit
5cd54be8d0
16 files changed
+1027
-21
No files matched your search
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user