From df71ffefc8cddfa035201ce760e2f3109639db38 Mon Sep 17 00:00:00 2001 From: Sewer Date: Sat, 9 Mar 2024 05:11:08 +0000 Subject: [PATCH] Fuck Microsoft Store DRM (Copy Protection) (#332) * Added: Unprotect MS Store Binaries * Added: Auto ASI Loader Deploy to Games (incl. GamePass) * Improved: Can now survive GamePass updates with last resort AppId match * Edit App Page: Display 'Don't Inject' Setting * Added: Respect the DontInject Flag * Fixed: Some comment work on TryUnprotectGamePassGame * Added: Remaining Sanity Bug Fixes for GamePass * Added: GamePass Related Note * Changed: Only show ASI Loader Install fail on GamePass Auto Deploy --- .../Application/AddApplicationCommand.cs | 21 ++- .../Application/DeployAsiLoaderCommand.cs | 9 +- source/Reloaded.Mod.Launcher.Lib/Setup.cs | 11 +- source/Reloaded.Mod.Launcher.Lib/Startup.cs | 21 ++- .../Static/Resources.cs | 3 + .../Utility/ApplicationLauncher.cs | 19 +- .../Utility/SymlinkResolver.cs | 49 +---- .../Utility/TryUnprotectGamePassGame.cs | 168 ++++++++++++++++++ .../Assets/Languages/en-GB.xaml | 8 +- .../BaseSubpages/ApplicationPage.xaml.cs | 2 +- .../ApplicationSubPages/EditAppPage.xaml | 10 +- .../Config/ApplicationConfig.cs | 2 + source/Reloaded.Mod.Loader/Loader.cs | 12 +- 13 files changed, 264 insertions(+), 71 deletions(-) create mode 100644 source/Reloaded.Mod.Launcher.Lib/Utility/TryUnprotectGamePassGame.cs diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs index f366887f..671e6b60 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/AddApplicationCommand.cs @@ -58,10 +58,11 @@ static string GetProductName(string exePath) return Path.GetFileName(exePath); } } - + try { exePath = SymlinkResolver.GetFinalPathName(exePath); } catch (Exception e) { Errors.HandleException(e, Resources.ErrorAddApplicationCantReadSymlink.Get()); } - + + var isMsStore = TryUnprotectGamePassGame.TryIt(exePath); var config = new ApplicationConfig(Path.GetFileName(exePath).ToLower(), GetProductName(exePath), exePath, Path.GetDirectoryName(exePath)); // Set AppName if empty & Ensure no duplicate ID. @@ -87,7 +88,23 @@ static string GetProductName(string exePath) Console.WriteLine(e); } + // Try to auto deploy ASI Loader. + var deployer = new AsiLoaderDeployer(new PathTuple(applicationConfigFile, config)); + if (deployer.CanDeploy()) + { + deployer.DeployAsiLoader(out var loaderPath, out var bootstrapperPath); + DeployAsiLoaderCommand.PrintDeployedAsiLoaderInfo(loaderPath!, bootstrapperPath); + config.DontInject = true; + } + else + { + // For GamePass, we can't dll inject, so we need to throw error to user screen. + if (isMsStore) + Actions.DisplayMessagebox.Invoke(Resources.AsiLoaderDialogTitle.Get(), Resources.AsiLoaderGamePassAutoInstallFail.Get()); + } + // Write file to disk. + config.IsMsStore = isMsStore; Directory.CreateDirectory(applicationDirectory); IConfig.ToPath(config, applicationConfigFile); diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/DeployAsiLoaderCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/DeployAsiLoaderCommand.cs index f9594d0f..823be4dd 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Application/DeployAsiLoaderCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Application/DeployAsiLoaderCommand.cs @@ -31,7 +31,12 @@ public void Execute(object? parameter) return; _deployer.DeployAsiLoader(out string? loaderPath, out string bootstrapperPath); - string deployedBootstrapper = $"{Resources.AsiLoaderDialogBootstrapperDeployed.Get()} {bootstrapperPath}"; + PrintDeployedAsiLoaderInfo(bootstrapperPath, loaderPath); + } + + internal static void PrintDeployedAsiLoaderInfo(string bootstrapperPath, string? loaderPath) + { + var deployedBootstrapper = $"{Resources.AsiLoaderDialogBootstrapperDeployed.Get()} {bootstrapperPath}"; if (loaderPath == null) { // Installed Bootstrapper but not loader. @@ -39,7 +44,7 @@ public void Execute(object? parameter) } else { - string deployedLoader = $"{Resources.AsiLoaderDialogLoaderDeployed.Get()} {loaderPath}"; + var deployedLoader = $"{Resources.AsiLoaderDialogLoaderDeployed.Get()} {loaderPath}"; Actions.DisplayMessagebox.Invoke(Resources.AsiLoaderDialogTitle.Get(), $"{deployedLoader}\n{deployedBootstrapper}"); } } diff --git a/source/Reloaded.Mod.Launcher.Lib/Setup.cs b/source/Reloaded.Mod.Launcher.Lib/Setup.cs index 8c45cc7c..e3746131 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Setup.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Setup.cs @@ -150,7 +150,16 @@ private static Task DoSanityTests() // Needs to be ran after SetupViewModelsAsync var apps = IoC.GetConstant().Items; var mods = IoC.GetConstant().Items.ToArray(); - + + // Unprotect all MS Store titles if needed. + foreach (var app in apps) + { + if (app.Config.IsMsStore) + { + TryUnprotectGamePassGame.TryIgnoringErrors(app.Config.AppLocation); + } + } + // Enforce compatibility non-async, since this is unlikely to do anything. foreach (var app in apps) EnforceModCompatibility(app, mods); diff --git a/source/Reloaded.Mod.Launcher.Lib/Startup.cs b/source/Reloaded.Mod.Launcher.Lib/Startup.cs index 97f8a291..091edd40 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Startup.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Startup.cs @@ -22,13 +22,19 @@ public static bool HandleCommandLineArgs() PopulateCommandLineArgs(); // Check if Kill Process + bool forceInject = false; if (_commandLineArguments.TryGetValue(Constants.ParameterKill, out string? processId)) + { KillProcessWithId(processId); + // for outdated bootstrappers, assume injection is required when kill specified. + // otherwise follow regular setting + forceInject = true; + } // Check if Launch if (_commandLineArguments.TryGetValue(Constants.ParameterLaunch, out string? applicationToLaunch)) { - LaunchApplicationAndExit(applicationToLaunch); + LaunchApplicationAndExit(applicationToLaunch, forceInject); result = true; } @@ -69,7 +75,7 @@ private static void KillProcessWithId(string processId) } [MethodImpl(MethodImplOptions.NoInlining)] - private static void LaunchApplicationAndExit(string applicationToLaunch) + private static void LaunchApplicationAndExit(string applicationToLaunch, bool forceInject) { // Acquire arguments var loaderConfig = IoC.Get(); @@ -85,17 +91,18 @@ private static void LaunchApplicationAndExit(string applicationToLaunch) arguments = $"{arguments} {application.Config.AppArguments}"; _commandLineArguments.TryGetValue(Constants.ParameterWorkingDirectory, out var workingDirectory); - + var inject = !application!.Config.DontInject | forceInject; + // Show warning for Wine users. if (Shared.Environment.IsWine) { // Set up UI Resources, since they're needed for the dialog. if (CompatibilityDialogs.WineShowLaunchDialog()) - StartGame(applicationToLaunch, arguments, workingDirectory); + StartGame(applicationToLaunch, arguments, workingDirectory, inject); } else { - StartGame(applicationToLaunch, arguments, workingDirectory); + StartGame(applicationToLaunch, arguments, workingDirectory, inject); } } @@ -142,11 +149,11 @@ private static void OpenPackAndExit(string r2PackLocation) private static void InitControllerSupport() => Actions.InitControllerSupport(); - private static void StartGame(string applicationToLaunch, string arguments, string? workingDirectory = null) + private static void StartGame(string applicationToLaunch, string arguments, string? workingDirectory, bool inject) { // Launch the application. var launcher = ApplicationLauncher.FromLocationAndArguments(applicationToLaunch, arguments, workingDirectory); - launcher.Start(); + launcher.Start(inject); } private static void PopulateCommandLineArgs() diff --git a/source/Reloaded.Mod.Launcher.Lib/Static/Resources.cs b/source/Reloaded.Mod.Launcher.Lib/Static/Resources.cs index 5bde2241..d872508f 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Static/Resources.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Static/Resources.cs @@ -197,4 +197,7 @@ public static void Init(IDictionaryResourceProvider provider) public static IDictionaryResource SearchOptionSortViews { get; set; } public static IDictionaryResource SearchOptionAscending { get; set; } public static IDictionaryResource SearchOptionDescending { get; set; } + + // Update 1.26.0: GamePass ASI Loader Auto Deploy + public static IDictionaryResource AsiLoaderGamePassAutoInstallFail { get; set; } } \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/ApplicationLauncher.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/ApplicationLauncher.cs index 04e03dfb..a4fa2ec3 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Utility/ApplicationLauncher.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/ApplicationLauncher.cs @@ -41,7 +41,7 @@ public static ApplicationLauncher FromApplicationConfig(PathTuple /// Starts the application, injecting Reloaded into it. /// - public void Start() + public void Start(bool inject = true) { // Start up the process Native.STARTUPINFO startupInfo = new Native.STARTUPINFO(); @@ -66,14 +66,17 @@ public void Start() var process = Process.GetProcessById((int) processInformation.dwProcessId); using var injector = new ApplicationInjector(process); - try + if (inject) { - injector.Inject(); - } - catch (Exception) - { - Native.ResumeThread(processInformation.hThread); - throw; + try + { + injector.Inject(); + } + catch (Exception) + { + Native.ResumeThread(processInformation.hThread); + throw; + } } Native.ResumeThread(processInformation.hThread); diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/SymlinkResolver.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/SymlinkResolver.cs index 16d9d390..a64f8f0c 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Utility/SymlinkResolver.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/SymlinkResolver.cs @@ -17,7 +17,6 @@ public static class SymlinkResolver private const short MaxPath = short.MaxValue; // Windows 10 with path extension. private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1); - private const uint FILE_READ_EA = 0x0008; private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x2000000; @@ -42,25 +41,12 @@ static extern IntPtr CreateFile( /// Resolves a symbolic link and normalizes the path. /// /// The path to be resolved. - /// Resolves UWP application paths. - public static string GetFinalPathName(string path, bool allowUwp = true) + public static string GetFinalPathName(string path) { // Special Case for UWP/MSStore. - if (allowUwp) - { - try - { - var folder = Path.GetDirectoryName(path)!; - var manifest = Path.Combine(folder, "appxmanifest.xml"); - if (File.Exists(manifest)) - return TryGetFilePathFromUWPAppManifest(path, manifest); - } - catch (Exception) { } - } - var h = CreateFile(path, FILE_READ_EA, FileShare.ReadWrite | FileShare.Delete, IntPtr.Zero, FileMode.Open, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (h == INVALID_HANDLE_VALUE) - throw new Win32Exception(); + return path; try { @@ -78,37 +64,6 @@ public static string GetFinalPathName(string path, bool allowUwp = true) } } - private static string TryGetFilePathFromUWPAppManifest(string path, string manifest) - { - var document = new XmlDocument(); - document.Load(manifest); - - var tag = document.GetElementsByTagName("Identity")[0]!; - var packageName = tag!.Attributes!["Name"]!.Value; - - // I wish I could use WinRT APIs but support is removed from runtime and the official way cuts off support for Win7/8.1 - var newFolder = GetPowershellPackageInstallLocation(packageName!); - return Path.Combine(newFolder, Path.GetFileName(path)); - } - - private static string GetPowershellPackageInstallLocation(string packageName) - { - var processStartInfo = new ProcessStartInfo - { - FileName = @"powershell", - Arguments = $"(Get-AppxPackage {packageName}).InstallLocation", - RedirectStandardOutput = true, - CreateNoWindow = true - }; - var process = Process.Start(processStartInfo); - process?.WaitForExit(); - var output = process?.StandardOutput.ReadToEnd().TrimEnd(); - if (output == null) - throw new Exception("Failed to get Package Install location via PowerShell."); - - return output; - } - private static string RemoveDevicePrefix(string path) { const string DevicePrefix = @"\\?\"; diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/TryUnprotectGamePassGame.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/TryUnprotectGamePassGame.cs new file mode 100644 index 00000000..2bf1ea40 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/TryUnprotectGamePassGame.cs @@ -0,0 +1,168 @@ +using System.Xml; +using Reloaded.Mod.Installer.DependencyInstaller.IO; +using FileMode = System.IO.FileMode; + +namespace Reloaded.Mod.Launcher.Lib.Utility; + +/// +/// GamePass games restrict our access to EXE files, making it difficult for Reloaded to do various operations: +/// - Displaying Game Icon +/// - Deploying ASI Loader +/// etc. +/// +public static class TryUnprotectGamePassGame +{ + /// + /// Path to the main game binary. + /// True if this was auto-unprotected. + public static bool TryIt(string exePath) + { + // Note: We assume this may be a GamePass game if we can't read the binary. + // This is a very naive approach, but as we're not parsing `.GamingRoot`, + // to 'know' that this is a library path, this is the best we can do for now. + var read = CanRead(exePath); + if (read) + return !read; + + // If we can't read it, try finding an AppxManifest.xml file by going up directories. + if (!GetAppXManifestPath(exePath, out var manifestPath)) + return false; + + ExtractInfoFromUWPAppManifest(manifestPath!, out var appId, out var packageFamilyName); + var contentFolder = Path.GetDirectoryName(manifestPath); + var exeFiles = Directory.GetFiles(contentFolder!, "*.exe", SearchOption.AllDirectories); + + // Make a CMD script to unprotect all binaries. + var script = new StringBuilder(); + foreach (var exeFile in exeFiles) + { + // Copy exe to strip protection. + var newExePath = Path.Combine(Path.GetDirectoryName(exePath), Path.GetFileName(exeFile) + ".new"); + script.AppendLine($"copy /y \"{exeFile}\" \"{newExePath}\""); + + // Move old exe to .old + var movedOldExePath = Path.Combine(Path.GetDirectoryName(exePath), Path.GetFileName(exeFile) + ".old"); + script.AppendLine($"move /y \"{exeFile}\" \"{movedOldExePath}\""); + + // Replace old exe with new exe + script.AppendLine($"move /y \"{newExePath}\" \"{exeFile}\""); + + // Delete old exe + script.AppendLine($"del /f /q \"{movedOldExePath}\""); + } + + // Append command to create 'terminate' file indicating script completion. + using var tempDir = new TemporaryFolderAllocation(); + var terminateFilePath = Path.Combine(tempDir.FolderPath, "terminate"); + script.AppendLine($"echo Done > \"{terminateFilePath}\""); + + // Execute the script in game context where we have perms to access the files. + var scriptPath = Path.Combine(tempDir.FolderPath, "script.bat"); + File.WriteAllText(scriptPath, script.ToString()); + + // Run the script + var command = $"Invoke-CommandInDesktopPackage -PackageFamilyName \"{packageFamilyName}\" -AppId \"{appId}\" -Command \"cmd.exe\" -Args '/c \"{scriptPath}\"'"; + var processStartInfo = new ProcessStartInfo + { + FileName = @"powershell", + Arguments = command, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + + // TODO: Need to write a custom program/binary here to do our deed, + // so we can avoid spawning a window. + using var process = Process.Start(processStartInfo); + + // Poll for the existence of 'terminate' file. + // Invoke-CommandInDesktopPackage seems to make it so you can't wait for child process to finish, + // so we have to improvise here. + while (!File.Exists(terminateFilePath)) + Thread.Sleep(16); + + process?.WaitForExit(); + return true; + } + + /// + /// Path to the main game binary. + /// True if this was auto-unprotected. + public static bool TryIgnoringErrors(string exePath) + { + try + { + return TryIt(exePath); + } + catch (Exception) + { + return false; + } + } + + private static bool GetAppXManifestPath(string exePath, [MaybeNullWhen(false)] out string appManifest) + { + var currentDirectory = Path.GetDirectoryName(exePath); + while (currentDirectory != null) + { + var appxManifestPath = Path.Combine(currentDirectory, "AppxManifest.xml"); + if (File.Exists(appxManifestPath)) + { + appManifest = appxManifestPath; + return true; + } + + currentDirectory = Path.GetDirectoryName(currentDirectory); + } + + appManifest = null; + return false; + } + + private static void ExtractInfoFromUWPAppManifest(string manifest, out string appId, out string packageFamilyName) + { + var document = new XmlDocument(); + document.Load(manifest); + + var tag = document.GetElementsByTagName("Identity")[0]!; + var packageName = tag!.Attributes!["Name"]!.Value; + var applicationTag = document.GetElementsByTagName("Application")[0]!; + + appId = applicationTag!.Attributes!["Id"]!.Value; + packageFamilyName = GetPowershellPackageFamilyName(packageName); + } + + private static string GetPowershellPackageFamilyName(string packageName) + { + // I wish I could use WinRT APIs but support is removed from runtime and the + // official way cuts off support for Win7/8.1 in main app. + var processStartInfo = new ProcessStartInfo + { + FileName = @"powershell", + Arguments = $"(Get-AppxPackage {packageName}).PackageFamilyName", + RedirectStandardOutput = true, + CreateNoWindow = true + }; + using var process = Process.Start(processStartInfo); + process?.WaitForExit(); + var output = process?.StandardOutput.ReadToEnd().TrimEnd(); + if (output == null) + throw new Exception("Failed to get Package Family Name via PowerShell."); + + return output; + } + + private static bool CanRead(string exePath) + { + try + { + using var fs = new FileStream(exePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 524288); + fs.ReadByte(); + return true; + } + catch + { + // We can't read the file. + return false; + } + } +} \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher/Assets/Languages/en-GB.xaml b/source/Reloaded.Mod.Launcher/Assets/Languages/en-GB.xaml index efd86735..d4fd8bdc 100644 --- a/source/Reloaded.Mod.Launcher/Assets/Languages/en-GB.xaml +++ b/source/Reloaded.Mod.Launcher/Assets/Languages/en-GB.xaml @@ -658,7 +658,8 @@ DRM: 'You will own nothing and you will be happy.' Failed to read EXE file for if can deploy ASI Loader. This might happen for a variety of reasons. - DRM (Microsoft Store/GamePass). - You don't have the right to access the EXE file. -- The file has moved from its original location. +- The file has moved from its original location. + Install All Mods @@ -694,4 +695,9 @@ For more info, refer to the tutorial. Don't forget to set correct Publish target Preserve Mod Order Across Restarts When ENABLED, order of all mods is preserved across App restarts. When DISABLED, 'enabled' mods are at the top in load order and 'disabled' mods at bottom, in alphabetical order. (Default is 'ENABLED' to gauge user feedback, but may change in future.) + + This GamePass title cannot be Auto Setup, sorry. You'll need a manual install of ASI Loader or other shim. + Don't Inject Loader + When ENABLED, doesn't inject loader. Use this when you are loading Reloaded via an external shim like ASI Loader. When DISABLED, injects loader as usual. + \ No newline at end of file diff --git a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs index e7b58b2c..fd3c60be 100644 --- a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs +++ b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationPage.xaml.cs @@ -98,7 +98,7 @@ private async void LaunchApplication_PreviewMouseDown(object sender, MouseButton var launcher = ApplicationLauncher.FromApplicationConfig(appTuple); if (!Environment.IsWine || (Environment.IsWine && CompatibilityDialogs.WineShowLaunchDialog())) - launcher.Start(); + launcher.Start(!appTuple.Config.DontInject); } catch (Exception ex) { diff --git a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationSubPages/EditAppPage.xaml b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationSubPages/EditAppPage.xaml index 3148362c..e038937f 100644 --- a/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationSubPages/EditAppPage.xaml +++ b/source/Reloaded.Mod.Launcher/Pages/BaseSubpages/ApplicationSubPages/EditAppPage.xaml @@ -121,7 +121,7 @@ Content="{DynamicResource PreserveDisabledModOrder}" ToolTip="{DynamicResource PreserveDisabledModOrderTooltip}" ToolTipService.InitialShowDelay="0" Style="{DynamicResource DefaultCheckBox}" /> - + + + +