From d2b29cd8839163ae808f5e8a90c8c77acce8d54e Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Wed, 9 Sep 2026 21:04:56 -0700 Subject: [PATCH] Say in the window when Steamless mode is off (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every setting in Customize Controls is inert while Steamless mode is off — trackpad modes, scroll speed, paddles, all of it. Nothing on screen said so. The event log has said so since the PADS line landed, which turned out to be no help at all: people are looking at the window while they test, not at a log. That has now eaten three test sessions on this issue alone. The reporter changed settings twice with it off and reported the feature broken; the maintainer then did the same and reported scrolling regressed. In every case the settings were saved correctly and simply were not driving anything, and working that out took a round trip each time. So the window says it, and offers to fix it. In manual mode the banner carries an Enable button; in the auto modes it explains that the mode decides instead, because a button there would either lie or be undone a moment later. The button routes back out to TrayApp::EnableFromUser — extracted from the tray toggle so both are literally the same path. A shortcut of its own would have skipped EnsureCycleTaskRegistered and moved the one-time UAC prompt to the first device cycle, which is precisely where it ambushes people. Clicking Enable does not hide the banner. Enabling can fail — no ViGEm, or a device another process will not let go — and a banner that vanished on click would claim a success nothing had verified. The state is pushed from UpdateTrayIcon, so the banner clears when control is actually taken, and comes back if it is lost. That push is posted through WM_CONTROLSTATE rather than made directly: the controller notifies from its read thread and WebView2 must only be touched on the UI thread. The state is read in the handler rather than carried in the message, so what the window gets is the current answer rather than the one that prompted the post. Two things about the wording, both learned by writing it wrong first. It says nothing about whether settings are saved. An earlier draft ended "they are saved either way", which reads as though the page saves on its own — it does not, Apply does. A banner about one thing being off is the wrong place to imply something else is automatic. And its punctuation is ASCII. GetHtml widens the embedded page a byte at a time, so a literal em dash arrives as two garbage units; the static markup has always sidestepped this by writing — instead. Every other non-ASCII character in the page sits in a comment, where it never reaches a reader, and this string was the first displayed text to trip over it. Co-Authored-By: Claude Opus 5 --- src/app/RemapWindow.cpp | 78 +++++++++++++++++++++++++++++++++++++++++ src/app/RemapWindow.h | 25 +++++++++++++ src/app/TrayApp.cpp | 51 +++++++++++++++++++-------- src/app/TrayApp.h | 5 +++ 4 files changed, 145 insertions(+), 14 deletions(-) diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index a6d06ea..2f6542a 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -133,6 +133,12 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .speed-slider:hover::-webkit-slider-thumb{background:#8fd3ff;} .speed-slider:focus{outline:none;} .speed-val{font-family:'JetBrains Mono',monospace;font-size:12px;color:#8f98a0;font-weight:600;min-width:44px;text-align:right;} +.offbar{display:none;margin:0 0 14px;padding:11px 13px;border-radius:8px;background:rgba(214,146,42,.13);border:1px solid rgba(214,146,42,.42);align-items:center;gap:12px;} +.offbar.on{display:flex;} +.offbar-text{flex:1;font-size:12.5px;color:#e8c07a;line-height:1.45;} +.offbar-text b{color:#ffd699;font-weight:700;} +.offbar-btn{padding:7px 14px;border-radius:3px;border:1px solid #4c7a1d;background:linear-gradient(to bottom,#94c63d,#5d8a1f);color:#13260a;font-weight:700;font-size:12.5px;white-space:nowrap;} +.offbar-btn:hover{opacity:.9;} .picker{margin-top:12px;background:#101925;border:1px solid rgba(102,192,244,.2);border-radius:8px;padding:12px 13px;} .picker-label{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:1px;color:#5c6b78;font-weight:600;} .picker-rows{display:flex;flex-direction:column;gap:7px;margin-top:10px;} @@ -196,6 +202,10 @@ R"HTML(

Customize Controls

Click Rebind on any button, then press any gamepad button on your controller — or any key on your keyboard, or your mouse's middle or thumb buttons. The new binding shows up here instantly. Pick Off to stop a button doing anything at all.

+
+
+ +
APPLY TO
@@ -619,6 +629,10 @@ if(window.chrome&&window.chrome.webview){ renderComboLabel(); renderModeSelects(); renderAll(); + } else if(msg.type==='controlState'){ + controlOn = msg.enabled==='1'; + controlManual = msg.manual==='1'; + renderOffbar(); } else if(msg.type==='games'){ // Finding every installed app takes a second or more, so it lands after // the window is already up — and possibly after the user has started @@ -1052,6 +1066,39 @@ function setPlatform(value){ // The checkbox belongs to a game, never to the default profile, and while it // is ticked everything below it describes controls this game is not using — // so the settings are dimmed and inert, and "reset this profile" with them. +// Whether the controller is actually being driven, and whether this window is +// allowed to change that. Both arrive from the app; assume off until told, so +// a page that somehow never hears warns rather than staying quiet about it. +var controlOn = false, controlManual = true; +// Every setting in this window does nothing while Steamless mode is off. The +// event log has said so for a while, which turned out to be no use at all — +// people are looking at this window, not at a log, and three separate test +// sessions were spent changing settings that were never connected to anything. +function renderOffbar(){ + var bar=document.getElementById('offbar'); + if(!bar) return; + bar.className = controlOn ? 'offbar' : 'offbar on'; + if(controlOn) return; + var text=document.getElementById('offbar-text'); + var btn=document.getElementById('offbar-btn'); + // Deliberately says nothing about whether the settings are saved. An earlier + // draft ended "they are saved either way", which reads as though the page + // saves on its own — and it does not; Apply does. A banner about one thing + // being off is the wrong place to imply something else is automatic. + if(controlManual){ + text.innerHTML='Steamless mode is off. Nothing on this page is '+ + 'driving your controller yet - the trackpads, paddles '+ + 'and buttons below take effect once it is on.'; + if(btn) btn.style.display=''; + }else{ + // Not ours to switch on: an auto mode owns the decision, and a button here + // would either lie or fight it a moment later. + text.innerHTML='Steamless mode is off right now. Control Mode is set '+ + 'to one of the automatic options, so it turns on by itself '+ + 'when the conditions are met. Settings below take effect then.'; + if(btn) btn.style.display='none'; + } +} function renderInherit(){ var group=document.getElementById('inherit-group'); if(group) group.style.display=(currentGame==='')?'none':''; @@ -1196,6 +1243,13 @@ document.getElementById('btn-min').onclick=function(){postMsg({type:'minimize'}) document.getElementById('btn-close').onclick=function(){guard(function(){postMsg({type:'close'});});}; document.getElementById('btn-reset').onclick=resetDefaults; document.getElementById('btn-apply').onclick=applyBindings; +var offBtn=document.getElementById('offbar-btn'); +if(offBtn) offBtn.onclick=function(){ + // No optimistic hiding: enabling can fail (no ViGEm, a device another + // process will not release) and a banner that vanished on click would claim + // a success nothing verified. The app pushes the real state when it knows. + postMsg({type:'requestEnable'}); +}; document.getElementById('use-default').addEventListener('change',function(e){ useDefault=e.target.checked; @@ -2150,11 +2204,30 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) { break; } + } else if (type == "requestEnable") { + // Only ever offered in manual mode, but checked here too: the mode can + // change while the window is up, and a stale button must not reach + // past the tray's own rule about who decides. + if (m_controlManual && m_onRequestEnable) m_onRequestEnable(); + } else if (type == "browseExe") { BrowseForExe(); } } + +// Pushed whenever the tray's view of the world changes, and again when the +// page comes up — the window is usually opened while the state is already +// settled, so waiting for the next change would leave the banner wrong until +// something unrelated happened. +void RemapWindow::SetControlState(bool enabled, bool manual) { + m_controlEnabled = enabled; + m_controlManual = manual; + if (!m_webview) return; + PostToWebView(std::wstring(L"{\"type\":\"controlState\",\"enabled\":\"") + + (enabled ? L"1" : L"0") + + L"\",\"manual\":\"" + (manual ? L"1" : L"0") + L"\"}"); +} void RemapWindow::PostToWebView(const std::wstring& jsonStr) { if (m_webview) m_webview->PostWebMessageAsString(jsonStr.c_str()); } @@ -2261,6 +2334,11 @@ void RemapWindow::SendInitState() { L",\"games\":[" + GamesJson() + L"]" L",\"profiles\":{" + ProfilesJson() + L"}}"; PostToWebView(json); + + // Sent separately rather than folded into the init message: the same state + // arrives later whenever it changes, so the page needs one handler for it + // either way, and having two ways to learn it is how they drift. + SetControlState(m_controlEnabled, m_controlManual); } // Token-based picker ids so the raw game id — possibly non-ASCII, sometimes diff --git a/src/app/RemapWindow.h b/src/app/RemapWindow.h index e3e7d69..3707508 100644 --- a/src/app/RemapWindow.h +++ b/src/app/RemapWindow.h @@ -62,6 +62,26 @@ public: // somebody has to re-evaluate once it comes down. void SetOnClose(std::function fn) { m_onClose = std::move(fn); } + // Whether the controller is actually being driven, and whether the tray + // toggle is the thing that decides it. + // + // Every setting in this window is inert while Steamless mode is off, and + // nothing on screen used to say so. That has now eaten three separate test + // sessions on #95 — settings changed, nothing happened, and the only place + // it was written down was the event log, which nobody reads while they are + // in the middle of testing. + // + // `manual` decides what can be offered about it: in manual mode the window + // can turn it on, and in the auto modes it is not the window's to change, + // so it says what does decide instead. + void SetControlState(bool enabled, bool manual); + + // The user asked to turn Steamless mode on from inside the window. Routed + // back out rather than handled here: enabling means acquiring the device, + // which is the tray's business and carries the elevated-helper and retry + // handling with it. + void SetOnRequestEnable(std::function fn) { m_onRequestEnable = std::move(fn); } + private: static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); LRESULT HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); @@ -150,6 +170,11 @@ private: std::function m_applyCallback; std::function m_deleteCallback; std::function m_onClose; + std::function m_onRequestEnable; + // Mirrored so the banner can be drawn as soon as the page is ready, which + // is not the moment the state was last known. + bool m_controlEnabled = false; + bool m_controlManual = true; Microsoft::WRL::ComPtr m_env; Microsoft::WRL::ComPtr m_controller; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index f8e094a..a0f1113 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -280,20 +280,8 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { // is the sole source of intent — record it and take the same // acquire/release path the auto modes use. A second click while an // acquisition is still cycling cancels it rather than restarting. - if (m_wantControl) { - ReleaseControl(); - } else { - // Taking the device back from a running Steam needs the - // elevated helper. Settle the one-time UAC prompt on this - // click instead of letting it ambush the first cycle. - // Registered even when already elevated: every cycle goes - // through the helper now, so an elevated run needs the task - // just as much. - EnsureCycleTaskRegistered(); - m_wantControl = true; - m_acquireRetries = 0; - TryAcquireController(); - } + if (m_wantControl) ReleaseControl(); + else EnableFromUser(); break; case IDM_REMAP_BACK: OpenRemapWindow(); @@ -355,6 +343,14 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return 0; } + case WM_CONTROLSTATE: + // Read here rather than carried in the message: by the time this + // arrives the state may have moved again, and the window wants the + // current answer, not the one that prompted the post. + m_remapWindow.SetControlState(m_controller->IsGameModeActive(), + m_autoMode == AutoMode::Manual); + return 0; + case WM_ALERT: { if (!m_notificationsEnabled) return 0; // the cause is still in the event log @@ -492,6 +488,23 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { return DefWindowProcW(hwnd, msg, wp, lp); } + +// Turning Steamless mode on at the user's request, from the tray toggle or +// from the remap window's banner. Both are the same intent and must take the +// same path — the window offering a shortcut that skipped the helper +// registration would move the UAC prompt to the first cycle, which is exactly +// where it ambushes people. +void TrayApp::EnableFromUser() { + // Taking the device back from a running Steam needs the elevated helper. + // Settle the one-time UAC prompt on this click instead of letting it + // ambush the first cycle. Registered even when already elevated: every + // cycle goes through the helper now, so an elevated run needs the task + // just as much. + EnsureCycleTaskRegistered(); + m_wantControl = true; + m_acquireRetries = 0; + TryAcquireController(); +} void TrayApp::SetAutoMode(AutoMode mode) { EventLog::Write("MODE: control mode set to %d " "(0=manual 1=offWhileSteam 2=offOnlyInGame 3=offUnlessProfile)", @@ -1453,6 +1466,12 @@ void TrayApp::OpenRemapWindow() { // foreground changes — so moving it would trade an unnoticeable delay for // a data race. RefreshSteamApps(); + // The banner's enable button. Same path as the tray toggle, deliberately. + m_remapWindow.SetOnRequestEnable([this] { EnableFromUser(); }); + // Whatever the state is right now, so the window does not open showing the + // last thing that happened to change. + m_remapWindow.SetControlState(m_controller->IsGameModeActive(), + m_autoMode == AutoMode::Manual); // The window fills its own picker, on a background thread: finding every // installed application runs to well over a second on an ordinary machine @@ -1521,6 +1540,10 @@ void TrayApp::RemoveTrayIcon() { void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool sharedHandle, bool padUnavailable) { + // The remap window dims and explains itself when nothing is being driven, + // so it has to hear about this from wherever the state actually changes. + PostMessageW(m_hwnd, WM_CONTROLSTATE, 0, 0); + if (padUnavailable) ShowViGEmBalloon(); // Cleared only by a pad that actually came up, not by any notification // that happens to carry false. The acquire path notifies repeatedly within diff --git a/src/app/TrayApp.h b/src/app/TrayApp.h index 95465c7..95e837a 100644 --- a/src/app/TrayApp.h +++ b/src/app/TrayApp.h @@ -152,6 +152,7 @@ private: void EvaluateControl(); void ApplySteamState(SteamState state); void TryAcquireController(uint32_t stateWaitMs = 250); + void EnableFromUser(); void ReleaseControl(); void RecoverStrandedDevices(); void WriteHeartbeat(); @@ -307,6 +308,10 @@ private: static constexpr UINT WM_TRAY = WM_APP + 1; static constexpr UINT WM_STEAMSTATE = WM_APP + 2; static constexpr UINT WM_ALERT = WM_APP + 3; + // Posted rather than acted on directly: the controller notifies from its + // read thread, and the remap window is WebView2, which must only be + // touched on the UI thread. + static constexpr UINT WM_CONTROLSTATE = WM_APP + 4; static constexpr UINT TRAY_UID = 1; static constexpr UINT_PTR IDT_ACQUIRE = 1; static constexpr UINT_PTR IDT_ACQUIRE_VERDICT = 2;