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(