Files
SteamlessController/src/app/TrayApp.cpp
T
Dylan DeverillandClaude Fable 5 5cd54be8d0 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>
2026-07-07 14:32:14 -07:00

706 lines
27 KiB
C++

#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;
}
bool TrayApp::Init(HINSTANCE hInstance) {
m_hInstance = hInstance;
m_iconOff = LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ICON_OFF));
m_iconOn = LoadIconW(hInstance, MAKEINTRESOURCEW(IDI_ICON_ON));
m_wmTaskbar = RegisterWindowMessageW(L"TaskbarCreated");
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = WNDCLASS_NAME;
if (!RegisterClassExW(&wc)) return false;
// Message-only window — invisible, never shown.
m_hwnd = CreateWindowExW(0, WNDCLASS_NAME, L"SteamlessController",
0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, hInstance, nullptr);
if (!m_hwnd) return false;
// Register for HID device arrival/removal notifications.
DEV_BROADCAST_DEVICEINTERFACE_W filter{};
filter.dbcc_size = sizeof(filter);
filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE;
filter.dbcc_classguid = {0x4D1E55B2, 0xF16F, 0x11CF,
{0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30}};
RegisterDeviceNotificationW(m_hwnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE);
EventLog::Write("=== SteamlessController 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;
}
int TrayApp::Run() {
MSG msg;
BOOL ret;
while ((ret = GetMessageW(&msg, nullptr, 0, 0)) != 0) {
if (ret == -1) return -1;
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
return static_cast<int>(msg.wParam);
}
LRESULT CALLBACK TrayApp::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (g_app) return g_app->HandleMessage(hwnd, msg, wp, lp);
return DefWindowProcW(hwnd, msg, wp, lp);
}
LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == m_wmTaskbar) {
AddTrayIcon();
return 0;
}
switch (msg) {
case WM_TRAY:
if (LOWORD(lp) == NIN_BALLOONUSERCLICK)
ShellExecuteW(nullptr, L"open", L"https://github.com/nefarius/ViGEmBus/releases/latest",
nullptr, nullptr, SW_SHOWNORMAL);
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
OpenRemapWindow();
else if (LOWORD(lp) == WM_RBUTTONUP || LOWORD(lp) == WM_LBUTTONUP)
ShowContextMenu();
return 0;
case WM_COMMAND:
switch (LOWORD(wp)) {
case IDM_TOGGLE:
if (m_controller->IsGameModeActive())
m_controller->DisableGameMode();
else
m_controller->EnableGameMode();
break;
case IDM_TRACKPAD:
m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled());
SaveSettings();
break;
case IDM_REMAP_BACK:
OpenRemapWindow();
break;
case IDM_BACKBUTTONS:
m_controller->SetBackButtonsEnabled(!m_controller->IsBackButtonsEnabled());
SaveSettings();
break;
case IDM_LEFT_TRACKPAD:
m_controller->SetUseLeftTrackpad(!m_controller->IsUseLeftTrackpad());
SaveSettings();
break;
case IDM_PLATFORM_XBOX:
m_controller->SetControllerPlatform(ControllerPlatform::Xbox);
SaveSettings();
break;
case IDM_PLATFORM_PS:
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:
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) {
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:
PostQuitMessage(0);
return 0;
}
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,
m_controller.get(),
m_controller->GetBackButtonConfig(),
[this](const BackButtonConfig& cfg) {
m_controller->SetBackButtonConfig(cfg);
SaveSettings();
});
}
void TrayApp::AddTrayIcon() {
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_TRAY;
nid.hIcon = m_iconOff;
wcscpy_s(nid.szTip, L"Steamless Controller");
Shell_NotifyIconW(NIM_ADD, &nid);
nid.uVersion = NOTIFYICON_VERSION_4;
Shell_NotifyIconW(NIM_SETVERSION, &nid);
}
void TrayApp::RemoveTrayIcon() {
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
Shell_NotifyIconW(NIM_DELETE, &nid);
}
void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMissing) {
if (vigemMissing) { ShowViGEmBalloon(); return; }
const wchar_t* tip = gameModeActive ? L"Steamless Controller — Steamless Mode ON"
: connected ? L"Steamless Controller — Connected (Steamless Mode OFF)"
: L"Steamless Controller — No controller found";
NOTIFYICONDATAW nid{};
nid.cbSize = sizeof(nid);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_TIP | NIF_ICON;
nid.hIcon = gameModeActive ? m_iconOn : m_iconOff;
wcscpy_s(nid.szTip, tip);
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& text) {
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);
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
wcscpy_s(nid.szInfoTitle, L"Driver required");
wcscpy_s(nid.szInfo, L"ViGEmBus is not installed. Click here to download it.");
Shell_NotifyIconW(NIM_MODIFY, &nid);
}
static constexpr wchar_t REG_KEY[] = L"Software\\SteamlessController";
static constexpr wchar_t REG_RUN_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
static constexpr wchar_t APP_NAME[] = L"SteamlessController";
bool TrayApp::RunKeyExists() const {
HKEY key;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_READ, &key) != ERROR_SUCCESS)
return false;
bool exists = RegQueryValueExW(key, APP_NAME, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS;
RegCloseKey(key);
return exists;
}
void TrayApp::SetRunKey(bool enabled) {
HKEY key;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_RUN_KEY, 0, KEY_WRITE, &key) != ERROR_SUCCESS)
return;
if (enabled) {
wchar_t path[MAX_PATH];
GetModuleFileNameW(nullptr, path, MAX_PATH);
RegSetValueExW(key, APP_NAME, 0, REG_SZ,
reinterpret_cast<const BYTE*>(path),
static_cast<DWORD>((wcslen(path) + 1) * sizeof(wchar_t)));
} else {
RegDeleteValueW(key, APP_NAME);
}
RegCloseKey(key);
}
bool TrayApp::IsProcessElevated() {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
return false;
TOKEN_ELEVATION elev{};
DWORD size = sizeof(elev);
const bool ok = GetTokenInformation(token, TokenElevation, &elev, sizeof(elev), &size) != 0;
CloseHandle(token);
return ok && elev.TokenIsElevated != 0;
}
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)
return;
auto readDw = [&](const wchar_t* name, DWORD def) -> DWORD {
DWORD val = 0, size = sizeof(val);
if (RegQueryValueExW(key, name, nullptr, nullptr,
reinterpret_cast<LPBYTE>(&val), &size) == ERROR_SUCCESS)
return val;
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);
m_controller->SetControllerPlatform (readDw(L"ControllerPlatform", 0) != 0
? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox);
BackButtonConfig cfg;
cfg.l4 = static_cast<BackButtonAction>(readDw(L"BackBtnL4", static_cast<DWORD>(BackButtonAction::None)));
cfg.l5 = static_cast<BackButtonAction>(readDw(L"BackBtnL5", static_cast<DWORD>(BackButtonAction::None)));
cfg.r4 = static_cast<BackButtonAction>(readDw(L"BackBtnR4", static_cast<DWORD>(BackButtonAction::None)));
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);
}
void TrayApp::SaveSettings() {
HKEY key;
if (RegCreateKeyExW(HKEY_CURRENT_USER, REG_KEY, 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr,
&key, nullptr) != ERROR_SUCCESS)
return;
auto writeDw = [&](const wchar_t* name, DWORD val) {
RegSetValueExW(key, name, 0, REG_DWORD,
reinterpret_cast<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);
writeDw(L"ControllerPlatform", m_controller->GetControllerPlatform() == ControllerPlatform::PlayStation ? 1 : 0);
const auto& cfg = m_controller->GetBackButtonConfig();
writeDw(L"BackBtnL4", static_cast<DWORD>(cfg.l4));
writeDw(L"BackBtnL5", static_cast<DWORD>(cfg.l5));
writeDw(L"BackBtnR4", static_cast<DWORD>(cfg.r4));
writeDw(L"BackBtnR5", static_cast<DWORD>(cfg.r5));
RegCloseKey(key);
}
void TrayApp::ShowContextMenu() {
bool connected = m_controller->IsConnected();
bool gameModeOn = m_controller->IsGameModeActive();
bool trackpadOn = m_controller->IsTrackpadMouseEnabled();
bool backMouseOn = m_controller->IsBackButtonsEnabled();
bool leftPad = m_controller->IsUseLeftTrackpad();
bool startupOn = m_startupEnabled;
HMENU menu = CreatePopupMenu();
// 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);
AppendMenuW(menu, MF_STRING | (trackpadOn ? MF_CHECKED : MF_UNCHECKED),
IDM_TRACKPAD, L"Enable Trackpad Mouse");
AppendMenuW(menu, MF_STRING | (backMouseOn ? MF_CHECKED : MF_UNCHECKED),
IDM_BACKBUTTONS, L"Back Buttons as Mouse Click");
AppendMenuW(menu, MF_STRING | (leftPad ? MF_CHECKED : MF_UNCHECKED),
IDM_LEFT_TRACKPAD, L"Use Left Trackpad Instead");
AppendMenuW(menu, MF_STRING, IDM_REMAP_BACK, L"Remap Back Buttons...");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
bool isPS = (m_controller->GetControllerPlatform() == ControllerPlatform::PlayStation);
HMENU platformMenu = CreatePopupMenu();
AppendMenuW(platformMenu, MF_STRING | (!isPS ? MF_CHECKED : MF_UNCHECKED),
IDM_PLATFORM_XBOX, L"Xbox");
AppendMenuW(platformMenu, MF_STRING | (isPS ? MF_CHECKED : MF_UNCHECKED),
IDM_PLATFORM_PS, L"PlayStation");
AppendMenuW(menu, MF_STRING | MF_POPUP,
reinterpret_cast<UINT_PTR>(platformMenu), L"Controller Platform");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING | (startupOn ? MF_CHECKED : MF_UNCHECKED),
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.
SetForegroundWindow(m_hwnd);
POINT pt;
GetCursorPos(&pt);
TrackPopupMenu(menu, TPM_RIGHTALIGN | TPM_BOTTOMALIGN | TPM_RIGHTBUTTON,
pt.x, pt.y, 0, m_hwnd, nullptr);
DestroyMenu(menu);
}