diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bd149f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "SteamStorefrontAPI"] + path = SteamStorefrontAPI + url = https://git.jeddunk.xyz/jeddunk/SteamStorefrontAPI.git diff --git a/auto-creamapi.sln b/auto-creamapi.sln index c220862..78be129 100644 --- a/auto-creamapi.sln +++ b/auto-creamapi.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30413.136 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34330.188 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "auto-creamapi", "auto-creamapi\auto-creamapi.csproj", "{26060B32-199E-4366-8FDE-6B1E10E0EF62}" EndProject diff --git a/auto-creamapi/App.xaml.cs b/auto-creamapi/App.xaml.cs index 95e07c3..d78cbe5 100644 --- a/auto-creamapi/App.xaml.cs +++ b/auto-creamapi/App.xaml.cs @@ -10,7 +10,7 @@ namespace auto_creamapi { protected override void RegisterSetup() { - this.RegisterSetupType>(); + this.RegisterSetupType(); } } } \ No newline at end of file diff --git a/auto-creamapi/Converters/ListOfDLcToStringConverter.cs b/auto-creamapi/Converters/ListOfDLcToStringConverter.cs index aabfdd6..5691ee6 100644 --- a/auto-creamapi/Converters/ListOfDLcToStringConverter.cs +++ b/auto-creamapi/Converters/ListOfDLcToStringConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; @@ -19,6 +20,7 @@ namespace auto_creamapi.Converters protected override string Convert(ObservableCollection value, Type targetType, object parameter, CultureInfo culture) { + if (value == null) return ""; MyLogger.Log.Debug("ListOfDLcToStringConverter: Convert"); var dlcListToString = DlcListToString(value); return dlcListToString.GetType() == targetType ? dlcListToString : ""; @@ -29,30 +31,32 @@ namespace auto_creamapi.Converters { MyLogger.Log.Debug("ListOfDLcToStringConverter: ConvertBack"); var stringToDlcList = StringToDlcList(value); - return stringToDlcList.GetType() == targetType ? stringToDlcList : new ObservableCollection(); + return stringToDlcList.GetType() == targetType ? stringToDlcList : []; } private static ObservableCollection StringToDlcList(string value) { var result = new ObservableCollection(); - var expression = new Regex(@"(?.*) *= *(?.*)"); + var expression = new Regex("(?.*) *= *(?.*)"); using var reader = new StringReader(value); string line; while ((line = reader.ReadLine()) != null) { var match = expression.Match(line); if (match.Success) + { result.Add(new SteamApp { AppId = int.Parse(match.Groups["id"].Value), Name = match.Groups["name"].Value }); + } } return result; } - private static string DlcListToString(ObservableCollection value) + private static string DlcListToString(IEnumerable value) { var result = ""; //value.ForEach(x => result += $"{x}\n"); diff --git a/auto-creamapi/Core/App.cs b/auto-creamapi/Core/MainApplication.cs similarity index 90% rename from auto-creamapi/Core/App.cs rename to auto-creamapi/Core/MainApplication.cs index f64d409..2201b8a 100644 --- a/auto-creamapi/Core/App.cs +++ b/auto-creamapi/Core/MainApplication.cs @@ -4,7 +4,7 @@ using MvvmCross.ViewModels; namespace auto_creamapi.Core { - public class App : MvxApplication + public class MainApplication : MvxApplication { public override void Initialize() { diff --git a/auto-creamapi/MainWindow.xaml b/auto-creamapi/MainWindow.xaml index ffcc975..7212a0c 100644 --- a/auto-creamapi/MainWindow.xaml +++ b/auto-creamapi/MainWindow.xaml @@ -6,6 +6,6 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" - Title="Auto-CreamAPI 2" MinWidth="420" MinHeight="600" Width="420" Height="600"> + Title="Auto-CreamAPI 2" MinWidth="420" MinHeight="640" Width="560" Height="720"> \ No newline at end of file diff --git a/auto-creamapi/Models/SteamAppModel.cs b/auto-creamapi/Models/SteamAppModel.cs index 5c85687..3f74258 100644 --- a/auto-creamapi/Models/SteamAppModel.cs +++ b/auto-creamapi/Models/SteamAppModel.cs @@ -1,24 +1,36 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using auto_creamapi.Utils; namespace auto_creamapi.Models { public class SteamApp { + private string _name; + private string _comparableName; [JsonPropertyName("appid")] public int AppId { get; set; } - [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("name")] + public string Name + { + get => _name; + set + { + _name = value; + _comparableName = Regex.Replace(value, Misc.SpecialCharsRegex, "").ToLower(); + } + } + + public bool CompareName(string value) + { + return _comparableName.Equals(value); + } public override string ToString() { - //return $"AppId: {AppId}, Name: {Name}"; return $"{AppId}={Name}"; } - - public bool CompareId(SteamApp steamApp) - { - return AppId.Equals(steamApp.AppId); - } } public class AppList diff --git a/auto-creamapi/Services/CacheService.cs b/auto-creamapi/Services/CacheService.cs index 1be6d7f..223bbe5 100644 --- a/auto-creamapi/Services/CacheService.cs +++ b/auto-creamapi/Services/CacheService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -23,7 +25,7 @@ namespace auto_creamapi.Services public IEnumerable GetListOfAppsByName(string name); public SteamApp GetAppByName(string name); public SteamApp GetAppById(int appid); - public Task> GetListOfDlc(SteamApp steamApp, bool useSteamDb); + public Task> GetListOfDlc(SteamApp steamApp, bool useSteamDb, bool ignoreUnknown); } public class CacheService : ICacheService @@ -31,28 +33,7 @@ namespace auto_creamapi.Services private const string CachePath = "steamapps.json"; private const string SteamUri = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"; - private const string UserAgent = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/87.0.4280.88 Safari/537.36"; - - private const string SpecialCharsRegex = "[^0-9a-zA-Z]+"; - - private List _cache = new List(); - - /*private static readonly Lazy Lazy = - new Lazy(() => new CacheService()); - - public static CacheService Instance => Lazy.Value;*/ - - public CacheService() - { - } - - /*public async void Initialize() - { - //Languages = _defaultLanguages; - await UpdateCache(); - }*/ + private HashSet _cache = []; public async Task Initialize() { @@ -61,17 +42,7 @@ namespace auto_creamapi.Services string cacheString; if (updateNeeded) { - MyLogger.Log.Information("Getting content from API..."); - var client = new HttpClient(); - var httpCall = client.GetAsync(SteamUri); - var response = await httpCall.ConfigureAwait(false); - var readAsStringAsync = response.Content.ReadAsStringAsync(); - var responseBody = await readAsStringAsync; - MyLogger.Log.Information("Got content from API successfully. Writing to file..."); - - await File.WriteAllTextAsync(CachePath, responseBody, Encoding.UTF8); - cacheString = responseBody; - MyLogger.Log.Information("Cache written to file successfully."); + cacheString = await UpdateCache().ConfigureAwait(false); } else { @@ -79,12 +50,27 @@ namespace auto_creamapi.Services // ReSharper disable once MethodHasAsyncOverload cacheString = File.ReadAllText(CachePath); } - var steamApps = JsonSerializer.Deserialize(cacheString); - _cache = steamApps.AppList.Apps; + _cache = new HashSet(steamApps.AppList.Apps); MyLogger.Log.Information("Loaded cache into memory!"); } + private static async Task UpdateCache() + { + MyLogger.Log.Information("Getting content from API..."); + var client = new HttpClient(); + var httpCall = client.GetAsync(SteamUri); + var response = await httpCall.ConfigureAwait(false); + var readAsStringAsync = response.Content.ReadAsStringAsync(); + var responseBody = await readAsStringAsync.ConfigureAwait(false); + MyLogger.Log.Information("Got content from API successfully. Writing to file..."); + + await File.WriteAllTextAsync(CachePath, responseBody, Encoding.UTF8).ConfigureAwait(false); + var cacheString = responseBody; + MyLogger.Log.Information("Cache written to file successfully."); + return cacheString; + } + public IEnumerable GetListOfAppsByName(string name) { var listOfAppsByName = _cache.Search(x => x.Name) @@ -95,107 +81,149 @@ namespace auto_creamapi.Services public SteamApp GetAppByName(string name) { - MyLogger.Log.Information($"Trying to get app {name}"); - var app = _cache.Find(x => - Regex.Replace(x.Name, SpecialCharsRegex, "").ToLower() - .Equals(Regex.Replace(name, SpecialCharsRegex, "").ToLower())); - if (app != null) MyLogger.Log.Information($"Successfully got app {app}"); + MyLogger.Log.Information("Trying to get app {Name}", name); + var comparableName = Regex.Replace(name, Misc.SpecialCharsRegex, "").ToLower(); + var app = _cache.FirstOrDefault(x => x.CompareName(comparableName)); + if (app != null) MyLogger.Log.Information("Successfully got app {App}", app); return app; } public SteamApp GetAppById(int appid) { - MyLogger.Log.Information($"Trying to get app with ID {appid}"); - var app = _cache.Find(x => x.AppId.Equals(appid)); - if (app != null) MyLogger.Log.Information($"Successfully got app {app}"); + MyLogger.Log.Information("Trying to get app with ID {AppId}", appid); + var app = _cache.FirstOrDefault(x => x.AppId.Equals(appid)); + if (app != null) MyLogger.Log.Information("Successfully got app {App}", app); return app; } - public async Task> GetListOfDlc(SteamApp steamApp, bool useSteamDb) + public async Task> GetListOfDlc(SteamApp steamApp, bool useSteamDb, bool ignoreUnknown) { - MyLogger.Log.Information("Get DLC"); + MyLogger.Log.Debug("Start: GetListOfDlc"); var dlcList = new List(); - if (steamApp != null) + try { - var task = AppDetails.GetAsync(steamApp.AppId); - var steamAppDetails = await task; - steamAppDetails?.DLC.ForEach(x => + if (steamApp != null) { - var result = _cache.Find(y => y.AppId.Equals(x)) ?? - new SteamApp {AppId = x, Name = $"Unknown DLC {x}"}; - dlcList.Add(result); - }); - - dlcList.ForEach(x => MyLogger.Log.Debug($"{x.AppId}={x.Name}")); - MyLogger.Log.Information("Got DLC successfully..."); - - // Get DLC from SteamDB - // Get Cloudflare cookie - // Scrape and parse HTML page - // Add missing to DLC list - if (useSteamDb) - { - var steamDbUri = new Uri($"https://steamdb.info/app/{steamApp.AppId}/dlc/"); - - /* var handler = new ClearanceHandler(); - - var client = new HttpClient(handler); - - var content = client.GetStringAsync(steamDbUri).Result; - MyLogger.Log.Debug(content); */ - - var client = new HttpClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); - - MyLogger.Log.Information("Get SteamDB App"); - var httpCall = client.GetAsync(steamDbUri); - var response = await httpCall; - MyLogger.Log.Debug(httpCall.Status.ToString()); - MyLogger.Log.Debug(response.EnsureSuccessStatusCode().ToString()); - - var readAsStringAsync = response.Content.ReadAsStringAsync(); - var responseBody = await readAsStringAsync; - MyLogger.Log.Debug(readAsStringAsync.Status.ToString()); - - var parser = new HtmlParser(); - var doc = parser.ParseDocument(responseBody); - // Console.WriteLine(doc.DocumentElement.OuterHtml); - - var query1 = doc.QuerySelector("#dlc"); - if (query1 != null) + var steamAppDetails = await AppDetails.GetAsync(steamApp.AppId).ConfigureAwait(false); + if (steamAppDetails != null) { - var query2 = query1.QuerySelectorAll(".app"); - foreach (var element in query2) + MyLogger.Log.Debug("Type for Steam App {Name}: \"{Type}\"", steamApp.Name, + steamAppDetails.Type); + if (steamAppDetails.Type == "game" || steamAppDetails.Type == "demo") { - var dlcId = element.GetAttribute("data-appid"); - var dlcName = $"Unknown DLC {dlcId}"; - var query3 = element.QuerySelectorAll("td"); - if (query3 != null) dlcName = query3[1].Text().Replace("\n", "").Trim(); - - var dlcApp = new SteamApp {AppId = Convert.ToInt32(dlcId), Name = dlcName}; - var i = dlcList.FindIndex(x => x.CompareId(dlcApp)); - if (i > -1) + steamAppDetails.DLC.ForEach(x => { - if (dlcList[i].Name.Contains("Unknown DLC")) dlcList[i] = dlcApp; + var result = _cache.FirstOrDefault(y => y.AppId.Equals(x)); + if (result == null) return; + var dlcDetails = AppDetails.GetAsync(x).Result; + dlcList.Add(dlcDetails != null + ? new SteamApp { AppId = dlcDetails.SteamAppId, Name = dlcDetails.Name } + : new SteamApp { AppId = x, Name = $"Unknown DLC {x}" }); + }); + + dlcList.ForEach(x => MyLogger.Log.Debug("{AppId}={Name}", x.AppId, x.Name)); + MyLogger.Log.Information("Got DLC successfully..."); + + // Return if Steam DB is deactivated + if (!useSteamDb) return dlcList; + + string steamDbUrl = $"https://steamdb.info/app/{steamApp.AppId}/dlc/"; + + var client = new HttpClient(); + string archiveJson = await client.GetStringAsync($"https://archive.org/wayback/available?url={steamDbUrl}"); + var archiveResult = JsonSerializer.Deserialize(archiveJson); + + if (archiveResult == null || archiveResult.ArchivedSnapshots.Closest?.Status != "200") + { + return dlcList; + } + + //language=regex + const string pattern = @"^(https?:\/\/web\.archive\.org\/web\/\d+)(\/.+)$"; + const string substitution = "$1id_$2"; + const RegexOptions options = RegexOptions.Multiline; + + Regex regex = new(pattern, options); + string newUrl = regex.Replace(archiveResult.ArchivedSnapshots.Closest.Url, substitution); + + //client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); + + MyLogger.Log.Information("Get SteamDB App"); + var httpCall = client.GetAsync(newUrl); + var response = await httpCall.ConfigureAwait(false); + MyLogger.Log.Debug("{Status}", httpCall.Status.ToString()); + MyLogger.Log.Debug("{Boolean}", response.IsSuccessStatusCode.ToString()); + + response.EnsureSuccessStatusCode(); + + var readAsStringAsync = response.Content.ReadAsStringAsync(); + var responseBody = await readAsStringAsync.ConfigureAwait(false); + MyLogger.Log.Debug("{Status}", readAsStringAsync.Status.ToString()); + + var parser = new HtmlParser(); + var doc = parser.ParseDocument(responseBody); + // Console.WriteLine(doc.DocumentElement.OuterHtml); + + var query1 = doc.QuerySelector("#dlc"); + if (query1 != null) + { + var query2 = query1.QuerySelectorAll(".app"); + foreach (var element in query2) + { + var dlcId = element.GetAttribute("data-appid"); + var query3 = element.QuerySelectorAll("td"); + var dlcName = query3 == null + ? $"Unknown DLC {dlcId}" + : query3[1].Text().Replace("\n", "").Trim(); + + if (ignoreUnknown && dlcName.Contains("SteamDB Unknown App")) + { + MyLogger.Log.Information("Skipping SteamDB Unknown App {DlcId}", dlcId); + } + else + { + var dlcApp = new SteamApp { AppId = Convert.ToInt32(dlcId), Name = dlcName }; + var i = dlcList.FindIndex(x => x.AppId.Equals(dlcApp.AppId)); + if (i > -1) + { + if (dlcList[i].Name.Contains("Unknown DLC")) dlcList[i] = dlcApp; + } + else + { + dlcList.Add(dlcApp); + } + } + } + + dlcList.ForEach(x => MyLogger.Log.Debug("{AppId}={Name}", x.AppId, x.Name)); + MyLogger.Log.Information("Got DLC from SteamDB successfully..."); } else { - dlcList.Add(dlcApp); + MyLogger.Log.Error("Could not get DLC from SteamDB!"); } } - - dlcList.ForEach(x => MyLogger.Log.Debug($"{x.AppId}={x.Name}")); - MyLogger.Log.Information("Got DLC from SteamDB successfully..."); + else + { + MyLogger.Log.Error("Could not get DLC: Steam App is not of type: \"Game\""); + } } else { - MyLogger.Log.Error("Could not get DLC from SteamDB1"); + MyLogger.Log.Error("Could not get DLC: Could not get Steam App details"); } } + else + { + MyLogger.Log.Error("Could not get DLC: Invalid Steam App"); + } + + //return dlcList; } - else + catch (Exception e) { - MyLogger.Log.Error("Could not get DLC: Invalid Steam App"); + MyLogger.Log.Error("Could not get DLC!"); + MyLogger.Log.Debug(e.Demystify(), "Exception thrown!"); } return dlcList; diff --git a/auto-creamapi/Services/CreamConfigService.cs b/auto-creamapi/Services/CreamConfigService.cs index b331410..d21f13d 100644 --- a/auto-creamapi/Services/CreamConfigService.cs +++ b/auto-creamapi/Services/CreamConfigService.cs @@ -38,7 +38,7 @@ namespace auto_creamapi.Services bool unlockAll, bool extraProtection, bool forceOffline, - ObservableCollection dlcList); + IEnumerable dlcList); public bool ConfigExists(); } @@ -47,7 +47,7 @@ namespace auto_creamapi.Services { private string _configFilePath; - public CreamConfig Config { get; set; } + public CreamConfig Config { get; private set; } public void Initialize() { @@ -65,7 +65,7 @@ namespace auto_creamapi.Services _configFilePath = configFilePath; if (File.Exists(configFilePath)) { - MyLogger.Log.Information($"Config file found @ {configFilePath}, parsing..."); + MyLogger.Log.Information("Config file found @ {ConfigFilePath}, parsing...", configFilePath); var parser = new FileIniDataParser(); var data = parser.ReadFile(_configFilePath, Encoding.UTF8); @@ -83,7 +83,7 @@ namespace auto_creamapi.Services } else { - MyLogger.Log.Information($"Config file does not exist @ {configFilePath}, skipping..."); + MyLogger.Log.Information("Config file does not exist @ {ConfigFilePath}, skipping...", configFilePath); ResetConfigData(); } } @@ -144,7 +144,7 @@ namespace auto_creamapi.Services bool unlockAll, bool extraProtection, bool forceOffline, - ObservableCollection dlcList) + IEnumerable dlcList) { Config.AppId = appId; Config.Language = language; diff --git a/auto-creamapi/Services/CreamDllService.cs b/auto-creamapi/Services/CreamDllService.cs index d41c35a..de3c9b7 100644 --- a/auto-creamapi/Services/CreamDllService.cs +++ b/auto-creamapi/Services/CreamDllService.cs @@ -66,8 +66,8 @@ namespace auto_creamapi.Services var x64File = Path.Combine(TargetPath, "steam_api64.dll"); _x86Exists = File.Exists(x86File); _x64Exists = File.Exists(x64File); - if (_x86Exists) MyLogger.Log.Information($"x86 SteamAPI DLL found: {x86File}"); - if (_x64Exists) MyLogger.Log.Information($"x64 SteamAPI DLL found: {x64File}"); + if (_x86Exists) MyLogger.Log.Information("x86 SteamAPI DLL found: {X}", x86File); + if (_x64Exists) MyLogger.Log.Information("x64 SteamAPI DLL found: {X}", x64File); } public bool CreamApiApplied() @@ -83,7 +83,7 @@ namespace auto_creamapi.Services var targetSteamApiDll = Path.Combine(TargetPath, _creamDlls[arch].Filename); var targetSteamApiOrigDll = Path.Combine(TargetPath, _creamDlls[arch].OrigFilename); var targetSteamApiDllBackup = Path.Combine(TargetPath, $"{_creamDlls[arch].Filename}.backup"); - MyLogger.Log.Information($"Setting up CreamAPI DLL @ {TargetPath} (arch :{arch})"); + MyLogger.Log.Information("Setting up CreamAPI DLL @ {TargetPath} (arch :{Arch})", TargetPath, arch); // Create backup of steam_api.dll File.Copy(targetSteamApiDll, targetSteamApiDllBackup, true); // Check if steam_api_o.dll already exists @@ -103,16 +103,13 @@ namespace auto_creamapi.Services private static string GetHash(string filename) { - if (File.Exists(filename)) - { - using var md5 = MD5.Create(); - using var stream = File.OpenRead(filename); - return BitConverter - .ToString(md5.ComputeHash(stream)) - .Replace("-", string.Empty); - } + if (!File.Exists(filename)) return ""; + using var md5 = MD5.Create(); + using var stream = File.OpenRead(filename); + return BitConverter + .ToString(md5.ComputeHash(stream)) + .Replace("-", string.Empty); - return ""; } } } \ No newline at end of file diff --git a/auto-creamapi/Services/DownloadCreamApiService.cs b/auto-creamapi/Services/DownloadCreamApiService.cs index 34126f1..ba9f75c 100644 --- a/auto-creamapi/Services/DownloadCreamApiService.cs +++ b/auto-creamapi/Services/DownloadCreamApiService.cs @@ -10,27 +10,20 @@ using auto_creamapi.Messenger; using auto_creamapi.Utils; using HttpProgress; using MvvmCross.Plugin.Messenger; -using SharpCompress.Archives; -using SharpCompress.Common; -using SharpCompress.Readers; +using SevenZip; namespace auto_creamapi.Services { public interface IDownloadCreamApiService { - /*public void Initialize(); - public Task InitializeAsync();*/ public Task Download(string username, string password); - public void Extract(string filename); + public Task Extract(string filename); } public class DownloadCreamApiService : IDownloadCreamApiService { private const string ArchivePassword = "cs.rin.ru"; - - //private string _filename; private readonly IMvxMessenger _messenger; - //private DownloadWindow _wnd; public DownloadCreamApiService(IMvxMessenger messenger) { @@ -43,6 +36,8 @@ namespace auto_creamapi.Services var container = new CookieContainer(); var handler = new HttpClientHandler {CookieContainer = container}; var client = new HttpClient(handler); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) " + + "Gecko/20100101 Firefox/86.0"); var formContent = new FormUrlEncodedContent(new[] { new KeyValuePair("username", username), @@ -51,23 +46,26 @@ namespace auto_creamapi.Services new KeyValuePair("login", "login") }); MyLogger.Log.Debug("Download: post login"); - var response1 = await client.PostAsync("https://cs.rin.ru/forum/ucp.php?mode=login", formContent); - MyLogger.Log.Debug($"Login Status Code: {response1.EnsureSuccessStatusCode().StatusCode.ToString()}"); + var response1 = await client.PostAsync("https://cs.rin.ru/forum/ucp.php?mode=login", formContent) + .ConfigureAwait(false); + MyLogger.Log.Debug("Login Status Code: {StatusCode}", + response1.EnsureSuccessStatusCode().StatusCode); var cookie = container.GetCookies(new Uri("https://cs.rin.ru/forum/ucp.php?mode=login")) .FirstOrDefault(c => c.Name.Contains("_sid")); - MyLogger.Log.Debug($"Login Cookie: {cookie}"); - var response2 = await client.GetAsync("https://cs.rin.ru/forum/viewtopic.php?t=70576"); - MyLogger.Log.Debug( - $"Download Page Status Code: {response2.EnsureSuccessStatusCode().StatusCode.ToString()}"); + MyLogger.Log.Debug("Login Cookie: {Cookie}", cookie); + var response2 = await client.GetAsync("https://cs.rin.ru/forum/viewtopic.php?t=70576") + .ConfigureAwait(false); + MyLogger.Log.Debug("Download Page Status Code: {StatusCode}", + response2.EnsureSuccessStatusCode().StatusCode); var content = response2.Content.ReadAsStringAsync(); - var contentResult = await content; + var contentResult = await content.ConfigureAwait(false); var expression = new Regex(".*\\/download\\/file\\.php\\?id=.*)\">(?.*)<\\/a>.*"); using var reader = new StringReader(contentResult); string line; var archiveFileList = new Dictionary(); - while ((line = await reader.ReadLineAsync()) != null) + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { var match = expression.Match(line); // ReSharper disable once InvertIf @@ -75,16 +73,15 @@ namespace auto_creamapi.Services { archiveFileList.Add(match.Groups["filename"].Value, $"https://cs.rin.ru/forum{match.Groups["url"].Value}"); - MyLogger.Log.Debug(archiveFileList.LastOrDefault().Key); + MyLogger.Log.Debug("{X}", archiveFileList.LastOrDefault().Key); } } MyLogger.Log.Debug("Choosing first element from list..."); var (filename, url) = archiveFileList.FirstOrDefault(); - //filename = filename; if (File.Exists(filename)) { - MyLogger.Log.Information($"{filename} already exists, skipping download..."); + MyLogger.Log.Information("{Filename} already exists, skipping download...", filename); return filename; } @@ -93,33 +90,53 @@ namespace auto_creamapi.Services x => _messenger.Publish(new ProgressMessage(this, "Downloading...", filename, x))); await using var fileStream = File.OpenWrite(filename); var task = client.GetAsync(url, fileStream, progress); - var response = await task; + await task.ConfigureAwait(false); if (task.IsCompletedSuccessfully) _messenger.Publish(new ProgressMessage(this, "Downloading...", filename, 1.0)); MyLogger.Log.Information("Download done."); return filename; } - public void Extract(string filename) + public async Task Extract(string filename) { MyLogger.Log.Debug("Extract"); - MyLogger.Log.Information($@"Start extraction of ""{filename}""..."); - var options = new ReaderOptions {Password = ArchivePassword}; - var archive = ArchiveFactory.Open(filename, options); - var expression1 = new Regex(@"nonlog_build\\steam_api(?:64)?\.dll"); + var cwd = Directory.GetCurrentDirectory(); + const string nonlogBuild = "nonlog_build"; + const string steamApi64Dll = "steam_api64.dll"; + const string steamApiDll = "steam_api.dll"; + MyLogger.Log.Information(@"Start extraction of ""{Filename}""...", filename); + var nonlogBuildPath = Path.Combine(cwd, nonlogBuild); + if (Directory.Exists(nonlogBuildPath)) + Directory.Delete(nonlogBuildPath, true); _messenger.Publish(new ProgressMessage(this, "Extracting...", filename, 1.0)); - foreach (var entry in archive.Entries) - // ReSharper disable once InvertIf - if (!entry.IsDirectory && expression1.IsMatch(entry.Key)) - { - MyLogger.Log.Debug(entry.Key); - entry.WriteToDirectory(Directory.GetCurrentDirectory(), new ExtractionOptions - { - ExtractFullPath = false, - Overwrite = true - }); - } + SevenZipBase.SetLibraryPath(Path.Combine(cwd, "resources/7z.dll")); + using (var extractor = + new SevenZipExtractor(filename, ArchivePassword, InArchiveFormat.Rar) + {PreserveDirectoryStructure = false}) + { + await extractor.ExtractFilesAsync( + cwd, + $@"{nonlogBuild}\{steamApi64Dll}", + $@"{nonlogBuild}\{steamApiDll}" + ).ConfigureAwait(false); + } + if (File.Exists(Path.Combine(nonlogBuildPath, steamApi64Dll))) + File.Move( + Path.Combine(cwd, nonlogBuild, steamApi64Dll), + Path.Combine(cwd, steamApi64Dll), + true + ); + + if (File.Exists(Path.Combine(nonlogBuildPath, steamApiDll))) + File.Move( + Path.Combine(nonlogBuildPath, steamApiDll), + Path.Combine(cwd, steamApiDll), + true + ); + + if (Directory.Exists(nonlogBuildPath)) + Directory.Delete(nonlogBuildPath, true); MyLogger.Log.Information("Extraction done!"); } } diff --git a/auto-creamapi/Setup.cs b/auto-creamapi/Setup.cs new file mode 100644 index 0000000..9a5682a --- /dev/null +++ b/auto-creamapi/Setup.cs @@ -0,0 +1,29 @@ +using auto_creamapi.Core; +using auto_creamapi.Utils; +using Microsoft.Extensions.Logging; +using MvvmCross.Platforms.Wpf.Core; +using Serilog; +using Serilog.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace auto_creamapi +{ + public class Setup : MvxWpfSetup + { + protected override ILoggerFactory CreateLogFactory() + { + Log.Logger = MyLogger.Log; + + return new SerilogLoggerFactory(); + } + + protected override ILoggerProvider CreateLogProvider() + { + return new SerilogLoggerProvider(); + } + } +} diff --git a/auto-creamapi/Utils/AvailabeArchive.cs b/auto-creamapi/Utils/AvailabeArchive.cs new file mode 100644 index 0000000..f7561b1 --- /dev/null +++ b/auto-creamapi/Utils/AvailabeArchive.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace auto_creamapi.Utils +{ + + public class AvailableArchive + { + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("archived_snapshots")] + public ArchivedSnapshot ArchivedSnapshots { get; set; } + } + + public class ArchivedSnapshot + { + [JsonPropertyName("closest")] + public Closest Closest { get; set; } + } + + public class Closest + { + [JsonPropertyName("status")] + public string Status { get; set; } + + [JsonPropertyName("available")] + public bool Available { get; set; } + + [JsonPropertyName("url")] + public string Url { get; set; } + + [JsonPropertyName("timestamp")] + public string Timestamp { get; set; } + } +} diff --git a/auto-creamapi/Utils/ISecrets.cs b/auto-creamapi/Utils/ISecrets.cs new file mode 100644 index 0000000..01840ac --- /dev/null +++ b/auto-creamapi/Utils/ISecrets.cs @@ -0,0 +1,8 @@ +namespace auto_creamapi.Utils +{ + public interface ISecrets + { + public string ForumUsername(); + public string ForumPassword(); + } +} \ No newline at end of file diff --git a/auto-creamapi/Utils/Misc.cs b/auto-creamapi/Utils/Misc.cs index 2bd85a2..5e0883c 100644 --- a/auto-creamapi/Utils/Misc.cs +++ b/auto-creamapi/Utils/Misc.cs @@ -3,11 +3,11 @@ using System.Collections.ObjectModel; namespace auto_creamapi.Utils { - public class Misc + public static class Misc { - + public const string SpecialCharsRegex = "[^0-9a-zA-Z]+"; public const string DefaultLanguageSelection = "english"; - public static readonly ObservableCollection DefaultLanguages = new ObservableCollection(new[] + public static readonly ObservableCollection DefaultLanguages = new(new[] { "arabic", "bulgarian", diff --git a/auto-creamapi/Utils/MyLogger.cs b/auto-creamapi/Utils/MyLogger.cs index 9e1b19a..903e5a9 100644 --- a/auto-creamapi/Utils/MyLogger.cs +++ b/auto-creamapi/Utils/MyLogger.cs @@ -1,12 +1,14 @@ using Serilog; using Serilog.Core; +using Serilog.Exceptions; namespace auto_creamapi.Utils { - public class MyLogger + public static class MyLogger { public static readonly Logger Log = new LoggerConfiguration() .MinimumLevel.Debug() + .Enrich.WithExceptionDetails() .WriteTo.Console() .WriteTo.File("autocreamapi.log", rollingInterval: RollingInterval.Day) .CreateLogger(); diff --git a/auto-creamapi/Utils/Secrets.EXAMPLE.cs b/auto-creamapi/Utils/Secrets.EXAMPLE.cs deleted file mode 100644 index f885562..0000000 --- a/auto-creamapi/Utils/Secrets.EXAMPLE.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace auto_creamapi.Utils -{ - /// - /// To use this: - /// Rename file Secrets.EXAMPLE.cs to Secrets.cs - /// Rename class Secrets_REMOVETHIS to Secrets - /// Enter the relevant info below - /// - public class Secrets_REMOVETHIS - { - public const string Username = "Enter username here"; - public const string Password = "Enter password here"; - } -} \ No newline at end of file diff --git a/auto-creamapi/ViewModels/DownloadViewModel.cs b/auto-creamapi/ViewModels/DownloadViewModel.cs index ef83b23..238509c 100644 --- a/auto-creamapi/ViewModels/DownloadViewModel.cs +++ b/auto-creamapi/ViewModels/DownloadViewModel.cs @@ -1,8 +1,10 @@ +using System; using System.Threading.Tasks; +using System.Windows; using auto_creamapi.Messenger; using auto_creamapi.Services; using auto_creamapi.Utils; -using MvvmCross.Logging; +using Microsoft.Extensions.Logging; using MvvmCross.Navigation; using MvvmCross.Plugin.Messenger; using MvvmCross.ViewModels; @@ -14,18 +16,22 @@ namespace auto_creamapi.ViewModels private readonly IDownloadCreamApiService _download; private readonly IMvxNavigationService _navigationService; private readonly MvxSubscriptionToken _token; + private readonly ILogger _logger; private string _filename; private string _info; private double _progress; - public DownloadViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService, - IDownloadCreamApiService download, IMvxMessenger messenger) : base(logProvider, navigationService) + private readonly Secrets _secrets = new(); + + public DownloadViewModel(ILoggerFactory loggerFactory, IMvxNavigationService navigationService, + IDownloadCreamApiService download, IMvxMessenger messenger) : base(loggerFactory, navigationService) { _navigationService = navigationService; + _logger = loggerFactory.CreateLogger(); _download = download; _token = messenger.Subscribe(OnProgressMessage); - MyLogger.Log.Debug(messenger.CountSubscriptionsFor().ToString()); + _logger.LogDebug("{Count}", messenger.CountSubscriptionsFor()); } public string InfoLabel @@ -61,25 +67,38 @@ namespace auto_creamapi.ViewModels public string ProgressPercent => _progress.ToString("P2"); - public override async Task Initialize() + public override void Prepare() { - await base.Initialize(); InfoLabel = "Please wait..."; FilenameLabel = ""; Progress = 0.0; - var download = _download.Download(Secrets.ForumUsername, Secrets.ForumPassword); - var filename = await download; - /*var extract = _download.Extract(filename); - await extract;*/ - var extract = Task.Run(() => _download.Extract(filename)); - await extract.ConfigureAwait(false); - _token.Dispose(); - await _navigationService.Close(this); + } + + public override async Task Initialize() + { + try + { + await base.Initialize().ConfigureAwait(false); + var download = _download.Download(_secrets.ForumUsername(), _secrets.ForumPassword()); + var filename = await download.ConfigureAwait(false); + var extract = _download.Extract(filename); + await extract.ConfigureAwait(false); + _token.Dispose(); + await _navigationService.Close(this).ConfigureAwait(false); + } + catch (Exception e) + { + MessageBox.Show("Could not download CreamAPI!\nPlease add CreamAPI DLLs manually!\nShutting down...", + "Error", MessageBoxButton.OK, MessageBoxImage.Error); + _token.Dispose(); + await _navigationService.Close(this).ConfigureAwait(false); + Console.WriteLine(e); + throw; + } } private void OnProgressMessage(ProgressMessage obj) { - //MyLogger.Log.Debug($"{obj.Filename}: {obj.BytesTransferred}"); InfoLabel = obj.Info; FilenameLabel = obj.Filename; Progress = obj.PercentComplete; diff --git a/auto-creamapi/ViewModels/MainViewModel.cs b/auto-creamapi/ViewModels/MainViewModel.cs index b4ee4d9..296164d 100644 --- a/auto-creamapi/ViewModels/MainViewModel.cs +++ b/auto-creamapi/ViewModels/MainViewModel.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using auto_creamapi.Models; using auto_creamapi.Services; using auto_creamapi.Utils; +using Microsoft.Extensions.Logging; using Microsoft.Win32; using MvvmCross.Commands; using MvvmCross.Navigation; @@ -19,6 +20,7 @@ namespace auto_creamapi.ViewModels private readonly ICacheService _cache; private readonly ICreamConfigService _config; + private readonly ILogger _logger; private readonly ICreamDllService _dll; private readonly IMvxNavigationService _navigationService; private int _appId; @@ -26,7 +28,7 @@ namespace auto_creamapi.ViewModels private ObservableCollection _dlcs; private bool _dllApplied; private string _dllPath; - private bool _extraprotection; + private bool _extraProtection; private string _gameName; private string _lang; private ObservableCollection _languages; @@ -35,29 +37,34 @@ namespace auto_creamapi.ViewModels private bool _mainWindowEnabled; private bool _offline; private string _status; - private bool _unlockall; + private bool _unlockAll; private bool _useSteamDb; + + private bool _ignoreUnknown; //private const string DlcRegexPattern = @"(?.*) *= *(?.*)"; public MainViewModel(ICacheService cache, ICreamConfigService config, ICreamDllService dll, - IMvxNavigationService navigationService) + IMvxNavigationService navigationService, ILoggerFactory loggerFactory) { _navigationService = navigationService; + _logger = loggerFactory.CreateLogger(); _cache = cache; _config = config; _dll = dll; //_download = download; } - public override async Task Initialize() + public override async void Prepare() { + base.Prepare(); _config.Initialize(); - var tasks = new List {base.Initialize(), _cache.Initialize()}; + var tasks = new List { _cache.Initialize() }; if (!File.Exists("steam_api.dll") | !File.Exists("steam_api64.dll")) tasks.Add(_navigationService.Navigate()); + //tasks.Add(_navigationService.Navigate()); tasks.Add(_dll.Initialize()); - await Task.WhenAll(tasks); + await Task.WhenAll(tasks).ConfigureAwait(false); Languages = new ObservableCollection(Misc.DefaultLanguages); ResetForm(); UseSteamDb = true; @@ -67,9 +74,9 @@ namespace auto_creamapi.ViewModels // // COMMANDS // // - public IMvxCommand OpenFileCommand => new MvxCommand(OpenFile); + public IMvxCommand OpenFileCommand => new MvxAsyncCommand(OpenFile); - public IMvxCommand SearchCommand => new MvxAsyncCommand(async () => await Search()); //Command(Search); + public IMvxCommand SearchCommand => new MvxAsyncCommand(async () => await Search().ConfigureAwait(false)); //Command(Search); public IMvxCommand GetListOfDlcCommand => new MvxAsyncCommand(GetListOfDlc); @@ -79,6 +86,8 @@ namespace auto_creamapi.ViewModels public IMvxCommand GoToForumThreadCommand => new MvxCommand(GoToForumThread); + public IMvxCommand GoToSteamdbCommand => new MvxCommand(GoToSteamdb); + // // ATTRIBUTES // // public bool MainWindowEnabled @@ -108,7 +117,6 @@ namespace auto_creamapi.ViewModels { _gameName = value; RaisePropertyChanged(() => GameName); - //MyLogger.Log.Debug($"GameName: {value}"); } } @@ -130,7 +138,6 @@ namespace auto_creamapi.ViewModels { _lang = value; RaisePropertyChanged(() => Lang); - //MyLogger.Log.Debug($"Lang: {value}"); } } @@ -144,23 +151,23 @@ namespace auto_creamapi.ViewModels } } - public bool Extraprotection + public bool ExtraProtection { - get => _extraprotection; + get => _extraProtection; set { - _extraprotection = value; - RaisePropertyChanged(() => Extraprotection); + _extraProtection = value; + RaisePropertyChanged(() => ExtraProtection); } } - public bool Unlockall + public bool UnlockAll { - get => _unlockall; + get => _unlockAll; set { - _unlockall = value; - RaisePropertyChanged(() => Unlockall); + _unlockAll = value; + RaisePropertyChanged(() => UnlockAll); } } @@ -224,7 +231,17 @@ namespace auto_creamapi.ViewModels } } - private void OpenFile() + public bool IgnoreUnknown + { + get => _ignoreUnknown; + set + { + _ignoreUnknown = value; + RaisePropertyChanged(() => IgnoreUnknown); + } + } + + private async Task OpenFile() { Status = "Waiting for file..."; var dialog = new OpenFileDialog @@ -245,7 +262,22 @@ namespace auto_creamapi.ViewModels ResetForm(); _dll.TargetPath = dirPath; _dll.CheckIfDllExistsAtTarget(); - CheckExistence(); + CheckSetupStatus(); + if (!ConfigExists) + { + var separator = Path.DirectorySeparatorChar; + var strings = new List(dirPath.Split(separator)); + var index = strings.Contains("common") ? strings.FindIndex(x => x.Equals("common")) + 1 : -1; + if (index == -1) + index = strings.Contains("steamapps") + ? strings.FindIndex(x => x.Equals("steamapps")) + 2 + : -1; + var s = index > -1 ? strings[index] : null; + if (s != null) GameName = s; + await Search().ConfigureAwait(false); + // await GetListOfDlc().ConfigureAwait(false); + } + Status = "Ready."; } } @@ -267,9 +299,10 @@ namespace auto_creamapi.ViewModels } else { + MainWindowEnabled = false; var navigate = _navigationService.Navigate, SteamApp>( _cache.GetListOfAppsByName(GameName)); - await navigate; + await navigate.ConfigureAwait(false); var navigateResult = navigate.Result; if (navigateResult != null) { @@ -277,27 +310,31 @@ namespace auto_creamapi.ViewModels AppId = navigateResult.AppId; } } + + // await GetListOfDlc().ConfigureAwait(false); } else { - MyLogger.Log.Warning("Empty game name, cannot initiate search!"); + _logger.LogWarning("Empty game name, cannot initiate search!"); } + + MainWindowEnabled = true; } private async Task GetListOfDlc() { - Status = "Trying to get DLC..."; + Status = "Trying to get DLC, please wait..."; if (AppId > 0) { - var app = new SteamApp {AppId = AppId, Name = GameName}; - var task = _cache.GetListOfDlc(app, UseSteamDb); + var app = new SteamApp { AppId = AppId, Name = GameName }; + var task = _cache.GetListOfDlc(app, UseSteamDb, IgnoreUnknown); MainWindowEnabled = false; - var listOfDlc = await task; + var listOfDlc = await task.ConfigureAwait(false); if (task.IsCompletedSuccessfully) { listOfDlc.Sort((app1, app2) => app1.AppId.CompareTo(app2.AppId)); Dlcs = new ObservableCollection(listOfDlc); - Status = $"Got DLC for AppID {AppId}"; + Status = $"Got DLC for AppID {AppId} (Count: {Dlcs.Count})"; } else { @@ -309,7 +346,7 @@ namespace auto_creamapi.ViewModels else { Status = $"Could not get DLC for AppID {AppId}"; - MyLogger.Log.Error($"GetListOfDlc: Invalid AppID {AppId}"); + _logger.LogError("GetListOfDlc: Invalid AppID {AppId}", AppId); } } @@ -319,14 +356,14 @@ namespace auto_creamapi.ViewModels _config.SetConfigData( AppId, Lang, - Unlockall, - Extraprotection, + UnlockAll, + ExtraProtection, Offline, Dlcs ); _config.SaveFile(); _dll.Save(); - CheckExistence(); + CheckSetupStatus(); Status = "Saving successful."; } @@ -334,8 +371,8 @@ namespace auto_creamapi.ViewModels { AppId = _config.Config.AppId; Lang = _config.Config.Language; - Unlockall = _config.Config.UnlockAll; - Extraprotection = _config.Config.ExtraProtection; + UnlockAll = _config.Config.UnlockAll; + ExtraProtection = _config.Config.ExtraProtection; Offline = _config.Config.ForceOffline; Dlcs = new ObservableCollection(_config.Config.DlcList); Status = "Changes have been reset."; @@ -348,9 +385,7 @@ namespace auto_creamapi.ViewModels { var searchTerm = AppId; //$"{GameName.Replace(" ", "+")}+{appId}"; var destinationUrl = - "https://cs.rin.ru/forum/search.php?keywords=" + - searchTerm + - "&terms=any&fid[]=10&sf=firstpost&sr=topics&submit=Search"; + $"https://cs.rin.ru/forum/search.php?keywords={searchTerm}&terms=any&fid[]=10&sf=firstpost&sr=topics&submit=Search"; var uri = new Uri(destinationUrl); var process = new ProcessStartInfo(uri.AbsoluteUri) { @@ -360,12 +395,34 @@ namespace auto_creamapi.ViewModels } else { - MyLogger.Log.Error($"OpenURL: Invalid AppID {AppId}"); + _logger.LogError("OpenURL: Invalid AppID {AppId}", AppId); Status = $"Could not open URL: Invalid AppID {AppId}"; } } - private void CheckExistence() + private void GoToSteamdb() + { + Status = "Opening URL..."; + if (AppId > 0) + { + var searchTerm = AppId; //$"{GameName.Replace(" ", "+")}+{appId}"; + var destinationUrl = + $"https://steamdb.info/app/{searchTerm}/dlc/"; + var uri = new Uri(destinationUrl); + var process = new ProcessStartInfo(uri.AbsoluteUri) + { + UseShellExecute = true + }; + Process.Start(process); + } + else + { + _logger.LogError("OpenURL: Invalid AppID {AppId}", AppId); + Status = $"Could not open URL: Invalid AppID {AppId}"; + } + } + + private void CheckSetupStatus() { DllApplied = _dll.CreamApiApplied(); ConfigExists = _config.ConfigExists(); diff --git a/auto-creamapi/ViewModels/SearchResultViewModel.cs b/auto-creamapi/ViewModels/SearchResultViewModel.cs index 8e26c1f..82e401c 100644 --- a/auto-creamapi/ViewModels/SearchResultViewModel.cs +++ b/auto-creamapi/ViewModels/SearchResultViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using auto_creamapi.Models; using auto_creamapi.Utils; +using Microsoft.Extensions.Logging; using MvvmCross.Commands; using MvvmCross.Logging; using MvvmCross.Navigation; @@ -13,16 +14,18 @@ namespace auto_creamapi.ViewModels IMvxViewModel, SteamApp> { private readonly IMvxNavigationService _navigationService; + private readonly ILogger _logger; private IEnumerable _steamApps; /*public override async Task Initialize() { await base.Initialize(); }*/ - public SearchResultViewModel(IMvxLogProvider logProvider, IMvxNavigationService navigationService) : base( - logProvider, navigationService) + public SearchResultViewModel(ILoggerFactory loggerFactory, IMvxNavigationService navigationService) : base( + loggerFactory, navigationService) { _navigationService = navigationService; + _logger = loggerFactory.CreateLogger(); } public IEnumerable Apps @@ -55,9 +58,11 @@ namespace auto_creamapi.ViewModels public override void ViewDestroy(bool viewFinishing = true) { - if (viewFinishing && CloseCompletionSource != null && !CloseCompletionSource.Task.IsCompleted && + if (viewFinishing && CloseCompletionSource?.Task.IsCompleted == false && !CloseCompletionSource.Task.IsFaulted) + { CloseCompletionSource?.TrySetCanceled(); + } base.ViewDestroy(viewFinishing); } @@ -66,8 +71,8 @@ namespace auto_creamapi.ViewModels { if (Selected != null) { - MyLogger.Log.Information($"Successfully got app {Selected}"); - await _navigationService.Close(this, Selected); + _logger.LogInformation("Successfully got app {Selected}", Selected); + await _navigationService.Close(this, Selected).ConfigureAwait(false); } } diff --git a/auto-creamapi/Views/MainView.xaml b/auto-creamapi/Views/MainView.xaml index 7324e3d..719893e 100644 --- a/auto-creamapi/Views/MainView.xaml +++ b/auto-creamapi/Views/MainView.xaml @@ -1,3 +1,4 @@ + + mc:Ignorable="d" + d:DesignHeight="720" d:DesignWidth="560"> @@ -25,36 +27,54 @@ - - +