[Workspaces]Fix for steam games capture&launch: capture and correctly launch steam games. (#38380)

* Workspaces fix: capture steam games.

* minor fix

* Launch steam apps by url appmodeluserid instead of directly exe call.

* fix copilot comment

* fix

* remove unnecessary string

* expect words

* white list words

* Order of alphabet

* exclude thin frame if it's not a steam game.

* fix build

* fix regression

* adjust comment
This commit is contained in:
Kai Tao
2025-04-23 15:39:54 +08:00
committed by GitHub
parent d4e577bb81
commit 583614449d
13 changed files with 294 additions and 5 deletions

View File

@@ -273,4 +273,4 @@ mengyuanchen
testhost testhost
#Tools #Tools
OIP OIP

View File

@@ -8,6 +8,7 @@ Acceleratorkeys
ACCEPTFILES ACCEPTFILES
ACCESSDENIED ACCESSDENIED
ACCESSTOKEN ACCESSTOKEN
acfs
AClient AClient
AColumn AColumn
acrt acrt
@@ -523,6 +524,7 @@ FZE
gacutil gacutil
Gaeilge Gaeilge
Gaidhlig Gaidhlig
gameid
GC'ed GC'ed
GCLP GCLP
gdi gdi
@@ -712,6 +714,7 @@ INPUTSINK
INPUTTYPE INPUTTYPE
INSTALLDESKTOPSHORTCUT INSTALLDESKTOPSHORTCUT
INSTALLDIR INSTALLDIR
installdir
INSTALLFOLDER INSTALLFOLDER
INSTALLFOLDERTOBOOTSTRAPPERINSTALLFOLDER INSTALLFOLDERTOBOOTSTRAPPERINSTALLFOLDER
INSTALLFOLDERTOPREVIOUSINSTALLFOLDER INSTALLFOLDERTOPREVIOUSINSTALLFOLDER
@@ -1569,6 +1572,7 @@ stdcpp
stdcpplatest stdcpplatest
STDMETHODCALLTYPE STDMETHODCALLTYPE
STDMETHODIMP STDMETHODIMP
steamapps
STGC STGC
STGM STGM
STGMEDIUM STGMEDIUM
@@ -1969,4 +1973,4 @@ zoomit
ZOOMITX ZOOMITX
ZXk ZXk
ZXNs ZXNs
zzz zzz

View File

@@ -121,6 +121,22 @@ namespace AppLauncher
// packaged apps: try launching first by AppUserModel.ID // packaged apps: try launching first by AppUserModel.ID
// usage example: elevated Terminal // usage example: elevated Terminal
if (!launched && !app.appUserModelId.empty() && !app.packageFullName.empty()) if (!launched && !app.appUserModelId.empty() && !app.packageFullName.empty())
{
Logger::trace(L"Launching {} as {} - {app.packageFullName}", app.name, app.appUserModelId, app.packageFullName);
auto res = LaunchApp(L"shell:AppsFolder\\" + app.appUserModelId, app.commandLineArgs, app.isElevated);
if (res.isOk())
{
launched = true;
}
else
{
launchErrors.push_back({ std::filesystem::path(app.path).filename(), res.error() });
}
}
// win32 app with appUserModelId:
// usage example: steam games
if (!launched && !app.appUserModelId.empty())
{ {
Logger::trace(L"Launching {} as {}", app.name, app.appUserModelId); Logger::trace(L"Launching {} as {}", app.name, app.appUserModelId);
auto res = LaunchApp(L"shell:AppsFolder\\" + app.appUserModelId, app.commandLineArgs, app.isElevated); auto res = LaunchApp(L"shell:AppsFolder\\" + app.appUserModelId, app.commandLineArgs, app.isElevated);

View File

@@ -1,5 +1,6 @@
#include "pch.h" #include "pch.h"
#include "AppUtils.h" #include "AppUtils.h"
#include "SteamHelper.h"
#include <atlbase.h> #include <atlbase.h>
#include <propvarutil.h> #include <propvarutil.h>
@@ -34,6 +35,8 @@ namespace Utils
constexpr const wchar_t* EdgeFilename = L"msedge.exe"; constexpr const wchar_t* EdgeFilename = L"msedge.exe";
constexpr const wchar_t* ChromeFilename = L"chrome.exe"; constexpr const wchar_t* ChromeFilename = L"chrome.exe";
constexpr const wchar_t* SteamUrlProtocol = L"steam:";
} }
AppList IterateAppsFolder() AppList IterateAppsFolder()
@@ -138,6 +141,34 @@ namespace Utils
else if (prop == NonLocalizable::PackageInstallPathProp || prop == NonLocalizable::InstallPathProp) else if (prop == NonLocalizable::PackageInstallPathProp || prop == NonLocalizable::InstallPathProp)
{ {
data.installPath = propVariantString.m_pData; data.installPath = propVariantString.m_pData;
if (!data.installPath.empty())
{
const bool isSteamProtocol = data.installPath.rfind(NonLocalizable::SteamUrlProtocol, 0) == 0;
if (isSteamProtocol)
{
Logger::info(L"Found steam game: protocol path: {}", data.installPath);
data.protocolPath = data.installPath;
try
{
auto gameId = Steam::GetGameIdFromUrlProtocolPath(data.installPath);
auto gameFolder = Steam::GetSteamGameInfoFromAcfFile(gameId);
if (gameFolder)
{
data.installPath = gameFolder->gameInstallationPath;
Logger::info(L"Found steam game: physical path: {}", data.installPath);
}
}
catch (std::exception ex)
{
Logger::error(L"Failed to get installPath for game {}", data.installPath);
Logger::error("Error: {}", ex.what());
}
}
}
} }
} }
@@ -397,5 +428,10 @@ namespace Utils
{ {
return installPath.ends_with(NonLocalizable::ChromeFilename); return installPath.ends_with(NonLocalizable::ChromeFilename);
} }
bool AppData::IsSteamGame() const
{
return protocolPath.rfind(NonLocalizable::SteamUrlProtocol, 0) == 0;
}
} }
} }

