diff --git a/source/Playnite/Database/GameDatabase.cs b/source/Playnite/Database/GameDatabase.cs index 2f7cbf657..f3aba0920 100644 --- a/source/Playnite/Database/GameDatabase.cs +++ b/source/Playnite/Database/GameDatabase.cs @@ -18,6 +18,7 @@ using System.Threading; using System.Windows.Threading; using Playnite.Providers.Uplay; +using Playnite.Providers.BattleNet; namespace Playnite.Database { @@ -235,6 +236,7 @@ public LiteCollection GamesCollection private ISteamLibrary steamLibrary; private IOriginLibrary originLibrary; private IUplayLibrary uplayLibrary; + private IBattleNetLibrary battleNetLibrary; public readonly ushort DBVersion = 1; @@ -255,14 +257,16 @@ public GameDatabase() steamLibrary = new SteamLibrary(); originLibrary = new OriginLibrary(); uplayLibrary = new UplayLibrary(); + battleNetLibrary = new BattleNetLibrary(); } - public GameDatabase(IGogLibrary gogLibrary, ISteamLibrary steamLibrary, IOriginLibrary originLibrary, IUplayLibrary uplayLibrary) + public GameDatabase(IGogLibrary gogLibrary, ISteamLibrary steamLibrary, IOriginLibrary originLibrary, IUplayLibrary uplayLibrary, IBattleNetLibrary battleNetLibrary) { this.gogLibrary = gogLibrary; this.steamLibrary = steamLibrary; this.originLibrary = originLibrary; this.uplayLibrary = uplayLibrary; + this.battleNetLibrary = battleNetLibrary; } private void CheckDbState() @@ -741,6 +745,9 @@ public void UpdateGameWithMetadata(IGame game) case Provider.Uplay: metadata = uplayLibrary.UpdateGameWithMetadata(game); break; + case Provider.BattleNet: + metadata = battleNetLibrary.UpdateGameWithMetadata(game); + break; case Provider.Custom: return; default: @@ -782,6 +789,9 @@ public void UpdateInstalledGames(Provider provider) case Provider.Uplay: installedGames = uplayLibrary.GetInstalledGames(); break; + case Provider.BattleNet: + installedGames = battleNetLibrary.GetInstalledGames(); + break; default: return; } @@ -860,6 +870,9 @@ public void UpdateOwnedGames(Provider provider) break; case Provider.Uplay: return; + case Provider.BattleNet: + importedGames = battleNetLibrary.GetLibraryGames(); + break; default: return; } diff --git a/source/Playnite/FilterSettings.cs b/source/Playnite/FilterSettings.cs index bc7db55d9..d77ece8d5 100644 --- a/source/Playnite/FilterSettings.cs +++ b/source/Playnite/FilterSettings.cs @@ -26,6 +26,7 @@ public bool Active Origin || GOG || Uplay || + BattleNet || Custom || !string.IsNullOrEmpty(Name) || !string.IsNullOrEmpty(ReleaseDate) || @@ -278,6 +279,22 @@ public bool Uplay } } + private bool battleNet; + public bool BattleNet + { + get + { + return battleNet; + } + + set + { + battleNet = value; + OnPropertyChanged("BattleNet"); + OnPropertyChanged("Active"); + } + } + private bool custom; public bool Custom { diff --git a/source/Playnite/GamesStats.cs b/source/Playnite/GamesStats.cs index 88e619848..b08a6893c 100644 --- a/source/Playnite/GamesStats.cs +++ b/source/Playnite/GamesStats.cs @@ -23,6 +23,7 @@ public class GamesStats : INotifyPropertyChanged public int Steam { get; private set; } = 0; public int GOG { get; private set; } = 0; public int Uplay { get; private set; } = 0; + public int BattleNet { get; private set; } = 0; public int Custom { get; private set; } = 0; public int Total @@ -70,6 +71,7 @@ private void Recalculate() Steam = 0; GOG = 0; Uplay = 0; + BattleNet = 0; Custom = 0; foreach (var game in database.GamesCollection.FindAll()) @@ -110,6 +112,9 @@ private void Recalculate() case Provider.Uplay: Uplay++; break; + case Provider.BattleNet: + BattleNet++; + break; default: break; } @@ -133,6 +138,7 @@ private void NotifiyAllChanged() OnPropertyChanged("Steam"); OnPropertyChanged("GOG"); OnPropertyChanged("Uplay"); + OnPropertyChanged("BattleNet"); OnPropertyChanged("Custom"); OnPropertyChanged("Total"); } @@ -222,6 +228,9 @@ private void IncrementalUpdate(IGame game, int modifier) case Provider.Uplay: Uplay = Uplay + (1 * modifier); break; + case Provider.BattleNet: + BattleNet = BattleNet + (1 * modifier); + break; } } } diff --git a/source/Playnite/Models/Game.cs b/source/Playnite/Models/Game.cs index 881485924..5a29ff1ed 100644 --- a/source/Playnite/Models/Game.cs +++ b/source/Playnite/Models/Game.cs @@ -15,6 +15,7 @@ using Playnite.Providers; using System.Collections.Concurrent; using Playnite.Providers.Uplay; +using Playnite.Providers.BattleNet; namespace Playnite.Models { @@ -65,6 +66,7 @@ public string DescriptionView case Provider.Custom: case Provider.Origin: case Provider.Uplay: + case Provider.BattleNet: case Provider.Steam: default: return string.IsNullOrEmpty(SteamSettings.DescriptionTemplate) ? Description : SteamSettings.DescriptionTemplate.Replace("{0}", Description); @@ -448,19 +450,31 @@ public void InstallGame() { case Provider.Steam: Process.Start(@"steam://install/" + ProviderId); - RegisterStateMonitor(new SteamGameStateMonitor(ProviderId, new SteamLibrary())); + RegisterStateMonitor(new SteamGameStateMonitor(ProviderId, new SteamLibrary()), GameStateMonitorType.Install); break; case Provider.GOG: Process.Start(@"goggalaxy://openGameView/" + ProviderId); - RegisterStateMonitor(new GogGameStateMonitor(ProviderId, InstallDirectory, new GogLibrary())); + RegisterStateMonitor(new GogGameStateMonitor(ProviderId, InstallDirectory, new GogLibrary()), GameStateMonitorType.Install); break; case Provider.Origin: Process.Start(string.Format(@"origin2://game/launch?offerIds={0}&autoDownload=true", ProviderId)); - RegisterStateMonitor(new OriginGameStateMonitor(ProviderId, new OriginLibrary())); + RegisterStateMonitor(new OriginGameStateMonitor(ProviderId, new OriginLibrary()), GameStateMonitorType.Install); break; case Provider.Uplay: Process.Start("uplay://install/" + ProviderId); - RegisterStateMonitor(new UplayGameStateMonitor(ProviderId, new UplayLibrary())); + RegisterStateMonitor(new UplayGameStateMonitor(ProviderId, new UplayLibrary()), GameStateMonitorType.Install); + break; + case Provider.BattleNet: + var product = BattleNetLibrary.GetAppDefinition(ProviderId); + if (product.Type == BattleNetLibrary.BNetAppType.Classic) + { + Process.Start(@"https://battle.net/account/management/download/"); + } + else + { + Process.Start(BattleNetSettings.ClientExecPath, $"--game={product.InternalId}"); + } + RegisterStateMonitor(new BattleNetGameStateMonitor(product, new BattleNetLibrary()), GameStateMonitorType.Install); break; case Provider.Custom: break; @@ -498,7 +512,7 @@ public void UninstallGame() { case Provider.Steam: Process.Start("steam://uninstall/" + ProviderId); - RegisterStateMonitor(new SteamGameStateMonitor(ProviderId, new SteamLibrary())); + RegisterStateMonitor(new SteamGameStateMonitor(ProviderId, new SteamLibrary()), GameStateMonitorType.Uninstall); break; case Provider.GOG: var uninstaller = Path.Combine(InstallDirectory, "unins000.exe"); @@ -508,15 +522,29 @@ public void UninstallGame() } Process.Start(uninstaller); - RegisterStateMonitor(new GogGameStateMonitor(ProviderId, InstallDirectory, new GogLibrary())); + RegisterStateMonitor(new GogGameStateMonitor(ProviderId, InstallDirectory, new GogLibrary()), GameStateMonitorType.Uninstall); break; case Provider.Origin: Process.Start("appwiz.cpl"); - RegisterStateMonitor(new OriginGameStateMonitor(ProviderId, new OriginLibrary())); + RegisterStateMonitor(new OriginGameStateMonitor(ProviderId, new OriginLibrary()), GameStateMonitorType.Uninstall); break; case Provider.Uplay: Process.Start("uplay://uninstall/" + ProviderId); - RegisterStateMonitor(new UplayGameStateMonitor(ProviderId, new UplayLibrary())); + RegisterStateMonitor(new UplayGameStateMonitor(ProviderId, new UplayLibrary()), GameStateMonitorType.Uninstall); + break; + case Provider.BattleNet: + var product = BattleNetLibrary.GetAppDefinition(ProviderId); + var entry = BattleNetLibrary.GetUninstallEntry(product); + if (entry != null) + { + var args = string.Format("/C \"{0}\"", entry.UninstallString); + Process.Start("cmd", args); + RegisterStateMonitor(new BattleNetGameStateMonitor(product, new BattleNetLibrary()), GameStateMonitorType.Uninstall); + } + else + { + RegisterStateMonitor(new BattleNetGameStateMonitor(product, new BattleNetLibrary()), GameStateMonitorType.Uninstall); + } break; case Provider.Custom: break; @@ -525,7 +553,7 @@ public void UninstallGame() } } - public void RegisterStateMonitor(IGameStateMonitor monitor) + public void RegisterStateMonitor(IGameStateMonitor monitor, GameStateMonitorType type) { if (stateMonitor != null) { @@ -535,7 +563,15 @@ public void RegisterStateMonitor(IGameStateMonitor monitor) stateMonitor = monitor; stateMonitor.GameInstalled += StateMonitor_GameInstalled; stateMonitor.GameUninstalled += StateMonitor_GameUninstalled; - stateMonitor.StartMonitoring(); + if (type == GameStateMonitorType.Install) + { + stateMonitor.StartInstallMonitoring(); + } + else + { + stateMonitor.StartUninstallMonitoring(); + } + IsSetupInProgress = true; } diff --git a/source/Playnite/Models/Provider.cs b/source/Playnite/Models/Provider.cs index 5f3c2dd0c..7642d53af 100644 --- a/source/Playnite/Models/Provider.cs +++ b/source/Playnite/Models/Provider.cs @@ -12,6 +12,7 @@ public enum Provider GOG, Origin, Steam, - Uplay + Uplay, + BattleNet } } diff --git a/source/Playnite/Playnite.csproj b/source/Playnite/Playnite.csproj index 6f0525606..50b4f36b4 100644 --- a/source/Playnite/Playnite.csproj +++ b/source/Playnite/Playnite.csproj @@ -176,8 +176,12 @@ + + + + diff --git a/source/Playnite/Providers/BattleNet/BattleNetGameStateMonitor.cs b/source/Playnite/Providers/BattleNet/BattleNetGameStateMonitor.cs new file mode 100644 index 000000000..4936f5ad3 --- /dev/null +++ b/source/Playnite/Providers/BattleNet/BattleNetGameStateMonitor.cs @@ -0,0 +1,109 @@ +using Microsoft.Win32; +using NLog; +using Playnite.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Playnite.Providers.BattleNet +{ + public class BattleNetGameStateMonitor : IGameStateMonitor + { + private static Logger logger = LogManager.GetCurrentClassLogger(); + private IBattleNetLibrary library; + private BattleNetLibrary.BNetApp app; + private CancellationTokenSource cancelToken; + + public event EventHandler GameUninstalled; + public event GameInstalledEventHandler GameInstalled; + + public BattleNetGameStateMonitor(BattleNetLibrary.BNetApp app, IBattleNetLibrary library) + { + this.app = app; + this.library = library; + } + + public void Dispose() + { + cancelToken?.Cancel(false); + } + + public void StartInstallMonitoring() + { + logger.Info("Starting install monitoring of BattleNet app " + app.ProductId); + Dispose(); + + cancelToken = new CancellationTokenSource(); + + Task.Factory.StartNew(() => + { + while (!cancelToken.Token.IsCancellationRequested) + { + var entry = BattleNetLibrary.GetUninstallEntry(app); + if (entry != null) + { + logger.Info($"BattleNet app {app.ProductId} has been installed."); + + GameTask playTask; + if (app.Type == BattleNetLibrary.BNetAppType.Classic) + { + playTask = new GameTask() + { + Type = GameTaskType.File, + WorkingDir = @"{InstallDir}", + Path = @"{InstallDir}\" + app.ClassicExecutable + }; + } + else + { + playTask = library.GetGamePlayTask(app.ProductId); + } + + GameInstalled?.Invoke(this, new GameInstalledEventArgs(new Game() + { + PlayTask = playTask, + InstallDirectory = entry.InstallLocation + })); + return; + } + + Thread.Sleep(5000); + } + }, cancelToken.Token); + } + + public void StartUninstallMonitoring() + { + logger.Info("Starting uninstall monitoring of BattleNet app " + app.ProductId); + Dispose(); + + cancelToken = new CancellationTokenSource(); + + Task.Factory.StartNew(() => + { + while (!cancelToken.Token.IsCancellationRequested) + { + var entry = BattleNetLibrary.GetUninstallEntry(app); + if (entry == null) + { + logger.Info($"BattleNet app {app.ProductId} has been uninstalled."); + GameUninstalled?.Invoke(this, null); + return; + } + + Thread.Sleep(5000); + } + }, cancelToken.Token); + } + + public void StopMonitoring() + { + logger.Info("Stopping monitoring of BattleNet app " + app.ProductId); + Dispose(); + } + } +} diff --git a/source/Playnite/Providers/BattleNet/BattleNetLibrary.cs b/source/Playnite/Providers/BattleNet/BattleNetLibrary.cs new file mode 100644 index 000000000..69f20f5c0 --- /dev/null +++ b/source/Playnite/Providers/BattleNet/BattleNetLibrary.cs @@ -0,0 +1,345 @@ +using AngleSharp.Parser.Html; +using Playnite.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Playnite.Providers.BattleNet +{ + public class BattleNetLibrary : IBattleNetLibrary + { + public enum BNetAppType + { + Default, + Classic + } + + public class BNetApp + { + public string ProductId; + public string InternalId; + public string WebLibraryId; + public string PurchaseId; + public string IconUrl; + public string Name; + public BNetAppType Type; + public string ClassicExecutable; + } + + public static readonly List BattleNetProducts = new List() + { + new BNetApp() + { + ProductId = "WoW", + InternalId = "wow", + WebLibraryId = "game-list-wow", + PurchaseId = "wowc-starter-link", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-wow-3dd2cfe06df74407.png", + Name = "World of Warcraft", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "D3", + InternalId = "diablo3", + WebLibraryId = "game-list-d3", + PurchaseId = "d3-starter-link", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-d3-ab08e4045fed09ee.png", + Name = "Diablo III", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "S2", + InternalId = "s2", + WebLibraryId = "game-list-s2", + PurchaseId = "s2-starter-link", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-sc2-6e33583ba0547b6a.png", + Name = "StarCraft II", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "S1", + InternalId = "s1", + WebLibraryId = "game-list-sc", + PurchaseId = "", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-scr-fef4f892c20f584c.png", + Name = "StarCraft", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "WTCG", + InternalId = "hs_beta", + WebLibraryId = "game-list-hearthstone", + PurchaseId = "", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-hs-beb1a37bc84beefb.png", + Name = "Hearthstone", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "Hero", + InternalId = "heroes", + WebLibraryId = "game-list-bas", + PurchaseId = "", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-heroes-78cae505b7a524fb.png", + Name = "Heroes of the Storm", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "Pro", + InternalId = "prometheus", + WebLibraryId = "game-list-overwatch", + PurchaseId = "overwatch-purchase-link", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-ow-1dd54d69712651a9.png", + Name = "Overwatch", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "DST2", + InternalId = "destiny2", + WebLibraryId = "game-list-destiny2", + PurchaseId = "destiny2-presale-link", + IconUrl = @"https://blznav.akamaized.net/img/games/logo-dest2-933dcf397eb647e0.png", + Name = "Destiny 2", + Type = BNetAppType.Default + }, + new BNetApp() + { + ProductId = "D2", + InternalId = "Diablo II", + WebLibraryId = "D2DV-se", + PurchaseId = "", + IconUrl = @"https://bneteu-a.akamaihd.net/account/static/local-common/images/game-icons/d2dv-32.4PqK2.png", + Name = "Diablo II", + Type = BNetAppType.Classic, + ClassicExecutable = "Diablo II.exe" + }, + new BNetApp() + { + ProductId = "D2X", + InternalId = "Diablo II", + WebLibraryId = "D2XP-se", + PurchaseId = "", + IconUrl = @"https://bneteu-a.akamaihd.net/account/static/local-common/images/game-icons/d2xp.1gR7W.png", + Name = "Diablo II: Lord of Destruction", + Type = BNetAppType.Classic, + ClassicExecutable = "Diablo II.exe" + }, + new BNetApp() + { + ProductId = "W3", + InternalId = "Warcraft III", + WebLibraryId = "WAR3-se", + PurchaseId = "", + IconUrl = @"https://bneteu-a.akamaihd.net/account/static/local-common/images/game-icons/war3-32.1N2FK.png", + Name = "Warcraft III: Reign of Chaos", + Type = BNetAppType.Classic, + ClassicExecutable = "Warcraft III Launcher.exe" + }, + new BNetApp() + { + ProductId = "W3X", + InternalId = "Warcraft III", + WebLibraryId = "W3XP-se", + PurchaseId = "", + IconUrl = @"https://bneteu-a.akamaihd.net/account/static/local-common/images/game-icons/w3xp-32.15Wgr.png", + Name = "Warcraft III: The Frozen Throne", + Type = BNetAppType.Classic, + ClassicExecutable = "Warcraft III Launcher.exe" + } + }; + + public static BNetApp GetAppDefinition(string productId) + { + return BattleNetProducts.First(a => a.ProductId == productId); + } + + public static UninstallProgram GetUninstallEntry(BNetApp app) + { + foreach (var prog in Programs.GetUnistallProgramsList()) + { + if (app.Type == BNetAppType.Classic) + { + if (prog.DisplayName == app.InternalId) + { + return prog; + } + } + else + { + if (string.IsNullOrEmpty(prog.UninstallString)) + { + continue; + } + + var match = Regex.Match(prog.UninstallString, string.Format(@"Battle\.net.*--uid={0}.*\s", app.InternalId)); + if (match.Success) + { + return prog; + } + } + } + + return null; + } + + public GameTask GetGamePlayTask(string id) + { + return new GameTask() + { + Type = GameTaskType.URL, + Path = $"battlenet://{id}/", + IsPrimary = true, + IsBuiltIn = true + }; + } + + public List GetInstalledGames() + { + var games = new List(); + foreach (var prog in Programs.GetUnistallProgramsList()) + { + if (string.IsNullOrEmpty(prog.UninstallString)) + { + continue; + } + + if (prog.Publisher == "Blizzard Entertainment" && BattleNetProducts.Any(a => a.Type == BNetAppType.Classic && prog.DisplayName == a.InternalId)) + { + var products = BattleNetProducts.Where(a => a.Type == BNetAppType.Classic && prog.DisplayName == a.InternalId); + foreach (var product in products) + { + var game = new Game() + { + Provider = Provider.BattleNet, + ProviderId = product.ProductId, + Name = product.Name, + PlayTask = new GameTask() + { + Type = GameTaskType.File, + WorkingDir = @"{InstallDir}", + Path = @"{InstallDir}\" + product.ClassicExecutable + }, + InstallDirectory = prog.InstallLocation + }; + + games.Add(game); + } + } + else + { + var match = Regex.Match(prog.UninstallString, @"Battle\.net.*--uid=(.*?)\s"); + if (!match.Success) + { + continue; + } + + var iId = match.Groups[1].Value; + var product = BattleNetProducts.FirstOrDefault(a => a.Type == BNetAppType.Default && iId.StartsWith(a.InternalId)); + if (product == null) + { + continue; + } + + var game = new Game() + { + Provider = Provider.BattleNet, + ProviderId = product.ProductId, + Name = product.Name, + PlayTask = GetGamePlayTask(product.ProductId), + InstallDirectory = prog.InstallLocation + }; + + games.Add(game); + } + } + + return games; + } + + public GameMetadata UpdateGameWithMetadata(IGame game) + { + var metadata = new GameMetadata(); + var product = BattleNetProducts.FirstOrDefault(a => a.ProductId == game.ProviderId); + if (product == null) + { + return metadata; + } + + if (string.IsNullOrEmpty(product.IconUrl)) + { + return metadata; + } + + var icon = Web.DownloadData(product.IconUrl); + var iconFile = Path.GetFileName(product.IconUrl); + metadata.Icon = new Database.FileDefinition($"images/battlenet/{game.ProviderId}/{iconFile}", iconFile, icon); + + game.IsProviderDataUpdated = true; + return metadata; + } + + public List GetLibraryGames() + { + var api = new WebApiClient(); + if (api.GetLoginRequired()) + { + throw new Exception("User is not logged in."); + } + + var page = api.GetOwnedGames(); + var games = new List(); + var parser = new HtmlParser(); + var document = parser.Parse(page); + + foreach (var product in BattleNetProducts) + { + if (product.Type == BNetAppType.Default) + { + var documentProduct = document.QuerySelector($"#{product.WebLibraryId}"); + if (documentProduct == null) + { + continue; + } + + if (!string.IsNullOrEmpty(product.PurchaseId)) + { + var saleOffer = documentProduct.QuerySelector($"#{product.PurchaseId}"); + if (saleOffer != null) + { + continue; + } + } + } + else + { + var documentProduct = document.QuerySelector($".{product.WebLibraryId}"); + if (documentProduct == null) + { + continue; + } + } + + var game = new Game() + { + Provider = Provider.BattleNet, + ProviderId = product.ProductId, + Name = product.Name + }; + + games.Add(game); + } + + return games; + } + } +} diff --git a/source/Playnite/Providers/BattleNet/BattleNetSettings.cs b/source/Playnite/Providers/BattleNet/BattleNetSettings.cs index 435f767fc..3d1476199 100644 --- a/source/Playnite/Providers/BattleNet/BattleNetSettings.cs +++ b/source/Playnite/Providers/BattleNet/BattleNetSettings.cs @@ -14,7 +14,7 @@ public static string ClientExecPath get { var path = InstallationPath; - return string.IsNullOrEmpty(path) ? string.Empty : Path.Combine(path, "Battle.net Launcher.exe"); + return string.IsNullOrEmpty(path) ? string.Empty : Path.Combine(path, "Battle.net.exe"); } } @@ -34,6 +34,16 @@ public static string InstallationPath } } + public bool LibraryDownloadEnabled + { + get; set; + } = false; + + public bool IntegrationEnabled + { + get; set; + } = false; + public static bool IsInstalled { get diff --git a/source/Playnite/Providers/BattleNet/IBattleNetLibrary.cs b/source/Playnite/Providers/BattleNet/IBattleNetLibrary.cs new file mode 100644 index 000000000..96c1b1e17 --- /dev/null +++ b/source/Playnite/Providers/BattleNet/IBattleNetLibrary.cs @@ -0,0 +1,20 @@ +using Playnite.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Playnite.Providers.BattleNet +{ + public interface IBattleNetLibrary + { + GameTask GetGamePlayTask(string id); + + List GetInstalledGames(); + + GameMetadata UpdateGameWithMetadata(IGame game); + + List GetLibraryGames(); + } +} diff --git a/source/Playnite/Providers/BattleNet/WebApiClient.cs b/source/Playnite/Providers/BattleNet/WebApiClient.cs new file mode 100644 index 000000000..590d56cdd --- /dev/null +++ b/source/Playnite/Providers/BattleNet/WebApiClient.cs @@ -0,0 +1,162 @@ +using CefSharp; +using CefSharp.Wpf; +using NLog; +using Playnite.Controls; +using Playnite.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; + +namespace Playnite.Providers.BattleNet +{ + public class WebApiClient + { + private static Logger logger = LogManager.GetCurrentClassLogger(); + private CefSharp.OffScreen.ChromiumWebBrowser browser; + + public WebApiClient() + { + browser = new CefSharp.OffScreen.ChromiumWebBrowser(automaticallyCreateBrowser: false); + browser.BrowserInitialized += Browser_BrowserInitialized; + browser.CreateBrowser(IntPtr.Zero); + } + + private AutoResetEvent browserInitializedEvent = new AutoResetEvent(false); + private void Browser_BrowserInitialized(object sender, EventArgs e) + { + browserInitializedEvent.Set(); + } + + #region GetLoginRequired + private void getLoginRequired_StateChanged(object sender, LoadingStateChangedEventArgs e) + { + if (e.IsLoading == false) + { + var b = (CefSharp.OffScreen.ChromiumWebBrowser)sender; + + if (b.Address.Contains("battle.net/login")) + { + loginRequired = true; + } + else + { + loginRequired = false; + } + + loginRequiredEvent.Set(); + } + } + + private bool loginRequired = true; + private AutoResetEvent loginRequiredEvent = new AutoResetEvent(false); + public bool GetLoginRequired() + { + if (!browser.IsBrowserInitialized) + { + browserInitializedEvent.WaitOne(5000); + } + + try + { + browser.LoadingStateChanged += getLoginRequired_StateChanged; + browser.Load(libraryUrl); + loginRequiredEvent.WaitOne(10000); + return loginRequired; + } + finally + { + browser.LoadingStateChanged -= getLoginRequired_StateChanged; + } + } + + #endregion GetLoginRequired + + #region GetOwnedGames + private async void getOwnedGames_StateChanged(object sender, LoadingStateChangedEventArgs e) + { + if (e.IsLoading == false) + { + var b = (CefSharp.OffScreen.ChromiumWebBrowser)sender; + gamesList = await b.GetSourceAsync(); + gamesGotEvent.Set(); + } + } + + private string gamesList = string.Empty; + private AutoResetEvent gamesGotEvent = new AutoResetEvent(false); + public string GetOwnedGames() + { + if (!browser.IsBrowserInitialized) + { + browserInitializedEvent.WaitOne(5000); + } + + try + { + gamesList = string.Empty; + browser.LoadingStateChanged += getOwnedGames_StateChanged; + browser.Load(libraryUrl); + gamesGotEvent.WaitOne(10000); + return gamesList; + } + finally + { + browser.LoadingStateChanged -= getOwnedGames_StateChanged; + } + } + + #endregion GetOwnedGames + + #region Login + private void login_StateChanged(object sender, LoadingStateChangedEventArgs e) + { + if (e.IsLoading == false) + { + var b = (ChromiumWebBrowser)sender; + + b.Dispatcher.Invoke(() => + { + if (b.Address.EndsWith(libraryUrl)) + { + loginWindow.Dispatcher.Invoke(() => + { + loginSuccess = true; + loginWindow.Close(); + }); + } + else + { + loginSuccess = false; + } + }); + } + } + + private bool loginSuccess = false; + private string loginUrl = "battle.net/account/management/?logout"; + private string libraryUrl = @"battle.net/account/management/"; + + LoginWindow loginWindow; + public bool Login(Window parent = null) + { + loginSuccess = false; + loginWindow = new LoginWindow() + { + Height = 500, + Width = 400 + }; + loginWindow.WindowStartupLocation = WindowStartupLocation.CenterScreen; + loginWindow.Browser.LoadingStateChanged += login_StateChanged; + loginWindow.Owner = parent; + loginWindow.Browser.Address = loginUrl; + loginWindow.ShowDialog(); + return loginSuccess; + } + #endregion Login + } +} diff --git a/source/Playnite/Providers/GOG/GogGameStateMonitor.cs b/source/Playnite/Providers/GOG/GogGameStateMonitor.cs index 74ba65167..5a3444132 100644 --- a/source/Playnite/Providers/GOG/GogGameStateMonitor.cs +++ b/source/Playnite/Providers/GOG/GogGameStateMonitor.cs @@ -39,15 +39,24 @@ public GogGameStateMonitor(string id, string installDirectory, IGogLibrary origi this.installDirectory = installDirectory; } - public void StartMonitoring() + public void StartInstallMonitoring() { - logger.Info("Starting monitoring of GOG app " + id); + logger.Info("Starting install monitoring of GOG app " + id); + Dispose(); + + // Installation detection not implemented for several technical limitations + // Maily because of shared access to GOG's local database + logger.Warn("GOG app {0} is currently not installed, NOT starting install monitor", id); + } + + public void StartUninstallMonitoring() + { + logger.Info("Starting uninstall monitoring of GOG app " + id); Dispose(); var infoFile = string.Format("goggame-{0}.info", id); if (File.Exists(Path.Combine(installDirectory, infoFile))) { - logger.Info("GOG app {0} is currently not installed, starting install monitor.", id); watcher = new FileSystemWatcher() { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, @@ -60,10 +69,8 @@ public void StartMonitoring() } else { - // Installation detection not implemented for several technical limitations - // Maily because of shared access to GOG's local database - logger.Warn("GOG app {0} is currently not installed, NOT starting install monitor", id); - } + Watcher_Deleted(this, null); + } } private void Watcher_Deleted(object sender, FileSystemEventArgs e) diff --git a/source/Playnite/Providers/IGameStateMonitor.cs b/source/Playnite/Providers/IGameStateMonitor.cs index e12d46fd2..4c1c997e7 100644 --- a/source/Playnite/Providers/IGameStateMonitor.cs +++ b/source/Playnite/Providers/IGameStateMonitor.cs @@ -6,13 +6,21 @@ namespace Playnite.Providers { + public enum GameStateMonitorType + { + Install, + Uninstall + } + public interface IGameStateMonitor : IDisposable { event EventHandler GameUninstalled; event GameInstalledEventHandler GameInstalled; - void StartMonitoring(); + void StartInstallMonitoring(); + + void StartUninstallMonitoring(); void StopMonitoring(); } diff --git a/source/Playnite/Providers/Origin/OriginGameStateMonitor.cs b/source/Playnite/Providers/Origin/OriginGameStateMonitor.cs index c22dc05a0..797e54ce9 100644 --- a/source/Playnite/Providers/Origin/OriginGameStateMonitor.cs +++ b/source/Playnite/Providers/Origin/OriginGameStateMonitor.cs @@ -56,58 +56,63 @@ private void Watcher_Deleted(object sender, FileSystemEventArgs e) GameUninstalled?.Invoke(this, null); } - public void StartMonitoring() + public void StartInstallMonitoring() { - logger.Info("Starting monitoring of Origin app " + id); + logger.Info("Starting install monitoring of Origin app " + id); Dispose(); executablePath = library.GetPathFromPlatformPath(platform.fulfillmentAttributes.installCheckOverride); - - // Game is not installed - if (string.IsNullOrEmpty(executablePath)) - { - logger.Info("Origin app {0} is currently not installed, starting install monitor.", id); - installWaitToken = new CancellationTokenSource(); + installWaitToken = new CancellationTokenSource(); - Task.Factory.StartNew(() => + Task.Factory.StartNew(() => + { + while (!installWaitToken.Token.IsCancellationRequested) { - while (!installWaitToken.Token.IsCancellationRequested) + executablePath = library.GetPathFromPlatformPath(platform.fulfillmentAttributes.installCheckOverride); + if (!string.IsNullOrEmpty(executablePath)) { - executablePath = library.GetPathFromPlatformPath(platform.fulfillmentAttributes.installCheckOverride); - if (!string.IsNullOrEmpty(executablePath)) + if (File.Exists(executablePath)) { - if (File.Exists(executablePath)) - { - Watcher_Created(this, null); - return; - } - else + Watcher_Created(this, null); + return; + } + else + { + watcher = new FileSystemWatcher() { - watcher = new FileSystemWatcher() - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, - Path = Path.GetDirectoryName(executablePath), - Filter = Path.GetFileName(executablePath) - }; - - watcher.Created += Watcher_Created; - watcher.EnableRaisingEvents = true; - return; - } + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, + Path = Path.GetDirectoryName(executablePath), + Filter = Path.GetFileName(executablePath) + }; + + watcher.Created += Watcher_Created; + watcher.EnableRaisingEvents = true; + return; } - - Thread.Sleep(2000); } - }, installWaitToken.Token); + + Thread.Sleep(2000); + } + }, installWaitToken.Token); + } + + public void StartUninstallMonitoring() + { + logger.Info("Starting uninstall monitoring of Origin app " + id); + Dispose(); + + executablePath = library.GetPathFromPlatformPath(platform.fulfillmentAttributes.installCheckOverride); + if (string.IsNullOrEmpty(executablePath)) + { + Watcher_Deleted(this, null); } else { - logger.Info("Origin app {0} is currently installed, starting uninstall monitor.", id); watcher = new FileSystemWatcher() { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, Path = Path.GetDirectoryName(executablePath), - Filter = Path.GetFileName(executablePath) + Filter = Path.GetFileName(executablePath) }; watcher.Deleted += Watcher_Deleted; diff --git a/source/Playnite/Providers/Steam/SteamGameStateMonitor.cs b/source/Playnite/Providers/Steam/SteamGameStateMonitor.cs index 4d2e31168..38beed9a5 100644 --- a/source/Playnite/Providers/Steam/SteamGameStateMonitor.cs +++ b/source/Playnite/Providers/Steam/SteamGameStateMonitor.cs @@ -41,8 +41,6 @@ public SteamGameStateMonitor(string id, ISteamLibrary steamLibrary) Filter = string.Format("appmanifest_{0}.acf", id) }; - watcher.Deleted += Watcher_Deleted; - watcher.Changed += Watcher_Changed; watchers.Add(watcher); } } @@ -72,12 +70,24 @@ private void Watcher_Deleted(object sender, FileSystemEventArgs e) GameUninstalled?.Invoke(this, null); } - public void StartMonitoring() + public void StartInstallMonitoring() + { + logger.Info("Starting install monitoring of Steam app " + id); + + foreach (var watcher in watchers) + { + watcher.Changed += Watcher_Changed; + watcher.EnableRaisingEvents = true; + } + } + + public void StartUninstallMonitoring() { - logger.Info("Starting monitoring of Steam app " + id); + logger.Info("Starting uninstall monitoring of Steam app " + id); foreach (var watcher in watchers) { + watcher.Deleted += Watcher_Deleted; watcher.EnableRaisingEvents = true; } } diff --git a/source/Playnite/Providers/Uplay/UplayGameStateMonitor.cs b/source/Playnite/Providers/Uplay/UplayGameStateMonitor.cs index df5943fc9..0724224d1 100644 --- a/source/Playnite/Providers/Uplay/UplayGameStateMonitor.cs +++ b/source/Playnite/Providers/Uplay/UplayGameStateMonitor.cs @@ -32,14 +32,12 @@ public void Dispose() cancelToken?.Cancel(false); } - public void StartMonitoring() + public void StartInstallMonitoring() { - logger.Info("Starting monitoring of Uplay app " + id); + logger.Info("Starting install monitoring of Uplay app " + id); Dispose(); cancelToken = new CancellationTokenSource(); - var gameInstalled = library.GetInstalledGames().FirstOrDefault(a => a.ProviderId == id) != null; - Task.Factory.StartNew(() => { // Uplay is currently 32bit only, but this will future proof this feature @@ -52,13 +50,7 @@ public void StartMonitoring() if (installsKey32 != null) { var gameKey = installsKey32.OpenSubKey(id); - if (gameInstalled && gameKey == null) - { - logger.Info($"Uplay app {id} has been uninstalled."); - GameUninstalled?.Invoke(this, null); - return; - } - else if (!gameInstalled && gameKey != null) + if (gameKey != null) { logger.Info($"Uplay app {id} has been installed."); GameInstalled?.Invoke(this, new GameInstalledEventArgs(new Game() @@ -76,13 +68,7 @@ public void StartMonitoring() if (installsKey64 != null) { var gameKey = installsKey64.OpenSubKey(id); - if (gameInstalled && gameKey == null) - { - logger.Info($"Uplay app {id} has been uninstalled."); - GameUninstalled?.Invoke(this, null); - return; - } - else if (!gameInstalled && gameKey != null) + if (gameKey != null) { logger.Info($"Uplay app {id} has been installed."); GameInstalled?.Invoke(this, new GameInstalledEventArgs(new Game() @@ -95,7 +81,56 @@ public void StartMonitoring() } } - Thread.Sleep(2000); + Thread.Sleep(5000); + } + }, cancelToken.Token); + } + + public void StartUninstallMonitoring() + { + + logger.Info("Starting uninstall monitoring of Uplay app " + id); + Dispose(); + + cancelToken = new CancellationTokenSource(); + var gameInstalled = library.GetInstalledGames().FirstOrDefault(a => a.ProviderId == id) != null; + + Task.Factory.StartNew(() => + { + // Uplay is currently 32bit only, but this will future proof this feature + var root32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + var root64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + + while (!cancelToken.Token.IsCancellationRequested) + { + var installsKey32 = root32.OpenSubKey(@"SOFTWARE\ubisoft\Launcher\Installs\"); + if (installsKey32 != null) + { + var gameKey = installsKey32.OpenSubKey(id); + if (gameKey == null) + { + logger.Info($"Uplay app {id} has been uninstalled."); + GameUninstalled?.Invoke(this, null); + return; + } + } + + if (Environment.Is64BitOperatingSystem) + { + var installsKey64 = root64.OpenSubKey(@"SOFTWARE\ubisoft\Launcher\Installs\"); + if (installsKey64 != null) + { + var gameKey = installsKey64.OpenSubKey(id); + if (gameKey == null) + { + logger.Info($"Uplay app {id} has been uninstalled."); + GameUninstalled?.Invoke(this, null); + return; + } + } + } + + Thread.Sleep(5000); } }, cancelToken.Token); } diff --git a/source/Playnite/Settings.cs b/source/Playnite/Settings.cs index 8783092e8..10929ba75 100644 --- a/source/Playnite/Settings.cs +++ b/source/Playnite/Settings.cs @@ -17,6 +17,7 @@ using CefSharp; using System.Configuration; using Playnite.Providers.Uplay; +using Playnite.Providers.BattleNet; namespace Playnite { @@ -343,6 +344,11 @@ public UplaySettings UplaySettings get; set; } = new UplaySettings(); + public BattleNetSettings BattleNetSettings + { + get; set; + } = new BattleNetSettings(); + private FilterSettings filterSettings = new FilterSettings(); public FilterSettings FilterSettings { diff --git a/source/PlayniteTests/BattleNetLibraryTests.cs b/source/PlayniteTests/BattleNetLibraryTests.cs new file mode 100644 index 000000000..4ce079053 --- /dev/null +++ b/source/PlayniteTests/BattleNetLibraryTests.cs @@ -0,0 +1,23 @@ +using NUnit.Framework; +using Playnite.Providers.BattleNet; +using Playnite.Providers.Uplay; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PlayniteTests.Providers.BattleNet +{ + [TestFixture] + public class BattleNetLibraryTests + { + [Test] + public void GetInstalledGamesTest() + { + var library = new BattleNetLibrary(); + var games = library.GetInstalledGames(); + CollectionAssert.IsNotEmpty(games); + } + } +} diff --git a/source/PlayniteTests/Database/GameDatabasePlatformsTests.cs b/source/PlayniteTests/Database/GameDatabasePlatformsTests.cs index 87860e28d..ad24a5006 100644 --- a/source/PlayniteTests/Database/GameDatabasePlatformsTests.cs +++ b/source/PlayniteTests/Database/GameDatabasePlatformsTests.cs @@ -67,7 +67,7 @@ public void PcPlatformAutoAssignTest() var steamLibrary = new Mock(); steamLibrary.Setup(oc => oc.GetLibraryGames(string.Empty)).Returns(libraryGames); - var db = new GameDatabase(null, steamLibrary.Object, null, null); + var db = new GameDatabase(null, steamLibrary.Object, null, null, null); using (db.OpenDatabase(dbPath)) { db.UpdateOwnedGames(Provider.Steam); diff --git a/source/PlayniteTests/Database/GameDatabaseTests.cs b/source/PlayniteTests/Database/GameDatabaseTests.cs index c51e04344..d0f055842 100644 --- a/source/PlayniteTests/Database/GameDatabaseTests.cs +++ b/source/PlayniteTests/Database/GameDatabaseTests.cs @@ -14,6 +14,7 @@ using NUnit.Framework; using LiteDB; using Playnite.Providers.Uplay; +using Playnite.Providers.BattleNet; namespace PlayniteTests.Database { @@ -181,7 +182,7 @@ public void UpdateOwnedGamesTest(Provider provider) steamLibrary.Setup(oc => oc.GetLibraryGames(string.Empty)).Returns(libraryGames); originLibrary.Setup(oc => oc.GetLibraryGames()).Returns(libraryGames); - var db = new GameDatabase(gogLibrary.Object, steamLibrary.Object, originLibrary.Object, null); + var db = new GameDatabase(gogLibrary.Object, steamLibrary.Object, originLibrary.Object, null, null); using (db.OpenDatabase(path)) { // Games are properly imported @@ -236,13 +237,15 @@ public void UpdateInstalledGamesTest(Provider provider) var steamLibrary = new Mock(); var originLibrary = new Mock(); var uplayLibrary = new Mock(); + var battleNetLibrary = new Mock(); gogLibrary.Setup(oc => oc.GetInstalledGames()).Returns(installedGames); steamLibrary.Setup(oc => oc.GetInstalledGames()).Returns(installedGames); originLibrary.Setup(oc => oc.GetInstalledGames(false)).Returns(installedGames); originLibrary.Setup(oc => oc.GetInstalledGames(true)).Returns(installedGames); uplayLibrary.Setup(oc => oc.GetInstalledGames()).Returns(installedGames); + battleNetLibrary.Setup(oc => oc.GetInstalledGames()).Returns(installedGames); - var db = new GameDatabase(gogLibrary.Object, steamLibrary.Object, originLibrary.Object, uplayLibrary.Object); + var db = new GameDatabase(gogLibrary.Object, steamLibrary.Object, originLibrary.Object, uplayLibrary.Object, battleNetLibrary.Object); using (db.OpenDatabase(path)) { // Games are imported diff --git a/source/PlayniteTests/PlayniteTests.csproj b/source/PlayniteTests/PlayniteTests.csproj index 4226bf27a..871988e75 100644 --- a/source/PlayniteTests/PlayniteTests.csproj +++ b/source/PlayniteTests/PlayniteTests.csproj @@ -122,6 +122,7 @@ + diff --git a/source/PlayniteTests/Providers/BattleNet/BattleNetLibraryTests.cs b/source/PlayniteTests/Providers/BattleNet/BattleNetLibraryTests.cs new file mode 100644 index 000000000..3c8f29b22 --- /dev/null +++ b/source/PlayniteTests/Providers/BattleNet/BattleNetLibraryTests.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using Playnite.Providers.BattleNet; +using Playnite.Providers.Uplay; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PlayniteTests.Providers.BattleNet +{ + [TestFixture] + public class BattleNetLibraryTests + { + [Test] + public void GetInstalledGamesTest() + { + var library = new BattleNetLibrary(); + var games = library.GetInstalledGames(); + CollectionAssert.IsNotEmpty(games); + } + + [Test] + public void UpdateGameWithMetadataTest() + { + var library = new BattleNetLibrary(); + var games = library.GetInstalledGames(); + var game = games.First(); + var metadata = library.UpdateGameWithMetadata(game); + Assert.IsNotNull(metadata.Icon); + } + } +} diff --git a/source/PlayniteUI/Controls/FilterSelector.xaml b/source/PlayniteUI/Controls/FilterSelector.xaml index 6ec51606e..ae56478a2 100644 --- a/source/PlayniteUI/Controls/FilterSelector.xaml +++ b/source/PlayniteUI/Controls/FilterSelector.xaml @@ -64,6 +64,10 @@ + + + + diff --git a/source/PlayniteUI/GamesCollectionView.cs b/source/PlayniteUI/GamesCollectionView.cs index 68ac45977..73825da83 100644 --- a/source/PlayniteUI/GamesCollectionView.cs +++ b/source/PlayniteUI/GamesCollectionView.cs @@ -253,6 +253,8 @@ public string DefaultIcon return @"resources:/Images/steamicon.png"; case Provider.Uplay: return @"resources:/Images/uplayicon.png"; + case Provider.BattleNet: + return @"resources:/Images/battleneticon.png"; case Provider.Custom: default: return @"resources:/Images/applogo.png"; @@ -461,7 +463,12 @@ private bool Filter(object item) // ------------------ Providers bool providersFilter = false; - if (Settings.FilterSettings.Steam == false && Settings.FilterSettings.Origin == false && Settings.FilterSettings.GOG == false && Settings.FilterSettings.Custom == false && Settings.FilterSettings.Uplay == false) + if (Settings.FilterSettings.Steam == false && + Settings.FilterSettings.Origin == false && + Settings.FilterSettings.GOG == false && + Settings.FilterSettings.Custom == false && + Settings.FilterSettings.Uplay == false && + Settings.FilterSettings.BattleNet == false) { providersFilter = true; } @@ -499,6 +506,12 @@ private bool Filter(object item) providersFilter = true; } break; + case Provider.BattleNet: + if (Settings.FilterSettings.BattleNet) + { + providersFilter = true; + } + break; } } diff --git a/source/PlayniteUI/Images/battleneticon.png b/source/PlayniteUI/Images/battleneticon.png new file mode 100644 index 000000000..55bdbe7bf Binary files /dev/null and b/source/PlayniteUI/Images/battleneticon.png differ diff --git a/source/PlayniteUI/NotificationCodes.cs b/source/PlayniteUI/NotificationCodes.cs index 7820180d8..8addee8c2 100644 --- a/source/PlayniteUI/NotificationCodes.cs +++ b/source/PlayniteUI/NotificationCodes.cs @@ -15,5 +15,7 @@ public static class NotificationCodes public static readonly int OriginInstalledImportError = 5; public static readonly int OriginLibDownloadError = 6; public static readonly int UplayInstalledImportError = 7; + public static readonly int BattleNetInstalledImportError = 8; + public static readonly int BattleNetLibDownloadImportError = 9; } } diff --git a/source/PlayniteUI/PlayniteUI.csproj b/source/PlayniteUI/PlayniteUI.csproj index f2fd2f92f..284a7e4c3 100644 --- a/source/PlayniteUI/PlayniteUI.csproj +++ b/source/PlayniteUI/PlayniteUI.csproj @@ -496,6 +496,9 @@ + + +