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;