View File

@@ -13,10 +13,12 @@ namespace Utils
std::wstring packageFullName; std::wstring packageFullName;
std::wstring appUserModelId; std::wstring appUserModelId;
std::wstring pwaAppId; std::wstring pwaAppId;
std::wstring protocolPath;
bool canLaunchElevated = false; bool canLaunchElevated = false;
bool IsEdge() const; bool IsEdge() const;
bool IsChrome() const; bool IsChrome() const;
bool IsSteamGame() const;
}; };
using AppList = std::vector<AppData>; using AppList = std::vector<AppData>;

View File

@@ -0,0 +1,171 @@
#include "pch.h"
#include "SteamHelper.h"
#include <fstream>
#include <sstream>
#include <unordered_map>
#include <filesystem>
#include <regex>
#include <string>
namespace Utils
{
static std::wstring Utf8ToWide(const std::string& utf8)
{
if (utf8.empty())
return L"";
int size = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), nullptr, 0);
if (size <= 0)
return L"";
std::wstring wide(size, L'\0');
MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), wide.data(), size);
return wide;
}
namespace Steam
{
using namespace std;
namespace fs = std::filesystem;
static std::optional<std::wstring> GetSteamExePathFromRegistry()
{
static std::optional<std::wstring> cachedPath;
if (cachedPath.has_value())
{
return cachedPath;
}
const std::vector<HKEY> roots = { HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, HKEY_USERS };
const std::vector<std::wstring> subKeys = {
L"steam\\shell\\open\\command",
L"Software\\Classes\\steam\\shell\\open\\command",
};
for (HKEY root : roots)
{
for (const auto& subKey : subKeys)
{
HKEY hKey;
if (RegOpenKeyExW(root, subKey.c_str(), 0, KEY_READ, &hKey) == ERROR_SUCCESS)
{
wchar_t value[512];
DWORD size = sizeof(value);
DWORD type = 0;
if (RegQueryValueExW(hKey, nullptr, nullptr, &type, reinterpret_cast<LPBYTE>(value), &size) == ERROR_SUCCESS &&
(type == REG_SZ || type == REG_EXPAND_SZ))
{
std::wregex exeRegex(LR"delim("([^"]+steam\.exe)")delim");
std::wcmatch match;
if (std::regex_search(value, match, exeRegex) && match.size() > 1)
{
RegCloseKey(hKey);
cachedPath = match[1].str();
return cachedPath;
}
}
RegCloseKey(hKey);
}
}
}
cachedPath = std::nullopt;
return std::nullopt;
}
static fs::path GetSteamBasePath()
{
auto steamFolderOpt = GetSteamExePathFromRegistry();
if (!steamFolderOpt)
{
return {};
}
return fs::path(*steamFolderOpt).parent_path() / L"steamapps";
}
static fs::path GetAcfFilePath(const std::wstring& gameId)
{
auto steamFolderOpt = GetSteamExePathFromRegistry();
if (!steamFolderOpt)
{
return {};
}
return GetSteamBasePath() / (L"appmanifest_" + gameId + L".acf");
}
static fs::path GetGameInstallPath(const std::wstring& gameFolderName)
{
auto steamFolderOpt = GetSteamExePathFromRegistry();
if (!steamFolderOpt)
{
return {};
}
return GetSteamBasePath() / L"common" / gameFolderName;
}
static unordered_map<wstring, wstring> ParseAcfFile(const fs::path& acfPath)
{
unordered_map<wstring, wstring> result;
ifstream file(acfPath);
if (!file.is_open())
return result;
string line;
while (getline(file, line))
{
smatch matches;
static const regex pattern(R"delim("([^"]+)"\s+"([^"]+)")delim");
if (regex_search(line, matches, pattern) && matches.size() == 3)
{
wstring key = Utf8ToWide(matches[1].str());
wstring value = Utf8ToWide(matches[2].str());
result[key] = value;
}
}
return result;
}
std::unique_ptr<Steam::SteamGame> GetSteamGameInfoFromAcfFile(const std::wstring& gameId)
{
fs::path acfPath = Steam::GetAcfFilePath(gameId);
if (!fs::exists(acfPath))
return nullptr;
auto kv = ParseAcfFile(acfPath);
if (kv.empty() || kv.find(L"installdir") == kv.end())
return nullptr;
fs::path gamePath = Steam::GetGameInstallPath(kv[L"installdir"]);
if (!fs::exists(gamePath))
return nullptr;
auto game = std::make_unique<Steam::SteamGame>();
game->gameId = gameId;
game->gameInstallationPath = gamePath.wstring();
return game;
}
std::wstring GetGameIdFromUrlProtocolPath(const std::wstring& urlPath)
{
const std::wstring steamGamePrefix = L"steam://rungameid/";
if (urlPath.rfind(steamGamePrefix, 0) == 0)
{
return urlPath.substr(steamGamePrefix.length());
}
return L"";
}
}
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include "pch.h"
namespace Utils
{
namespace NonLocalizable
{
const std::wstring AcfFileNameTemplate = L"appmanifest_<gameid>.acfs";
}
namespace Steam
{
struct SteamGame
{
std::wstring gameId;
std::wstring gameInstallationPath;
};
std::unique_ptr<SteamGame> GetSteamGameInfoFromAcfFile(const std::wstring& gameId);
std::wstring GetGameIdFromUrlProtocolPath(const std::wstring& urlPath);
}
}

View File

@@ -41,6 +41,7 @@
<ClInclude Include="pch.h" /> <ClInclude Include="pch.h" />
<ClInclude Include="PwaHelper.h" /> <ClInclude Include="PwaHelper.h" />
<ClInclude Include="Result.h" /> <ClInclude Include="Result.h" />
<ClInclude Include="SteamHelper.h" />
<ClInclude Include="StringUtils.h" /> <ClInclude Include="StringUtils.h" />
<ClInclude Include="utils.h" /> <ClInclude Include="utils.h" />
<ClInclude Include="WbemHelper.h" /> <ClInclude Include="WbemHelper.h" />
@@ -57,6 +58,7 @@
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile> </ClCompile>
<ClCompile Include="PwaHelper.cpp" /> <ClCompile Include="PwaHelper.cpp" />
<ClCompile Include="SteamGameHelper.cpp" />
<ClCompile Include="two_way_pipe_message_ipc.cpp" /> <ClCompile Include="two_way_pipe_message_ipc.cpp" />
<ClCompile Include="WbemHelper.cpp" /> <ClCompile Include="WbemHelper.cpp" />
<ClCompile Include="WorkspacesData.cpp" /> <ClCompile Include="WorkspacesData.cpp" />

View File

@@ -53,6 +53,9 @@
<ClInclude Include="StringUtils.h"> <ClInclude Include="StringUtils.h">
<Filter>Header Files</Filter> <Filter>Header Files</Filter>
</ClInclude> </ClInclude>
<ClInclude Include="SteamHelper.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ClCompile Include="pch.cpp"> <ClCompile Include="pch.cpp">
@@ -88,6 +91,9 @@
<ClCompile Include="WbemHelper.cpp"> <ClCompile Include="WbemHelper.cpp">
<Filter>Source Files</Filter> <Filter>Source Files</Filter>
</ClCompile> </ClCompile>
<ClCompile Include="SteamGameHelper.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="packages.config" /> <None Include="packages.config" />

View File

@@ -71,6 +71,8 @@ namespace SnapshotUtils
continue; continue;
} }
Logger::info("Try to get window app:{}", reinterpret_cast<void*>(window));
DWORD pid{}; DWORD pid{};
GetWindowThreadProcessId(window, &pid); GetWindowThreadProcessId(window, &pid);
@@ -118,10 +120,19 @@ namespace SnapshotUtils
auto data = Utils::Apps::GetApp(processPath, pid, installedApps); auto data = Utils::Apps::GetApp(processPath, pid, installedApps);
if (!data.has_value() || data->name.empty()) if (!data.has_value() || data->name.empty())
{ {
Logger::info(L"Installed app not found: {}", processPath); Logger::info(L"Installed app not found:{},{}", reinterpret_cast<void*>(window), processPath);
continue; continue;
} }
if (!data->IsSteamGame() && !WindowUtils::HasThickFrame(window))
{
// Only care about steam games if it has no thick frame to remain consistent with
// the behavior as before.
continue;
}
Logger::info(L"Found app for window:{},{}", reinterpret_cast<void*>(window), processPath);
auto appData = data.value(); auto appData = data.value();
bool isEdge = appData.IsEdge(); bool isEdge = appData.IsEdge();

