diff --git a/source/Libraries/XboxLibrary/Models/AuthenticationData.cs b/source/Libraries/XboxLibrary/Models/AuthenticationData.cs index 3e4033b5..ef44ffed 100644 --- a/source/Libraries/XboxLibrary/Models/AuthenticationData.cs +++ b/source/Libraries/XboxLibrary/Models/AuthenticationData.cs @@ -75,6 +75,7 @@ public class Detail public string developerName; public DateTime? releaseDate; public int? minAge; + public List availabilities; } public class TitleHistory @@ -82,6 +83,12 @@ public class TitleHistory public DateTime? lastTimePlayed; } + public class Availability + { + public List Platforms; + public string ProductId; + } + public class Title { public string titleId; diff --git a/source/Libraries/XboxLibrary/Xbox.cs b/source/Libraries/XboxLibrary/Xbox.cs index c3cecdd9..a744320a 100644 --- a/source/Libraries/XboxLibrary/Xbox.cs +++ b/source/Libraries/XboxLibrary/Xbox.cs @@ -1,6 +1,7 @@ using Playnite.Common; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -14,6 +15,7 @@ public class Xbox private const string xboxPassAppFN = "Microsoft.GamingApp_8wekyb3d8bbwe"; private static bool? isXboxPassAppInstalled; + public static bool IsRunning => Process.GetProcessesByName("XboxPcApp").Length > 0; public static bool IsXboxPassAppInstalled { get @@ -58,6 +60,11 @@ public static void OpenXboxPassApp() } } + public static void OpenGamePage(string productId) + { + ProcessStarter.StartUrl($"msxbox://game/?productId={productId}"); + } + public static string Icon { get; } = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"icon.png"); } } diff --git a/source/Libraries/XboxLibrary/XboxGameController.cs b/source/Libraries/XboxLibrary/XboxGameController.cs index 7bf3128d..2f63aacc 100644 --- a/source/Libraries/XboxLibrary/XboxGameController.cs +++ b/source/Libraries/XboxLibrary/XboxGameController.cs @@ -18,16 +18,23 @@ public class XboxInstallController : InstallController { private readonly bool userXboxApp; private CancellationTokenSource watcherToken; + private readonly string productId; + private readonly string logsDirectoryWatcherPath; + private FileSystemWatcher fileSystemWatcher; - public XboxInstallController(Game game, bool useXboxApp) : base(game) + public XboxInstallController(Game game, bool useXboxApp, string productId) : base(game) { this.userXboxApp = useXboxApp; - Name = useXboxApp ? "Install using Xbox app" : "Install using MS Store"; - } + this.productId = productId; + logsDirectoryWatcherPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Packages", + "Microsoft.GamingApp_8wekyb3d8bbwe", + "AC", + "Temp" + ); - public override void Dispose() - { - watcherToken?.Cancel(); + Name = useXboxApp ? "Install using Xbox app" : "Install using MS Store"; } public override void Install(InstallActionArgs args) @@ -35,7 +42,27 @@ public override void Install(InstallActionArgs args) Dispose(); if (userXboxApp) { - Xbox.OpenXboxPassApp(); + if (productId.IsNullOrEmpty()) + { + Xbox.OpenXboxPassApp(); + } + else if (Xbox.IsRunning) + { + // If the Xbox app is already running, it's possible to open the game page without issues. + Xbox.OpenGamePage(productId); + } + else + { + // Note: A recent update to the Xbox app introduced a bug where the URI to open the product page fails + // until the app has fully initialized. This issue did not occur in earlier versions of the app. + // + // To determine when the app has fully initialized, we monitor for changes in a specific log file, + // which is created or modified only after the initialization is complete. + // Alternative approaches, such as monitoring running processes, tracking changes in application windows, + // or detecting locked files, were ineffective for addressing this scenario. + Xbox.OpenXboxPassApp(); + InitializeFileSystemWatcher(); + } } else { @@ -54,12 +81,14 @@ await Task.Run(async () => { if (watcherToken.IsCancellationRequested) { + DisableFileSystemWatcher(); return; } var app = Programs.GetUWPApps().FirstOrDefault(a => a.AppId == Game.GameId); if (app != null) { + DisableFileSystemWatcher(); var installInfo = new GameInstallationData { InstallDirectory = app.WorkDir @@ -73,6 +102,53 @@ await Task.Run(async () => } }); } + + private void DisableFileSystemWatcher() + { + if (fileSystemWatcher != null) + { + fileSystemWatcher.EnableRaisingEvents = false; + } + } + + private void InitializeFileSystemWatcher() + { + if (!FileSystem.DirectoryExists(logsDirectoryWatcherPath)) + { + return; + } + + fileSystemWatcher = new FileSystemWatcher(logsDirectoryWatcherPath) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + Filter = "*.*", + EnableRaisingEvents = true + }; + + fileSystemWatcher.Changed += OnFileChanged; + fileSystemWatcher.Created += OnFileChanged; + fileSystemWatcher.Renamed += OnFileChanged; + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + DisableFileSystemWatcher(); + Xbox.OpenGamePage(productId); + } + + public override void Dispose() + { + watcherToken?.Cancel(); + if (fileSystemWatcher != null) + { + fileSystemWatcher.EnableRaisingEvents = false; + fileSystemWatcher.Changed -= OnFileChanged; + fileSystemWatcher.Created -= OnFileChanged; + fileSystemWatcher.Renamed -= OnFileChanged; + fileSystemWatcher.Dispose(); + fileSystemWatcher = null; + } + } } public class XboxUninstallController : UninstallController diff --git a/source/Libraries/XboxLibrary/XboxLibrary.cs b/source/Libraries/XboxLibrary/XboxLibrary.cs index 4fa8fe03..0718b5e7 100644 --- a/source/Libraries/XboxLibrary/XboxLibrary.cs +++ b/source/Libraries/XboxLibrary/XboxLibrary.cs @@ -20,6 +20,7 @@ namespace XboxLibrary public class XboxLibrary : LibraryPluginBase { private readonly string pfnInfoCacheDir; + private readonly string pfnToProductIdMapCachePath; public override LibraryClient Client => new XboxLibraryClient(SettingsViewModel); @@ -34,6 +35,7 @@ public XboxLibrary(IPlayniteAPI api) : base( { SettingsViewModel = new XboxLibrarySettingsViewModel(this, api); pfnInfoCacheDir = Path.Combine(GetPluginUserDataPath(), "PfnInfoCache"); + pfnToProductIdMapCachePath = Path.Combine(GetPluginUserDataPath(), "pfnToProductIdMapCache.json"); } public override ISettings GetSettings(bool firstRunSettings) @@ -131,8 +133,8 @@ internal GameMetadata GetGameMetadataFromTitle(TitleHistoryResponse.Title title) public void WriteAppDataCache(TitleHistoryResponse.Title data) { var filePath = Path.Combine(pfnInfoCacheDir, data.pfn + ".json"); - FileSystem.PrepareSaveFile(filePath); - File.WriteAllText(filePath, Serialization.ToJson(data)); + var serializedData = Serialization.ToJson(data); + FileSystem.WriteStringToFile(filePath, serializedData); } private static HashSet GetPlatforms(List devices, out bool containsPC, out bool containsConsole) @@ -338,17 +340,74 @@ public override IEnumerable GetGames(LibraryGetGamesArgs args) PlayniteApi.Notifications.Remove(ImportErrorMessageId); } + PersistPcPfnToProductIdMapCache(pcTitles); return allGames; } + public void PersistPcPfnToProductIdMapCache(List titles) + { + try + { + var cache = GetPfnToProductIdMapCache(); + var cacheChanged = false; + foreach (var title in titles) + { + var pcProductId = title.detail.availabilities + .FirstOrDefault(a => a.Platforms.Contains("PC"))?.ProductId; + if (pcProductId.IsNullOrEmpty()) + { + continue; + } + + if (!cache.TryGetValue(title.pfn, out var existingCache) || existingCache != pcProductId) + { + cacheChanged = true; + cache[title.pfn] = pcProductId; + } + } + + if (cacheChanged) + { + var serializedData = Serialization.ToJson(cache); + FileSystem.WriteStringToFile(pfnToProductIdMapCachePath, serializedData); + } + } + catch (Exception e) + { + Logger.Error(e, "Error while persisting PC Title Id Cache"); + }; + } + + public Dictionary GetPfnToProductIdMapCache() + { + if (FileSystem.FileExists(pfnToProductIdMapCachePath)) + { + return Serialization.FromJsonFile>(pfnToProductIdMapCachePath); + } + + return new Dictionary(); + } + public override IEnumerable GetInstallActions(GetInstallActionsArgs args) { - if (args.Game.PluginId != Id || args.Game.GameId.StartsWith("CONSOLE")) + var game = args.Game; + if (game.PluginId != Id || game.GameId.StartsWith("CONSOLE")) { yield break; } - yield return new XboxInstallController(args.Game, SettingsViewModel.Settings.XboxAppClientPriorityLaunch && Xbox.IsXboxPassAppInstalled); + var shouldUseXboxApp = SettingsViewModel.Settings.XboxAppClientPriorityLaunch && Xbox.IsXboxPassAppInstalled; + var productId = string.Empty; + if (shouldUseXboxApp) + { + var pfnToProductIdMapCache = GetPfnToProductIdMapCache(); + if (pfnToProductIdMapCache.TryGetValue(game.GameId, out var cachedProductId)) + { + productId = cachedProductId; + } + } + + yield return new XboxInstallController(args.Game, shouldUseXboxApp, productId); } public override IEnumerable GetUninstallActions(GetUninstallActionsArgs args)