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(
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;