From a087c236b2e00a0cc287e15d1a80d569e56d404e Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Thu, 13 Aug 2026 16:49:25 -0700 Subject: [PATCH] Add per-game control profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything about how the controller behaves — paddle bindings, both trackpads, and which kind of pad the game sees — is now configured in one window and saved per game, rather than spread across tray checkboxes that applied everywhere at once. Installed games come from the Start Menu and the packaged-app list. Steam does not shortcut its library with .lnk files, so its games are read from the .url shortcuts it does write; Xbox and Store titles register no shortcut file at all and come from the shell's app list instead. None of these sources can tell a game from any other program, so the picker searches rather than scrolls — a saved profile is pinned, everything else is reached by typing. Trackpads are configured per pad. The previous pair of toggles could only ever describe one pad doing one thing, because a single TrackpadMouse instance was switched between pads by a bool; there are two now. A pad can move the pointer, scroll, feed the DS4 touchpad, or do nothing, and its click is an ordinary binding dispatched down the same path as the paddles — it used to be hardwired to a left click that only fired while that pad drove the mouse. Key bindings can carry Ctrl, Alt, Shift and Win. The modifiers ride in the free top byte of the packed binding, so values written by earlier builds decode unchanged. Windows is chosen from a toggle rather than pressed: the Start menu opens before the page is told anything, which is what made that key unbindable before. Choosing modifiers rather than typing them also keeps combinations like Alt+Tab from being claimed by the system mid-capture. Ctrl+Alt+Delete is refused outright, being the Secure Attention Sequence that no ordinary process can send. Profiles follow the foreground application, and nothing polls to do it. An unelevated process cannot subscribe to process-start events — the WMI class that reports them requires administrator rights, as does an ETW kernel session — but SetWinEventHook reports foreground changes for the cost of one hook and no thread. Focus is also the better question than "is it running": a launcher that starts a game and stays resident answers yes long after the game has exited. Steam profiles are keyed by app id, which a foreground window cannot supply, so Steam's manifests are read to map that id to an install directory; a game's launcher, its anti-cheat service and the game itself all live under it and all resolve to the same profile. Auto Mode still decides whether the controller is ours at all, so a profile only loads while we hold the pad. A balloon names the profile when one loads, under the notification setting, because a profile that quietly fails to match is otherwise invisible. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 53 +++ src/app/BackButtonConfig.h | 58 ++- src/app/ControllerManager.cpp | 146 ++++--- src/app/ControllerManager.h | 24 +- src/app/ForegroundWatcher.cpp | 188 ++++++++ src/app/ForegroundWatcher.h | 80 ++++ src/app/GameLibrary.cpp | 286 +++++++++++++ src/app/GameLibrary.h | 44 ++ src/app/GameProfiles.cpp | 133 ++++++ src/app/GameProfiles.h | 16 + src/app/KeyInput.cpp | 87 ++++ src/app/KeyInput.h | 21 + src/app/RemapWindow.cpp | 735 ++++++++++++++++++++++++++++++-- src/app/RemapWindow.h | 37 +- src/app/SteamAppLocator.cpp | 217 ++++++++++ src/app/SteamAppLocator.h | 40 ++ src/app/TrackpadConfig.h | 145 +++++++ src/app/TrackpadMouse.cpp | 145 ++++--- src/app/TrackpadMouse.h | 33 +- src/app/TrayApp.cpp | 325 ++++++++++---- src/app/TrayApp.h | 65 ++- src/app/VirtualController.cpp | 40 +- src/app/VirtualController.h | 12 +- src/probe/BindingCodecProbe.cpp | 64 +++ src/probe/ForegroundProbe.cpp | 42 ++ src/probe/GameLibraryProbe.cpp | 14 + src/probe/SteamAppProbe.cpp | 20 + 27 files changed, 2780 insertions(+), 290 deletions(-) create mode 100644 src/app/ForegroundWatcher.cpp create mode 100644 src/app/ForegroundWatcher.h create mode 100644 src/app/GameLibrary.cpp create mode 100644 src/app/GameLibrary.h create mode 100644 src/app/GameProfiles.cpp create mode 100644 src/app/GameProfiles.h create mode 100644 src/app/SteamAppLocator.cpp create mode 100644 src/app/SteamAppLocator.h create mode 100644 src/app/TrackpadConfig.h create mode 100644 src/probe/BindingCodecProbe.cpp create mode 100644 src/probe/ForegroundProbe.cpp create mode 100644 src/probe/GameLibraryProbe.cpp create mode 100644 src/probe/SteamAppProbe.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b6905b7..53cbc39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,53 @@ if (MSVC) target_compile_options(RawControllerProbe PRIVATE /W4 /utf-8) endif() +# Game-library diagnostic probe — dumps what the Start Menu walk finds +add_executable(GameLibraryProbe + src/probe/GameLibraryProbe.cpp + src/app/GameLibrary.cpp +) +target_include_directories(GameLibraryProbe PRIVATE src) +target_link_libraries(GameLibraryProbe PRIVATE ole32 shell32 shlwapi windowsapp) +if (MSVC) + target_compile_options(GameLibraryProbe PRIVATE /W4 /WX /utf-8) + set_target_properties(GameLibraryProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") +endif() + +# Foreground-watcher diagnostic probe — prints what the per-game profile +# matcher would see as the user switches applications +add_executable(ForegroundProbe + src/probe/ForegroundProbe.cpp + src/app/ForegroundWatcher.cpp +) +target_include_directories(ForegroundProbe PRIVATE src) +if (MSVC) + target_compile_options(ForegroundProbe PRIVATE /W4 /WX /utf-8) + set_target_properties(ForegroundProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") +endif() + +# Steam manifest diagnostic probe — dumps the app id to install directory map +# that per-game profiles for Steam games are matched through +add_executable(SteamAppProbe + src/probe/SteamAppProbe.cpp + src/app/SteamAppLocator.cpp +) +target_include_directories(SteamAppProbe PRIVATE src) +if (MSVC) + target_compile_options(SteamAppProbe PRIVATE /W4 /WX /utf-8) + set_target_properties(SteamAppProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") +endif() + +# Binding encoding checks — the packed and wire forms both have to keep +# decoding values written by earlier builds +add_executable(BindingCodecProbe + src/probe/BindingCodecProbe.cpp +) +target_include_directories(BindingCodecProbe PRIVATE src) +if (MSVC) + target_compile_options(BindingCodecProbe PRIVATE /W4 /WX /utf-8) + set_target_properties(BindingCodecProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") +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 @@ -96,8 +143,12 @@ add_executable(SteamlessController WIN32 src/app/SteamWatcher.cpp src/app/DeviceRestart.cpp src/app/EventLog.cpp + src/app/ForegroundWatcher.cpp + src/app/GameLibrary.cpp + src/app/GameProfiles.cpp src/app/HandleFinder.cpp src/app/RemapWindow.cpp + src/app/SteamAppLocator.cpp resources/app.rc ) target_include_directories(SteamlessController PRIVATE @@ -110,8 +161,10 @@ target_link_libraries(SteamlessController PRIVATE ${webview2_SOURCE_DIR}/build/native/${WV2_ARCH}/WebView2LoaderStatic.lib version ole32 + shell32 shlwapi cfgmgr32 + windowsapp ) if (MSVC) target_compile_options(SteamlessController PRIVATE /W4 /WX /utf-8 /wd4828) diff --git a/src/app/BackButtonConfig.h b/src/app/BackButtonConfig.h index 103c5bd..917ccd5 100644 --- a/src/app/BackButtonConfig.h +++ b/src/app/BackButtonConfig.h @@ -59,10 +59,11 @@ inline BackButtonAction BackButtonActionFromId(const std::string& id) { // from a space too large to enumerate (a keyboard virtual-key code, an extended // mouse button). // -// Persisted as a single DWORD, (kind << 16) | code. Every value written by -// builds that stored a bare BackButtonAction is 0-18, so its kind bits are -// already zero and it decodes as Kind::Action — old settings load unchanged -// with no migration step. +// Persisted as a single DWORD, (mods << 24) | (kind << 16) | code. Every value +// written by builds that stored a bare BackButtonAction is 0-18, so its kind +// bits are already zero and it decodes as Kind::Action; every value written +// before modifiers existed has zero in the top byte and decodes as a plain +// key — old settings load unchanged with no migration step. struct BackButtonBinding { enum class Kind : uint8_t { Action = 0, // code is a BackButtonAction @@ -74,19 +75,38 @@ struct BackButtonBinding { // where they were first persisted. enum class MouseButtonCode : uint16_t { Middle = 0, X1 = 1, X2 = 2, COUNT }; + // Held alongside a Kind::Key binding, so a paddle can send Ctrl+Alt+K + // rather than one key. Meaningless on the other kinds and ignored there. + // + // Windows is in this set even though it cannot be captured by pressing it: + // the Start menu opens on the way, before any page sees the key. It is + // set from its toggle in the picker instead, which is the whole reason + // modifiers are a separate field rather than more virtual-key codes. + enum Modifier : uint8_t { + ModNone = 0, + ModCtrl = 1 << 0, + ModAlt = 1 << 1, + ModShift = 1 << 2, + ModWin = 1 << 3, + ModAll = ModCtrl | ModAlt | ModShift | ModWin, + }; + Kind kind = Kind::Action; uint16_t code = static_cast(BackButtonAction::None); + uint8_t mods = ModNone; static BackButtonBinding FromAction(BackButtonAction a) { return { Kind::Action, static_cast(a) }; } - static BackButtonBinding FromKey(uint16_t vk) { - return { Kind::Key, vk }; + static BackButtonBinding FromKey(uint16_t vk, uint8_t modifiers = ModNone) { + return { Kind::Key, vk, static_cast(modifiers & ModAll) }; } static BackButtonBinding FromMouseButton(MouseButtonCode b) { return { Kind::MouseButton, static_cast(b) }; } + bool HasModifiers() const { return kind == Kind::Key && mods != ModNone; } + bool IsAction() const { return kind == Kind::Action; } // The built-in action this binding names, or None when it is a key or an @@ -101,12 +121,14 @@ struct BackButtonBinding { } bool operator==(const BackButtonBinding& o) const { - return kind == o.kind && code == o.code; + return kind == o.kind && code == o.code && mods == o.mods; } bool operator!=(const BackButtonBinding& o) const { return !(*this == o); } uint32_t Pack() const { - return (static_cast(kind) << 16) | code; + return (static_cast(mods) << 24) + | (static_cast(kind) << 16) + | code; } // Anything unrecognised — a corrupt value, or one written by a future build @@ -114,13 +136,16 @@ struct BackButtonBinding { static BackButtonBinding Unpack(uint32_t packed) { const auto kind = static_cast((packed >> 16) & 0xFF); const auto code = static_cast(packed & 0xFFFF); + // Unknown modifier bits are dropped rather than rejecting the whole + // binding: the key it names is still exactly what the user chose. + const auto mods = static_cast((packed >> 24) & ModAll); switch (kind) { case Kind::Action: return code < static_cast(BackButtonAction::COUNT) ? BackButtonBinding{ Kind::Action, code } : BackButtonBinding{}; case Kind::Key: - return code != 0 ? BackButtonBinding{ Kind::Key, code } + return code != 0 ? BackButtonBinding{ Kind::Key, code, mods } : BackButtonBinding{}; case Kind::MouseButton: return code < static_cast(MouseButtonCode::COUNT) @@ -136,7 +161,10 @@ struct BackButtonBinding { std::string Id() const { switch (kind) { case Kind::Key: - return "key:" + std::to_string(code); + // Modifiers ride in the same number they occupy in the packed + // form, so an unmodified key produces exactly the string earlier + // builds did and profiles written by them still read back. + return "key:" + std::to_string((static_cast(mods) << 16) | code); case Kind::MouseButton: switch (static_cast(code)) { case MouseButtonCode::Middle: return "mouse:middle"; @@ -152,13 +180,15 @@ struct BackButtonBinding { static BackButtonBinding FromId(const std::string& id) { if (id.rfind("key:", 0) == 0) { - uint32_t vk = 0; + uint32_t value = 0; for (size_t i = 4; i < id.size(); ++i) { if (id[i] < '0' || id[i] > '9') return {}; - vk = vk * 10 + static_cast(id[i] - '0'); - if (vk > 0xFFFF) return {}; + value = value * 10 + static_cast(id[i] - '0'); + if (value > 0xFFFFFF) return {}; // room for the modifier byte } - return vk != 0 ? FromKey(static_cast(vk)) : BackButtonBinding{}; + const auto vk = static_cast(value & 0xFFFF); + const auto mods = static_cast((value >> 16) & ModAll); + return vk != 0 ? FromKey(vk, mods) : BackButtonBinding{}; } if (id == "mouse:middle") return FromMouseButton(MouseButtonCode::Middle); if (id == "mouse:x1") return FromMouseButton(MouseButtonCode::X1); diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 200a8bb..df5ecf8 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -25,7 +25,9 @@ struct ControllerManager::Slot { SteamController::Transport transport = SteamController::Transport::Unknown; std::unique_ptr sc; std::unique_ptr vc; - TrackpadMouse trackpad; + // One per physical pad — they are configured independently. + TrackpadMouse leftPad; + TrackpadMouse rightPad; std::thread readThread; std::atomic readRunning{false}; bool gameModeActive = false; @@ -37,17 +39,21 @@ struct ControllerManager::Slot { // thread for that timeout on every one of those attempts. std::chrono::steady_clock::time_point lastSilentAt{}; - // What each paddle (L4, L5, R4, R5) is currently holding down, so leaving - // game mode mid-press releases it instead of stranding the input down. - // Records what the press actually sent rather than reading the binding - // again on release, so rebinding a paddle mid-hold still releases cleanly. - BackButtonBinding paddleHeld[4]; + // What each bindable button is currently holding down, so leaving game + // mode mid-press releases it instead of stranding the input down. Records + // what the press actually sent rather than reading the binding again on + // release, so rebinding mid-hold still releases cleanly. + // + // Indices 0-3 are the paddles (L4, L5, R4, R5); 4-5 are the left and right + // pad clicks, which are ordinary bindings dispatched down this same path. + static constexpr size_t kBindableCount = 6; + BackButtonBinding paddleHeld[kBindableCount]; // Auto-repeat for held key bindings. When the next repeat is due, and the // gap to use after that — both captured at press time from the user's // keyboard settings, so the rate cannot shift mid-hold. - std::chrono::steady_clock::time_point paddleRepeatAt[4]{}; - std::chrono::milliseconds paddleRepeatGap[4]{}; + std::chrono::steady_clock::time_point paddleRepeatAt[kBindableCount]{}; + std::chrono::milliseconds paddleRepeatGap[kBindableCount]{}; // Haptic edge-detection state for touch (movement ticks). bool hapticWasRightTouching = false; @@ -213,7 +219,8 @@ ControllerManager::~ControllerManager() { // but without the gameModeActive guard — we always want to restore lizard mode). if (slot->gameModeActive) { StopReadLoop(*slot); - slot->trackpad.Reset(); + slot->leftPad.Reset(); + slot->rightPad.Reset(); ReleaseHeldPaddleInputs(*slot); slot->vc.reset(); slot->gameModeActive = false; @@ -272,26 +279,38 @@ void ControllerManager::ReleaseDevices() { NotifyStateChanged(); } -void ControllerManager::SetTrackpadMouseEnabled(bool enabled) { - m_trackpadMouseEnabled = enabled; - for (auto& slot : m_slots) { - slot->trackpad.SetTrackpadEnabled(enabled); - if (slot->vc) slot->vc->SetTrackpadMouseClaim(enabled, m_useLeftTrackpad); - } +void ControllerManager::ApplyPadSettings(Slot& slot) { + slot.leftPad.SetPad(true); + slot.rightPad.SetPad(false); + slot.leftPad.SetMode(m_profile.leftPad.mode); + slot.rightPad.SetMode(m_profile.rightPad.mode); + slot.leftPad.SetScrollDirection(m_profile.leftPad.scrollDir); + slot.rightPad.SetScrollDirection(m_profile.rightPad.scrollDir); + // The virtual controller reads pad modes straight off the profile it is + // handed every frame, so there is nothing to push to it here. } -void ControllerManager::SetUseLeftTrackpad(bool enabled) { - m_useLeftTrackpad = enabled; - for (auto& slot : m_slots) { - slot->trackpad.SetUseLeftTrackpad(enabled); - if (slot->vc) slot->vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, enabled); - } -} +void ControllerManager::SetProfile(const ControllerProfile& profile) { + const bool platformChanged = profile.platform != m_profile.platform; + m_profile = profile; -void ControllerManager::SetBackButtonConfig(const BackButtonConfig& cfg) { - m_backConfig = cfg; - // Live update: the ReadLoop reads m_backConfig on every frame, so the - // change takes effect on the very next report. + // The virtual pad is created as an X360 target or a DS4 one and cannot + // become the other, so a platform change is the one setting here that + // needs the controller rebuilt rather than merely re-read. Cheap to get + // wrong in the other direction, too: rebuilding on every apply would + // disconnect and reconnect the pad under the running game each time + // somebody edited a paddle binding. + if (platformChanged && IsGameModeActive()) { + DisableGameMode(); + EnableGameMode(); // re-applies pad settings to each slot as it comes up + return; + } + + // Live update: the ReadLoop reads m_profile on every frame, so paddle and + // pad-click changes take effect on the very next report. Movement modes + // live inside the per-slot TrackpadMouse objects, so those are pushed. + for (auto& slot : m_slots) + ApplyPadSettings(*slot); } // Sends the key or mouse event a binding stands for. Gamepad actions reach the @@ -306,7 +325,16 @@ static bool SendPaddleInput(const BackButtonBinding& binding, bool down) { switch (binding.kind) { case BackButtonBinding::Kind::Key: - SendKeyInput(binding.code, down); + // Modifiers wrap the key: down before it so the application sees them + // already held when the key arrives, up after it so the shortcut is + // never briefly the unmodified key on the way out. + if (down) { + SendModifiers(binding.mods, true); + SendKeyInput(binding.code, true); + } else { + SendKeyInput(binding.code, false); + SendModifiers(binding.mods, false); + } return true; case BackButtonBinding::Kind::MouseButton: { @@ -353,15 +381,6 @@ void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) { } } -void ControllerManager::SetControllerPlatform(ControllerPlatform platform) { - if (m_controllerPlatform == platform) return; - m_controllerPlatform = platform; - if (IsGameModeActive()) { - DisableGameMode(); - EnableGameMode(); - } -} - void ControllerManager::StartButtonCapture(std::function callback) { std::lock_guard lk(m_captureMutex); m_captureCallback = std::move(callback); @@ -496,7 +515,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, } slot.vc = std::make_unique( - m_controllerPlatform, + m_profile.platform, [sc = slot.sc.get()](uint8_t largeMotor, uint8_t smallMotor) { if (sc->IsOpen()) sc->SetRumble(largeMotor, smallMotor); }); @@ -510,21 +529,20 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut, return GameModeOutcome::Blocked; } - if (m_controllerPlatform == ControllerPlatform::PlayStation) + if (m_profile.platform == ControllerPlatform::PlayStation) slot.sc->SetImuEnabled(true); - slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad); EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str()); // Takeover is the moment users report the trackpad arriving dead, and what // decides whether injection can land is whatever window happens to be in // front right then — so record it here rather than reconstructing it later. - if (m_trackpadMouseEnabled) + if (m_profile.leftPad.ClaimedForDesktop() || m_profile.rightPad.ClaimedForDesktop()) InputInjection::LogEnvironment("game mode taken"); slot.gameModeActive = true; - slot.trackpad.Reset(); + slot.leftPad.Reset(); + slot.rightPad.Reset(); ReleaseHeldPaddleInputs(slot); - slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled); - slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad); + ApplyPadSettings(slot); StartReadLoop(slot); return GameModeOutcome::Enabled; } @@ -535,7 +553,8 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) { StopReadLoop(slot); if (slot.sc->IsOpen()) slot.sc->SetRumble(0, 0); - slot.trackpad.Reset(); + slot.leftPad.Reset(); + slot.rightPad.Reset(); ReleaseHeldPaddleInputs(slot); slot.vc.reset(); slot.sc->EnableLizardMode(); @@ -660,8 +679,9 @@ void ControllerManager::ReadLoop(Slot* slot) { "dropped before it reaches SendInput", n); } - if (slot->vc) slot->vc->Update(buf, n, m_backConfig); - slot->trackpad.Update(buf, n); + if (slot->vc) slot->vc->Update(buf, n, m_profile); + slot->leftPad.Update(buf, n); + slot->rightPad.Update(buf, n); // Trackpad haptics. { @@ -859,17 +879,34 @@ void ControllerManager::ReadLoop(Slot* slot) { slot->hapticWasLeftTouching = lt; } - // Deliver back paddles bound to a key or a mouse button. These go out - // through SendInput rather than the virtual pad. Edge-detected so each - // press sends exactly one down and each release exactly one up. + // Deliver back paddles and pad clicks bound to a key or a mouse button. + // These go out through SendInput rather than the virtual pad. + // Edge-detected so each press sends exactly one down and each release + // exactly one up. + // + // Pad clicks ride this same path rather than living in TrackpadMouse, + // which is what makes a pad's click independent of its movement mode — + // the click used to be hardcoded to a left button and only fired while + // that pad was driving the mouse. if (hasPrev) { + // A pad set to feed the DS4 touchpad has no click binding to + // dispatch — its press is the touchpad press. EffectiveClick + // returns nothing in that case, so a binding left behind by an + // earlier mode cannot fire from behind the hidden UI. + const BackButtonBinding leftPadClick = m_profile.leftPad.EffectiveClick(); + const BackButtonBinding rightPadClick = m_profile.rightPad.EffectiveClick(); + struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; }; const PaddleEdge edges[] = { - { n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_backConfig.l4 }, - { n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_backConfig.l5 }, - { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_backConfig.r4 }, - { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_backConfig.r5 }, + { n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_profile.back.l4 }, + { n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_profile.back.l5 }, + { n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_profile.back.r4 }, + { n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_profile.back.r5 }, + { n>5 && (buf[5]&SteamController::BTN_TP_LT_CLICK)!=0, (prevBuf[5]&SteamController::BTN_TP_LT_CLICK)!=0, leftPadClick }, + { n>4 && (buf[4]&SteamController::BTN_TP_RT_CLICK)!=0, (prevBuf[4]&SteamController::BTN_TP_RT_CLICK)!=0, rightPadClick }, }; + static_assert(std::size(edges) == Slot::kBindableCount, + "paddleHeld must have one entry per edge-dispatched binding"); for (size_t i = 0; i < std::size(edges); ++i) { const auto& e = edges[i]; if (e.cur == e.prev) continue; @@ -901,6 +938,9 @@ void ControllerManager::ReadLoop(Slot* slot) { const auto now = std::chrono::steady_clock::now(); if (now < slot->paddleRepeatAt[i]) continue; + // The base key only. Modifiers stay held across the repeat, which + // is exactly what a real keyboard does — holding Ctrl+K repeats + // the K, it does not re-press Ctrl. SendKeyInput(held.code, true); slot->paddleRepeatAt[i] = now + slot->paddleRepeatGap[i]; } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 0ad91f3..06d6f5b 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -1,6 +1,7 @@ #pragma once #include "BackButtonConfig.h" #include "ControllerPlatform.h" +#include "TrackpadConfig.h" #include #include #include @@ -54,17 +55,15 @@ public: // than refusing — refusing is what escalates to a device cycle. void SetSteamPresent(bool present) { m_steamPresent = present; } - void SetTrackpadMouseEnabled(bool enabled); - void SetUseLeftTrackpad(bool enabled); - void SetBackButtonConfig(const BackButtonConfig& cfg); - void SetControllerPlatform(ControllerPlatform platform); + // The whole active profile — platform, paddle bindings and both pads. + // Mostly applied live: the read loop reads the profile every frame, so + // bindings land on the next report. A changed platform is the exception — + // it rebuilds the virtual controller, see the definition. + void SetProfile(const ControllerProfile& profile); bool IsConnected() const { return !m_slots.empty(); } bool IsGameModeActive() const; - bool IsTrackpadMouseEnabled() const { return m_trackpadMouseEnabled; } - bool IsUseLeftTrackpad() const { return m_useLeftTrackpad; } - ControllerPlatform GetControllerPlatform() const { return m_controllerPlatform; } - const BackButtonConfig& GetBackButtonConfig() const { return m_backConfig; } + const ControllerProfile& GetProfile() const { return m_profile; } // Called by RemapWindow when a row enters/exits listening state. // Callback fires on the read thread — use PostMessage to marshal to the UI thread. @@ -85,14 +84,15 @@ private: void ReadLoop(Slot* slot); void NotifyStateChanged(bool vigemMissing = false); + // Pushes the current profile's pad settings into one slot's trackpads and + // its virtual controller. Shared by SetProfile and slot creation. + void ApplyPadSettings(Slot& slot); + StateChangedFn m_onStateChanged; AlertFn m_alertFn; std::vector> m_slots; - bool m_trackpadMouseEnabled = false; - bool m_useLeftTrackpad = false; bool m_steamPresent = false; - ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; - BackButtonConfig m_backConfig; + ControllerProfile m_profile; std::atomic m_capturing{false}; std::mutex m_captureMutex; diff --git a/src/app/ForegroundWatcher.cpp b/src/app/ForegroundWatcher.cpp new file mode 100644 index 0000000..521edff --- /dev/null +++ b/src/app/ForegroundWatcher.cpp @@ -0,0 +1,188 @@ +#include "ForegroundWatcher.h" +#include +#include + +ForegroundWatcher* ForegroundWatcher::s_instance = nullptr; + +namespace { + +// Packaged apps do not own their own top-level window. Windows hosts it in +// ApplicationFrameHost.exe, so the foreground window resolves to the host and +// every Store app would look like the same application. The app's real window +// is a child of the frame, owned by a different process — that one carries +// the identity worth having. +constexpr wchar_t kFrameHostExe[] = L"ApplicationFrameHost.exe"; + +bool IsFrameHost(const std::wstring& exePath) { + const size_t slash = exePath.find_last_of(L'\\'); + const wchar_t* leaf = slash == std::wstring::npos ? exePath.c_str() + : exePath.c_str() + slash + 1; + return _wcsicmp(leaf, kFrameHostExe) == 0; +} + +struct FrameChildSearch { + DWORD hostPid = 0; + HWND found = nullptr; +}; + +BOOL CALLBACK FindFrameChild(HWND child, LPARAM param) { + auto* search = reinterpret_cast(param); + DWORD pid = 0; + GetWindowThreadProcessId(child, &pid); + if (pid != 0 && pid != search->hostPid) { + search->found = child; + return FALSE; // stop at the first child that is not the host itself + } + return TRUE; +} + +std::wstring ImagePathOf(HANDLE process) { + // Longer than MAX_PATH on purpose: a packaged app's path under + // WindowsApps carries a versioned package name and overruns it easily. + wchar_t buf[1024]; + DWORD len = ARRAYSIZE(buf); + if (!QueryFullProcessImageNameW(process, 0, buf, &len)) return {}; + return std::wstring(buf, len); +} + +std::wstring AumidOf(HANDLE process) { + UINT32 len = 0; + // Asking with no buffer is how the length comes back; anything other than + // "too small" means this process has no package identity, which is the + // ordinary case for a desktop application. + if (GetApplicationUserModelId(process, &len, nullptr) != ERROR_INSUFFICIENT_BUFFER + || len == 0) + return {}; + + std::vector buf(len); + if (GetApplicationUserModelId(process, &len, buf.data()) != ERROR_SUCCESS) + return {}; + return std::wstring(buf.data()); // len counts the terminator; trust the string +} + +ForegroundIdentity IdentifyProcess(DWORD pid) { + ForegroundIdentity id; + if (pid == 0) return id; + + // Limited information is enough for both queries and, unlike the fuller + // rights, is granted to an unelevated process for most things the user + // is likely to be looking at. + HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!proc) return id; // protected or elevated — reported as unknown + + id.exePath = ImagePathOf(proc); + id.aumid = AumidOf(proc); + CloseHandle(proc); + return id; +} + +} // namespace + +ForegroundIdentity ForegroundWatcher::Current() { + HWND hwnd = GetForegroundWindow(); + if (!hwnd) return {}; + + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + ForegroundIdentity id = IdentifyProcess(pid); + + if (!id.exePath.empty() && IsFrameHost(id.exePath)) { + FrameChildSearch search{ pid, nullptr }; + EnumChildWindows(hwnd, FindFrameChild, reinterpret_cast(&search)); + if (search.found) { + DWORD childPid = 0; + GetWindowThreadProcessId(search.found, &childPid); + ForegroundIdentity hosted = IdentifyProcess(childPid); + // Only take the hosted identity if it resolved; a frame with no + // reachable child is still better described by the frame itself + // than by nothing at all. + if (!hosted.Empty()) id = hosted; + } + } + + return id; +} + +void CALLBACK ForegroundWatcher::HookProc(HWINEVENTHOOK, DWORD event, HWND hwnd, + LONG idObject, LONG, DWORD, DWORD) { + if (!s_instance || !s_instance->m_hwnd) return; + if (event != EVENT_SYSTEM_FOREGROUND || idObject != OBJID_WINDOW || !hwnd) return; + + // Deliberately does not resolve anything here. The window that raised the + // event is often not the one focus settles on, so the timer re-reads the + // foreground when it expires and a burst collapses into one answer. + SetTimer(s_instance->m_hwnd, IDT_DEBOUNCE, DEBOUNCE_MS, nullptr); +} + +LRESULT CALLBACK ForegroundWatcher::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_TIMER && wp == IDT_DEBOUNCE && s_instance) { + KillTimer(hwnd, IDT_DEBOUNCE); + s_instance->OnDebounceElapsed(); + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +void ForegroundWatcher::OnDebounceElapsed() { + const ForegroundIdentity now = Current(); + // An unidentifiable foreground is not a change worth reporting: it says + // nothing about what the user switched to, and treating it as "no game" + // would drop a profile every time focus passed through something we + // cannot open. + if (now.Empty() || now == m_last) return; + m_last = now; + if (m_onChange) m_onChange(now); +} + +bool ForegroundWatcher::Start(ChangedFn onChange) { + Stop(); + m_onChange = std::move(onChange); + s_instance = this; + + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = WndProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = CLASS_NAME; + RegisterClassExW(&wc); // OK if already registered + + // Message-only: it exists purely to own the debounce timer, and must + // never appear anywhere a user could see it. + m_hwnd = CreateWindowExW(0, CLASS_NAME, L"", 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, wc.hInstance, nullptr); + if (!m_hwnd) { + s_instance = nullptr; + return false; + } + + // SKIPOWNPROCESS: our own tray menu and settings window taking focus is + // not the user switching applications, and would otherwise clear whatever + // profile is running the moment they opened the menu. + m_hook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, + nullptr, HookProc, 0, 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + if (!m_hook) { + DestroyWindow(m_hwnd); + m_hwnd = nullptr; + s_instance = nullptr; + return false; + } + + m_last = Current(); + return true; +} + +void ForegroundWatcher::Stop() { + if (m_hook) { + UnhookWinEvent(m_hook); + m_hook = nullptr; + } + if (m_hwnd) { + KillTimer(m_hwnd, IDT_DEBOUNCE); + DestroyWindow(m_hwnd); + m_hwnd = nullptr; + } + if (s_instance == this) s_instance = nullptr; + m_onChange = nullptr; + m_last = {}; +} diff --git a/src/app/ForegroundWatcher.h b/src/app/ForegroundWatcher.h new file mode 100644 index 0000000..aeef8c5 --- /dev/null +++ b/src/app/ForegroundWatcher.h @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include + +// Who owns the window the user is currently working in. +// +// Both fields are filled where they apply: every process has an image path, +// and only a packaged (MSIX/UWP) one has an AUMID. A foreground window we +// could not identify at all — a protected process, or one that closed while +// being asked — reports empty, which callers should read as "unknown", never +// as "the desktop". +struct ForegroundIdentity { + std::wstring exePath; + std::wstring aumid; + + bool Empty() const { return exePath.empty() && aumid.empty(); } + bool operator==(const ForegroundIdentity& o) const { + return _wcsicmp(exePath.c_str(), o.exePath.c_str()) == 0 + && _wcsicmp(aumid.c_str(), o.aumid.c_str()) == 0; + } + bool operator!=(const ForegroundIdentity& o) const { return !(*this == o); } +}; + +// Reports which application the user has switched to, without polling. +// +// Built on SetWinEventHook(EVENT_SYSTEM_FOREGROUND), which is the only +// push-based answer to "what is the user doing now" available to a process +// that never runs elevated: the event-driven alternatives for watching +// processes start (Win32_ProcessStartTrace, an ETW kernel session) all +// require administrator rights, and the unelevated WMI route polls +// internally. Registering costs one hook and no thread; the callback only +// runs when focus actually changes, which is a human-scale rate. +// +// Focus is also the better question than "did a game launch". A launcher +// that starts a game and stays resident — Ubisoft Connect, the EA app — +// keeps its process alive long after the game exits, so "is it running" +// answers yes for far too long. What is in front does not have that problem, +// and it means alt-tabbing to a browser hands the desktop its own settings +// back. +class ForegroundWatcher { +public: + using ChangedFn = std::function; + + ~ForegroundWatcher() { Stop(); } + + // Begins reporting. The callback runs on the calling thread, which must + // be the one pumping messages — out-of-context hooks are delivered + // through the message queue, and the debounce below is a window timer. + bool Start(ChangedFn onChange); + void Stop(); + + // Resolve the foreground window right now, for callers that need to + // catch up rather than wait for the next switch (taking the controller + // back, say, when the user has been sitting in a game the whole time). + static ForegroundIdentity Current(); + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); + static void CALLBACK HookProc(HWINEVENTHOOK hook, DWORD event, HWND hwnd, + LONG idObject, LONG idChild, + DWORD thread, DWORD time); + void OnDebounceElapsed(); + + HWINEVENTHOOK m_hook = nullptr; + HWND m_hwnd = nullptr; // message-only, owns the debounce timer + ChangedFn m_onChange; + ForegroundIdentity m_last; + + static ForegroundWatcher* s_instance; + + static constexpr wchar_t CLASS_NAME[] = L"SteamlessForegroundWatcher"; + static constexpr UINT_PTR IDT_DEBOUNCE = 1; + // Focus churns while an application starts — splash screens, launcher + // windows, the game's own window replacing its loader — and every one of + // those is a foreground change. Settling first keeps a burst from being + // read as several different applications, which matters because a + // profile switch can rebuild the virtual controller. + static constexpr UINT DEBOUNCE_MS = 400; +}; diff --git a/src/app/GameLibrary.cpp b/src/app/GameLibrary.cpp new file mode 100644 index 0000000..6bf9a8a --- /dev/null +++ b/src/app/GameLibrary.cpp @@ -0,0 +1,286 @@ +#define NOMINMAX // avoid Windows.h's min/max macros colliding with C++/WinRT templates +#include "GameLibrary.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#pragma comment(lib, "shlwapi.lib") + +namespace { + +// Targets under the Windows directory are system utilities (Notepad, Control +// Panel applets, accessories) that Start Menu shortcuts surface right +// alongside real applications — filtered out so the picker isn't dominated +// by them. +bool IsUnderWindowsDir(const std::wstring& path) { + wchar_t winDir[MAX_PATH]; + if (!GetWindowsDirectoryW(winDir, MAX_PATH)) return false; + const size_t len = wcslen(winDir); + return path.size() > len + && _wcsnicmp(path.c_str(), winDir, len) == 0 + && (path[len] == L'\\' || path[len] == L'\0'); +} + +// Shortcut names that are never the game/app itself, just noise that rides +// along with it in the same Start Menu folder — "Uninstall X", "X Readme". +// Only applied to .lnk-sourced entries: a real name can contain one of these +// words as part of a longer word ("Truck-kun is Supporting Me..." contains +// "support"), so matching is whole-word, not substring. +bool LooksLikeNoise(const std::wstring& name) { + static const wchar_t* kNoisePhrases[] = { L"read me" }; // has a space; safe as a substring + static const wchar_t* kNoiseWords[] = { + L"uninstall", L"readme", L"license", L"changelog", + L"help", L"documentation", L"website", L"support", L"faq", + }; + + std::wstring lower = name; + for (wchar_t& c : lower) c = static_cast(towlower(c)); + + for (const wchar_t* phrase : kNoisePhrases) + if (lower.find(phrase) != std::wstring::npos) return true; + + size_t start = 0; + while (start < lower.size()) { + size_t end = start; + while (end < lower.size() && iswalnum(lower[end])) ++end; + if (end > start) { + const std::wstring word = lower.substr(start, end - start); + for (const wchar_t* noise : kNoiseWords) + if (word == noise) return true; + } + start = end + 1; + } + return false; +} + +// Case-insensitive identity for dedup. Only a bare filesystem path benefits +// from PathCanonicalizeW (collapsing "." / ".." segments); running it over a +// "steam://" URI or an "aumid:" id risks mangling them for no reason, since +// neither one follows filesystem path rules. +std::wstring DedupKey(const std::wstring& id) { + std::wstring key; + if (id.rfind(L"steam://", 0) == 0 || id.rfind(L"aumid:", 0) == 0) { + key = id; + } else { + wchar_t buf[MAX_PATH]; + key = PathCanonicalizeW(buf, id.c_str()) ? buf : id; + } + for (wchar_t& c : key) c = static_cast(towlower(c)); + return key; +} + +// A file extension worth resolving further — anything else in the Start +// Menu (icons, config, whatever an installer left behind) is not a shortcut. +bool HasExtension(const std::wstring& path, const wchar_t* ext) { + const size_t dot = path.find_last_of(L'.'); + return dot != std::wstring::npos && _wcsicmp(path.c_str() + dot, ext) == 0; +} + +void WalkDirectory(const std::wstring& dir, std::vector& shortcutPaths) { + WIN32_FIND_DATAW fd{}; + HANDLE find = FindFirstFileW((dir + L"\\*").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) return; + + do { + if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) + continue; + const std::wstring full = dir + L"\\" + fd.cFileName; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + WalkDirectory(full, shortcutPaths); + } else if (HasExtension(full, L".lnk") || HasExtension(full, L".url")) { + shortcutPaths.push_back(full); + } + } while (FindNextFileW(find, &fd)); + FindClose(find); +} + +// Empty when the shortcut has no literal file target — the case for a +// packaged/UWP app, which resolves through an AUMID instead of a path. +std::wstring ResolveLnkTarget(IShellLinkW* link) { + wchar_t target[MAX_PATH] = {}; + if (FAILED(link->GetPath(target, MAX_PATH, nullptr, 0)) || target[0] == L'\0') + return {}; + return target; +} + +// Every Steam-installed game gets one of these instead of a .lnk — an +// Internet Shortcut whose URL is "steam://rungameid/". Anything else +// under a "URL=" line (Steam also drops a couple of plain website links in +// the same folder, e.g. its support center) is not a game and is skipped. +// .url files are plain INI-style text, always ASCII in the one line this +// cares about, so a byte-for-byte narrow read is safe. +std::wstring ResolveUrlSteamId(const std::wstring& path) { + FILE* f = nullptr; + if (_wfopen_s(&f, path.c_str(), L"r") != 0 || !f) return {}; + + static constexpr char kLinePrefix[] = "URL="; + static constexpr size_t kLinePrefixLen = sizeof(kLinePrefix) - 1; + static constexpr char kUriPrefix[] = "steam://rungameid/"; + static constexpr size_t kUriPrefixLen = sizeof(kUriPrefix) - 1; + + char line[512]; + std::string uri; + while (fgets(line, sizeof(line), f)) { + if (strncmp(line, kLinePrefix, kLinePrefixLen) != 0) continue; + const char* value = line + kLinePrefixLen; + if (strncmp(value, kUriPrefix, kUriPrefixLen) != 0) continue; + uri = value; // keep the steam:// URI itself, "URL=" already dropped + break; + } + fclose(f); + + while (!uri.empty() && (uri.back() == '\n' || uri.back() == '\r')) + uri.pop_back(); + if (uri.size() <= kUriPrefixLen) return {}; + // The appid must be purely numeric — anything else means this line was + // not the simple form assumed above, and guessing further isn't worth it. + for (size_t i = kUriPrefixLen; i < uri.size(); ++i) + if (uri[i] < '0' || uri[i] > '9') return {}; + + return std::wstring(uri.begin(), uri.end()); +} + +std::wstring NameFromShortcutPath(const std::wstring& shortcutPath) { + std::wstring name = shortcutPath; + const size_t slash = name.find_last_of(L'\\'); + if (slash != std::wstring::npos) name.erase(0, slash + 1); + const size_t dot = name.find_last_of(L'.'); + if (dot != std::wstring::npos) name.erase(dot); + return name; +} + +// Packaged (MSIX/UWP) apps — the Xbox app's Game Pass titles among them — +// register no shortcut file the Start Menu walk above can see; Windows +// builds their Start Menu presence straight from the package manifest. +// Resolved through the real WinRT PackageManager API rather than the +// simpler shell:AppsFolder namespace specifically because only PackageManager +// exposes each package's signing info — Playnite's own Xbox library extension +// filters on exactly this (see its Programs2.cs::GetUWPApps()) to drop +// vendor/OEM-signed utilities (GPU control panels, audio consoles) that +// register as packaged apps but were never distributed through the Store, +// and would otherwise flood this list alongside actual Store/Game Pass +// titles. shell:AppsFolder's IShellItem properties have no equivalent. +// +// This still can't tell a game apart from any other Store app — Calculator +// and Paint are Store-signed too. Nothing local can make that call; Playnite +// only gets it by cross-referencing each package against Microsoft's Xbox +// Live catalog over an authenticated account, which is out of scope here. +// That filtering is left to the user picking from the list, same as +// everywhere else in this file. +std::vector EnumeratePackagedApps() { + std::vector games; + + try { + // Matches the STA the classic-COM shortcut resolution above already + // set up via CoInitializeEx — WinRT's activation layer needs its own + // compatible init call on top of that, not a competing one. + winrt::init_apartment(winrt::apartment_type::single_threaded); + + winrt::Windows::Management::Deployment::PackageManager manager; + for (const auto& package : manager.FindPackagesForUser(L"")) { + if (package.IsFramework() || package.IsResourcePackage()) continue; + if (package.SignatureKind() != winrt::Windows::ApplicationModel::PackageSignatureKind::Store) + continue; + + for (const auto& entry : package.GetAppListEntries()) { + const std::wstring name(entry.DisplayInfo().DisplayName()); + const std::wstring aumid(entry.AppUserModelId()); + if (name.empty() || aumid.empty()) continue; + games.push_back({ name, L"aumid:" + aumid }); + } + } + } catch (const winrt::hresult_error&) { + // Best-effort: an empty result here just means the picker falls back + // to whatever the Start Menu walk found on its own. + } + + return games; +} + +} // namespace + +namespace GameLibrary { + +std::vector EnumerateInstalled() { + std::vector games; + + // COM may already be initialized on this thread (WebView2 runs on the + // same UI thread once opened) — only tear down what we set up ourselves. + const HRESULT coInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + const bool comOwnedByUs = coInit == S_OK || coInit == S_FALSE; + + std::vector shortcutPaths; + for (REFKNOWNFOLDERID folder : { FOLDERID_CommonPrograms, FOLDERID_Programs }) { + PWSTR path = nullptr; + if (SUCCEEDED(SHGetKnownFolderPath(folder, 0, nullptr, &path)) && path) + WalkDirectory(path, shortcutPaths); + if (path) CoTaskMemFree(path); + } + + std::unordered_set seen; + for (const auto& shortcutPath : shortcutPaths) { + std::wstring id; + + bool isSteamLink = false; + if (HasExtension(shortcutPath, L".url")) { + id = ResolveUrlSteamId(shortcutPath); + if (id.empty()) continue; // not a "play this game" link + isSteamLink = true; + } else { + Microsoft::WRL::ComPtr link; + if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&link)))) + continue; + Microsoft::WRL::ComPtr file; + if (FAILED(link.As(&file)) || FAILED(file->Load(shortcutPath.c_str(), STGM_READ))) + continue; + + id = ResolveLnkTarget(link.Get()); + if (id.empty()) continue; // packaged app — not this pass + if (!HasExtension(id, L".exe")) continue; + if (GetFileAttributesW(id.c_str()) == INVALID_FILE_ATTRIBUTES) + continue; // shortcut is stale — target no longer exists + if (IsUnderWindowsDir(id)) continue; + } + + // The shortcut's own filename is the friendly name a user or + // installer chose ("Rainbow Six Siege"); the exe's own name rarely + // is ("RainbowSix.exe") — so the display name comes from the link, + // not the target. + std::wstring name = NameFromShortcutPath(shortcutPath); + // A steam://rungameid/ link is already proof this is a real, + // launchable game — Steam's own non-game links (support center, EULA) + // don't take that form, so the noise-word filter only has false + // positives left to contribute here (a game title that happens to + // contain one of those words, as this app's own test machine has). + if (!isSteamLink && LooksLikeNoise(name)) continue; + + if (!seen.insert(DedupKey(id)).second) + continue; // already have this game from another shortcut + + games.push_back({ std::move(name), std::move(id) }); + } + + for (auto& game : EnumeratePackagedApps()) { + if (!seen.insert(DedupKey(game.id)).second) + continue; + games.push_back(std::move(game)); + } + + if (comOwnedByUs) CoUninitialize(); + + std::sort(games.begin(), games.end(), [](const InstalledGame& a, const InstalledGame& b) { + return _wcsicmp(a.name.c_str(), b.name.c_str()) < 0; + }); + return games; +} + +} // namespace GameLibrary diff --git a/src/app/GameLibrary.h b/src/app/GameLibrary.h new file mode 100644 index 0000000..4c82b13 --- /dev/null +++ b/src/app/GameLibrary.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +// One installed application resolved from a Start Menu shortcut. Called a +// "game" for the feature it serves, but nothing here can actually tell a +// game from any other program — that filtering is left to the user picking +// from the list. +struct InstalledGame { + std::wstring name; // the shortcut's own name, e.g. "Rainbow Six Siege" + // Opaque stable identity for this game — what per-game profiles are keyed + // on. Takes one of three shapes depending on where the entry came from: + // - a resolved exe path, for an ordinary Start Menu .lnk; + // - "steam://rungameid/N", for a Steam game — Steam shortcuts its + // library with .url Internet Shortcuts rather than .lnk files, and + // that URI identifies the game precisely (matches the RunningAppID + // SteamWatcher already reads) without needing to locate its install + // directory; + // - "aumid:PackageFamilyName!AppId", for a packaged/MSIX app (Xbox app + // and Microsoft Store titles among them) resolved through + // shell:AppsFolder rather than any shortcut file, since those apps + // register no shortcut file for the Start Menu walk to find. + std::wstring id; +}; + +namespace GameLibrary { + +// Combines two sources into one list, sorted by name, one entry per distinct +// game: +// - both Start Menu Programs folders (current user and all users), walked +// for .lnk shortcuts (resolved to a target executable) and .url +// shortcuts (resolved to a Steam game's steam://rungameid/N); and +// - shell:AppsFolder, for packaged/MSIX apps — Xbox app and Microsoft +// Store titles among them — which register no shortcut file in the +// Start Menu folders for the walk above to find. +// +// Broad coverage from one code path, at the cost of occasionally surfacing a +// launcher stub instead of the real game (a shortcut that punches out to +// another launcher, for example) and of not being able to tell an actual +// game apart from any other installed program — that filtering is left to +// the user picking from the list. +std::vector EnumerateInstalled(); + +} diff --git a/src/app/GameProfiles.cpp b/src/app/GameProfiles.cpp new file mode 100644 index 0000000..9e32a07 --- /dev/null +++ b/src/app/GameProfiles.cpp @@ -0,0 +1,133 @@ +#include "GameProfiles.h" +#include +#include + +namespace { + +constexpr wchar_t kProfilesKey[] = L"Software\\SteamlessController\\GameProfiles"; + +DWORD ReadDw(HKEY key, const wchar_t* name, DWORD def) { + DWORD val = 0, size = sizeof(val); + if (RegQueryValueExW(key, name, nullptr, nullptr, + reinterpret_cast(&val), &size) == ERROR_SUCCESS) + return val; + return def; +} + +std::wstring ReadSz(HKEY key, const wchar_t* name) { + wchar_t buf[MAX_PATH] = {}; + DWORD size = sizeof(buf); + if (RegQueryValueExW(key, name, nullptr, nullptr, + reinterpret_cast(buf), &size) == ERROR_SUCCESS) + return buf; + return {}; +} + +void WriteDw(HKEY key, const wchar_t* name, DWORD val) { + RegSetValueExW(key, name, 0, REG_DWORD, + reinterpret_cast(&val), sizeof(val)); +} + +void WriteSz(HKEY key, const wchar_t* name, const std::wstring& val) { + RegSetValueExW(key, name, 0, REG_SZ, + reinterpret_cast(val.c_str()), + static_cast((val.size() + 1) * sizeof(wchar_t))); +} + +} // namespace + +namespace GameProfiles { + +std::map Load() { + std::map profiles; + + HKEY parent; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kProfilesKey, 0, KEY_READ, &parent) != ERROR_SUCCESS) + return profiles; + + const DWORD unbound = BackButtonBinding::FromAction(BackButtonAction::None).Pack(); + + // Numbered subkeys ("0", "1", ...) rather than the game id itself — an + // exe-path id is not a legal single registry key name, since its + // backslashes would be read as key-hierarchy separators. + for (DWORD i = 0;; ++i) { + wchar_t sub[16]; + swprintf_s(sub, L"%lu", i); + HKEY child; + if (RegOpenKeyExW(parent, sub, 0, KEY_READ, &child) != ERROR_SUCCESS) + break; + + const std::wstring id = ReadSz(child, L"Id"); + if (!id.empty()) { + ControllerProfile p; + p.displayName = ReadSz(child, L"Name"); + p.platform = ReadDw(child, L"Platform", 0) != 0 ? ControllerPlatform::PlayStation + : ControllerPlatform::Xbox; + p.back.l4 = BackButtonBinding::Unpack(ReadDw(child, L"L4", unbound)); + p.back.l5 = BackButtonBinding::Unpack(ReadDw(child, L"L5", unbound)); + p.back.r4 = BackButtonBinding::Unpack(ReadDw(child, L"R4", unbound)); + p.back.r5 = BackButtonBinding::Unpack(ReadDw(child, L"R5", unbound)); + // Profiles written before per-pad settings existed have none of + // these values; the defaults leave both pads unclaimed, which is + // exactly how those profiles behaved. + p.leftPad.mode = TrackpadModeFromDword(ReadDw(child, L"LeftPadMode", 0)); + p.leftPad.click = BackButtonBinding::Unpack(ReadDw(child, L"LeftPadClick", unbound)); + p.leftPad.scrollDir = ScrollDirectionFromDword(ReadDw(child, L"LeftPadScrollDir", 0)); + p.rightPad.mode = TrackpadModeFromDword(ReadDw(child, L"RightPadMode", 0)); + p.rightPad.click = BackButtonBinding::Unpack(ReadDw(child, L"RightPadClick", unbound)); + p.rightPad.scrollDir = ScrollDirectionFromDword(ReadDw(child, L"RightPadScrollDir", 0)); + profiles[id] = p; + } + RegCloseKey(child); + } + + RegCloseKey(parent); + return profiles; +} + +void Save(const std::map& profiles) { + HKEY parent; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kProfilesKey, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, nullptr, + &parent, nullptr) != ERROR_SUCCESS) + return; + + DWORD i = 0; + for (const auto& [id, p] : profiles) { + wchar_t sub[16]; + swprintf_s(sub, L"%lu", i); + HKEY child; + if (RegCreateKeyExW(parent, sub, 0, nullptr, REG_OPTION_NON_VOLATILE, + KEY_WRITE, nullptr, &child, nullptr) == ERROR_SUCCESS) { + WriteSz(child, L"Id", id); + WriteSz(child, L"Name", p.displayName); + WriteDw(child, L"Platform", + p.platform == ControllerPlatform::PlayStation ? 1 : 0); + WriteDw(child, L"L4", p.back.l4.Pack()); + WriteDw(child, L"L5", p.back.l5.Pack()); + WriteDw(child, L"R4", p.back.r4.Pack()); + WriteDw(child, L"R5", p.back.r5.Pack()); + WriteDw(child, L"LeftPadMode", static_cast(p.leftPad.mode)); + WriteDw(child, L"LeftPadClick", p.leftPad.click.Pack()); + WriteDw(child, L"LeftPadScrollDir", static_cast(p.leftPad.scrollDir)); + WriteDw(child, L"RightPadMode", static_cast(p.rightPad.mode)); + WriteDw(child, L"RightPadClick", p.rightPad.click.Pack()); + WriteDw(child, L"RightPadScrollDir", static_cast(p.rightPad.scrollDir)); + RegCloseKey(child); + } + ++i; + } + + // Numbered subkeys have no children of their own, so dropping whatever + // range existed past the current count is a plain per-key delete — no + // recursive-delete dependency needed when the map has shrunk. + for (;; ++i) { + wchar_t sub[16]; + swprintf_s(sub, L"%lu", i); + if (RegDeleteKeyW(parent, sub) != ERROR_SUCCESS) break; + } + + RegCloseKey(parent); +} + +} // namespace GameProfiles diff --git a/src/app/GameProfiles.h b/src/app/GameProfiles.h new file mode 100644 index 0000000..5e18ad5 --- /dev/null +++ b/src/app/GameProfiles.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include +#include "TrackpadConfig.h" + +// Per-game overrides, keyed by the game's id (the same opaque identity +// GameLibrary::InstalledGame::id produces — an exe path for most shortcuts, a +// "steam://rungameid/N" URI for Steam games, an "aumid:..." for packaged +// apps). A game with no entry here uses the global default profile instead — +// most users never create any of these. +namespace GameProfiles { + +std::map Load(); +void Save(const std::map& profiles); + +} diff --git a/src/app/KeyInput.cpp b/src/app/KeyInput.cpp index 8765da9..c4e127a 100644 --- a/src/app/KeyInput.cpp +++ b/src/app/KeyInput.cpp @@ -44,6 +44,10 @@ const JsCodeToVk kCodeMap[] = { {"ShiftLeft", VK_LSHIFT}, {"ShiftRight", VK_RSHIFT}, {"ControlLeft", VK_LCONTROL}, {"ControlRight", VK_RCONTROL}, {"AltLeft", VK_LMENU}, {"AltRight", VK_RMENU}, + // Present for completeness, not because they can be captured: Windows + // opens the Start menu on the way down, so the page is never told. A + // binding that wants Windows carries it as a modifier flag instead. + {"MetaLeft", VK_LWIN}, {"MetaRight", VK_RWIN}, // Function row {"F1",VK_F1},{"F2",VK_F2},{"F3",VK_F3},{"F4",VK_F4},{"F5",VK_F5},{"F6",VK_F6}, @@ -121,6 +125,89 @@ uint16_t VkFromJsCode(const std::string& jsCode) { return 0; } +namespace { + +// The physical key each modifier flag sends. Left-hand keys throughout: +// applications test the merged state, and a left modifier is what a user +// pressing the shortcut themselves would almost always produce. +struct ModifierKey { BackButtonBinding::Modifier flag; uint16_t vk; const wchar_t* name; }; + +// Order matters twice over: it is the order modifiers are pressed in, and the +// order they read in a label. Ctrl, Alt, Shift, Win is the convention Windows +// itself uses when it spells out a shortcut. +const ModifierKey kModifierKeys[] = { + { BackButtonBinding::ModCtrl, VK_LCONTROL, L"Ctrl" }, + { BackButtonBinding::ModAlt, VK_LMENU, L"Alt" }, + { BackButtonBinding::ModShift, VK_LSHIFT, L"Shift" }, + { BackButtonBinding::ModWin, VK_LWIN, L"Win" }, +}; + +// How many live bindings are currently holding each modifier down, indexed +// alongside kModifierKeys. Only ever touched from the read thread that +// dispatches bindings, which is the same thread for every slot. +int g_modifierHolds[std::size(kModifierKeys)] = {}; + +// Whether this app is the one holding the key down, as opposed to having +// found it already held. Recorded rather than re-tested on release, because +// the test cannot tell the two apart: an injected key reads as pressed to +// GetAsyncKeyState exactly like a real one, so asking again on the way out +// would see our own press and decline to undo it. +bool g_modifierSentByUs[std::size(kModifierKeys)] = {}; + +// Whether the key is down right now, by anyone's hand. Checked only before a +// press, where the answer means "the user is holding it" — nothing of ours is +// down at that point, or the hold count would have caught it first. +bool PhysicallyHeld(uint16_t vk) { + // The merged virtual key, not the left/right one: the user may be holding + // the right-hand key while this sends the left, and either satisfies the + // shortcut. + uint16_t merged = vk; + switch (vk) { + case VK_LCONTROL: merged = VK_CONTROL; break; + case VK_LMENU: merged = VK_MENU; break; + case VK_LSHIFT: merged = VK_SHIFT; break; + default: break; + } + return (GetAsyncKeyState(merged) & 0x8000) != 0; +} + +} // namespace + +void SendModifiers(uint8_t mods, bool down) { + for (size_t i = 0; i < std::size(kModifierKeys); ++i) { + const auto& m = kModifierKeys[i]; + if (!(mods & m.flag)) continue; + + if (down) { + // Only the first binding to want it does anything; the rest just + // join the count so the key outlives their individual releases. + if (g_modifierHolds[i]++ > 0) continue; + // Already down under the user's own finger — leave it theirs, and + // remember not to release what we never pressed. + g_modifierSentByUs[i] = !PhysicallyHeld(m.vk); + if (g_modifierSentByUs[i]) SendKeyInput(m.vk, true); + } else { + if (g_modifierHolds[i] == 0) continue; // never pressed, or already undone + if (--g_modifierHolds[i] > 0) continue; // another binding still wants it + if (!g_modifierSentByUs[i]) continue; + g_modifierSentByUs[i] = false; + SendKeyInput(m.vk, false); + } + } +} + +std::wstring KeyComboDisplayName(const BackButtonBinding& binding) { + if (binding.kind != BackButtonBinding::Kind::Key) return L"Key"; + + std::wstring name; + for (const auto& m : kModifierKeys) { + if (!(binding.mods & m.flag)) continue; + name += m.name; + name += L" + "; + } + return name + KeyDisplayName(binding.code); +} + std::wstring KeyDisplayName(uint16_t vk) { if (vk == 0) return L"Key"; diff --git a/src/app/KeyInput.h b/src/app/KeyInput.h index c840685..89f1ae9 100644 --- a/src/app/KeyInput.h +++ b/src/app/KeyInput.h @@ -2,6 +2,7 @@ #include #include #include +#include "BackButtonConfig.h" // Keyboard support for paddle bindings. // @@ -20,6 +21,11 @@ uint16_t VkFromJsCode(const std::string& jsCode); // has no name for it. std::wstring KeyDisplayName(uint16_t vk); +// Name for a whole key binding, modifiers included: "Ctrl + Alt + K". The +// modifier names are fixed rather than drawn from the layout, because that is +// how every application spells them in its own shortcut lists. +std::wstring KeyComboDisplayName(const BackButtonBinding& binding); + // Presses or releases a key. Sends a scan code rather than a virtual key, and // flags the extended keys, because software that reads scan codes directly // (games, in particular) sees nothing useful otherwise. @@ -30,6 +36,21 @@ std::wstring KeyDisplayName(uint16_t vk); // to repeat has to re-send the press itself, paced by the two values below. void SendKeyInput(uint16_t vk, bool down); +// Presses or releases the modifiers of a binding around its base key. +// +// Two things make this more than a loop over SendKeyInput. +// +// A modifier the user is physically holding must be left alone. Windows +// tracks one global state per key, so releasing Ctrl on a binding's behalf +// while the real Ctrl is still down tells every application the key came up, +// and the keyboard behaves oddly until the user taps it again. +// +// And two bindings can want the same modifier at once — paddles bound to +// Ctrl+C and Ctrl+V, pressed overlapping. Releasing the first must not drop +// the Ctrl the second is still relying on, so presses are counted and the key +// is released on the last one out. +void SendModifiers(uint8_t mods, bool down); + // The user's Windows keyboard settings: how long a key must be held before it // starts repeating, and the gap between repeats after that. Read fresh rather // than cached at startup so a change in Control Panel is picked up. diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index a912931..3331de9 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -71,6 +71,23 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .btn-cancel:hover{border-color:#c0392b;color:#fff;background:rgba(192,57,43,.08);} .btn-row-reset{padding:6px 12px;border-radius:5px;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.03);color:#8f98a0;font-size:12px;font-weight:600;transition:border-color .15s,color .15s,background .15s;} .btn-row-reset:hover{border-color:#66c0f4;color:#66c0f4;background:rgba(102,192,244,.06);} +.combo-wrap{position:relative;} +.combo-toggle{width:100%;background:#101925;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13.5px;font-family:'Barlow',system-ui,sans-serif;padding:9px 12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;} +.combo-toggle:hover{border-color:#66c0f4;} +.combo-toggle .chev{color:#5c6b78;font-size:11px;flex:none;} +.combo-panel{position:absolute;top:calc(100% + 6px);left:0;right:0;background:#101925;border:1px solid rgba(102,192,244,.3);border-radius:8px;box-shadow:0 12px 28px rgba(0,0,0,.45);padding:10px;z-index:20;} +.combo-search{width:100%;box-sizing:border-box;background:#0b131c;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13px;font-family:'Barlow',system-ui,sans-serif;padding:8px 10px;} +.combo-search:focus{outline:none;border-color:#66c0f4;} +.combo-section-label{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:1px;color:#5c6b78;font-weight:600;margin:10px 2px 6px;} +.combo-list{max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;} +.combo-item{padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;color:#c7d5e0;} +.combo-item:hover{background:rgba(102,192,244,.12);color:#fff;} +.combo-item.active{background:rgba(102,192,244,.18);color:#fff;} +.combo-empty{padding:10px;font-size:12px;color:#5c6b78;text-align:center;} +.mode-row{padding-top:14px;padding-bottom:14px;} +.mode-select{background:#101925;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13px;font-family:'Barlow',system-ui,sans-serif;padding:7px 10px;font-weight:600;cursor:pointer;flex:none;min-width:215px;} +.mode-select:hover{border-color:#66c0f4;} +.mode-select:focus{outline:none;border-color:#66c0f4;} .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;} @@ -79,6 +96,11 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .picker-pill:hover{border-color:#66c0f4;background:rgba(102,192,244,.1);} .chip-sm{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;flex:none;} .picker-name{font-size:12px;color:#c7d5e0;font-weight:600;} +.mod-pill{padding:6px 14px;border-radius:7px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.03);color:#aeb9c2;font-size:12px;font-weight:700;letter-spacing:.3px;transition:border-color .15s,color .15s,background .15s;} +.mod-pill:hover{border-color:#66c0f4;color:#66c0f4;} +.mod-pill.on{border-color:#66c0f4;background:rgba(102,192,244,.18);color:#fff;} +.mod-note{font-size:11.5px;color:#5c6b78;line-height:1.45;margin-top:9px;} +.mod-note b{color:#8f98a0;font-weight:700;} #footer{height:62px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 20px;background:#15202c;border-top:1px solid rgba(0,0,0,.45);} .footer-right{display:flex;gap:10px;align-items:center;} .btn-reset{padding:9px 16px;border-radius:3px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13.5px;transition:color .15s;} @@ -86,6 +108,17 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .btn-apply{padding:9px 22px;border-radius:3px;border:1px solid #4c7a1d;background:linear-gradient(to bottom,#94c63d,#5d8a1f);color:#13260a;font-weight:700;font-size:13.5px;box-shadow:0 1px 0 rgba(255,255,255,.25) inset;min-width:96px;text-align:center;transition:opacity .15s;} .btn-apply:hover{opacity:.9;} .btn-apply:disabled{opacity:.7;cursor:default;} +#modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:50;} +.modal{background:#1b2838;border:1px solid rgba(102,192,244,.3);border-radius:10px;box-shadow:0 20px 50px rgba(0,0,0,.6);padding:22px 24px;max-width:420px;} +.modal h3{margin:0 0 8px;font-size:16px;font-weight:800;color:#fff;} +.modal p{margin:0 0 18px;font-size:13.5px;color:#aeb9c2;line-height:1.5;} +.modal-btns{display:flex;gap:9px;justify-content:flex-end;} +.btn-discard{padding:8px 15px;border-radius:4px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13px;} +.btn-discard:hover{border-color:#c0392b;color:#fff;} +.btn-modal-cancel{padding:8px 15px;border-radius:4px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13px;} +.btn-modal-cancel:hover{color:#fff;} +.btn-save{padding:8px 18px;border-radius:4px;border:1px solid #4c7a1d;background:linear-gradient(to bottom,#94c63d,#5d8a1f);color:#13260a;font-weight:700;font-size:13px;} +.btn-save:hover{opacity:.9;} @@ -94,7 +127,7 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
SteamlessController - — Back Button Mapping + — Customize Controls
@@ -104,8 +137,67 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
-

Back Button Mapping

-

Click Rebind on a 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 paddle doing anything at all.

+

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
+
+ + +
+
+
+
CONTROLLER PLATFORM
+
+
+ Appear to games as +
+ +
+
+
+
+
LEFT TRACKPAD
+
+
+ Movement Mode +
+ +
+
+
+
+ Scroll Direction +
+ +
+
+
+
+
+
RIGHT TRACKPAD
+
+
+ Movement Mode +
+ +
+
+
+
+ Scroll Direction +
+ +
+
+
LEFT GRIP
@@ -121,10 +213,22 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back + + )HTML" // Split here only to stay under MSVC's ~16KB limit on a single string literal // (C2026); the two halves are concatenated back into one document. Markup and @@ -133,19 +237,111 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back R"HTML(