View File

@@ -200,6 +200,14 @@ std::optional<WindowWithDistance> WindowArranger::GetNearestWindow(const Workspa
} }
auto data = Utils::Apps::GetApp(processPath, pid, m_installedApps); auto data = Utils::Apps::GetApp(processPath, pid, m_installedApps);
if (!data->IsSteamGame() && !WindowUtils::HasThickFrame(window))
{
// Only care about steam games if it has no thick frame to remain consistent with
// the behavior as before.
continue;
}
if (!data.has_value()) if (!data.has_value())
{ {
continue; continue;

View File

@@ -9,10 +9,12 @@ namespace WindowFilter
{ {
auto style = GetWindowLong(window, GWL_STYLE); auto style = GetWindowLong(window, GWL_STYLE);
bool isPopup = WindowUtils::HasStyle(style, WS_POPUP); bool isPopup = WindowUtils::HasStyle(style, WS_POPUP);
bool hasThickFrame = WindowUtils::HasStyle(style, WS_THICKFRAME);
bool hasCaption = WindowUtils::HasStyle(style, WS_CAPTION); bool hasCaption = WindowUtils::HasStyle(style, WS_CAPTION);
bool hasMinimizeMaximizeButtons = WindowUtils::HasStyle(style, WS_MINIMIZEBOX) || WindowUtils::HasStyle(style, WS_MAXIMIZEBOX); bool hasMinimizeMaximizeButtons = WindowUtils::HasStyle(style, WS_MINIMIZEBOX) || WindowUtils::HasStyle(style, WS_MAXIMIZEBOX);
if (isPopup && !(hasThickFrame && (hasCaption || hasMinimizeMaximizeButtons)))
Logger::info("Style for window: {}, {:#x}", reinterpret_cast<void*>(window), style);
if (isPopup && !(hasCaption || hasMinimizeMaximizeButtons))
{ {
// popup windows we want to snap: e.g. Calculator, Telegram // popup windows we want to snap: e.g. Calculator, Telegram
// popup windows we don't want to snap: start menu, notification popup, tray window, etc. // popup windows we don't want to snap: start menu, notification popup, tray window, etc.

View File

@@ -121,4 +121,11 @@ namespace WindowUtils
return std::wstring(title); return std::wstring(title);
} }
inline bool HasThickFrame(HWND window)
{
auto style = GetWindowLong(window, GWL_STYLE);
return WindowUtils::HasStyle(style, WS_THICKFRAME);
}
} }