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