From bd4206e89116f387d8c1b3c95eccf253c842e3f6 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 31 Aug 2026 01:33:18 -0700 Subject: [PATCH] Find far more games, and let a user name one we missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-game picker drew on two sources: a Start Menu shortcut walk and the installed MSIX package list. Issue #83 is a user whose app never appeared in it, asking to just point at an .exe. Two structural gaps sat behind that. Launcher-installed games were missing or wrong. Epic shortcuts a game with a .url carrying a com.epicgames.launcher:// URI, and the walk keeps only .url files pointing at Steam, so Epic games were dropped outright — Among Us was invisible on the machine this was written on. Battle.net and Ubisoft write .lnk files targeting the launcher executable with a per-game argument, and IShellLink::GetPath returns only the executable, so a whole Blizzard library collapsed into one wrongly-named "Battle.net Launcher.exe" entry. LauncherGames reads what each launcher records about itself instead: Epic's .item manifests, GOG's and Ubisoft's registry keys, Battle.net's uninstall entries. Those name an install directory rather than an executable, which is the better identity anyway and gets a fourth game-id shape, "dir:". A game folder holds its launcher, its anti-cheat service and the game itself, and which of them reaches the foreground varies by title and by moment — the same reasoning SteamAppLocator already documents for Steam, and MatchProfile resolves these by longest path prefix for the same reason. Nothing without an installer entry could be listed at all, which is the long tail the issue is really about. The picker now offers the applications with a window open right now, and a file browser, both producing an ordinary exe-path id that needed no new plumbing. The running list is the more useful of the two: it asks the user to know nothing about where a game was installed. Also moves the enumeration off the UI thread. It measured 1.4s warm and 5s cold before this change added five sources to it, and OpenRemapWindow was calling it synchronously — the window did not appear until it finished. Fixes #83 --- CMakeLists.txt | 9 +- README.md | 18 + resources/InnoInstallerScript.iss | 2 +- resources/resource.h | 4 +- src/app/ForegroundWatcher.cpp | 101 +---- src/app/ForegroundWatcher.h | 20 +- src/app/GameLibrary.cpp | 293 ++++++++++++++- src/app/GameLibrary.h | 107 ++++-- src/app/GameProfiles.h | 10 +- src/app/LauncherGames.cpp | 436 ++++++++++++++++++++++ src/app/LauncherGames.h | 54 +++ src/app/ProcessIdentity.cpp | 105 ++++++ src/app/ProcessIdentity.h | 40 ++ src/app/RemapWindow.cpp | 601 ++++++++++++++++++++++++------ src/app/RemapWindow.h | 95 ++++- src/app/TrayApp.cpp | 45 ++- src/probe/GameLibraryProbe.cpp | 66 +++- 17 files changed, 1702 insertions(+), 304 deletions(-) create mode 100644 src/app/LauncherGames.cpp create mode 100644 src/app/LauncherGames.h create mode 100644 src/app/ProcessIdentity.cpp create mode 100644 src/app/ProcessIdentity.h diff --git a/CMakeLists.txt b/CMakeLists.txt index bb971d6..4371d06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,13 +48,15 @@ if (MSVC) target_compile_options(RawControllerProbe PRIVATE /W4 /utf-8) endif() -# Game-library diagnostic probe — dumps what the Start Menu walk finds +# Game-library diagnostic probe — dumps every source the picker draws on add_executable(GameLibraryProbe src/probe/GameLibraryProbe.cpp src/app/GameLibrary.cpp + src/app/LauncherGames.cpp + src/app/ProcessIdentity.cpp ) target_include_directories(GameLibraryProbe PRIVATE src) -target_link_libraries(GameLibraryProbe PRIVATE ole32 shell32 shlwapi windowsapp) +target_link_libraries(GameLibraryProbe PRIVATE ole32 shell32 shlwapi version windowsapp) if (MSVC) target_compile_options(GameLibraryProbe PRIVATE /W4 /WX /utf-8) set_target_properties(GameLibraryProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE") @@ -65,6 +67,7 @@ endif() add_executable(ForegroundProbe src/probe/ForegroundProbe.cpp src/app/ForegroundWatcher.cpp + src/app/ProcessIdentity.cpp ) target_include_directories(ForegroundProbe PRIVATE src) if (MSVC) @@ -161,6 +164,8 @@ add_executable(SteamlessController WIN32 src/app/GameLibrary.cpp src/app/GameProfiles.cpp src/app/HandleFinder.cpp + src/app/LauncherGames.cpp + src/app/ProcessIdentity.cpp src/app/RemapWindow.cpp src/app/SteamAppLocator.cpp src/app/ViGEmBusInfo.cpp diff --git a/README.md b/README.md index b1eb487..67432a6 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ The filename must match `#define ViGEmSetup` at the top of the script. Inno reso | `ViGEmBusProbe` | Reports whether a ViGEm bus is present by enumerating its interface. Used by Setup to verify the driver installed, and shipped as a diagnostic. | | `SteamProbe` | Console diagnostic tool — dumps raw HID report bytes as you interact with the controller. Useful for protocol research. | | `RawControllerProbe` | Checks whether `Windows.Gaming.Input.RawGameController` can enumerate the Steam Controller (requires WinRT). | +| `GameLibraryProbe` | Console diagnostic — lists every application the per-game profile picker can find, with the source each came from and a per-source count. Ask for its output when someone reports that a game is missing from the picker. Given a directory, it instead runs only the Epic manifest reader against it. | ## How it works @@ -91,6 +92,23 @@ SteamlessController sends HID feature reports to disable lizard mode, then reads The full input report layout is documented in [`src/steam/SteamController.h`](src/steam/SteamController.h). +### Per-game profiles + +A profile can be attached to one game instead of applying everywhere. The game +is chosen from a picker that draws on several sources at once: both Start Menu +and both Desktop shortcut folders, the installed MSIX/Store package list, Steam's +own manifests, and the records kept by the Epic Games Store, GOG Galaxy, Ubisoft +Connect and Battle.net. Anything none of those know about — a portable build, an +emulator, an itch.io download — can be added by hand, either by picking it out of +the applications currently running or by browsing to its `.exe`. + +Whichever profile applies is decided by what is in the *foreground*, not by what +is running: a launcher that stays resident long after the game exits would +otherwise keep claiming the controller. See +[`src/app/ForegroundWatcher.h`](src/app/ForegroundWatcher.h) for why, and +[`src/app/GameLibrary.h`](src/app/GameLibrary.h) for the four shapes a game's +saved identity can take. + ## Third-party - [ViGEmClient](https://github.com/nefarius/ViGEmClient) — MIT License, built from source as a static library diff --git a/resources/InnoInstallerScript.iss b/resources/InnoInstallerScript.iss index 8e94266..77d3f92 100644 --- a/resources/InnoInstallerScript.iss +++ b/resources/InnoInstallerScript.iss @@ -3,7 +3,7 @@ ; Non-commercial use only #define MyAppName "SteamlessController" -#define MyAppVersion "1.18" +#define MyAppVersion "1.19" #define ViGEmSetup "ViGEmBus_1.22.0_x64_x86_arm64.exe" #define MyAppPublisher "Dylan Deverill" #define MyAppURL "https://github.com/ddeverill/SteamlessController" diff --git a/resources/resource.h b/resources/resource.h index 65b4ec0..7f457b9 100644 --- a/resources/resource.h +++ b/resources/resource.h @@ -13,6 +13,6 @@ // // Keep in step with MyAppVersion in resources/InnoInstallerScript.iss. #define APP_VERSION_MAJOR 1 -#define APP_VERSION_MINOR 18 +#define APP_VERSION_MINOR 19 #define APP_VERSION_PATCH 0 -#define APP_VERSION_STR "1.18" +#define APP_VERSION_STR "1.19" diff --git a/src/app/ForegroundWatcher.cpp b/src/app/ForegroundWatcher.cpp index 521edff..edcaf1a 100644 --- a/src/app/ForegroundWatcher.cpp +++ b/src/app/ForegroundWatcher.cpp @@ -1,106 +1,11 @@ #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 - +// The whole of resolving a window to an application now lives in +// ProcessIdentity, shared with the remap window's running-app picker. 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; + return ProcessIdentity::ForWindow(GetForegroundWindow()); } void CALLBACK ForegroundWatcher::HookProc(HWINEVENTHOOK, DWORD event, HWND hwnd, diff --git a/src/app/ForegroundWatcher.h b/src/app/ForegroundWatcher.h index aeef8c5..a1fe721 100644 --- a/src/app/ForegroundWatcher.h +++ b/src/app/ForegroundWatcher.h @@ -2,25 +2,7 @@ #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); } -}; +#include "ProcessIdentity.h" // Reports which application the user has switched to, without polling. // diff --git a/src/app/GameLibrary.cpp b/src/app/GameLibrary.cpp index 6bf9a8a..b1465f0 100644 --- a/src/app/GameLibrary.cpp +++ b/src/app/GameLibrary.cpp @@ -1,5 +1,7 @@ #define NOMINMAX // avoid Windows.h's min/max macros colliding with C++/WinRT templates #include "GameLibrary.h" +#include "LauncherGames.h" +#include "ProcessIdentity.h" #include #include #include @@ -16,17 +18,33 @@ 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'); +// The executables a launcher's own shortcuts point at. Every game a launcher +// installs gets a .lnk targeting one of these with a per-game argument, and +// IShellLink::GetPath hands back only the executable — so without this the +// whole Blizzard library resolves to one "Battle.net Launcher.exe" entry and +// DedupKey collapses it to a single wrongly-named game. Skipping them costs +// nothing now that LauncherGames reads each launcher's own records, which +// name the games properly and give a directory to match on. +// +// EA is the exception: its catalogue is encrypted with a key derived from the +// machine's hardware, so there is no enumerator for it and its games are +// simply absent rather than present-but-wrong. Absent is the better failure, +// and the manual picker in the remap window covers it. +bool IsLauncherStub(const std::wstring& exePath) { + static const wchar_t* kStubs[] = { + L"battle.net launcher.exe", L"battle.net.exe", + L"eadesktop.exe", L"eabackgroundservice.exe", L"origin.exe", + L"upc.exe", L"ubisoftconnect.exe", L"uplay.exe", + L"epicgameslauncher.exe", L"galaxyclient.exe", + }; + + const size_t slash = exePath.find_last_of(L'\\'); + std::wstring leaf = slash == std::wstring::npos ? exePath + : exePath.substr(slash + 1); + for (wchar_t& c : leaf) c = static_cast(towlower(c)); + for (const wchar_t* stub : kStubs) + if (leaf == stub) return true; + return false; } // Shortcut names that are never the game/app itself, just noise that rides @@ -63,11 +81,13 @@ bool LooksLikeNoise(const std::wstring& name) { // 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. +// "steam://" URI, an "aumid:" id or a "dir:" one risks mangling them for no +// reason, since none of the three follows filesystem path rules — the "dir:" +// prefix in particular would be read as a relative first segment. std::wstring DedupKey(const std::wstring& id) { std::wstring key; - if (id.rfind(L"steam://", 0) == 0 || id.rfind(L"aumid:", 0) == 0) { + if (id.rfind(L"steam://", 0) == 0 || id.rfind(L"aumid:", 0) == 0 + || id.rfind(L"dir:", 0) == 0) { key = id; } else { wchar_t buf[MAX_PATH]; @@ -194,7 +214,7 @@ std::vector EnumeratePackagedApps() { 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 }); + games.push_back({ name, L"aumid:" + aumid, GameSource::Packaged }); } } } catch (const winrt::hresult_error&) { @@ -205,8 +225,224 @@ std::vector EnumeratePackagedApps() { return games; } +// Lowercased, with a trailing separator, so a prefix test cannot match a +// sibling folder whose name merely starts the same way ("Portal" against +// "Portal 2") — the same convention SteamAppLocator uses on its own map. +std::wstring ContainmentPrefix(const std::wstring& dir) { + std::wstring key = dir; + for (wchar_t& c : key) c = static_cast(towlower(c)); + if (!key.empty() && key.back() != L'\\') key += L'\\'; + return key; +} + +bool IsInsideAny(const std::wstring& exePath, const std::vector& prefixes) { + std::wstring lower = exePath; + for (wchar_t& c : lower) c = static_cast(towlower(c)); + for (const auto& prefix : prefixes) + if (lower.compare(0, prefix.size(), prefix) == 0) return true; + return false; +} + } // namespace +struct LangCodePage { WORD language; WORD codePage; }; + +// One string out of a loaded version resource, for a given language block. +std::wstring VersionString(const std::vector& block, LangCodePage lang, + const wchar_t* field) { + wchar_t query[80]; + swprintf_s(query, L"\\StringFileInfo\\%04x%04x\\%ls", + lang.language, lang.codePage, field); + + wchar_t* value = nullptr; + UINT chars = 0; + if (!VerQueryValueW(const_cast(block.data()), query, + reinterpret_cast(&value), &chars) + || !value) + return {}; + + std::wstring text(value, chars); + // The returned length includes the terminator, and some resources pad. + while (!text.empty() && (text.back() == L'\0' || text.back() == L' ')) + text.pop_back(); + return text; +} + +// The name an executable calls itself in its version resource — "Among Us" +// for AmongUs.exe, "Firefox" for firefox.exe. Far better prose than a +// filename, and the only name a portable game with no installer has anywhere +// at all. +static std::wstring FileDescriptionOf(const std::wstring& exePath) { + DWORD ignored = 0; + const DWORD size = GetFileVersionInfoSizeW(exePath.c_str(), &ignored); + if (size == 0) return {}; + + std::vector block(size); + if (!GetFileVersionInfoW(exePath.c_str(), 0, size, block.data())) return {}; + + // An executable is supposed to declare which languages its strings are + // in, and the string blocks are keyed by that. Plenty do not — several + // Microsoft Store binaries among them — so the two conventional keys are + // tried as well: US English and language-neutral, both with the Unicode + // codepage. Without that fallback those binaries yield nothing and the + // caller ends up naming a profile after a window title. + std::vector langs; + LangCodePage* declared = nullptr; + UINT langBytes = 0; + if (VerQueryValueW(block.data(), L"\\VarFileInfo\\Translation", + reinterpret_cast(&declared), &langBytes) + && declared) + langs.assign(declared, declared + langBytes / sizeof(LangCodePage)); + langs.push_back({ 0x0409, 0x04b0 }); + langs.push_back({ 0x0000, 0x04b0 }); + + for (const LangCodePage& lang : langs) { + // FileDescription is the friendlier of the two and what most binaries + // fill in properly. ProductName is the better answer when a build has + // put its own filename in FileDescription, which is exactly what + // Windows' own Notepad does ("Notepad.exe" against "Notepad"). + const std::wstring description = VersionString(block, lang, L"FileDescription"); + const std::wstring product = VersionString(block, lang, L"ProductName"); + + const bool descriptionIsFilename = + description.size() > 4 + && _wcsicmp(description.c_str() + description.size() - 4, L".exe") == 0; + if (!description.empty() && !descriptionIsFilename) return description; + if (!product.empty()) return product; + if (!description.empty()) return description; + } + return {}; +} + +namespace { + +// Background helpers that happen to own a visible window. A launcher itself +// is worth offering — someone may genuinely want a profile that applies while +// they are browsing their library — but these are pieces of one, doing a job +// on its behalf, and nobody is playing them. Steam is the offender in +// practice: its library and store are a Chromium instance, so +// steamwebhelper.exe owns a real titled window and passes every other test +// here. +bool IsHelperProcess(const std::wstring& exePath) { + static const wchar_t* kHelpers[] = { + L"steamwebhelper.exe", // the CEF host behind Steam's own UI + L"gameoverlayui.exe", // the in-game overlay, drawn over a game + L"steamerrorreporter.exe", + L"steamerrorreporter64.exe", + L"streaming_client.exe", // Remote Play + }; + + const size_t slash = exePath.find_last_of(L'\\'); + std::wstring leaf = slash == std::wstring::npos ? exePath + : exePath.substr(slash + 1); + for (wchar_t& c : leaf) c = static_cast(towlower(c)); + for (const wchar_t* helper : kHelpers) + if (leaf == helper) return true; + return false; +} + +BOOL CALLBACK CollectRunningWindow(HWND hwnd, LPARAM param) { + auto* found = reinterpret_cast*>(param); + + // What the user could alt-tab to, which is the same population they are + // choosing from in their head. A window with no title, or one owned by + // another window, is a tooltip or a palette rather than an application. + if (!IsWindowVisible(hwnd)) return TRUE; + if (GetWindow(hwnd, GW_OWNER) != nullptr) return TRUE; + if (GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) return TRUE; + + wchar_t title[256]; + const int len = GetWindowTextW(hwnd, title, ARRAYSIZE(title)); + if (len <= 0) return TRUE; + + // ProcessIdentity resolves a packaged app past ApplicationFrameHost, so + // Store and Xbox app titles name themselves here rather than all looking + // like the same host process. + const ForegroundIdentity id = ProcessIdentity::ForWindow(hwnd); + if (id.exePath.empty()) return TRUE; // protected, or closed under us + + // Windows itself keeps several windowed processes alive at all times, and + // they are "running" by every test above: the desktop and taskbar + // (explorer.exe), the frame packaged apps are hosted in + // (ApplicationFrameHost.exe, when its real child could not be reached), + // and the touch keyboard (TextInputHost.exe). The same rule that keeps + // them out of the installed list keeps them out of this one. + if (GameLibrary::IsSystemProgram(id.exePath)) return TRUE; + if (IsHelperProcess(id.exePath)) return TRUE; + + // Our own window is in this list too, and a profile for it would be a + // joke at the user's expense. + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid == GetCurrentProcessId()) return TRUE; + + for (const auto& already : *found) + if (_wcsicmp(already.id.c_str(), id.exePath.c_str()) == 0) + return TRUE; // one entry per application, not per window + + InstalledGame app; + app.id = id.exePath; + app.source = GameSource::Manual; + // The executable's own description first: a window title changes with + // what the application is doing ("Untitled - Notepad", a level name), and + // would be a poor thing to label a saved profile with. The title is still + // the better fallback, since a binary with no version resource at all is + // usually the sort of small game that has no installer either. + app.name = FileDescriptionOf(id.exePath); + if (app.name.empty()) app.name.assign(title, static_cast(len)); + if (app.name.empty()) app.name = GameLibrary::NameForExecutable(id.exePath); + + found->push_back(std::move(app)); + return TRUE; +} + +} // namespace + +namespace GameLibrary { + +std::wstring NameForExecutable(const std::wstring& exePath) { + std::wstring name = FileDescriptionOf(exePath); + // NameFromShortcutPath does exactly the right thing to a full path too: + // take the leaf, drop the extension. + if (name.empty()) name = NameFromShortcutPath(exePath); + return name; +} + +std::vector EnumerateRunning() { + std::vector found; + EnumWindows(CollectRunningWindow, reinterpret_cast(&found)); + std::sort(found.begin(), found.end(), [](const InstalledGame& a, const InstalledGame& b) { + return _wcsicmp(a.name.c_str(), b.name.c_str()) < 0; + }); + return found; +} + +bool IsSystemProgram(const std::wstring& exePath) { + wchar_t winDir[MAX_PATH]; + if (!GetWindowsDirectoryW(winDir, MAX_PATH)) return false; + const size_t len = wcslen(winDir); + return exePath.size() > len + && _wcsnicmp(exePath.c_str(), winDir, len) == 0 + && (exePath[len] == L'\\' || exePath[len] == L'\0'); +} + +} // namespace GameLibrary + +const wchar_t* GameSourceName(GameSource source) { + switch (source) { + case GameSource::StartMenu: return L"start-menu"; + case GameSource::Steam: return L"steam"; + case GameSource::Packaged: return L"packaged"; + case GameSource::Epic: return L"epic"; + case GameSource::Gog: return L"gog"; + case GameSource::Ubisoft: return L"ubisoft"; + case GameSource::BattleNet: return L"battle.net"; + case GameSource::Manual: return L"manual"; + case GameSource::Missing: return L"missing"; + } + return L"unknown"; +} + namespace GameLibrary { std::vector EnumerateInstalled() { @@ -217,15 +453,29 @@ std::vector EnumerateInstalled() { const HRESULT coInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); const bool comOwnedByUs = coInit == S_OK || coInit == S_FALSE; + // First, because the shortcut walk below is filtered against what this + // finds: a launcher's own records name a game better than its shortcut + // does, and give an install directory rather than one executable. + std::unordered_set seen; + std::vector launcherDirs; + for (auto& game : LauncherGames::EnumerateAll()) { + if (!seen.insert(DedupKey(game.id)).second) continue; + launcherDirs.push_back(ContainmentPrefix(game.id.substr(4))); // past "dir:" + games.push_back(std::move(game)); + } + std::vector shortcutPaths; - for (REFKNOWNFOLDERID folder : { FOLDERID_CommonPrograms, FOLDERID_Programs }) { + // Both Desktop folders as well as both Start Menu ones: plenty of + // installers — Epic's "create desktop shortcut" among them — offer only a + // desktop icon, and a game with no Start Menu entry was invisible here. + for (REFKNOWNFOLDERID folder : { FOLDERID_CommonPrograms, FOLDERID_Programs, + FOLDERID_PublicDesktop, FOLDERID_Desktop }) { 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; @@ -248,7 +498,11 @@ std::vector EnumerateInstalled() { 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; + if (IsSystemProgram(id)) continue; + if (IsLauncherStub(id)) continue; + // The launcher pass already has this game, under a directory id + // that matches more of it than this one executable would. + if (IsInsideAny(id, launcherDirs)) continue; } // The shortcut's own filename is the friendly name a user or @@ -266,7 +520,8 @@ std::vector EnumerateInstalled() { if (!seen.insert(DedupKey(id)).second) continue; // already have this game from another shortcut - games.push_back({ std::move(name), std::move(id) }); + games.push_back({ std::move(name), std::move(id), + isSteamLink ? GameSource::Steam : GameSource::StartMenu }); } for (auto& game : EnumeratePackagedApps()) { diff --git a/src/app/GameLibrary.h b/src/app/GameLibrary.h index 4c82b13..866d6c5 100644 --- a/src/app/GameLibrary.h +++ b/src/app/GameLibrary.h @@ -2,15 +2,37 @@ #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. +// Where an entry came from. Carried purely for diagnosis: "my game isn't in +// the list" is the most common report this feature attracts, and the answer +// is almost always that one source came back empty. GameLibraryProbe prints +// a per-source count, which is the thing worth asking a reporter for. +enum class GameSource { + StartMenu, // a .lnk under Start Menu or Desktop, resolved to its target + Steam, // a Steam .url Internet Shortcut + Packaged, // an MSIX/UWP app from PackageManager + Epic, // an Epic Games Launcher .item manifest + Gog, // a GOG Galaxy registry entry + Ubisoft, // a Ubisoft Connect registry entry + BattleNet, // a Battle.net uninstall registry entry + Manual, // the user pointed at this one themselves + // Not a source at all, but the absence of one: a saved profile names this + // game and no source accounts for it, so the picker carries an entry for + // it anyway. That is what lets a profile for something uninstalled still + // be looked at and deleted. Never produced by EnumerateInstalled. + Missing, +}; + +const wchar_t* GameSourceName(GameSource source); + +// One installed application. 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" + std::wstring name; // a friendly 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; + // on. Takes one of four shapes depending on where the entry came from: + // - a resolved exe path, for an ordinary .lnk shortcut or an + // application the user picked by hand; // - "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 @@ -18,27 +40,70 @@ struct InstalledGame { // 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. + // PackageManager rather than any shortcut file, since those apps + // register no shortcut file for the Start Menu walk to find; + // - "dir:C:\path\to\install", for a game found through a launcher's own + // records. Those name an install directory rather than an executable, + // and the directory is the better matcher anyway: a game's folder + // holds its launcher, its anti-cheat service and the game itself, and + // which of those reaches the foreground varies by title and by + // moment. Matched by longest path prefix, the same rule + // SteamAppLocator already applies to Steam games. std::wstring id; + GameSource source = GameSource::StartMenu; }; 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. +// True for an executable that lives under the Windows directory, which is +// this app's one rule for "part of Windows rather than something the user +// installed". Every place a program can reach the picker applies it: the +// shortcut walk, where accessories and Control Panel applets would otherwise +// crowd out real applications; the browse dialog, which refuses them; and the +// running-application list, where the shell itself (explorer.exe), the host +// packaged apps are displayed inside (ApplicationFrameHost.exe) and the touch +// keyboard (TextInputHost.exe) all have windows that qualify as "running" and +// none of which anyone wants a controller profile for. +bool IsSystemProgram(const std::wstring& exePath); + +// What to call an executable the user picked themselves, where there is no +// shortcut to take a friendly name from. Prefers the FileDescription in the +// binary's own version resource ("Among Us" for AmongUs.exe), falling back to +// the filename without its extension. Never empty for a real path. +std::wstring NameForExecutable(const std::wstring& exePath); + +// Every application this can find, sorted by name, one entry per distinct +// game. Draws on, in order: +// - both Start Menu Programs folders and both Desktop 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); +// - PackageManager, for packaged/MSIX apps — Xbox app and Microsoft Store +// titles among them — which register no shortcut file in those folders +// for the walk above to find; and +// - LauncherGames, for the Epic Games Store, GOG Galaxy, Ubisoft Connect +// and Battle.net, none of which the shortcut walk can describe correctly +// on its own. // // 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. +// launcher stub instead of the real game 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. Takes a second or more: call it +// off the UI thread. std::vector EnumerateInstalled(); +// The applications with a window open right now, one entry per application, +// each with GameSource::Manual and its executable path as its id. +// +// A separate question from EnumerateInstalled: this is what the remap window +// offers under "add an app that's running now", for the game a user cannot +// find in the list but is looking straight at. Answering it by window rather +// than by process is deliberate — it is the same population the user sees +// when they alt-tab, and a process with no window is not something they could +// have been playing. +// +// Cheap enough to call on the UI thread: one EnumWindows pass and a process +// query per window, with no disk or registry access anywhere in it. +std::vector EnumerateRunning(); + } diff --git a/src/app/GameProfiles.h b/src/app/GameProfiles.h index 5e18ad5..864cae8 100644 --- a/src/app/GameProfiles.h +++ b/src/app/GameProfiles.h @@ -4,10 +4,12 @@ #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. +// GameLibrary::InstalledGame::id produces — an exe path for most shortcuts +// and for an application the user picked by hand, a "steam://rungameid/N" +// URI for Steam games, an "aumid:..." for packaged apps, and a +// "dir:C:\path" install directory for a game found through a launcher's own +// records). A game with no entry here uses the global default profile +// instead — most users never create any of these. namespace GameProfiles { std::map Load(); diff --git a/src/app/LauncherGames.cpp b/src/app/LauncherGames.cpp new file mode 100644 index 0000000..42b5288 --- /dev/null +++ b/src/app/LauncherGames.cpp @@ -0,0 +1,436 @@ +#include "LauncherGames.h" +#include +#include +#include +#include + +namespace { + +// --------------------------------------------------------------------------- +// Small shared helpers +// --------------------------------------------------------------------------- + +std::wstring ToLower(std::wstring s) { + for (wchar_t& c : s) c = static_cast(towlower(c)); + return s; +} + +// Launchers are inconsistent about which separator they write and whether +// they leave a trailing one; every comparison below assumes neither. +std::wstring NormalizePath(std::wstring path) { + for (wchar_t& c : path) if (c == L'/') c = L'\\'; + while (!path.empty() && (path.back() == L'\\' || path.back() == L' ')) + path.pop_back(); + return path; +} + +bool DirectoryExists(const std::wstring& path) { + const DWORD attrs = GetFileAttributesW(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; +} + +// "C:\Program Files\Epic Games\Hades" -> "Hades". A launcher that gives us an +// install directory but no title has nothing better to offer, and the folder +// a game installs into is very nearly always its name. +std::wstring LeafName(const std::wstring& path) { + const size_t slash = path.find_last_of(L'\\'); + return slash == std::wstring::npos ? path : path.substr(slash + 1); +} + +// The folders a game is installed *inside*, never the install itself. A +// launcher that reported one of these would produce a profile matching every +// application under it, which is worse than having no profile at all. +bool IsTooShallow(const std::wstring& dir) { + // Held by pointer: REFKNOWNFOLDERID is a reference type, which an array + // cannot hold. + static const KNOWNFOLDERID* const kShallowFolders[] = { + &FOLDERID_ProgramFiles, &FOLDERID_ProgramFilesX86, &FOLDERID_ProgramData, + &FOLDERID_UserProgramFiles, &FOLDERID_Windows, &FOLDERID_UserProfiles, + &FOLDERID_LocalAppData, &FOLDERID_RoamingAppData, &FOLDERID_Profile, + }; + + const std::wstring lower = ToLower(dir); + for (const KNOWNFOLDERID* folder : kShallowFolders) { + PWSTR known = nullptr; + if (SUCCEEDED(SHGetKnownFolderPath(*folder, 0, nullptr, &known)) && known) { + const bool same = ToLower(NormalizePath(known)) == lower; + CoTaskMemFree(known); + if (same) return true; + } else if (known) { + CoTaskMemFree(known); + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +// Both views, always. GOG, Ubisoft and Epic all install as 32-bit +// applications and write to the WOW6432Node side of a 64-bit machine, but +// hardcoding that path is the wrong fix — it breaks on a 32-bit OS and says +// nothing about which view a future version will pick. Asking for each view +// explicitly and merging leaves the redirection to Windows. +constexpr REGSAM kViews[] = { KEY_WOW64_64KEY, KEY_WOW64_32KEY }; + +std::wstring ReadSz(HKEY key, const wchar_t* name) { + // Long enough for any install path a launcher writes; a value that would + // not fit is not one worth guessing at. + wchar_t buf[1024] = {}; + DWORD size = sizeof(buf); + DWORD type = 0; + if (RegQueryValueExW(key, name, nullptr, &type, + reinterpret_cast(buf), &size) != ERROR_SUCCESS) + return {}; + if (type != REG_SZ && type != REG_EXPAND_SZ) return {}; + buf[ARRAYSIZE(buf) - 1] = L'\0'; // RegQueryValueEx does not promise one + return buf; +} + +std::vector SubKeyNames(HKEY key) { + std::vector names; + for (DWORD i = 0;; ++i) { + wchar_t name[256]; + DWORD len = ARRAYSIZE(name); + if (RegEnumKeyExW(key, i, name, &len, nullptr, nullptr, nullptr, nullptr) + != ERROR_SUCCESS) + break; + names.emplace_back(name, len); + } + return names; +} + +// Calls visit(childKey) for every subkey of path, in both registry views. A +// game present in both views is visited twice; that costs nothing, because +// both visits produce the same install directory and therefore the same id, +// which GameLibrary's dedup collapses. +template +void ForEachSubKey(HKEY root, const wchar_t* path, Fn visit) { + for (REGSAM view : kViews) { + HKEY parent = nullptr; + if (RegOpenKeyExW(root, path, 0, KEY_READ | view, &parent) != ERROR_SUCCESS) + continue; + for (const auto& name : SubKeyNames(parent)) { + HKEY child = nullptr; + if (RegOpenKeyExW(parent, name.c_str(), 0, KEY_READ | view, &child) + == ERROR_SUCCESS) { + visit(child); + RegCloseKey(child); + } + } + RegCloseKey(parent); + } +} + +// --------------------------------------------------------------------------- +// A very small JSON reader, for Epic's manifests +// --------------------------------------------------------------------------- +// +// Four fields out of one flat object does not justify taking on a JSON +// dependency, and the hand-rolled reader in RemapWindow.cpp cannot be reused: +// it deliberately unescapes nothing, because the channel it serves is ASCII +// by construction. These files are UTF-8 and full of escaped backslashes +// (every path) and occasionally \uXXXX (an accented title), so both have to +// be handled. Written in the same spirit as SteamAppLocator.cpp's VdfScanner. + +std::wstring ReadFileUtf8(const std::wstring& path) { + HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) return {}; + + LARGE_INTEGER size{}; + if (!GetFileSizeEx(f, &size) || size.QuadPart <= 0 || size.QuadPart > (4 << 20)) { + CloseHandle(f); + return {}; + } + + std::string bytes(static_cast(size.QuadPart), '\0'); + DWORD read = 0; + const bool ok = ReadFile(f, bytes.data(), static_cast(bytes.size()), + &read, nullptr) != FALSE; + CloseHandle(f); + if (!ok) return {}; + bytes.resize(read); + + const int n = MultiByteToWideChar(CP_UTF8, 0, bytes.data(), + static_cast(bytes.size()), nullptr, 0); + if (n <= 0) return {}; + std::wstring text(static_cast(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, bytes.data(), static_cast(bytes.size()), + text.data(), n); + return text; +} + +int HexDigit(wchar_t c) { + if (c >= L'0' && c <= L'9') return c - L'0'; + if (c >= L'a' && c <= L'f') return c - L'a' + 10; + if (c >= L'A' && c <= L'F') return c - L'A' + 10; + return -1; +} + +// Reads a JSON string body starting just past its opening quote, leaving pos +// just past the closing one. +std::wstring ReadJsonString(const std::wstring& text, size_t& pos) { + std::wstring out; + while (pos < text.size() && text[pos] != L'"') { + if (text[pos] != L'\\') { out += text[pos++]; continue; } + if (++pos >= text.size()) break; + const wchar_t esc = text[pos++]; + switch (esc) { + case L'n': out += L'\n'; break; + case L'r': out += L'\r'; break; + case L't': out += L'\t'; break; + case L'b': out += L'\b'; break; + case L'f': out += L'\f'; break; + case L'u': { + // A surrogate pair arrives as two consecutive escapes and + // needs no special handling: each half is a valid wchar_t, so + // copying both through reproduces the original UTF-16. + if (pos + 4 > text.size()) return out; + int code = 0; + for (int i = 0; i < 4; ++i) { + const int digit = HexDigit(text[pos + static_cast(i)]); + if (digit < 0) return out; + code = code * 16 + digit; + } + pos += 4; + out += static_cast(code); + break; + } + default: out += esc; break; // covers \" \\ \/ + } + } + if (pos < text.size()) ++pos; // closing quote + return out; +} + +// The offset just past `"":`, or npos. Every field this needs is at the +// top level of the object and no nested key shares a name with one of them, +// so a plain scan is enough — building a parse tree would be machinery in +// service of nothing. +size_t FindKey(const std::wstring& text, const wchar_t* key) { + const std::wstring needle = std::wstring(L"\"") + key + L"\""; + size_t pos = text.find(needle); + if (pos == std::wstring::npos) return std::wstring::npos; + pos += needle.size(); + while (pos < text.size() && iswspace(text[pos])) ++pos; + if (pos >= text.size() || text[pos] != L':') return std::wstring::npos; + ++pos; + while (pos < text.size() && iswspace(text[pos])) ++pos; + return pos; +} + +std::wstring JsonString(const std::wstring& text, const wchar_t* key) { + size_t pos = FindKey(text, key); + if (pos == std::wstring::npos || pos >= text.size() || text[pos] != L'"') + return {}; + ++pos; + return ReadJsonString(text, pos); +} + +// Absent reads as the default, so a manifest written before Epic added a flag +// keeps whatever behaviour it had. +bool JsonBool(const std::wstring& text, const wchar_t* key, bool def) { + const size_t pos = FindKey(text, key); + if (pos == std::wstring::npos) return def; + if (text.compare(pos, 4, L"true") == 0) return true; + if (text.compare(pos, 5, L"false") == 0) return false; + return def; +} + +// --------------------------------------------------------------------------- + +// The one place an entry is built, so every source gets the same validation: +// a directory that does not exist, or is too broad to identify anything, is +// dropped rather than turned into a profile that misfires. +void AddIfUsable(std::vector& out, std::wstring name, + const std::wstring& installDir, GameSource source) { + const std::wstring dir = NormalizePath(installDir); + if (!LauncherGames::IsUsableInstallDir(dir)) return; + if (name.empty()) name = LeafName(dir); + if (name.empty()) return; + out.push_back({ std::move(name), L"dir:" + dir, source }); +} + +void Append(std::vector& into, std::vector found) { + into.insert(into.end(), std::make_move_iterator(found.begin()), + std::make_move_iterator(found.end())); +} + +} // namespace + +namespace LauncherGames { + +bool IsUsableInstallDir(const std::wstring& dir) { + if (dir.size() < 4) return false; // shorter than "C:\x" identifies nothing + // A drive root survives NormalizePath as "C:" and a UNC share root as + // "\\server\share"; neither names an install. + if (dir.find(L'\\') == std::wstring::npos) return false; + if (IsTooShallow(dir)) return false; + return DirectoryExists(dir); +} + +// --------------------------------------------------------------------------- +// Epic Games Store +// --------------------------------------------------------------------------- + +std::vector EnumerateEpic(const std::wstring& manifestsDir) { + std::vector games; + + std::wstring dir = NormalizePath(manifestsDir); + if (dir.empty()) { + // The launcher's own record of where it put them wins, because a user + // can move this. The fixed path under ProgramData below is where it + // lands by default, and the only answer left on a machine whose + // registry entry has gone missing. + for (REGSAM view : kViews) { + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, + L"SOFTWARE\\Epic Games\\EpicGamesLauncher", + 0, KEY_READ | view, &key) != ERROR_SUCCESS) + continue; + const std::wstring appData = ReadSz(key, L"AppDataPath"); + RegCloseKey(key); + if (!appData.empty()) { + dir = NormalizePath(appData) + L"\\Manifests"; + break; + } + } + } + if (dir.empty()) { + PWSTR programData = nullptr; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_ProgramData, 0, nullptr, &programData)) + && programData) + dir = NormalizePath(programData) + L"\\Epic\\EpicGamesLauncher\\Data\\Manifests"; + if (programData) CoTaskMemFree(programData); + } + if (dir.empty()) return games; + + WIN32_FIND_DATAW fd{}; + HANDLE find = FindFirstFileW((dir + L"\\*.item").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) return games; + + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; + + const std::wstring text = ReadFileUtf8(dir + L"\\" + fd.cFileName); + if (text.empty()) continue; + + // Epic writes a manifest for every installed *item*, which includes + // DLC, language packs and engine plugins. bIsApplication is what + // separates the things a user could put in the foreground from the + // ones that only ride along inside another. + if (!JsonBool(text, L"bIsApplication", true)) continue; + // A download that stopped part-way still has a manifest and a + // directory, but not a game. + if (JsonBool(text, L"bIsIncompleteInstall", false)) continue; + + AddIfUsable(games, JsonString(text, L"DisplayName"), + JsonString(text, L"InstallLocation"), GameSource::Epic); + } while (FindNextFileW(find, &fd)); + + FindClose(find); + return games; +} + +// --------------------------------------------------------------------------- +// GOG Galaxy +// --------------------------------------------------------------------------- + +std::vector EnumerateGog() { + std::vector games; + ForEachSubKey(HKEY_LOCAL_MACHINE, L"SOFTWARE\\GOG.com\\Games", [&games](HKEY key) { + // "gameName" is the title as GOG shows it, "path" is where it went. + // Both are written by the installer for every game, so a subkey + // missing either is not one to guess about. + AddIfUsable(games, ReadSz(key, L"gameName"), ReadSz(key, L"path"), + GameSource::Gog); + }); + return games; +} + +// --------------------------------------------------------------------------- +// Ubisoft Connect +// --------------------------------------------------------------------------- + +std::vector EnumerateUbisoft() { + std::vector games; + ForEachSubKey(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Ubisoft\\Launcher\\Installs", + [&games](HKEY key) { + // The subkey name is a Ubisoft product id and the key holds no title, + // so the name has to come from the install folder — AddIfUsable falls + // back to it when handed nothing. InstallDir is written with forward + // slashes, which NormalizePath deals with. + AddIfUsable(games, {}, ReadSz(key, L"InstallDir"), GameSource::Ubisoft); + }); + return games; +} + +// --------------------------------------------------------------------------- +// Battle.net +// --------------------------------------------------------------------------- + +std::vector EnumerateBattleNet() { + std::vector games; + + // Blizzard's own catalogue is a protobuf dump (product.db) that would + // need a decoder to read. The uninstall entries Battle.net writes carry + // the same two facts and are plain registry values — the same trade + // Playnite's Battle.net library makes, which keeps product.db only as a + // fallback for games imported rather than installed. + static constexpr wchar_t kUninstallPath[] = + L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; + + for (HKEY root : { HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER }) { + ForEachSubKey(root, kUninstallPath, [&games](HKEY key) { + // Battle.net uninstalls run through the launcher itself, as + // "...Battle.net.exe --uninstall --uid= ...". Both + // markers together are what separates a Blizzard game from the + // hundred other things in this part of the registry, which is far + // too noisy to read wholesale. + const std::wstring lower = ToLower(ReadSz(key, L"UninstallString")); + const size_t uidPos = lower.find(L"--uid="); + if (uidPos == std::wstring::npos + || lower.find(L"battle.net") == std::wstring::npos) + return; + + size_t uidEnd = lower.find_first_of(L" \"", uidPos); + if (uidEnd == std::wstring::npos) uidEnd = lower.size(); + const std::wstring uid = lower.substr(uidPos + 6, uidEnd - uidPos - 6); + + // The launcher registers itself the same way its games do, so + // without this the client shows up in the picker as though it + // were one of them — which is how it first turned up on the + // machine this was written on, Battle.net installed and no + // Blizzard game on it at all. + for (const wchar_t* client : { L"battle.net", L"bna", L"agent" }) + if (uid == client) return; + + // Public test and beta clients install alongside the real game + // and share its name; a profile for one would be a surprise. + for (const wchar_t* suffix : { L"test", L"beta", L"ptr" }) { + const size_t len = wcslen(suffix); + if (uid.size() > len && uid.compare(uid.size() - len, len, suffix) == 0) + return; + } + + AddIfUsable(games, ReadSz(key, L"DisplayName"), + ReadSz(key, L"InstallLocation"), GameSource::BattleNet); + }); + } + return games; +} + +// --------------------------------------------------------------------------- + +std::vector EnumerateAll() { + std::vector games; + Append(games, EnumerateEpic()); + Append(games, EnumerateGog()); + Append(games, EnumerateUbisoft()); + Append(games, EnumerateBattleNet()); + return games; +} + +} // namespace LauncherGames diff --git a/src/app/LauncherGames.h b/src/app/LauncherGames.h new file mode 100644 index 0000000..87aa00b --- /dev/null +++ b/src/app/LauncherGames.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include "GameLibrary.h" + +// Games belonging to the PC launchers that the Start Menu walk in GameLibrary +// either misses or describes wrongly. +// +// It misses Epic's, because Epic shortcuts a game with a .url whose URL is a +// "com.epicgames.launcher://apps/..." URI, and the walk only keeps .url files +// pointing at Steam. It describes Battle.net's and Ubisoft's wrongly, because +// those shortcuts are .lnk files targeting the *launcher* executable with a +// per-game argument — IShellLink::GetPath returns only the executable, so +// every Blizzard game on a machine resolves to the same +// "Battle.net Launcher.exe" and collapses into a single wrongly-named entry. +// +// Reading each launcher's own records instead fixes both, and yields the +// game's install directory, which is a better identity than any single +// executable — see the "dir:" note on InstalledGame::id. +// +// Every function here is best-effort: a launcher that is not installed +// returns an empty list rather than failing, exactly as GameLibrary's +// packaged-app pass already does. +namespace LauncherGames { + +// All four launchers below, concatenated. Not sorted and not deduplicated — +// GameLibrary does both, over the merged list. +std::vector EnumerateAll(); + +// %ProgramData%\Epic\EpicGamesLauncher\Data\Manifests\*.item, each a JSON +// object describing one installed item. manifestsDir overrides where to look, +// which is the only way to exercise this on a machine without Epic installed. +std::vector EnumerateEpic(const std::wstring& manifestsDir = {}); + +// HKLM\SOFTWARE\GOG.com\Games — one subkey per game, carrying its own name. +std::vector EnumerateGog(); + +// HKLM\SOFTWARE\Ubisoft\Launcher\Installs — one subkey per game, carrying an +// install directory but no name. +std::vector EnumerateUbisoft(); + +// The uninstall registry, filtered to entries Battle.net wrote. Blizzard +// games have no launcher-owned manifest an unprivileged reader can parse +// without a protobuf decoder, and these entries carry everything needed. +std::vector EnumerateBattleNet(); + +// True for a directory specific enough to be worth matching a foreground +// process against. A launcher that reports something like "C:\" or +// "C:\Program Files" would otherwise produce a profile that claims every +// application on the machine. Shared with GameLibrary, which applies the +// same test before trusting a directory from any source. +bool IsUsableInstallDir(const std::wstring& dir); + +} // namespace LauncherGames diff --git a/src/app/ProcessIdentity.cpp b/src/app/ProcessIdentity.cpp new file mode 100644 index 0000000..1b55a63 --- /dev/null +++ b/src/app/ProcessIdentity.cpp @@ -0,0 +1,105 @@ +#include "ProcessIdentity.h" +#include +#include + +namespace { + +// Packaged apps do not own their own top-level window. Windows hosts it in +// ApplicationFrameHost.exe, so a 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 +} + +} // namespace + +namespace ProcessIdentity { + +ForegroundIdentity ForProcess(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; +} + +ForegroundIdentity ForWindow(HWND hwnd) { + if (!hwnd) return {}; + + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + ForegroundIdentity id = ForProcess(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 = ForProcess(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; +} + +} // namespace ProcessIdentity diff --git a/src/app/ProcessIdentity.h b/src/app/ProcessIdentity.h new file mode 100644 index 0000000..389d791 --- /dev/null +++ b/src/app/ProcessIdentity.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +// Who owns a window. +// +// Both fields are filled where they apply: every process has an image path, +// and only a packaged (MSIX/UWP) one has an AUMID. A 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); } +}; + +// Resolving a window to the application behind it, shared by the two things +// that need to do it: ForegroundWatcher, which asks about whatever the user +// switched to, and the remap window's running-app picker, which asks the same +// question of every open window at once. Both have to handle packaged apps +// the same way, so the frame-host unwrapping below lives here rather than in +// either caller. +namespace ProcessIdentity { + +// Image path and AUMID for a process id. Empty for a process we cannot open, +// which for an unelevated caller means a protected or elevated one. +ForegroundIdentity ForProcess(DWORD pid); + +// The application a top-level window belongs to, with packaged apps resolved +// past their host — see the note on kFrameHostExe in the implementation. +ForegroundIdentity ForWindow(HWND hwnd); + +} // namespace ProcessIdentity diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index 3eb57ac..89f3963 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -1,14 +1,25 @@ #include "RemapWindow.h" #include "KeyInput.h" #include "ControllerManager.h" +#include "EventLog.h" +#include "ProcessIdentity.h" #include #include +#include #include +#include +#include +#include #include +#include #include RemapWindow* RemapWindow::s_instance = nullptr; +// Defined further down, alongside the rest of the page-message plumbing; +// declared here because the picker's "add an application" paths sit above it. +static std::wstring JsonEscape(const std::wstring& s); + // --------------------------------------------------------------------------- // Embedded UI — self-contained HTML/CSS/JS matching the design handoff. // Google Fonts are loaded at runtime; if offline, system-ui fallback applies. @@ -49,8 +60,16 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back #body h2{font-size:21px;font-weight:800;color:#fff;letter-spacing:-.2px;} .instr{font-size:13.5px;color:#8f98a0;line-height:1.5;margin-top:4px;} .instr b{color:#66c0f4;font-weight:700;} +/* Everything below the APPLY TO picker lives in #settings so it can be dimmed + as one block. That made it a plain div between #body's flex children, so its + groups stacked with no gap at all while the groups above it got #body's — + which is why each section heading sat jammed against the box above it. Same + axis, same gap, so the two halves of the page space identically. */ +#settings{display:flex;flex-direction:column;gap:22px;} .group{display:flex;flex-direction:column;gap:11px;} -.group-label{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:1.5px;color:#5c6b78;font-weight:600;} +/* An explicit line box: at 11px with this letter-spacing the uppercase + headings were riding the top of their line and losing a pixel off the caps. */ +.group-label{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:1.5px;color:#5c6b78;font-weight:600;line-height:1.5;} .row{display:flex;flex-direction:column;background:rgba(255,255,255,.025);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:12px 16px;transition:border-color .2s,box-shadow .2s,background .2s;} .row.listening{background:rgba(255,255,255,.05);border-color:#66c0f4;box-shadow:0 0 0 1px rgba(102,192,244,.4),0 10px 26px rgba(0,0,0,.32);} .row.flash{border-color:#5ba32b;box-shadow:0 0 0 1px rgba(91,163,43,.4);} @@ -93,6 +112,13 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .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;} +.combo-add{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,.08);display:flex;flex-direction:column;gap:2px;} +.combo-add-item{padding:8px 10px;border-radius:6px;cursor:pointer;font-size:12.5px;color:#66c0f4;font-weight:600;} +.combo-add-item:hover{background:rgba(102,192,244,.12);} +.combo-note{padding:8px 10px 2px;font-size:11.5px;color:#5c6b78;text-align:center;} +.combo-back{padding:6px 10px 8px;font-size:11.5px;color:#8f98a0;cursor:pointer;} +.combo-back:hover{color:#66c0f4;} +.combo-item .exe{display:block;font-size:11px;color:#5c6b78;margin-top:2px;} .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;} @@ -165,8 +191,12 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back ▼ @@ -358,10 +388,18 @@ var keyLabels = {}; var flash = null; var flashTimer = null; var applyTimer = null; -// Per-game picker. Game ids are small indices ("0","1",...) into GAMES, not -// the raw exe path — keeps the hand-rolled JSON channel back to C++ free of -// Unicode and backslash escaping. "" is the always-present default profile. +// Per-game picker. Game ids are opaque decimal tokens issued by C++, not the +// raw exe path — that keeps the hand-rolled JSON channel free of Unicode and +// backslash escaping. A token is never reused or renumbered, so one held here +// stays valid when GAMES is replaced. "" is the always-present default profile. var GAMES = []; +// True between the window opening and the installed list arriving. The list +// is built on a background thread because it takes a second or more. +var gamesPending = true; +// The "add an app that's running now" list, and whether it is showing in +// place of the normal picker contents. Same token discipline as GAMES. +var RUNNING = []; +var runningOpen = false; // Each profile is a flat object: one entry per bindable row id, plus // "mode" for each trackpad's movement mode. Flat because the JSON // reader on the C++ side matches "key":"value" pairs without nesting. @@ -440,12 +478,32 @@ if(window.chrome&&window.chrome.webview){ keyLabels=msg.labels||{}; GAMES=msg.games||[]; PROFILES=msg.profiles||PROFILES; + // Set by C++, not assumed: when the enumeration beat WebView2's startup + // the list is already in this message and no 'games' message follows. + gamesPending=(msg.pending==='1'); currentGame=''; loadProfileInto(PROFILES['']||{}); closeCombo(); renderComboLabel(); renderModeSelects(); renderAll(); + } else if(msg.type==='games'){ + // Finding every installed app takes a second or more, so it lands after + // the window is already up — and possibly after the user has started + // editing. Deliberately touches nothing but the list: currentGame and + // the bindings in flight are theirs, not ours to reset. + GAMES=msg.games||[]; + PROFILES=msg.profiles||PROFILES; + gamesPending=false; + renderComboLabel(); + if(comboOpen) renderCombo(); + } else if(msg.type==='runningApps'){ + RUNNING=msg.apps||[]; + runningOpen=true; + renderCombo(); + } else if(msg.type==='gameAdded'){ + runningOpen=false; + pickGame(msg.id); } else if(msg.type==='closeRequested'){ // C++ asks before hiding the window so an unsaved profile isn't lost. guard(function(){postMsg({type:'close'});}); @@ -667,6 +725,13 @@ function renderCombo(){ var results=document.getElementById('combo-results'); if(!results) return; + var add=document.getElementById('combo-add'); + // The two "add" actions are the way out of the running-app list, not + // something to offer inside it. + if(add) add.style.display=runningOpen?'none':''; + + if(runningOpen){ results.innerHTML=runningHTML(); return; } + var q=comboQuery.trim().toLowerCase(); var html=''; @@ -689,21 +754,54 @@ function renderCombo(){ return !pinnedIds[g.id]&&g.name.toLowerCase().indexOf(q)>=0; }).slice(0,50); // a two-letter query can match hundreds; cap the DOM work if(matches.length){ - html+='
'; + html+='
'; matches.forEach(function(g){html+=itemHTML(g.id,g.name,g.missing==='1');}); html+='
'; } else if(!pinned.length&&!showDefault){ - html+='
No games match "'+escapeHTML(comboQuery)+'"
'; + // The moment the user finds out their game is missing. The two actions + // below the results are what answers it, so say so rather than leaving + // them at a dead end. + html+='
'+(gamesPending + ?'Still looking for your games…' + :'Nothing matches "'+escapeHTML(comboQuery)+'".
Add it yourself below.')+'
'; } + } else if(gamesPending){ + html+='
Looking for your games…
'; } results.innerHTML=html; } +// The applications with a window open right now. Offered because the game a +// user wants a profile for is very often the one they just alt-tabbed out of, +// and this asks them to know nothing about where it was installed. +function runningHTML(){ + var html='
← Back
' + +''; + if(!RUNNING.length) + return html+'
Nothing else is running that we can see.
'; + html+='
'; + RUNNING.forEach(function(a){ + // The executable name is shown as well as the friendly one: two windows + // can describe themselves identically, and this is what tells them apart. + html+='
' + +escapeHTML(a.name)+''+escapeHTML(a.exe)+'
'; + }); + return html+'
'; +} function itemHTML(id,name,missing){ var cls='combo-item'+(id===currentGame?' active':''); var badge=missing?'NOT INSTALLED':''; return '
'+escapeHTML(name)+badge+'
'; } +function askRunningApps(){ postMsg({type:'listRunning'}); } +function askBrowseExe(){ + // The file dialog is modal to this window, so the panel would otherwise sit + // open behind it and still be open when the dialog closes. + closeCombo(); + postMsg({type:'browseExe'}); +} +function pickRunning(token){ postMsg({type:'pickRunning',app:token}); } +function closeRunning(){ runningOpen=false; renderCombo(); } // True for a picker entry that exists only because a profile refers to it. function gameMissing(gameId){ for(var i=0;i(lp); + const LONG frameTop = params->rgrc[0].top; + const LRESULT result = DefWindowProcW(hwnd, msg, wp, lp); + if (result != 0) return result; // Windows wants the client moved; defer + params->rgrc[0].top = frameTop; + return 0; + } + case WM_NCHITTEST: { // Let Windows handle non-client areas (resize border, etc.) first. LRESULT hit = DefWindowProcW(hwnd, msg, wp, lp); + + const POINT pt = { GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; + const UINT dpi = GetDpiForWindow(hwnd); + + // The top frame is client area now (see WM_NCCALCSIZE), so Windows no + // longer reports a resize target there and would let the title bar + // below claim it as somewhere to drag from. Restoring it by hand is + // the price of reclaiming those pixels, and it has to come before the + // caption test for the same reason. + RECT window{}; + GetWindowRect(hwnd, &window); + const int grip = GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi) + + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi); + // Worked out from the window rect rather than from what + // DefWindowProc said: with the top no longer part of the frame it + // stops reporting HTLEFT/HTRIGHT in the top corners, so asking it + // would cost the two diagonal resize grips. + if (pt.y < window.top + grip) { + if (pt.x < window.left + grip) return HTTOPLEFT; + if (pt.x >= window.right - grip) return HTTOPRIGHT; + return HTTOP; + } + if (hit == HTCLIENT) { - POINT pt = { GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; - ScreenToClient(hwnd, &pt); + POINT local = pt; + ScreenToClient(hwnd, &local); RECT rc; GetClientRect(hwnd, &rc); // The right ~96px of the title bar hosts our close/min buttons — // leave those as HTCLIENT so WebView2 can receive the clicks. - UINT dpi = GetDpiForWindow(hwnd); - int tbPx = MulDiv(46, dpi, 96); // CSS 46px → physical pixels - int ctlPx = MulDiv(96, dpi, 96); - if (pt.y < tbPx && pt.x < rc.right - ctlPx) + int tbPx = MulDiv(46, dpi, 96); // CSS 46px → physical pixels + int ctlPx = MulDiv(96, dpi, 96); + if (local.y < tbPx && local.x < rc.right - ctlPx) return HTCAPTION; } return hit; @@ -1152,6 +1304,37 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (m_onClose) m_onClose(); return 0; + case WM_GAMES_READY: { + // Ownership arrives with the message; see StartEnumeration. + std::unique_ptr> found( + reinterpret_cast*>(lp)); + // A second enumeration can land after the user has already added an + // application by hand — they can do that before the list arrives, and + // reopening the window starts another pass. Their entries are kept + // and re-appended, with their tokens, so a selection made against one + // still means the same thing afterwards. + std::vector manual; + for (auto& entry : m_games) + if (entry.game.source == GameSource::Manual + && m_gameProfiles.find(entry.game.id) == m_gameProfiles.end()) + manual.push_back(std::move(entry)); + + m_games.clear(); + for (auto& game : *found) { + const size_t token = TokenFor(game.id); + m_games.push_back({ std::move(game), token }); + } + AppendOrphanProfiles(); + // Anything the user added that has a profile is already back, put + // there by AppendOrphanProfiles; only the not-yet-applied ones need + // carrying over by hand. + for (auto& entry : manual) + m_games.push_back(std::move(entry)); + + SendGameList(); + return 0; + } + case WM_DESTROY: if (m_mgr) m_mgr->StopButtonCapture(); m_webview.Reset(); @@ -1172,39 +1355,219 @@ RemapWindow::~RemapWindow() { s_instance = nullptr; } -void RemapWindow::AppendOrphanProfiles() { - m_installedCount = m_games.size(); +// True for a profile id that names something we can still see on disk. An +// application the user picked by hand is never in the enumerated list, so +// without this every one of them would come back wearing the "not installed" +// badge and an invitation to delete a profile that works. Also rescues an +// ordinary game whose Start Menu shortcut was removed while the game stayed. +static bool IdStillOnDisk(const std::wstring& id) { + // Only the two path-shaped ids can be checked this way. A "steam://" or + // "aumid:" id says nothing about the filesystem, and its game being + // absent from the enumerated list really does mean it is gone. + const bool isDir = id.rfind(L"dir:", 0) == 0; + if (!isDir && (id.rfind(L"steam://", 0) == 0 || id.rfind(L"aumid:", 0) == 0)) + return false; + const std::wstring path = isDir ? id.substr(4) : id; + if (path.empty()) return false; + const DWORD attrs = GetFileAttributesW(path.c_str()); + if (attrs == INVALID_FILE_ATTRIBUTES) return false; + return isDir == ((attrs & FILE_ATTRIBUTE_DIRECTORY) != 0); +} + +void RemapWindow::AppendOrphanProfiles() { // An enumeration that found nothing at all looks exactly like every game // having been uninstalled, and is by far the likelier of the two — a COM - // failure reading shell:AppsFolder, or a Start Menu walk that came back + // failure reading the package list, or a Start Menu walk that came back // empty. Flagging every profile as missing on that evidence would invite // the user to delete the lot, so say nothing rather than guess. if (m_games.empty()) return; + const size_t enumerated = m_games.size(); for (const auto& [id, profile] : m_gameProfiles) { - bool installed = false; - for (size_t i = 0; i < m_installedCount; ++i) { - if (_wcsicmp(m_games[i].id.c_str(), id.c_str()) == 0) { - installed = true; + bool found = false; + for (size_t i = 0; i < enumerated; ++i) { + if (_wcsicmp(m_games[i].game.id.c_str(), id.c_str()) == 0) { + found = true; break; } } - if (installed) continue; + if (found) continue; InstalledGame orphan; orphan.id = id; // What displayName was stored for: naming a profile at a moment the // installed list cannot. Profiles written before it existed fall back // to the raw id — poor prose, but it still identifies the game. - orphan.name = profile.displayName.empty() ? id : profile.displayName; - m_games.push_back(std::move(orphan)); + orphan.name = profile.displayName.empty() ? id : profile.displayName; + orphan.source = IdStillOnDisk(id) ? GameSource::Manual : GameSource::Missing; + const size_t token = TokenFor(id); + m_games.push_back({ std::move(orphan), token }); } } +// --------------------------------------------------------------------------- +// Naming an application the installed list does not have +// --------------------------------------------------------------------------- + +// "C:\Games\Celeste\Celeste.exe" -> "Celeste.exe". +static std::wstring LeafOf(const std::wstring& path) { + const size_t slash = path.find_last_of(L'\\'); + return slash == std::wstring::npos ? path : path.substr(slash + 1); +} + +void RemapWindow::SendRunningApps() { + if (!m_webview) return; + + // Tokens for these come from the same sequence as the picker's, so a + // stale "pickRunning" cannot collide with a game's token. + m_runningApps.clear(); + std::wstring json; + for (auto& app : GameLibrary::EnumerateRunning()) { + const size_t token = m_nextToken++; + if (!json.empty()) json += L","; + // The executable name is shown alongside the friendly one because two + // applications can describe themselves identically, and it is what + // tells them apart. + json += L"{\"i\":\"" + std::to_wstring(token) + L"\",\"name\":\"" + + JsonEscape(app.name) + L"\",\"exe\":\"" + + JsonEscape(LeafOf(app.id)) + L"\"}"; + m_runningApps.push_back({ std::move(app), token }); + } + + PostToWebView(L"{\"type\":\"runningApps\",\"apps\":[" + json + L"]}"); +} + +void RemapWindow::BrowseForExe() { + Microsoft::WRL::ComPtr dialog; + if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&dialog)))) + return; + + COMDLG_FILTERSPEC filter[] = { { L"Programs", L"*.exe" } }; + dialog->SetFileTypes(ARRAYSIZE(filter), filter); + dialog->SetTitle(L"Pick the program to make a profile for"); + + // Where games are, far more often than the last folder the user happened + // to open something from. + Microsoft::WRL::ComPtr programFiles; + if (SUCCEEDED(SHCreateItemInKnownFolder(FOLDERID_ProgramFiles, 0, nullptr, + IID_PPV_ARGS(&programFiles)))) + dialog->SetDefaultFolder(programFiles.Get()); + + // Modal to our own window, so it cannot end up behind it. + if (FAILED(dialog->Show(m_hwnd))) return; // includes the user cancelling + + Microsoft::WRL::ComPtr result; + if (FAILED(dialog->GetResult(&result))) return; + + PWSTR path = nullptr; + if (FAILED(result->GetDisplayName(SIGDN_FILESYSPATH, &path)) || !path) { + if (path) CoTaskMemFree(path); + return; + } + const std::wstring exePath(path); + CoTaskMemFree(path); + + // The same rule the shortcut walk and the running-application list apply: + // a profile for part of Windows would fire at moments that have nothing + // to do with playing a game. + if (GameLibrary::IsSystemProgram(exePath)) { + MessageBoxW(m_hwnd, + L"That program is part of Windows itself.\n\n" + L"Pick a game or application you installed instead.", + L"Not a game", MB_OK | MB_ICONINFORMATION); + return; + } + + InstalledGame game; + game.id = exePath; + game.source = GameSource::Manual; + game.name = GameLibrary::NameForExecutable(exePath); + AddManualGame(std::move(game)); +} + +void RemapWindow::AddManualGame(InstalledGame game) { + if (game.id.empty() || !m_webview) return; + + const size_t token = TokenFor(game.id); + + // Picking something already in the list selects it rather than adding a + // second copy of it — which is the sensible reading of the action, and + // stops a browsed exe that turned out to be a listed game from producing + // two entries that both claim it. + for (const auto& entry : m_games) { + if (entry.token != token) continue; + PostToWebView(L"{\"type\":\"gameAdded\",\"id\":\"" + std::to_wstring(token) + + L"\"}"); + return; + } + + m_games.push_back({ std::move(game), token }); + // Only the list changed — the page keeps whatever the user was editing, + // and routes selecting the new entry through its own unsaved-changes + // prompt, exactly as if they had picked it from the list themselves. + SendGameList(); + PostToWebView(L"{\"type\":\"gameAdded\",\"id\":\"" + std::to_wstring(token) + L"\"}"); +} + +size_t RemapWindow::TokenFor(const std::wstring& id) { + auto [it, inserted] = m_tokens.try_emplace(id, m_nextToken); + if (inserted) ++m_nextToken; + return it->second; +} + +PickerEntry* RemapWindow::EntryForToken(const std::string& value) { + if (value.empty()) return nullptr; + size_t token = 0; + for (char c : value) { + if (c < '0' || c > '9') return nullptr; + token = token * 10 + static_cast(c - '0'); + } + for (auto& entry : m_games) + if (entry.token == token) return &entry; + return nullptr; +} + +void RemapWindow::StartEnumeration() { + // Detached rather than joined. The only thing it touches afterwards is the + // window handle, by value, so it cannot outlive anything it refers to; and + // joining would mean blocking the UI thread on close for exactly as long + // as running this on the UI thread would have cost in the first place. + HWND hwnd = m_hwnd; + std::thread([hwnd] { + auto games = std::make_unique>( + GameLibrary::EnumerateInstalled()); + + // Counted per source because "my game isn't in the list" is what this + // feature gets reported for, and one source coming back empty is + // nearly always the reason. GameLibraryProbe prints the same summary. + // Source names are ASCII literals, so narrowing them is exact; done a + // character at a time to say so explicitly rather than leaning on an + // implicit conversion the compiler is right to warn about. + std::map counts; + for (const auto& g : *games) { + std::string name; + for (const wchar_t* c = GameSourceName(g.source); *c; ++c) + name += static_cast(*c); + ++counts[name]; + } + std::string summary; + for (const auto& [source, count] : counts) + summary += " " + source + "=" + std::to_string(count); + EventLog::Write("PROFILE: game picker found %zu app(s):%s", + games->size(), summary.empty() ? " none" : summary.c_str()); + + // Fails when the window has already gone, which is the whole reason + // ownership only transfers on success. + if (PostMessageW(hwnd, WM_GAMES_READY, 0, + reinterpret_cast(games.get()))) + games.release(); + }).detach(); +} + void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr, const ControllerProfile& cfg, - std::vector games, std::map gameProfiles, std::function applyCallback, std::function deleteCallback) @@ -1212,16 +1575,18 @@ void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr, // If already open, just bring it to front. if (m_hwnd) { if (IsWindowVisible(m_hwnd)) { BringToFront(); return; } - // Was hidden. Refresh config and show. + // Was hidden. Refresh config and show. The list is rebuilt from + // scratch rather than kept, because the likeliest thing to have + // happened since it was last up is that the user installed a game. m_config = cfg; - m_games = std::move(games); + m_games.clear(); m_gameProfiles = std::move(gameProfiles); m_applyCallback = std::move(applyCallback); m_deleteCallback = std::move(deleteCallback); - AppendOrphanProfiles(); ShowWindow(m_hwnd, SW_SHOW); BringToFront(); SendInitState(); + StartEnumeration(); return; } @@ -1239,11 +1604,10 @@ void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr, m_hInst = hInst; m_mgr = mgr; m_config = cfg; - m_games = std::move(games); + m_games.clear(); m_gameProfiles = std::move(gameProfiles); m_applyCallback = std::move(applyCallback); m_deleteCallback = std::move(deleteCallback); - AppendOrphanProfiles(); s_instance = this; // --- Register window class (once) --- @@ -1289,6 +1653,12 @@ void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr, // WebView2 initialization is async; callbacks run on the UI thread // via the app's existing GetMessage/DispatchMessage loop in TrayApp::Run(). CreateWebViewAsync(m_hwnd); + + // Started after the window exists, since that is what the result is + // posted back to. Finding every installed application takes well over a + // second on an ordinary machine — long enough that doing it before the + // window went up read as the app having hung. + StartEnumeration(); } void RemapWindow::BringToFront() const { @@ -1426,26 +1796,6 @@ static std::string JsonStr(const std::string& json, const std::string& key) { return end != std::string::npos ? json.substr(pos, end - pos) : std::string{}; } -// The page names a game by its index into m_games (ASCII decimal), never by -// the raw game id — that can hold non-ASCII this hand-rolled channel would -// mangle, and backslashes this parser does not unescape. Shared by "apply" -// and "delete" so there is one place that decides what a valid index is. -// -// False for an absent index, which "apply" reads as the default profile, and -// equally for a malformed or out-of-range one, which is a message we did not -// send and neither handler acts on. -static bool GameIndexFrom(const std::string& value, size_t count, size_t& out) { - if (value.empty()) return false; - size_t idx = 0; - for (char c : value) { - if (c < '0' || c > '9') return false; - idx = idx * 10 + static_cast(c - '0'); - } - if (idx >= count) return false; - out = idx; - return true; -} - void RemapWindow::OnWebMessage(const std::wstring& raw) { // All message content is ASCII; narrow explicitly to suppress C4244. std::string msg; @@ -1529,34 +1879,48 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) { cfg.leftPad.scrollDir = ScrollDirectionFromId(JsonStr(msg, "LPADdir")); cfg.rightPad.scrollDir = ScrollDirectionFromId(JsonStr(msg, "RPADdir")); - size_t idx = 0; - if (!GameIndexFrom(JsonStr(msg, "game"), m_games.size(), idx)) { - // No index at all is the default profile; a bad one is a message - // we did not send and will not act on. - if (JsonStr(msg, "game").empty()) { - m_config = cfg; - if (m_applyCallback) m_applyCallback(L"", cfg); - } - } else { - const std::wstring& gameId = m_games[idx].id; + const std::string token = JsonStr(msg, "game"); + if (PickerEntry* entry = EntryForToken(token)) { + const std::wstring gameId = entry->game.id; // Captured here because this is the only place both halves // are in hand: the picker knows the friendly name, and // nothing downstream re-enumerates the installed list. - cfg.displayName = m_games[idx].name; + cfg.displayName = entry->game.name; m_gameProfiles[gameId] = cfg; if (m_applyCallback) m_applyCallback(gameId, cfg); + } else if (token.empty()) { + // No token at all is the default profile; an unknown one is a + // message we did not send and will not act on. + m_config = cfg; + if (m_applyCallback) m_applyCallback(L"", cfg); } } else if (type == "delete") { - // Never carries an empty index: the page does not offer removal for + // Never carries an empty token: the page does not offer removal for // the default profile, which has to exist for anything else to fall // back to. - size_t idx = 0; - if (GameIndexFrom(JsonStr(msg, "game"), m_games.size(), idx)) { - const std::wstring& gameId = m_games[idx].id; + if (PickerEntry* entry = EntryForToken(JsonStr(msg, "game"))) { + const std::wstring gameId = entry->game.id; m_gameProfiles.erase(gameId); if (m_deleteCallback) m_deleteCallback(gameId); } + + } else if (type == "listRunning") { + SendRunningApps(); + + } else if (type == "pickRunning") { + // The list was captured when it was sent; an application that has + // closed since simply is not in it any more, which reads to the user + // as the click doing nothing — better than adding a profile for a + // window that is gone. + for (const auto& app : m_runningApps) { + if (std::to_string(app.token) != JsonStr(msg, "app")) continue; + AddManualGame(app.game); + break; + } + + } else if (type == "browseExe") { + BrowseForExe(); } } @@ -1572,22 +1936,23 @@ void RemapWindow::PostCapturedBinding(const BackButtonBinding& binding) { PostToWebView(json); } -void RemapWindow::SendInitState() { - if (!m_webview) return; +// Convert ASCII action ID string to wstring without char→wchar_t narrowing warnings. +static std::wstring Wid(const BackButtonBinding& b) { + const std::string s = b.Id(); + return std::wstring(s.begin(), s.end()); +} - // Convert ASCII action ID string to wstring without char→wchar_t narrowing warnings. - auto wid = [](const BackButtonBinding& b) -> std::wstring { - const std::string s = b.Id(); - return std::wstring(s.begin(), s.end()); - }; - auto narrow = [](const char* s) { - const std::string v = s; - return std::wstring(v.begin(), v.end()); - }; - // One flat object per profile — the page reads it back the same way, and - // the narrow JSON reader on this side cannot descend into nesting. - auto profileJson = [&](const ControllerProfile& p) { - return L"{\"useDefault\":\"" +static std::wstring Narrow(const char* s) { + const std::string v = s; + return std::wstring(v.begin(), v.end()); +} + +// One flat object per profile — the page reads it back the same way, and the +// narrow JSON reader on this side cannot descend into nesting. +std::wstring RemapWindow::ProfileJson(const ControllerProfile& p) { + const auto wid = &Wid; + const auto narrow = &Narrow; + return L"{\"useDefault\":\"" + std::wstring(p.useDefaultMappings ? L"1" : L"0") + L"\"," L"\"platform\":\"" @@ -1603,7 +1968,12 @@ void RemapWindow::SendInitState() { L"\"RPADmode\":\"" + narrow(TrackpadModeId(p.rightPad.mode)) + L"\"," L"\"LPADdir\":\"" + narrow(ScrollDirectionId(p.leftPad.scrollDir)) + L"\"," L"\"RPADdir\":\"" + narrow(ScrollDirectionId(p.rightPad.scrollDir)) + L"\"}"; - }; +} + +void RemapWindow::SendInitState() { + if (!m_webview) return; + + auto wid = &Wid; // Key bindings need a label the page cannot derive on its own — their names // come from the keyboard layout. Sent as an id→name map so a paddle pair @@ -1628,33 +1998,54 @@ void RemapWindow::SendInitState() { for (const auto& [id, cfg] : m_gameProfiles) addProfileLabels(cfg); - // Games list: index-based picker ids so the raw game id — possibly - // non-ASCII, sometimes backslash-laden — never has to cross the - // hand-rolled JSON channel; see OnWebMessage's "apply" handling for the - // other direction. - std::wstring gamesJson; - for (size_t i = 0; i < m_games.size(); ++i) { - if (!gamesJson.empty()) gamesJson += L","; - gamesJson += L"{\"id\":\"" + std::to_wstring(i) + L"\",\"name\":\"" - + JsonEscape(m_games[i].name) + L"\"" - + (i >= m_installedCount ? L",\"missing\":\"1\"" : L"") - + L"}"; - } - - // Profiles: the default plus every game that already has a saved - // override. A listed game with no entry here simply has none yet — the - // page falls back to the default profile's bindings when first selected. - std::wstring profilesJson = L"\"\":" + profileJson(m_config); - for (size_t i = 0; i < m_games.size(); ++i) { - auto it = m_gameProfiles.find(m_games[i].id); - if (it == m_gameProfiles.end()) continue; - profilesJson += L",\"" + std::to_wstring(i) + L"\":" + profileJson(it->second); - } - + // Which of the enumeration and WebView2's own startup finishes first is + // not ours to decide, and both orders happen. If the list got here first + // it is already in this message and nothing further is coming; saying so + // is what stops the picker sitting on "Looking for your games" forever + // waiting for a "games" message that was sent before anything could + // receive it. std::wstring json = L"{\"type\":\"init\"" + L",\"pending\":\"" + std::wstring(m_games.empty() ? L"1" : L"0") + L"\"" L",\"labels\":{" + labels + L"}" - L",\"games\":[" + gamesJson + L"]" - L",\"profiles\":{" + profilesJson + L"}}"; + L",\"games\":[" + GamesJson() + L"]" + L",\"profiles\":{" + ProfilesJson() + L"}}"; PostToWebView(json); } + +// Token-based picker ids so the raw game id — possibly non-ASCII, sometimes +// backslash-laden — never has to cross the hand-rolled JSON channel; see +// OnWebMessage's "apply" handling for the other direction. +std::wstring RemapWindow::GamesJson() const { + std::wstring json; + for (const auto& entry : m_games) { + if (!json.empty()) json += L","; + json += L"{\"id\":\"" + std::to_wstring(entry.token) + L"\",\"name\":\"" + + JsonEscape(entry.game.name) + L"\"" + + (entry.game.source == GameSource::Missing ? L",\"missing\":\"1\"" : L"") + + L"}"; + } + return json; +} + +// The default plus every game that already has a saved override. A listed +// game with no entry here simply has none yet — the page falls back to the +// default profile's bindings when it is first selected. +std::wstring RemapWindow::ProfilesJson() const { + std::wstring json = L"\"\":" + ProfileJson(m_config); + for (const auto& entry : m_games) { + auto it = m_gameProfiles.find(entry.game.id); + if (it == m_gameProfiles.end()) continue; + json += L",\"" + std::to_wstring(entry.token) + L"\":" + ProfileJson(it->second); + } + return json; +} + +// Everything init carries except which profile is selected, because this is +// sent while the user may already be part-way through editing one. +void RemapWindow::SendGameList() { + if (!m_webview) return; + PostToWebView(L"{\"type\":\"games\"" + L",\"games\":[" + GamesJson() + L"]" + L",\"profiles\":{" + ProfilesJson() + L"}}"); +} diff --git a/src/app/RemapWindow.h b/src/app/RemapWindow.h index 674b126..e3e7d69 100644 --- a/src/app/RemapWindow.h +++ b/src/app/RemapWindow.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "BackButtonConfig.h" #include "ControllerPlatform.h" @@ -12,6 +13,20 @@ class ControllerManager; +// One row the picker can offer, and the token the page names it by. +// +// The page never sees a raw game id — those hold non-ASCII and backslashes +// the hand-rolled JSON channel would mangle — so it addresses an entry by an +// opaque decimal token instead. A token is issued once and never reused or +// renumbered, which matters because the list changes underneath the page +// twice: when the background enumeration lands, and whenever the user adds an +// application by hand. A positional index would silently come to mean a +// different game at both of those moments. +struct PickerEntry { + InstalledGame game; + size_t token = 0; +}; + class RemapWindow { public: RemapWindow() = default; @@ -22,15 +37,16 @@ public: // Opens (or re-focuses) the remap window. // Shows a "not supported" message box and returns without opening if WebView2 // is unavailable (Windows 7/8 or no runtime installed). - // games populates the per-game picker; gameProfiles supplies whatever - // overrides already exist for entries in it. applyCallback fires on the - // UI thread when the user clicks Apply — the game id identifies whichever - // game was selected, or is empty for the default profile. + // gameProfiles supplies whatever overrides already exist. The picker fills + // itself: finding the installed games takes a second or more, so the + // window goes up first and the list arrives afterwards. + // applyCallback fires on the UI thread when the user clicks Apply — the + // game id identifies whichever game was selected, or is empty for the + // default profile. // deleteCallback fires when the user removes a game's profile; its id is // one that was in gameProfiles, and never empty — the default profile // cannot be removed. void Open(HINSTANCE hInst, ControllerManager* mgr, const ControllerProfile& profile, - std::vector games, std::map gameProfiles, std::function applyCallback, std::function deleteCallback); @@ -50,19 +66,53 @@ private: static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); LRESULT HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); - // Add a picker entry for every saved profile whose game is not in the - // installed list, so a profile for something no longer on the PC can - // still be reached — to look at, or to delete. Sets m_installedCount to - // where the real entries stop, which is the only thing separating the two - // kinds afterwards. + // Add a picker entry for every saved profile no enumerated game accounts + // for, so a profile for something the list cannot see is still reachable — + // to look at, or to delete. + // + // Two quite different things end up here. One is a game that really has + // gone: it gets GameSource::Missing and the picker says so. The other is + // an application the user pointed at by hand, which was never going to be + // in the installed list and is working perfectly; that one is checked for + // on disk and marked GameSource::Manual, so a profile that is doing its + // job is not labelled as broken. void AppendOrphanProfiles(); + // Runs GameLibrary::EnumerateInstalled() on a background thread and posts + // the result back as WM_GAMES_READY. Started once per opening. + void StartEnumeration(); + + // Adds a picker entry the user chose themselves, selects it on the page, + // and returns its token. Nothing is saved until they hit Apply. + void AddManualGame(InstalledGame game); + + // The two ways to name an application the installed list does not have. + void SendRunningApps(); + void BrowseForExe(); + + // The entry the page means by this token, or null for an absent, malformed + // or unknown one — a message we did not send, which no handler acts on. + PickerEntry* EntryForToken(const std::string& value); + + // This game's token, issuing one the first time it is asked for. + size_t TokenFor(const std::wstring& id); + + // Hands the page the current list, plus the profiles that go with it. + // Distinct from SendInitState, which also resets which game is selected — + // the list can land while the user is already editing, and throwing their + // work away because the enumeration finished would be indefensible. + void SendGameList(); + void CreateWebViewAsync(HWND hwnd); void OnControllerReady(ICoreWebView2Controller* ctrl); void OnWebMessage(const std::wstring& raw); void PostToWebView(const std::wstring& jsonStr); void SendInitState(); + static std::wstring ProfileJson(const ControllerProfile& p); + std::wstring GamesJson() const; + std::wstring ProfilesJson() const; + // Tells the page a binding was captured. The page applies it to whichever // row is listening — it owns that state, not us. void PostCapturedBinding(const BackButtonBinding& binding); @@ -74,19 +124,28 @@ private: // be hidden. Distinct from WM_CLOSE so the confirmed close does not loop // back into asking the page again. static constexpr UINT WM_CLOSE_CONFIRMED = WM_APP + 2; + // The background enumeration finished. LPARAM owns a + // std::vector the handler takes and deletes. + static constexpr UINT WM_GAMES_READY = WM_APP + 3; HWND m_hwnd = nullptr; HINSTANCE m_hInst = nullptr; ControllerManager* m_mgr = nullptr; ControllerProfile m_config; - // Installed games first, then one entry per orphaned profile. The page - // names games by index into this, so appending rather than keeping a - // second list is what lets an orphan be applied and deleted through the - // paths that already existed. - std::vector m_games; - // Where the installed entries stop. Everything at or past it is a profile - // whose game we could not find. - size_t m_installedCount = 0; + // Everything the picker can offer: the enumerated games, then one entry + // per profile they did not account for, then anything the user added by + // hand this session. Kept as one list so an entry of any kind is applied + // and deleted through the paths that already existed. + std::vector m_games; + // Game id to the token the page knows it by. Kept for the window's whole + // life and never renumbered, so rebuilding m_games — which happens every + // time an enumeration lands — cannot change what a token the page is + // already holding refers to. + std::map m_tokens; + size_t m_nextToken = 1; + // The applications offered by the "running now" picker, held between + // sending the list and the user choosing from it. Same token discipline. + std::vector m_runningApps; std::map m_gameProfiles; std::function m_applyCallback; std::function m_deleteCallback; diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index d38fc18..80bdfd9 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include static TrayApp* g_app = nullptr; @@ -517,12 +518,27 @@ static bool SamePath(const std::wstring& a, const std::wstring& b) { std::wstring TrayApp::MatchProfile(const ForegroundIdentity& id) const { static constexpr wchar_t kSteamPrefix[] = L"steam://rungameid/"; static constexpr size_t kSteamPrefixLen = ARRAYSIZE(kSteamPrefix) - 1; + static constexpr size_t kDirPrefixLen = 4; // "dir:" // Resolved once rather than per profile: the lookup walks every known // Steam install, and the answer cannot differ between profiles. const std::wstring steamAppId = m_steamApps.AppIdForPath(id.exePath); + std::wstring lowerExe = id.exePath; + for (wchar_t& c : lowerExe) c = static_cast(towlower(c)); + + // A "dir:" profile claims a whole install folder, so two of them can both + // match one process — a game installed inside another game's directory. + // The longer match is the more specific answer, which is the same rule + // SteamAppLocator::AppIdForPath applies within Steam's own libraries. + std::wstring bestDirId; + size_t bestDirLen = 0; + for (const auto& [profileId, profile] : m_gameProfiles) { + // An exact identity beats any directory, so these return straight + // away rather than competing on length: a profile naming this precise + // executable or package was made for this application and nothing + // else, while a directory only says the application lives there. if (profileId.rfind(L"aumid:", 0) == 0) { if (!id.aumid.empty() && _wcsicmp(profileId.c_str() + 6, id.aumid.c_str()) == 0) @@ -532,11 +548,22 @@ std::wstring TrayApp::MatchProfile(const ForegroundIdentity& id) const { std::wstring::npos, steamAppId) == 0) return profileId; + } else if (profileId.rfind(L"dir:", 0) == 0) { + if (lowerExe.empty()) continue; + std::wstring dir = profileId.substr(kDirPrefixLen); + for (wchar_t& c : dir) c = static_cast(towlower(c)); + // The trailing separator is what stops "...\Portal" matching an + // executable under "...\Portal 2". + if (!dir.empty() && dir.back() != L'\\') dir += L'\\'; + if (dir.size() <= bestDirLen) continue; // cannot beat what we have + if (lowerExe.compare(0, dir.size(), dir) != 0) continue; + bestDirId = profileId; + bestDirLen = dir.size(); } else if (SamePath(profileId, id.exePath)) { return profileId; } } - return {}; + return bestDirId; } void TrayApp::RefreshSteamApps() { @@ -1314,18 +1341,22 @@ void TrayApp::OpenRemapWindow() { // The moment a user is most likely to have installed something since the // app started, and the moment a stale list would matter most — profiles // created here are matched against it. + // + // Left on the UI thread, unlike the picker's own enumeration below. It is + // a handful of small text files and measures in tens of milliseconds, and + // m_steamApps is read by MatchProfile on this thread every time the + // foreground changes — so moving it would trade an unnoticeable delay for + // a data race. RefreshSteamApps(); - // Synchronous on the UI thread — the Start Menu walk plus the - // shell:AppsFolder enumeration together are still fast enough in - // practice (local disk and COM calls, no network) to not need a - // background thread for an action the user just explicitly asked to - // open a window for. + // The window fills its own picker, on a background thread: finding every + // installed application runs to well over a second on an ordinary machine + // — several, on a cold cache — and doing it here meant the window did not + // appear at all until it finished. m_remapWindow.Open( m_hInstance, m_controller.get(), m_defaultProfile, - GameLibrary::EnumerateInstalled(), m_gameProfiles, [this](const std::wstring& gameId, const ControllerProfile& profile) { if (gameId.empty()) { diff --git a/src/probe/GameLibraryProbe.cpp b/src/probe/GameLibraryProbe.cpp index 42ce9e0..78c12e0 100644 --- a/src/probe/GameLibraryProbe.cpp +++ b/src/probe/GameLibraryProbe.cpp @@ -1,14 +1,64 @@ -// Console diagnostic: dumps what GameLibrary::EnumerateInstalled() finds by -// walking the Start Menu, so the path-matching approach can be checked -// against a real machine's installed games without going through the -// remap window's UI. +// Console diagnostic: dumps what GameLibrary::EnumerateInstalled() finds, so +// the picker's coverage can be checked against a real machine without going +// through the remap window's UI. +// +// The per-source summary at the end is the point of the tool. "My game isn't +// in the list" is the report this feature attracts, and the answer is nearly +// always that one source came back empty — which this shows at a glance and +// a screenshot of the picker never can. +// +// Pass "running" to instead dump what the remap window's "add an app that's +// running now" list would offer, which is the other half of the picker and +// the only way to check it without clicking through the UI: +// GameLibraryProbe.exe running +// +// Pass a directory to instead run only the Epic manifest reader against it, +// which is how that parser gets exercised on a machine with no Epic install: +// GameLibraryProbe.exe C:\path\to\fixture\Manifests #include "app/GameLibrary.h" +#include "app/LauncherGames.h" +#include +#include #include +#include -int main() { - const auto games = GameLibrary::EnumerateInstalled(); - wprintf(L"%zu game(s) found:\n", games.size()); +namespace { + +void Dump(const std::vector& games) { + wprintf(L"%zu entr%ls found:\n", games.size(), games.size() == 1 ? L"y" : L"ies"); for (const auto& g : games) - wprintf(L" %-40ls %ls\n", g.name.c_str(), g.id.c_str()); + wprintf(L" %-12ls %-42ls %ls\n", GameSourceName(g.source), + g.name.c_str(), g.id.c_str()); + + std::map counts; + for (const auto& g : games) ++counts[GameSourceName(g.source)]; + wprintf(L"\nby source:\n"); + for (const auto& [source, count] : counts) + wprintf(L" %-12ls %zu\n", source.c_str(), count); +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + // Plenty of game titles are not ASCII. Without this, wprintf gives up on + // the first character it cannot encode and abandons the rest of the line, + // which in a tool whose whole job is showing what was found would quietly + // hide the entries most worth looking at. Same reason wWinMain sets it. + setlocale(LC_CTYPE, ".UTF8"); + SetConsoleOutputCP(CP_UTF8); + + if (argc > 1 && _wcsicmp(argv[1], L"running") == 0) { + wprintf(L"Applications with a window open right now\n\n"); + Dump(GameLibrary::EnumerateRunning()); + return 0; + } + + if (argc > 1) { + wprintf(L"Epic manifests from [%ls]\n\n", argv[1]); + Dump(LauncherGames::EnumerateEpic(argv[1])); + return 0; + } + + Dump(GameLibrary::EnumerateInstalled()); return 0; }