diff --git a/AcManager.Controls/AcManager.Controls.csproj b/AcManager.Controls/AcManager.Controls.csproj index 1f185edba..549b4de5a 100644 --- a/AcManager.Controls/AcManager.Controls.csproj +++ b/AcManager.Controls/AcManager.Controls.csproj @@ -260,6 +260,12 @@ + + WorkshopAuthorized.xaml + + + WorkshopCurrentUserBlock.xaml + ModernPopup.xaml @@ -405,6 +411,7 @@ MSBuild:Compile Designer + MSBuild:Compile Designer @@ -556,6 +563,8 @@ Designer MSBuild:Compile + + @@ -580,6 +589,10 @@ AcManager.Tools False + + {abcd8f3f-5730-4fac-86ba-8a28086b75f8} + AcManager.Workshop + {38A04388-FC0C-4E96-BED5-1DC5C6C0B18F} AcTools.LapTimes @@ -612,6 +625,9 @@ + + + diff --git a/AcManager.Controls/Assets/IconData.xaml b/AcManager.Controls/Assets/IconData.xaml index e9d365d02..adb83b551 100644 --- a/AcManager.Controls/Assets/IconData.xaml +++ b/AcManager.Controls/Assets/IconData.xaml @@ -2,6 +2,9 @@ + F1 M 26.9204,19.0027L 28.504,19.0027C 29.3785,19.0027 30.0875,19.7116 30.0875,20.5862L 30.0875,55.4244C 30.0875,56.299 29.3785,57.008 28.504,57.008L 26.9204,57.008C 26.0459,57.008 25.3369,56.299 25.3369,55.4244L 25.3369,20.5862C 25.3369,19.7116 26.0459,19.0027 26.9204,19.0027 Z M 31.6711,23.7533C 33.2546,22.6976 34.8382,21.6419 37.4775,21.9059C 40.1167,22.1698 43.8117,23.7535 46.7148,23.7535C 49.618,23.7535 51.7294,22.1698 53.8408,20.5862L 53.8408,34.838C 51.7294,36.4216 49.618,38.0052 46.7148,38.0052C 43.8117,38.0052 40.1167,36.4216 37.4775,36.1576C 34.8382,35.8937 33.2546,36.9494 31.6711,38.0051L 31.6711,23.7533 Z + F1 M 29.9796,22.2C 33.4365,22.2 36.2389,25.0024 36.2389,28.4593C 36.2389,31.9162 33.4365,34.7185 29.9796,34.7185C 26.5228,34.7185 23.7204,31.9162 23.7204,28.4593C 23.7204,25.0024 26.5228,22.2 29.9796,22.2 Z M 23.7204,43.2315C 22.8858,43.6488 21.2167,46.987 21.2167,46.987C 21.2167,46.987 20.3821,48.2388 19.9648,53.2462L 16.2093,51.9944L 17.4611,45.7352C 17.4611,45.7352 19.9648,36.9722 24.9722,36.9723L 34.9869,36.9723C 39.9944,36.9722 42.4981,45.7352 42.4981,45.7352L 43.75,51.9945L 39.9944,53.2463C 39.5772,48.2389 38.7427,46.987 38.7427,46.987C 38.7427,46.987 37.0735,43.6488 36.2389,43.2315L 36.7447,54.6004C 34.8417,55.3427 32.7712,55.75 30.6056,55.75C 27.9657,55.75 25.4672,55.1447 23.2413,54.0654L 23.7204,43.2315 Z M 39,24L 57,24L 57,27L 39,27L 39,24 Z M 39,30L 57,30L 57,33L 39,33L 39,30 Z M 41,36L 57,36L 57,39L 41,39L 41,36 Z M 44,42L 57,42L 57,45L 44,45L 44,42 Z M 46,48L 57,48L 57,51L 46,51L 46,48 Z + F1 M 49.0833,33.25C 53.4555,33.25 57,36.7944 57,41.1666C 57,45.5389 53.3723,48.9999 49,49L 23,49.0001C 20.8139,49 19,47.3111 19,45.125C 19,43.1866 20.3931,41.5737 22.2327,41.233C 22.1892,40.9533 22.1667,40.6667 22.1667,40.3749C 22.1667,37.3993 24.5122,34.9712 27.4553,34.8389C 28.7579,31.1462 32.2782,28.4999 36.4167,28.4999C 40.3458,28.4999 43.7179,30.8853 45.1637,34.2868C 46.3193,33.627 47.6573,33.25 49.0833,33.25 Z F1 M 39,46L 46,46L 46,39L 51,39L 51,46L 58,46L 58,51L 51,51L 51,58L 46,58L 46,51L 39,51L 39,46 Z M 31,25L 38,25L 38,18L 43,18L 43,25L 50,25L 50,30L 43,30L 43,37L 38,37L 38,30L 31,30L 31,25 Z M 18,39L 25,39L 25,32L 30,32L 30,39L 37,39L 37,44L 30,44L 30,51L 25,51L 25,44L 18,44L 18,39 Z F1 M 58.5833,45.9167L 58.5833,57L 19,57L 19,45.9167L 28.8998,45.9167C 31.0306,47.2793 33.6795,48.4476 36.6434,49.2418C 40.3467,50.2341 44.7079,50.4746 47.6249,50.0692L 47.6249,45.9167L 58.5833,45.9167 Z M 56.2083,48.2917L 53.4374,48.2917L 53.4374,53.0417L 56.2082,53.0417L 56.2083,48.2917 Z M 45.7433,48.2824C 43.1578,48.6418 39.9938,48.0327 36.7113,47.1532C 31.1866,45.6728 26.8965,42.7258 25.5163,39.7952L 32.2902,33.0213L 45.7433,38.0663L 45.7433,48.2824 Z M 39.2905,28.1719C 39.6351,28.1719 39.9737,28.1941 40.3047,28.2368L 46.0048,17.0498L 49.0712,18.6122L 43.5443,29.4594C 45.1454,30.5622 46.1735,32.2733 46.1735,34.1946C 46.1735,35.0197 45.9839,35.806 45.6407,36.5221L 33.4151,31.7762C 34.4796,29.6541 36.4722,28.1719 39.2905,28.1719 Z F1 M 57,42L 57,34L 32.25,34L 42.25,24L 31.75,24L 17.75,38L 31.75,52L 42.25,52L 32.25,42L 57,42 Z diff --git a/AcManager.Controls/Assets/ModernUI.Default.xaml b/AcManager.Controls/Assets/ModernUI.Default.xaml index a32a37362..03d5b0261 100644 --- a/AcManager.Controls/Assets/ModernUI.Default.xaml +++ b/AcManager.Controls/Assets/ModernUI.Default.xaml @@ -42,7 +42,7 @@ - + diff --git a/AcManager.Controls/Assets/WorkshopResources.xaml b/AcManager.Controls/Assets/WorkshopResources.xaml new file mode 100644 index 000000000..976070578 --- /dev/null +++ b/AcManager.Controls/Assets/WorkshopResources.xaml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AcManager.Controls/Helpers/SharingUiHelper.cs b/AcManager.Controls/Helpers/SharingUiHelper.cs index 24c3bd7ea..159d8f426 100644 --- a/AcManager.Controls/Helpers/SharingUiHelper.cs +++ b/AcManager.Controls/Helpers/SharingUiHelper.cs @@ -106,25 +106,39 @@ public static void ShowShared(string type, string link, bool showDialog) { if (_custom?.ShowShared(type, link) == true) return; if (SettingsHolder.Sharing.CopyLinkToClipboard) { - ClipboardHelper.SetText(link); + try { + ClipboardHelper.SetText(link); + } catch (Exception e) { + Logging.Warning(e); + } } if (showDialog && SettingsHolder.Sharing.ShowSharedDialog) { - var copied = SettingsHolder.Sharing.CopyLinkToClipboard && Clipboard.GetText() == link; + var copied = false; + try { + copied = SettingsHolder.Sharing.CopyLinkToClipboard && Clipboard.GetText() == link; + } catch (Exception e) { + Logging.Warning(e); + } + + var buttons = copied ? new MessageDialogButton(MessageBoxButton.OKCancel) { + [MessageBoxResult.OK] = "Open link in browser", + [MessageBoxResult.Cancel] = "Close" + } : new MessageDialogButton(MessageBoxButton.YesNoCancel) { + [MessageBoxResult.Yes] = "Copy link to the clipboard", + [MessageBoxResult.No] = "Open link in browser", + [MessageBoxResult.Cancel] = "Close" + }; + var response = MessageDialog.Show(copied ? $"Link has been copied to the clipboard:[br]{link}" - : $"Here is the link:[br]{link}", type, new MessageDialogButton(copied ? MessageBoxButton.YesNoCancel : MessageBoxButton.OKCancel) { - [MessageBoxResult.Yes] = "Copy link to the clipboard", - [MessageBoxResult.No] = "Open link in browser", - [MessageBoxResult.OK] = "Open link in browser", - [MessageBoxResult.Cancel] = "Close" - }); - - if (response == MessageBoxResult.OK) { + : $"Here is the link:[br]{link}", type, buttons); + + if (response == MessageBoxResult.OK || response == MessageBoxResult.Yes) { ClipboardHelper.SetText(link); } - if (response == MessageBoxResult.Yes) { + if (response == MessageBoxResult.No) { WindowsHelper.ViewInBrowser(link + "#noauto"); } } else { diff --git a/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml b/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml new file mode 100644 index 000000000..86e89d2b0 --- /dev/null +++ b/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml.cs b/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml.cs new file mode 100644 index 000000000..110b8d208 --- /dev/null +++ b/AcManager.Controls/UserControls/Workshop/WorkshopAuthorized.xaml.cs @@ -0,0 +1,7 @@ +namespace AcManager.Controls.UserControls.Workshop { + public partial class WorkshopAuthorized { + public WorkshopAuthorized() { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml b/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml new file mode 100644 index 000000000..5a2690fe2 --- /dev/null +++ b/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml.cs b/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml.cs new file mode 100644 index 000000000..79a4f7ac8 --- /dev/null +++ b/AcManager.Controls/UserControls/Workshop/WorkshopCurrentUserBlock.xaml.cs @@ -0,0 +1,9 @@ +using System.Windows.Controls; + +namespace AcManager.Controls.UserControls.Workshop { + public partial class WorkshopCurrentUserBlock : UserControl { + public WorkshopCurrentUserBlock() { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/AcManager.Tools/AcObjectsNew/AcCommonObject.Pack.cs b/AcManager.Tools/AcObjectsNew/AcCommonObject.Pack.cs index 697fa1218..e2cb5326a 100644 --- a/AcManager.Tools/AcObjectsNew/AcCommonObject.Pack.cs +++ b/AcManager.Tools/AcObjectsNew/AcCommonObject.Pack.cs @@ -35,6 +35,8 @@ public class AcCommonObjectPackerParams { [CanBeNull] public string Destination { get; set; } + public Func> Override { get; set; } + public bool ShowInExplorer { get; set; } = true; [CanBeNull] @@ -91,7 +93,13 @@ private bool Write(string name, Action fn) { if (_added.Contains(lower)) return false; _added.Add(lower); _progress?.Report(key); - fn?.Invoke(_writer, key); + + var overrideFn = _packerParams?.Override?.Invoke(key); + if (overrideFn != null) { + overrideFn.Invoke(_writer, key); + } else { + fn?.Invoke(_writer, key); + } return true; } @@ -112,7 +120,7 @@ private IEnumerable GetFiles(string mask) { if (_subFiles == null) { _subFiles = Directory.GetFiles(location, "*", SearchOption.AllDirectories) - .Select(x => FileUtils.GetRelativePath(x, location).Replace('\\', '/')).ToArray(); + .Select(x => FileUtils.GetRelativePath(x, location).Replace('\\', '/')).ToArray(); } var f = RegexFromQuery.Create(mask.Replace('\\', '/'), StringMatchMode.CompleteMatch); diff --git a/AcManager.Tools/GameProperties/WorldSimSeriesMark.cs b/AcManager.Tools/GameProperties/WorldSimSeriesMark.cs index 70089f737..4c4c0a4f1 100644 --- a/AcManager.Tools/GameProperties/WorldSimSeriesMark.cs +++ b/AcManager.Tools/GameProperties/WorldSimSeriesMark.cs @@ -4,14 +4,28 @@ namespace AcManager.Tools.GameProperties { public class WorldSimSeriesMark : Game.RaceIniProperties { - public string Name; + public string Name { get; set; } + public string Nationality { get; set; } + public string NationCode { get; set; } + public string Team { get; set; } public override void Set(IniFile file) { - file["REMOTE"].Set("NAME", Name); - file["REMOTE"].Set("GUID", SteamIdHelper.Instance.Value ?? ""); - file["CAR_0"].Set("DRIVER_NAME", Name); - file["CAR_0"].Set("SETUP", ""); - file["CAR_0"].Remove(@"AI_LEVEL"); + if (Name != null) { + file["REMOTE"].Set("NAME", Name); + file["REMOTE"].Set("GUID", SteamIdHelper.Instance.Value ?? ""); + file["CAR_0"].Set("DRIVER_NAME", Name); + file["CAR_0"].Set("SETUP", ""); + file["CAR_0"].Remove(@"AI_LEVEL"); + } + + if (Nationality != null) { + file["CAR_0"].Set("NATIONALITY", Nationality); + file["CAR_0"].Set("NATION_CODE", NationCode ?? NationCodeProvider.Instance.GetNationCode(Nationality)); + } + + if (Team != null) { + file["REMOTE"].Set("TEAM", Team); + } } } } \ No newline at end of file diff --git a/AcManager.Tools/Helpers/AcLog/WhatsGoingOn.cs b/AcManager.Tools/Helpers/AcLog/WhatsGoingOn.cs index db7521a75..ac92b674b 100644 --- a/AcManager.Tools/Helpers/AcLog/WhatsGoingOn.cs +++ b/AcManager.Tools/Helpers/AcLog/WhatsGoingOn.cs @@ -46,7 +46,7 @@ public string GetDescription() { private LazierThis _solution; public NonfatalErrorSolution Solution => _solution.Get(() => Fix == null ? null : - new NonfatalErrorSolution(FixDisplayName, null, token => { + new NonfatalErrorSolution(FixDisplayName, token => { if (FixAffectingDataOriginalLog != null) { var carIds = GetCarsIds(FixAffectingDataOriginalLog).ToList(); if (!DataUpdateWarning.Warn(carIds.Select(CarsManager.Instance.GetById))) { diff --git a/AcManager.Tools/Helpers/Api/HttpClientHolder.cs b/AcManager.Tools/Helpers/Api/HttpClientHolder.cs index 35fb2f648..2d1722585 100644 --- a/AcManager.Tools/Helpers/Api/HttpClientHolder.cs +++ b/AcManager.Tools/Helpers/Api/HttpClientHolder.cs @@ -4,8 +4,9 @@ using AcManager.Internal; namespace AcManager.Tools.Helpers.Api { - public class HttpClientHolder { + public static class HttpClientHolder { private static HttpClient _httpClient; + public static HttpClient Get() { if (_httpClient == null) { var handler = new HttpClientHandler { diff --git a/AcManager.Tools/Helpers/Api/SteamWebProvider.cs b/AcManager.Tools/Helpers/Api/SteamWebProvider.cs index 72f912d0e..fcbf6f43a 100644 --- a/AcManager.Tools/Helpers/Api/SteamWebProvider.cs +++ b/AcManager.Tools/Helpers/Api/SteamWebProvider.cs @@ -15,7 +15,7 @@ public static class SteamWebProvider { [Localizable(false), NotNull] public static string[] GetAchievments(string appId, string steamId) { var requestUri = string.Format(RequestStatsUri, appId, steamId, InternalUtils.GetSteamApiCode()); - + var httpRequest = WebRequest.Create(requestUri); httpRequest.Method = "GET"; @@ -62,7 +62,7 @@ public static string[] TryToGetAchievments(string appId, string steamId) { } } - private const string RequestPlayerSummariesUri = "http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={1}&steamids={0}"; + private const string RequestPlayerSummariesUri = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={1}&steamids={0}"; [Localizable(false), CanBeNull] public static string TryToGetUserName(string steamId) { diff --git a/AcManager.Tools/Helpers/FaultTolerantHeapFix.cs b/AcManager.Tools/Helpers/FaultTolerantHeapFix.cs index 8523b5fd3..8f22023ab 100644 --- a/AcManager.Tools/Helpers/FaultTolerantHeapFix.cs +++ b/AcManager.Tools/Helpers/FaultTolerantHeapFix.cs @@ -17,7 +17,7 @@ private static IEnumerable GetRelatedValues(RegistryKey stateKey) { return stateKey.GetValueNames().Where(name => string.Equals(Path.GetFileName(name), @"acs.exe", StringComparison.OrdinalIgnoreCase)); } - private static bool Check() { + public static bool Check() { try { using (var localMachineKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32)) @@ -62,10 +62,15 @@ private static async Task FixAsync() { } } - if (await patch.ApplyAsync("Potential performance issue detected", + await patch.ApplyAsync("Potential performance issue detected", "[url=\"https://docs.microsoft.com/en-us/windows/win32/win7appqual/fault-tolerant-heap\"]Fault Tolerant Heap[/url] might be considerably slowing down loading process of Assetto Corsa, but to disable it, a few values in Windows Registry have to be changed, and that would require administrator privilegies. Would you like for Content Manager to disable it automatically, or just prepare a .reg-file for you to inspect and import manually?[br][br]You might need to restart your Windows for changes to apply.", - "FixFTH.reg")) { - await ResetService(); + "FixFTH.reg"); + for (var i = 0; i < 120; ++i) { + await Task.Delay(TimeSpan.FromSeconds(1d)); + if (!Check()) { + await ResetService(); + break; + } } } @@ -81,13 +86,15 @@ private static async Task ResetService() { var filename = FilesStorage.Instance.GetTemporaryFilename("RunElevated", "FixFTH.bat"); File.WriteAllText(filename, @"@echo off +:: More info: https://docs.microsoft.com/en-us/windows/win32/win7appqual/fault-tolerant-heap echo Running rundll32.exe fthsvc.dll,FthSysprepSpecialize... -%windir%\\system32\\rundll32.exe fthsvc.dll,FthSysprepSpecialize +cd %windir%\system32 +%windir%\system32\rundll32.exe fthsvc.dll,FthSysprepSpecialize echo Done pause"); if (secondResponse == MessageBoxResult.Yes) { - var procRunDll32 = ProcessExtension.Start(filename, new string[0], new ProcessStartInfo { Verb = "runas" }); + var procRunDll32 = ProcessExtension.Start("explorer.exe", new[] { filename }, new ProcessStartInfo { Verb = "runas" }); await procRunDll32.WaitForExitAsync().ConfigureAwait(false); Logging.Debug("Done: " + procRunDll32.ExitCode); } else if (secondResponse == MessageBoxResult.No) { @@ -95,8 +102,9 @@ echo Done } } - public static async Task CheckAsync() { + public static async Task CheckAndFixAsync() { if (Check()) { + ValuesStorage.Set(".fth.shown", true); try { await FixAsync(); } catch (Exception e) { diff --git a/AcManager.Tools/Helpers/Loaders/DirectLoader.cs b/AcManager.Tools/Helpers/Loaders/DirectLoader.cs index 10a6882f8..c16430c70 100644 --- a/AcManager.Tools/Helpers/Loaders/DirectLoader.cs +++ b/AcManager.Tools/Helpers/Loaders/DirectLoader.cs @@ -47,8 +47,8 @@ public virtual Task PrepareAsync(CookieAwareWebClient client, Cancellation public Task DownloadAsync(CookieAwareWebClient client, FlexibleLoaderGetPreferredDestinationCallback getPreferredDestination, - FlexibleLoaderReportDestinationCallback reportDestination, Func checkIfPaused, - IProgress progress, CancellationToken cancellation) { + FlexibleLoaderReportDestinationCallback reportDestination = null, Func checkIfPaused = null, + IProgress progress = null, CancellationToken cancellation = default) { return DownloadAsyncInner(client, getPreferredDestination, reportDestination, checkIfPaused, progress, cancellation); } diff --git a/AcManager.Tools/Helpers/RegistryPatch.cs b/AcManager.Tools/Helpers/RegistryPatch.cs index 942755f27..e48205f09 100644 --- a/AcManager.Tools/Helpers/RegistryPatch.cs +++ b/AcManager.Tools/Helpers/RegistryPatch.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.IO; using System.Text; using System.Threading.Tasks; @@ -39,7 +38,7 @@ private static string ToRegValue(object v) { void AddValue(int b) { data.Append($@"{b:x2},"); if (--commasLeft == 0) { - data.Append("\\\n "); + data.Append("\\\r\n "); commasLeft = 25; } } @@ -88,7 +87,7 @@ public async Task ApplyAsync(string title, string message, string fileName foreach (var p in pair.Value) { var commentValue = ToCommentValue(p.Value); if (commentValue != null) { - data.Append("; ").Append(commentValue).Append(Environment.NewLine); + data.Append("; ").Append(commentValue.Replace("\n", "\n; ")).Append(Environment.NewLine); } // ReSharper disable once MethodHasAsyncOverload @@ -106,7 +105,7 @@ public async Task ApplyAsync(string title, string message, string fileName } try { - var procRegEdit = ProcessExtension.Start("regedit.exe", new[] { filename }, new ProcessStartInfo { Verb = "runas" }); + var procRegEdit = ProcessExtension.Start("explorer.exe", new[] { filename }); await procRegEdit.WaitForExitAsync().ConfigureAwait(false); Logging.Debug("Done: " + procRegEdit.ExitCode); return procRegEdit.ExitCode == 0; diff --git a/AcManager.Tools/Helpers/SettingsHolder.Drive.cs b/AcManager.Tools/Helpers/SettingsHolder.Drive.cs index c6496354c..c776b1ade 100644 --- a/AcManager.Tools/Helpers/SettingsHolder.Drive.cs +++ b/AcManager.Tools/Helpers/SettingsHolder.Drive.cs @@ -683,6 +683,19 @@ public bool QuickDriveAllowCustomData { } } + private bool? _quickDriveAllowExtendedPhysics; + + public bool QuickDriveAllowExtendedPhysics { + get => _quickDriveAllowExtendedPhysics + ?? (_quickDriveAllowExtendedPhysics = ValuesStorage.Get("Settings.DriveSettings.QuickDriveAllowExtendedPhysics", false)).Value; + set { + if (Equals(value, _quickDriveAllowExtendedPhysics)) return; + _quickDriveAllowExtendedPhysics = value; + ValuesStorage.Set("Settings.DriveSettings.QuickDriveAllowExtendedPhysics", value); + OnPropertyChanged(); + } + } + private bool? _quickDriveExpandBounds; public bool QuickDriveExpandBounds { @@ -816,6 +829,22 @@ public bool QuickSwitches { } } + private bool? _quickSwitchesRightMouseButton; + + public bool QuickSwitchesRightMouseButton { + get + => + _quickSwitchesRightMouseButton + ?? (_quickSwitchesRightMouseButton = ValuesStorage.Get("Settings.DriveSettings.QuickSwitchesRightMouseButton", true)) + .Value; + set { + if (Equals(value, _quickSwitchesRightMouseButton)) return; + _quickSwitchesRightMouseButton = value; + ValuesStorage.Set("Settings.DriveSettings.QuickSwitchesRightMouseButton", value); + OnPropertyChanged(); + } + } + private string[] _quickSwitchesList; public string[] QuickSwitchesList { @@ -1011,7 +1040,8 @@ public DelayEntry RhmKeepAlivePeriod { private BeepingNoiseType? _crashBeepingNoise; public BeepingNoiseType CrashBeepingNoise { - get => _crashBeepingNoise ?? (_crashBeepingNoise = ValuesStorage.Get("Settings.DriveSettings.CrashBeepingNoise", BeepingNoiseType.System)).Value; + get => _crashBeepingNoise ?? (_crashBeepingNoise = ValuesStorage.Get("Settings.DriveSettings.CrashBeepingNoise", BeepingNoiseType.System)).Value + ; set { if (Equals(value, _crashBeepingNoise)) return; _crashBeepingNoise = value; diff --git a/AcManager.Tools/Helpers/SteamIdHelper.cs b/AcManager.Tools/Helpers/SteamIdHelper.cs index 8a51a3c22..695e02e59 100644 --- a/AcManager.Tools/Helpers/SteamIdHelper.cs +++ b/AcManager.Tools/Helpers/SteamIdHelper.cs @@ -133,9 +133,12 @@ public static bool IsValidSteamId(string id) { return id != null && Regex.IsMatch(id.Trim(), @"\d{10,30}"); } + private static Dictionary _steamNameCache = new Dictionary(); + [ItemCanBeNull] - public static async Task GetSteamName(string id) { - return IsValidSteamId(id) ? await Task.Run(() => SteamWebProvider.TryToGetUserName(id)) : null; + public static async Task GetSteamNameAsync(string id) { + if (_steamNameCache.TryGetValue(id, out var name)) return name; + return _steamNameCache[id] = IsValidSteamId(id) ? await Task.Run(() => SteamWebProvider.TryToGetUserName(id)) : null; } } diff --git a/AcManager.Tools/Helpers/VisualCppTool.cs b/AcManager.Tools/Helpers/VisualCppTool.cs index 1b0c2c89a..695564b53 100644 --- a/AcManager.Tools/Helpers/VisualCppTool.cs +++ b/AcManager.Tools/Helpers/VisualCppTool.cs @@ -131,7 +131,7 @@ private static bool IsVisualCppInstalled() { private static void ShowMessage(Exception e) { NonfatalError.Notify("Looks like app can’t load native library", "Visual C++ Redistributable might be missing or damaged. Would you like to install the package prepared specially for CM?", e, new[] { - new NonfatalErrorSolution("Download and install", null, DownloadAndInstall, "DownloadIconData"), + new NonfatalErrorSolution("Download and install", DownloadAndInstall, "DownloadIconData"), }); } @@ -170,7 +170,7 @@ private static void ManualInstallation([CanBeNull] Exception e, string directory NonfatalError.Notify("Can’t download the package", $"You can try to [url={BbCodeBlock.EncodeAttribute(downloadUrl)}]download[/url] and install it manually. Don’t forget to restart CM after it was installed.", e, new[] { - new NonfatalErrorSolution("Open destination folder", null, t => { + new NonfatalErrorSolution("Open destination folder", t => { WindowsHelper.ViewDirectory(directory); return Task.Delay(0); }, "FolderIconData"), diff --git a/AcManager.Tools/Managers/Presets/PresetsManager.cs b/AcManager.Tools/Managers/Presets/PresetsManager.cs index 0926b98c7..5f09529ad 100644 --- a/AcManager.Tools/Managers/Presets/PresetsManager.cs +++ b/AcManager.Tools/Managers/Presets/PresetsManager.cs @@ -125,6 +125,13 @@ public void RegisterBuiltInPreset(byte[] data, string categoryName, params strin RegisterBuiltInPreset(data, new PresetsCategory(categoryName), localFilename); } + public byte[] GetBuiltInPresetData(string categoryName, params string[] localFilename) { + var category = new PresetsCategory(categoryName); + var directory = GetDirectory(category); + var filename = Path.Combine(directory, Path.Combine(localFilename)) + category.Extension; + return GetBuiltInPresetsList(category).FirstOrDefault(x => x.VirtualFilename == filename)?.ReadBinaryData(); + } + public void ClearBuiltInPresets(PresetsCategory category) { _builtInPresets.Remove(category); } diff --git a/AcManager.Tools/Objects/CarObject.CustomData.cs b/AcManager.Tools/Objects/CarObject.CustomData.cs index 307f87242..ab37069cf 100644 --- a/AcManager.Tools/Objects/CarObject.CustomData.cs +++ b/AcManager.Tools/Objects/CarObject.CustomData.cs @@ -55,7 +55,9 @@ public bool SetExtendedPhysics(bool allowCustom) { var dataAcd = Path.Combine(Location, "data.acd"); if (File.Exists(dataAcd)) { var backupAcd = Path.Combine(Location, "data.acd~cm_bak_ep"); - var custom = allowCustom && UseExtendedPhysics && PatchHelper.IsFeatureSupported(PatchHelper.FeatureFullDay); + var custom = allowCustom && UseExtendedPhysics + && PatchHelper.IsFeatureSupported(PatchHelper.FeatureFullDay) + && SettingsHolder.Drive.QuickDriveAllowExtendedPhysics; if (custom) { FileUtils.TryToDelete(backupAcd); if (File.Exists(dataAcd) && !File.Exists(backupAcd)) { diff --git a/AcManager.Tools/Objects/CarSkinObject.cs b/AcManager.Tools/Objects/CarSkinObject.cs index d86763e46..2cbb17d6f 100644 --- a/AcManager.Tools/Objects/CarSkinObject.cs +++ b/AcManager.Tools/Objects/CarSkinObject.cs @@ -39,6 +39,8 @@ public CarSkinObject([NotNull] string carId, IFileAcManager manager, string id, }); } + public string NameFromId => _nameFromId.Value; + protected override bool LoadJsonOrThrow() { if (!File.Exists(JsonFilename)) { ClearData(); @@ -337,7 +339,7 @@ protected override IEnumerable PackOverride(CarSkinObject t) { yield return Add("cm_skin.json"); } - if (t.CarId == _recentCarId) { + if (t.CarId == _recentCarId && _recentTextures != null) { yield return Add(_recentTextures); } else { var car = CarsManager.Instance.GetById(t.CarId); diff --git a/AcManager.Tools/Objects/PythonAppConfig.cs b/AcManager.Tools/Objects/PythonAppConfig.cs index f351e34ba..30924fcc9 100644 --- a/AcManager.Tools/Objects/PythonAppConfig.cs +++ b/AcManager.Tools/Objects/PythonAppConfig.cs @@ -10,6 +10,7 @@ using AcTools.Utils; using AcTools.Utils.Helpers; using FirstFloor.ModernUI.Commands; +using FirstFloor.ModernUI.Helpers; using FirstFloor.ModernUI.Presentation; using FirstFloor.ModernUI.Serialization; using JetBrains.Annotations; @@ -228,33 +229,38 @@ public static PythonAppConfig Create([NotNull] PythonAppConfigParams configParam return null; } - const string extension = @".ini"; - string defaults; + try { + const string extension = @".ini"; + string defaults; - if (userEditedFile == null) { - const string defaultsPostfix = @"_defaults" + extension; + if (userEditedFile == null) { + const string defaultsPostfix = @"_defaults" + extension; - if (relative.EndsWith(defaultsPostfix, StringComparison.OrdinalIgnoreCase)) { - var original = filename.ApartFromLast(defaultsPostfix) + extension; - if (File.Exists(original)) return null; - filename = original; + if (relative.EndsWith(defaultsPostfix, StringComparison.OrdinalIgnoreCase)) { + var original = filename.ApartFromLast(defaultsPostfix) + extension; + if (File.Exists(original)) return null; + filename = original; + } + defaults = filename.ApartFromLast(extension, StringComparison.OrdinalIgnoreCase) + defaultsPostfix; + } else { + defaults = filename; + filename = userEditedFile; } - defaults = filename.ApartFromLast(extension, StringComparison.OrdinalIgnoreCase) + defaultsPostfix; - } else { - defaults = filename; - filename = userEditedFile; - } - var defaultsMode = File.Exists(defaults); - var ini = defaultsMode ? new IniFile(defaults, IniFileMode.Comments) : new IniFile(filename, IniFileMode.Comments); - if (!force && (!ini.Any() || ini.Any(x => !Regex.IsMatch(x.Key, @"^[\w -]+$")))) return null; - return new PythonAppConfig(configParams, filename, ini, - (ini.ContainsKey("ℹ") ? ini["ℹ"].GetNonEmpty("FULLNAME") : null) - ?? relative.ApartFromLast(extension, StringComparison.OrdinalIgnoreCase) - .Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) - .Select(AcStringValues.NameFromId).JoinToString('/'), - PythonAppsManager.Instance.FirstOrDefault(x => x.Location == configParams.PythonAppLocation), - defaultsMode ? new IniFile(filename) : null); + var defaultsMode = File.Exists(defaults); + var ini = defaultsMode ? new IniFile(defaults, IniFileMode.Comments) : new IniFile(filename, IniFileMode.Comments); + if (!force && (!ini.Any() || ini.Any(x => !Regex.IsMatch(x.Key, @"^[\w -]+$")))) return null; + return new PythonAppConfig(configParams, filename, ini, + (ini.ContainsKey("ℹ") ? ini["ℹ"].GetNonEmpty("FULLNAME") : null) + ?? relative.ApartFromLast(extension, StringComparison.OrdinalIgnoreCase) + .Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) + .Select(AcStringValues.NameFromId).JoinToString('/'), + PythonAppsManager.Instance.FirstOrDefault(x => x.Location == configParams.PythonAppLocation), + defaultsMode ? new IniFile(filename) : null); + } catch (Exception e) when (!force) { + Logging.Warning(e); + return null; + } } internal static string CapitalizeFirst(string s) { diff --git a/AcManager.Tools/Profile/LapTimesManager.cs b/AcManager.Tools/Profile/LapTimesManager.cs index 97d7d204e..ae8595fad 100644 --- a/AcManager.Tools/Profile/LapTimesManager.cs +++ b/AcManager.Tools/Profile/LapTimesManager.cs @@ -315,7 +315,7 @@ public async Task RemoveEntryAsync(LapTimeEntry entry) { if (EnabledSources.Any(x => x.ReadOnly)) { NonfatalError.Notify("Can’t remove entry from read-only sources", $"Please, disable {EnabledSources.Where(x => x.ReadOnly).Select(x => x.DisplayName).JoinToReadableString()}.", solutions: new[] { - new NonfatalErrorSolution("Disable read-only sources", null, token => { + new NonfatalErrorSolution("Disable read-only sources", token => { foreach (var source in EnabledSources) { source.IsEnabled = false; } diff --git a/AcManager.Tools/Profile/RaceWebServer.cs b/AcManager.Tools/Profile/RaceWebServer.cs index 6ef0723d4..9173b5399 100644 --- a/AcManager.Tools/Profile/RaceWebServer.cs +++ b/AcManager.Tools/Profile/RaceWebServer.cs @@ -62,7 +62,7 @@ public void Start(int port, [CanBeNull] string filename) { } catch (HttpListenerException e) when (e.ToString().Contains("0x80004005")) { NonfatalError.NotifyBackground("Can’t start web server", $"Don’t forget to allow port’s usage with something like “netsh http add urlacl url=\"http://+:{port}/\" user=everyone”.", e, new[] { - new NonfatalErrorSolution($"Use “netsh” to allow usage of port {port}", null, async token => { + new NonfatalErrorSolution($"Use “netsh” to allow usage of port {port}", async token => { try { var command = $"netsh http add urlacl url=\"http://+:{port}/\" user=everyone"; var proc = ProcessExtension.Start("cmd", new[] { diff --git a/AcManager.Tools/SemiGui/GameWrapper.cs b/AcManager.Tools/SemiGui/GameWrapper.cs index ff51bcad8..0ec7136fe 100644 --- a/AcManager.Tools/SemiGui/GameWrapper.cs +++ b/AcManager.Tools/SemiGui/GameWrapper.cs @@ -91,7 +91,10 @@ public bool Test(string obj, string key, ITestEntry value) { } private static void PrepareRaceDriverName(Game.StartProperties properties) { - if (properties.HasAdditional()) return; + if (properties.HasAdditional() + || properties.HasAdditional()) { + return; + } if (properties.BasicProperties?.DriverName != null) { properties.SetAdditional(new DriverName(properties.BasicProperties.DriverName, properties.BasicProperties.DriverNationality)); diff --git a/AcManager.Workshop/AcManager.Workshop.csproj b/AcManager.Workshop/AcManager.Workshop.csproj index c92cec004..21a4e8dad 100644 --- a/AcManager.Workshop/AcManager.Workshop.csproj +++ b/AcManager.Workshop/AcManager.Workshop.csproj @@ -66,6 +66,8 @@ ..\Libraries\Newtonsoft.Json\Newtonsoft.Json.dll + + @@ -73,14 +75,28 @@ + + + + + + + + + + + + + + diff --git a/AcManager.Workshop/Data/ContentInfoBase.cs b/AcManager.Workshop/Data/ContentInfoBase.cs new file mode 100644 index 000000000..79964fcf0 --- /dev/null +++ b/AcManager.Workshop/Data/ContentInfoBase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using AcManager.Tools.Helpers; +using AcManager.Workshop.Providers; +using AcTools.Utils; +using AcTools.Utils.Helpers; +using FirstFloor.ModernUI.Presentation; +using Newtonsoft.Json; + +namespace AcManager.Workshop.Data { + public class VersionInfo : NotifyPropertyChanged { + [JsonProperty("date")] + public long Timestamp { get; set; } + + [JsonIgnore] + public DateTime Date => Timestamp.ToDateTimeFromMilliseconds(); + + [JsonIgnore] + public string DisplayDate => Date.ToShortDateString(); + + [JsonProperty("version")] + public string Version { get; set; } + + [JsonProperty("changelog")] + public string Changelog { get; set; } + } + + public class ContentInfoBase : NotifyPropertyChanged { + public ContentInfoBase() { + UserInfo = Lazier.CreateAsync(() => UserInfoProvider.GetAsync(UserId)); + } + + [JsonProperty("entryID")] + public string Id { get; set; } + + [JsonProperty("userID")] + public string UserId { get; set; } + + [JsonIgnore] + public Lazier UserInfo { get; } + + [JsonProperty("releaseDate")] + public long ReleaseTimestamp { get; set; } + + [JsonIgnore] + public DateTime ReleaseDate => ReleaseTimestamp.ToDateTimeFromMilliseconds(); + + [JsonIgnore] + public string DisplayReleaseDate => ReleaseDate.ToShortDateString(); + + [JsonProperty("lastDate")] + public long LastTimestamp{ get; set; } + + [JsonIgnore] + public DateTime LastDate => LastTimestamp.ToDateTimeFromMilliseconds(); + + [JsonIgnore] + public string DisplayLastDate => LastDate.ToShortDateString(); + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonIgnore] + public virtual string DisplayName => Name; + + [JsonProperty("tags")] + public ObservableCollection Tags { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("originality")] + public Originality Originality { get; set; } + + [JsonProperty("version")] + public string Version { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("size")] + public long Size { get; set; } + + [JsonProperty("sizeFull")] + public long SizeFull { get; set; } + + public long SizeToInstall => SizeFull != 0 ? SizeFull : Size; + + [JsonProperty("versions")] + public List Versions { get; set; } + + public bool IsNew => DateTime.Now - ReleaseDate < SettingsHolder.Content.NewContentPeriod.TimeSpan; + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/Originality.cs b/AcManager.Workshop/Data/Originality.cs new file mode 100644 index 000000000..8170d8566 --- /dev/null +++ b/AcManager.Workshop/Data/Originality.cs @@ -0,0 +1,8 @@ +namespace AcManager.Workshop.Data { + public enum Originality { + Stolen = 0, + PortedIndy = 1, + Ported = 2, + Original = 3 + }; +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/UserFlags.cs b/AcManager.Workshop/Data/UserFlags.cs new file mode 100644 index 000000000..a200cd6d1 --- /dev/null +++ b/AcManager.Workshop/Data/UserFlags.cs @@ -0,0 +1,9 @@ +using System; + +namespace AcManager.Workshop.Data { + [Flags] + public enum UserFlags { + None = 0, + Hidden = 1 + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/UserInfo.cs b/AcManager.Workshop/Data/UserInfo.cs index c5940de74..69603d4bd 100644 --- a/AcManager.Workshop/Data/UserInfo.cs +++ b/AcManager.Workshop/Data/UserInfo.cs @@ -1,27 +1,19 @@ -using System; -using System.Collections.Generic; using FirstFloor.ModernUI.Presentation; using Newtonsoft.Json; namespace AcManager.Workshop.Data { - [Flags] - public enum UserFlags { - None = 0, - Hidden = 1 - } - public class UserInfo : NotifyPropertyChanged { [JsonProperty("userID")] - public string Username { get; set; } + public string UserId { get; set; } - [JsonProperty("flags")] - public UserFlags Flags { get; set; } + [JsonProperty("isHidden")] + public bool IsHidden { get; set; } - [JsonIgnore] - public bool IsHidden { - get => Flags.HasFlag(UserFlags.Hidden); - set => Flags = (Flags & ~UserFlags.Hidden) | (value ? UserFlags.Hidden : UserFlags.None); - } + [JsonProperty("isVirtual")] + public bool IsVirtual { get; set; } + + [JsonProperty("username")] + public string Username { get; set; } [JsonProperty("name")] public string Name { get; set; } @@ -38,7 +30,7 @@ public bool IsHidden { [JsonProperty("avatarImageLarge")] public string AvatarLarge { get; set; } - [JsonProperty("userURLs")] - public Dictionary UserUrls { get; set; } + /*[JsonProperty("userURLs")] + public Dictionary UserUrls { get; set; }*/ } } \ No newline at end of file diff --git a/AcManager.Workshop/Data/WorkshopContentCar.cs b/AcManager.Workshop/Data/WorkshopContentCar.cs new file mode 100644 index 000000000..a3fcb4b51 --- /dev/null +++ b/AcManager.Workshop/Data/WorkshopContentCar.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.Linq; +using AcManager.Tools.Helpers; +using AcManager.Workshop.Providers; +using AcTools.Utils; +using AcTools.Utils.Helpers; +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace AcManager.Workshop.Data { + public class WorkshopContentCar : ContentInfoBase { + public override string DisplayName { + get { + var name = Name; + if (name == null) return Id; + + if (SettingsHolder.Content.CarsDisplayNameCleanUp) { + name = name.Replace(@"™", ""); + } + + var yearValue = Year; + if (yearValue > 1900 && SettingsHolder.Content.CarsYearPostfix) { + if (SettingsHolder.Content.CarsYearPostfixAlt) { + return $@"{name} ({yearValue})"; + } + var year = yearValue.ToString(); + var index = name.Length - year.Length - 1; + if ((!name.EndsWith(year) || index > 0 && char.IsLetterOrDigit(name[index])) + && !AcStringValues.GetYearFromName(name).HasValue) { + return $@"{name} ’{yearValue % 100:D2}"; + } + } + + return name; + } + } + + public WorkshopContentCar() { + BrandBadge = Lazier.CreateAsync(() => BrandBadgeProvider.GetAsync(Brand)); + } + + [JsonProperty("parentID")] + public string ParentID { get; set; } + + [JsonProperty("carBrand")] + public string Brand { get; set; } + + public Lazier BrandBadge { get; } + + [JsonProperty("carClass")] + public string CarClass { get; set; } + + [JsonProperty("country")] + public string Country { get; set; } + + [JsonProperty("year")] + public int Year { get; set; } + + [JsonProperty("weight")] + public double Weight { get; set; } + + [JsonProperty("power")] + public double Power { get; set; } + + [JsonProperty("torque")] + public double Torque { get; set; } + + [JsonProperty("speed")] + public double Speed { get; set; } + + [JsonProperty("acceleration")] + public double Acceleration { get; set; } + + [JsonProperty("previewImage")] + public string PreviewImage { get; set; } + + [JsonProperty("upgradeIcon")] + public string UpgradeIcon { get; set; } + + [JsonProperty("defaultSkinsCount")] + public int DefaultSkinsCount { get; set; } + + [JsonProperty("skinsCount")] + public int SkinsCount { get; set; } + + [JsonProperty("skins"), CanBeNull] + public List Skins { get; set; } + + private WorkshopContentCarSkin _selectedSkin; + + public WorkshopContentCarSkin SelectedSkin { + get => _selectedSkin ?? (_selectedSkin = Skins?.FirstOrDefault()); + set => Apply(value, ref _selectedSkin); + } + + [JsonIgnore, NotNull] + public string ShortName => DisplayName.ApartFromFirst(Brand).TrimStart(); + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/WorkshopContentCarSkin.cs b/AcManager.Workshop/Data/WorkshopContentCarSkin.cs new file mode 100644 index 000000000..d041843be --- /dev/null +++ b/AcManager.Workshop/Data/WorkshopContentCarSkin.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace AcManager.Workshop.Data { + public class WorkshopContentCarSkin : ContentInfoBase { + [JsonProperty("skinIcon")] + public string LiveryImage { get; set; } + + [JsonProperty("previewImage")] + public string PreviewImage { get; set; } + + [JsonProperty("driverName")] + public string DriverName { get; set; } + + [JsonProperty("country")] + public string Country { get; set; } + + [JsonProperty("isDefault")] + public int IsDefaultNum { get; set; } + + [JsonIgnore] // TODO: custom serializer + public bool IsDefault => IsDefaultNum == 1; + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/WorkshopContentCategory.cs b/AcManager.Workshop/Data/WorkshopContentCategory.cs new file mode 100644 index 000000000..eaeb72bab --- /dev/null +++ b/AcManager.Workshop/Data/WorkshopContentCategory.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace AcManager.Workshop.Data { + public class WorkshopContentCategory { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("icon")] + public string Icon { get; set; } + + [JsonProperty("uses")] + public int Uses { get; set; } + + [JsonProperty("new")] + public int NewItems { get; set; } + + [JsonIgnore] + public bool HasNew => NewItems > 0; + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Data/WorkshopContentTag.cs b/AcManager.Workshop/Data/WorkshopContentTag.cs new file mode 100644 index 000000000..ef8c951b8 --- /dev/null +++ b/AcManager.Workshop/Data/WorkshopContentTag.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace AcManager.Workshop.Data { + public class WorkshopContentTag { + [JsonProperty("tagID")] + public string Name { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("uses")] + public int Uses { get; set; } + + [JsonProperty("new")] + public int NewItems { get; set; } + + [JsonIgnore] + public bool HasNew => NewItems > 0; + + [JsonIgnore] + public bool Accented => Name?.StartsWith(@"#") == true; + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Properties/AssemblyInfo.cs b/AcManager.Workshop/Properties/AssemblyInfo.cs index a05d4fc35..adc3567dd 100644 --- a/AcManager.Workshop/Properties/AssemblyInfo.cs +++ b/AcManager.Workshop/Properties/AssemblyInfo.cs @@ -33,12 +33,12 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.1.219")] -[assembly: AssemblyFileVersion("1.0.1.219")] +[assembly: AssemblyVersion("1.0.1.337")] +[assembly: AssemblyFileVersion("1.0.1.337")] [assembly: XmlnsDefinition("http://acstuff.ru/app/workshop", "AcManager.Workshop")] -[assembly: XmlnsDefinition("http://acstuff.ru/app/workshop", "AcManager.Workshop.Implementations")] +[assembly: XmlnsDefinition("http://acstuff.ru/app/workshop", "AcManager.Workshop.Data")] [assembly: XmlnsPrefix("http://acstuff.ru/app/workshop", "ws")] [assembly: NeutralResourcesLanguage("en-US")] -// Modified at: 10/9/2020 1:22:57 AM \ No newline at end of file +// Modified at: 10/24/2020 6:31:20 PM \ No newline at end of file diff --git a/AcManager.Workshop/Providers/BrandBadgeProvider.cs b/AcManager.Workshop/Providers/BrandBadgeProvider.cs new file mode 100644 index 000000000..d280d0684 --- /dev/null +++ b/AcManager.Workshop/Providers/BrandBadgeProvider.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AcManager.Workshop.Data; +using AcTools.Utils.Helpers; + +namespace AcManager.Workshop.Providers { + public class BrandBadgeProvider { + private static List _carBrands; + private static TaskCache _tasks = new TaskCache(); + + public static Task GetAsync(string badgeName) { + // TODO: error handling + if (_carBrands != null) { + return Task.FromResult(_carBrands.FirstOrDefault(x => x.Name == badgeName)?.Icon); + } + + return _tasks.Get(async () => { + _carBrands = await WorkshopHolder.Client.GetAsync>("/car-brands"); + return _carBrands; + }).ContinueWith(r => { + return r.Result.FirstOrDefault(x => x.Name == badgeName)?.Icon; + }); + } + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Providers/UserInfoProvider.cs b/AcManager.Workshop/Providers/UserInfoProvider.cs new file mode 100644 index 000000000..d10fdf0ee --- /dev/null +++ b/AcManager.Workshop/Providers/UserInfoProvider.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AcManager.Workshop.Data; +using AcTools.Utils.Helpers; + +namespace AcManager.Workshop.Providers { + public class UserInfoProvider { + private static Dictionary _cache = new Dictionary(); + private static TaskCache _tasks = new TaskCache(); + + public static Task GetAsync(string userId) { + // TODO: error handling + if (_cache.TryGetValue(userId, out var ret)) { + return Task.FromResult(ret); + } + + return _tasks.Get(async () => { + _cache[userId] = await WorkshopHolder.Client.GetAsync($"/users/{userId}"); + return _cache[userId]; + }, userId); + } + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Uploaders/AcStuffWorkshopUploader.cs b/AcManager.Workshop/Uploaders/AcStuffWorkshopUploader.cs new file mode 100644 index 000000000..2993e4779 --- /dev/null +++ b/AcManager.Workshop/Uploaders/AcStuffWorkshopUploader.cs @@ -0,0 +1,78 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using AcManager.Tools.Helpers.Api; +using AcTools.Utils.Helpers; +using B2Net; +using FirstFloor.ModernUI.Dialogs; +using FirstFloor.ModernUI.Helpers; +using Newtonsoft.Json.Linq; + +namespace AcManager.Workshop.Uploaders { + public class AcStuffWorkshopUploader : IWorkshopUploader { + private readonly string _endpoint; + private readonly string _checksum; + + public AcStuffWorkshopUploader(JObject uploadParams) { + _endpoint = uploadParams["endpoint"].ToString(); + _checksum = uploadParams["checksum"].ToString(); + } + + public async Task UploadAsync(byte[] data, string group, string name, + IProgress progress = null, CancellationToken cancellation = default) { + for (var i = 0; i < 3; ++i) { + progress?.Report(AsyncProgressEntry.FromStringIndetermitate(i == 0 + ? "Starting upload…" + : $"Trying again, {(i + 1).ToOrdinal("attempt").ToSentenceMember()} attempt")); + try { + return await TryToUploadAsync(); + } catch (HttpRequestException e) { + Logging.Warning(e); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Upload is failed, waiting a bit before the next attempt…")); + await Task.Delay(TimeSpan.FromSeconds(i + 1d)); + cancellation.ThrowIfCancellationRequested(); + } catch (WebException e) { + Logging.Warning(e); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Upload is failed, waiting a bit before the next attempt…")); + await Task.Delay(TimeSpan.FromSeconds(i + 1d)); + cancellation.ThrowIfCancellationRequested(); + } + } + + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate($"Trying again, last attempt")); + return await TryToUploadAsync(); + + async Task TryToUploadAsync() { + var request = new HttpRequestMessage(HttpMethod.Post, _endpoint); + request.Headers.TryAddWithoutValidation("X-Data-File-Group", group); + request.Headers.TryAddWithoutValidation("X-Data-File-Name", name); + request.Headers.TryAddWithoutValidation("X-Data-Checksum", _checksum); + var stopwatch = new AsyncProgressBytesStopwatch(); + request.Content = progress == null + ? (HttpContent)new ByteArrayContent(data) + : new ProgressableByteArrayContent(data, 8192, + new Progress(x => progress.Report(AsyncProgressEntry.CreateUploading(x, data.Length, stopwatch)))); + using (var response = await HttpClientHolder.Get().SendAsync(request, cancellation).ConfigureAwait(false)) { + if (response.StatusCode != HttpStatusCode.OK) { + throw new WebException($"Failed to upload: {response.StatusCode}, response: {await LoadContent()}"); + } + var result = JObject.Parse(await LoadContent()); + return new WorkshopUploadResult { + Size = data.Length, + Tag = result["key"].ToString() + }; + + ConfiguredTaskAwaitable LoadContent() { + return response.Content.ReadAsStringAsync().WithCancellation(cancellation).ConfigureAwait(false); + } + } + } + } + } +} \ No newline at end of file diff --git a/AcManager.Workshop/Uploaders/B2WorkshopUploader.cs b/AcManager.Workshop/Uploaders/B2WorkshopUploader.cs index 3bfe992ef..4f15a1b57 100644 --- a/AcManager.Workshop/Uploaders/B2WorkshopUploader.cs +++ b/AcManager.Workshop/Uploaders/B2WorkshopUploader.cs @@ -1,65 +1,126 @@ using System; using System.Collections.Generic; +using System.Net; +using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using System.Web; using AcManager.Tools.Helpers.Api; +using AcTools.Utils.Helpers; using B2Net; using B2Net.Http; using B2Net.Models; using FirstFloor.ModernUI.Dialogs; +using FirstFloor.ModernUI.Helpers; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace AcManager.Workshop.Uploaders { public class B2WorkshopUploader : IWorkshopUploader { - private B2Client _b2Client; - private string _b2ClientPrefix; - private string _b2BucketName; - private string _hostName; - private string _currentGroup; + private static B2Client _b2ClientLast; + private static string _b2ClientConfig; + + private readonly B2Client _b2Client; + private readonly string _b2ClientPrefix; public B2WorkshopUploader(JObject uploadParams) { HttpClientFactory.SetHttpClient(HttpClientHolder.Get()); - _b2Client = new B2Client(new B2Options { - KeyId = uploadParams["keyID"].ToString(), - ApplicationKey = uploadParams["keyValue"].ToString(), - BucketId = uploadParams["bucketID"].ToString(), - PersistBucket = true - }); + + var config = uploadParams.ToString(); + if (_b2ClientConfig == config) { + _b2Client = _b2ClientLast; + } else { + _b2Client = new B2Client(new B2Options { + KeyId = uploadParams["keyID"].ToString(), + ApplicationKey = uploadParams["keyValue"].ToString(), + BucketId = uploadParams["bucketID"].ToString(), + PersistBucket = true + }); + _b2ClientLast = _b2Client; + _b2ClientConfig = config; + } _b2ClientPrefix = uploadParams["prefix"].ToString(); - _b2BucketName = uploadParams["bucketName"].ToString(); - _hostName = uploadParams["hostName"].ToString(); } - public void MarkNewGroup() { - _currentGroup = Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16).ToLowerInvariant(); + private Task _authorizing; + private object _authorizingLock = new object(); + + private async Task AuthorizeInner() { + await _b2Client.Authorize().ConfigureAwait(false); + lock (_authorizingLock) { + _authorizing = null; + } } - async Task IWorkshopUploader.UploadAsync(byte[] data, string downloadName, string origin, IProgress progress, - CancellationToken cancellation) { - await _b2Client.Authorize(cancellation); - if (cancellation.IsCancellationRequested) { - throw new TaskCanceledException(); + private Task Authorize() { + lock (_authorizingLock) { + return _authorizing ?? (_authorizing = AuthorizeInner()); } + } + + public async Task UploadAsync(byte[] data, string group, string name, + IProgress progress = null, CancellationToken cancellation = default) { + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Authorizing…")); + await Authorize().WithCancellation(cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); - if (_currentGroup == null) { - MarkNewGroup(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Finding a vault to upload…")); + var uploadUrl = await _b2Client.Files.GetUploadUrl(cancelToken: cancellation); + cancellation.ThrowIfCancellationRequested(); + + for (var i = 0; i < 4; ++i) { + progress?.Report(AsyncProgressEntry.FromStringIndetermitate(i == 0 + ? "Starting upload…" + : $"Trying again, {(i + 1).ToOrdinal("attempt").ToSentenceMember()} attempt")); + try { + return await TryToUploadAsync(uploadUrl); + } catch (B2Exception e) when (e.Code == "bad_auth_token" || e.ShouldRetryRequest) { + cancellation.ThrowIfCancellationRequested(); + uploadUrl = await _b2Client.Files.GetUploadUrl(cancelToken: cancellation); + } catch (HttpRequestException e) { + Logging.Warning(e); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Target vault is not available, waiting a bit before the next attempt…")); + await Task.Delay(TimeSpan.FromSeconds(i + 1d)); + cancellation.ThrowIfCancellationRequested(); + } catch (WebException e) { + Logging.Warning(e); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Target vault is not available, waiting a bit before the next attempt…")); + await Task.Delay(TimeSpan.FromSeconds(i + 1d)); + cancellation.ThrowIfCancellationRequested(); + } catch (B2Exception e) when (e.Status == "500" || e.Status == "503") { + Logging.Warning(e); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate("Target vault is not full, waiting a bit before the next attempt…")); + await Task.Delay(TimeSpan.FromSeconds(i + 1d)); + cancellation.ThrowIfCancellationRequested(); + } catch (B2Exception e) { + Logging.Warning("B2Exception.Code=" + e.Code); + Logging.Warning("B2Exception.Status=" + e.Status); + Logging.Warning("B2Exception.Message=" + e.Message); + Logging.Warning("B2Exception.ShouldRetryRequest=" + e.ShouldRetryRequest); + throw; + } } - var fileName = _b2ClientPrefix + _currentGroup + "/" + downloadName; - var uploadUrl = await _b2Client.Files.GetUploadUrl().ConfigureAwait(false); - if (cancellation.IsCancellationRequested) throw new TaskCanceledException(); + cancellation.ThrowIfCancellationRequested(); + progress?.Report(AsyncProgressEntry.FromStringIndetermitate($"Trying again, last attempt")); + return await TryToUploadAsync(uploadUrl); - var stopwatch = new AsyncProgressBytesStopwatch(); - var file = await _b2Client.Files.Upload(data, fileName, uploadUrl, "", "", new Dictionary { - ["b2-content-disposition"] = Regex.IsMatch(downloadName, @"\.(png|jpe?g)$") ? "inline" : "attachment", - ["b2-cache-control"] = "immutable", - ["x-origin"] = origin == null ? "" :HttpUtility.UrlEncode(origin), - }, progress == null ? null : new Progress(x => { - progress.Report(AsyncProgressEntry.CreateUploading(x, data.Length, stopwatch)); - }), cancellation).ConfigureAwait(false); - return $"{_hostName}/file/{_b2BucketName}/{file.FileName}"; + async Task TryToUploadAsync(B2UploadUrl url) { + var fileName = _b2ClientPrefix + group + "/" + name; + var stopwatch = new AsyncProgressBytesStopwatch(); + var file = await _b2Client.Files.Upload(data, fileName, url, "", "", new Dictionary { + ["b2-content-disposition"] = Regex.IsMatch(name, @"\.(png|jpg)$") ? "inline" : "attachment", + ["b2-cache-control"] = "immutable" + }, progress == null ? null : new Progress(x => progress.Report(AsyncProgressEntry.CreateUploading(x, data.Length, stopwatch))), + cancellation).ConfigureAwait(false); + return new WorkshopUploadResult { + Tag = JsonConvert.SerializeObject(new { fileName = file.FileName, fileID = file.FileId }), + Size = data.LongLength + }; + } } } } \ No newline at end of file diff --git a/AcManager.Workshop/Uploaders/IWorkshopUploader.cs b/AcManager.Workshop/Uploaders/IWorkshopUploader.cs index b3c4baaf0..0062b5a46 100644 --- a/AcManager.Workshop/Uploaders/IWorkshopUploader.cs +++ b/AcManager.Workshop/Uploaders/IWorkshopUploader.cs @@ -5,19 +5,22 @@ using JetBrains.Annotations; namespace AcManager.Workshop.Uploaders { - public interface IWorkshopUploader { - void MarkNewGroup(); + public class WorkshopUploadResult { + public long Size { get; set; } + public string Tag { get; set; } + } + public interface IWorkshopUploader { /// - /// Uploads a file online for it to be downloaded later, returns a direct link. + /// Uploads a file online for it to be downloaded later, returns a file tag to send to Workshop server to get a final link. /// /// Data to upload - /// File name for downloads - /// Any tag for the file marking its origin, role or something like that + /// File group (aka name of its folder), to avoid collisions + /// File name for downloads /// Progress callback /// Cancellation token /// Direct link to download or view content in browser - Task UploadAsync([NotNull] byte[] data, [NotNull] string downloadName, [CanBeNull] string origin, + Task UploadAsync([NotNull] byte[] data, [NotNull] string group, [NotNull] string name, [CanBeNull] IProgress progress = null, CancellationToken cancellation = default); } } \ No newline at end of file diff --git a/AcManager.Workshop/Uploaders/WorkshopUploaderFactory.cs b/AcManager.Workshop/Uploaders/WorkshopUploaderFactory.cs index f5bd8e755..1ec2f4fa5 100644 --- a/AcManager.Workshop/Uploaders/WorkshopUploaderFactory.cs +++ b/AcManager.Workshop/Uploaders/WorkshopUploaderFactory.cs @@ -5,8 +5,11 @@ namespace AcManager.Workshop.Uploaders { public static class WorkshopUploaderFactory { [NotNull] - public static IWorkshopUploader Create([NotNull] JObject uploadParams) { - if (uploadParams["version"].ToString() == "B2/1") { + public static IWorkshopUploader Create(string uploaderId, [NotNull] JObject uploadParams) { + if (uploaderId == "AS/1") { + return new AcStuffWorkshopUploader(uploadParams); + } + if (uploaderId == "B2/1") { return new B2WorkshopUploader(uploadParams); } throw new NotImplementedException("Unsupported upload parameters"); diff --git a/AcManager.Workshop/WorkshopClient.cs b/AcManager.Workshop/WorkshopClient.cs index b82c34ad4..657cb9378 100644 --- a/AcManager.Workshop/WorkshopClient.cs +++ b/AcManager.Workshop/WorkshopClient.cs @@ -2,52 +2,145 @@ using System.ComponentModel; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AcManager.Tools.Helpers.Api; using AcManager.Workshop.Uploaders; using AcTools.Utils.Helpers; using FirstFloor.ModernUI.Dialogs; +using FirstFloor.ModernUI.Helpers; +using FirstFloor.ModernUI.Presentation; using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace AcManager.Workshop { - public class WorkshopClient { + public class WorkshopClient : NotifyPropertyChanged { + public static bool OptionUserAvailable = false; + public static bool OptionCreatorAvailable = false; + private const string Salt1 = "Mghf4ZTzPQFGYJMA"; private const string Salt2 = "5qzAfOyqBIgNHG0V"; private const string Salt3 = "h3y2zIonOlgf1AQS"; + private const string Salt4 = "5wXRea0U5wXRea0U"; + private const string Salt5 = "IARgcb0Jk3mksyfeQl3xUqHJnEKLmi8f"; + + [NotNull] + private readonly string _apiHost; + + [CanBeNull] + private string _userId; + + [CanBeNull] + internal string UserId { + get => _userId; + set { + _userId = value; + _pokeKey = null; + } + } + + [CanBeNull] + private string _userPasswordChecksum; + + [CanBeNull] + internal string UserPasswordChecksum { + get => _userPasswordChecksum; + set { + _userPasswordChecksum = value; + _pokeKey = null; + } + } - private string ApiHost; - private string UserId; - private string UserPassword; + private string _pokeKey; - public WorkshopClient([NotNull] string apiHost, [NotNull] string userId, [NotNull] string userPassword) { - ApiHost = apiHost; - UserId = userId; - UserPassword = GetChecksum(GetChecksum(Salt3 + userId) + userPassword); + public WorkshopClient([NotNull] string apiHost) { + _apiHost = apiHost; } + #region Basic stuff for sending requests [NotNull] private string GetFullUrl([NotNull] string path) { - return ApiHost + path; + return $@"{_apiHost}/v1{path}"; + } + + internal static string GetUserId([NotNull] string steamId) { + if (steamId == null) throw new ArgumentNullException(nameof(steamId)); + using (var sha = SHA256.Create()) { + return Regex.Replace(Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(Salt4 + steamId))), @"\W+|=+$", "").Substring(0, 16); + } + } + + internal static string GetPasswordChecksum([NotNull] string userId, [NotNull] string userPassword) { + if (userId == null) throw new ArgumentNullException(nameof(userId)); + if (userPassword == null) throw new ArgumentNullException(nameof(userPassword)); + Logging.Debug($"userId={userId}, userPassword=`{userPassword}`, checksum={GetChecksum(GetChecksum(Salt3 + userId) + userPassword)}"); + return GetChecksum(GetChecksum(Salt3 + userId) + userPassword); + } + + private static string ToHexString([NotNull] byte[] data) { + const string lookup = "0123456789abcdef"; + int i = -1, p = -1, l = data.Length; + var c = new char[l-- * 2]; + while (i < l) { + var d = data[++i]; + c[++p] = lookup[d >> 4]; + c[++p] = lookup[d & 0xF]; + } + return new string(c, 0, c.Length); } [NotNull] - private string GetChecksum([NotNull] string s) { - using (var sha1 = SHA256.Create()) { - return sha1.ComputeHash(Encoding.UTF8.GetBytes(s)).ToHexString().ToLowerInvariant(); + private static string GetChecksum([NotNull] string s) { + using (var sha = SHA256.Create()) { + return ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(s))); + } + } + + private static ConfiguredTaskAwaitable LoadBinaryContent(HttpResponseMessage response, CancellationToken cancellation) { + return response.Content.ReadAsByteArrayAsync().WithCancellation(cancellation).ConfigureAwait(false); + } + + private static ConfiguredTaskAwaitable LoadContent(HttpResponseMessage response, CancellationToken cancellation) { + return response.Content.ReadAsStringAsync().WithCancellation(cancellation).ConfigureAwait(false); + } + + private static async Task TestResponse(HttpResponseMessage response, CancellationToken cancellation) { + if (response.StatusCode >= (HttpStatusCode)400) { + var errorMessage = response.ReasonPhrase; + string remoteException = null; + string[] remoteStackTrace = null; + try { + var content = await LoadContent(response, cancellation); + var details = JObject.Parse(content); + errorMessage = details["error"].ToString(); + var exception = details["exception"]; + if (exception != null) { + remoteException = exception["message"].ToString(); + remoteStackTrace = exception["stack"]?.ToObject(); + if (remoteStackTrace?.Length == 0) { + remoteStackTrace = null; + } + } + } catch (Exception e) { + Logging.Warning(e); + // ignored + } + throw new WorkshopException(response.StatusCode, errorMessage, remoteException, remoteStackTrace); } } [ItemNotNull] - private async Task PokeAsync([NotNull] string url, CancellationToken cancellation) { - var request = new HttpRequestMessage(HttpMethod.Get, GetFullUrl("/poke/" + GetChecksum(Salt1 + url))); + private async Task PokeAsync(CancellationToken cancellation) { + var request = new HttpRequestMessage(HttpMethod.Post, GetFullUrl("/poke/" + GetChecksum(Salt1 + UserId))); try { using (var response = await HttpClientHolder.Get().SendAsync(request, cancellation).ConfigureAwait(false)) { - if (response.StatusCode != HttpStatusCode.OK) throw new WebException(response.ReasonPhrase); + await TestResponse(response, cancellation).ConfigureAwait(false); return JObject.Parse(await response.Content.ReadAsStringAsync().WithCancellation(cancellation).ConfigureAwait(false))["poke"].ToString(); } } catch (Exception e) when (e.IsCancelled()) { @@ -56,73 +149,136 @@ private async Task PokeAsync([NotNull] string url, CancellationToken can } [ItemNotNull] - private async Task CreateHttpRequestAsync(HttpMethod method, [NotNull] string url, [CanBeNull] string data, + private async Task CreateHttpRequestAsync([NotNull] HttpMethod method, [NotNull] string url, [CanBeNull] string data, CancellationToken cancellation) { - var poke = await PokeAsync(url, cancellation).ConfigureAwait(false); - var randomLine = GetChecksum(Guid.NewGuid().ToString()); - var userIdShuffled = GetChecksum(Salt2 + UserId); - var userPasswordShuffled = GetChecksum((method == HttpMethod.Get ? poke : poke + data) + userIdShuffled + UserPassword + randomLine); var request = new HttpRequestMessage(method, GetFullUrl(url)); - request.Headers.Add("X-Validation", randomLine + userIdShuffled + userPasswordShuffled); + if (UserId != null && UserPasswordChecksum != null) { + var poke = _pokeKey ?? (_pokeKey = await PokeAsync(cancellation).ConfigureAwait(false)); + var randomLine = GetChecksum(Guid.NewGuid().ToString()); + var userIdShuffled = GetChecksum(Salt2 + UserId); + var userPasswordShuffled = GetChecksum((method == HttpMethod.Get ? poke : poke + data) + userIdShuffled + UserPasswordChecksum + randomLine); + Logging.Debug("Poke argument base: " + Salt1 + UserId); + Logging.Debug("Poke argument: " + GetChecksum(Salt1 + UserId)); + Logging.Debug("Poke: " + poke); + Logging.Debug("Data: " + data); + Logging.Debug("UserIdShuffled: " + userIdShuffled); + Logging.Debug("UserPasswordChecksum: " + UserPasswordChecksum); + Logging.Debug("RandomLine: " + randomLine); + request.Headers.Add("X-Validation", randomLine + userIdShuffled + userPasswordShuffled); + } return request; } - private static async Task RunRequest(HttpRequestMessage request, CancellationToken cancellation) { + private static async Task RunRequest([NotNull] HttpRequestMessage request, [CanBeNull] Action headersCallback, + CancellationToken cancellation) { using (var response = await HttpClientHolder.Get().SendAsync(request, cancellation).ConfigureAwait(false)) { - if (response.StatusCode >= (HttpStatusCode)400) { - throw new WebException( - (await response.Content.ReadAsStringAsync().WithCancellation(cancellation).ConfigureAwait(false)).Or(response.ReasonPhrase)); - } + await TestResponse(response, cancellation).ConfigureAwait(false); + headersCallback?.Invoke(response.Headers); return typeof(T) == typeof(object) ? (T)(object)null - : JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync().WithCancellation(cancellation).ConfigureAwait(false)); + : typeof(T) == typeof(byte[]) + ? (T)(object)await LoadBinaryContent(response, cancellation) + : JsonConvert.DeserializeObject(await LoadContent(response, cancellation)); } } - [CanBeNull] - private JObject _uploaderParams; - - [CanBeNull] - private IWorkshopUploader _uploader; + private string _currentGroup; - public void MarkNewUploadGroup() { - _uploader?.MarkNewGroup(); + public void MarkNewUploadGroup(string groupId = null) { + _currentGroup = groupId.Or(Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16).ToLowerInvariant()); } - [ItemNotNull] - public async Task UploadAsync([NotNull] byte[] data, [NotNull] string downloadName, [CanBeNull] string origin = null, - IProgress progress = null, CancellationToken cancellation = default) { - if (_uploader == null) { - if (_uploaderParams == null) { - _uploaderParams = await GetAsync("/manage/upload-params", cancellation).ConfigureAwait(false); - if (_uploaderParams == null) { - throw new Exception("Upload parameters are not available"); - } - } - _uploader = WorkshopUploaderFactory.Create(_uploaderParams); + private static string CalculateUploadChecksum(byte[] data) { + using (var sha1 = SHA1.Create()) { + return ToHexString(sha1.ComputeHash(Encoding.UTF8.GetBytes(Salt5 + ToHexString(sha1.ComputeHash(data))))); } - return await _uploader.UploadAsync(data, downloadName, origin, progress, cancellation).ConfigureAwait(false); } - public async Task GetAsync([Localizable(false), NotNull] string url, CancellationToken cancellation = default) { - var request = await CreateHttpRequestAsync(HttpMethod.Get, url, null, cancellation).ConfigureAwait(false); - return await RunRequest(request, cancellation).ConfigureAwait(false); + internal async Task RequestAsync(HttpMethod method, [Localizable(false), NotNull] string url, + [Localizable(false), CanBeNull] TData data, Action headersCallback = null, + CancellationToken cancellation = default) { + var serialized = data != null ? JsonConvert.SerializeObject(data) : null; + var request = await CreateHttpRequestAsync(method, url, serialized, cancellation).ConfigureAwait(false); + if (data != null) request.Content = new StringContent(serialized, Encoding.UTF8, "application/json"); + return await RunRequest(request, headersCallback, cancellation).ConfigureAwait(false); + } + #endregion + + #region Various public methods for REST API + public Task GetAsync([Localizable(false), NotNull] string url, CancellationToken cancellation = default) { + return RequestAsync(HttpMethod.Get, url, null, null, cancellation); + } + + public Task PostAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] TData data, + CancellationToken cancellation = default) { + return PostAsync(url, data, cancellation); + } + + public Task PatchAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] TData data, + CancellationToken cancellation = default) { + return PatchAsync(url, data, cancellation); } - public async Task PostAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] T data, CancellationToken cancellation = default) { - // ReSharper disable once MethodHasAsyncOverload - var serialized = JsonConvert.SerializeObject(data); - var request = await CreateHttpRequestAsync(HttpMethod.Post, url, serialized, cancellation).ConfigureAwait(false); - request.Content = new StringContent(serialized, Encoding.UTF8, "application/json"); - await RunRequest(request, cancellation).ConfigureAwait(false); + public Task PutAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] TData data, + CancellationToken cancellation = default) { + return PutAsync(url, data, cancellation); } - public async Task PatchAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] T data, CancellationToken cancellation = default) { - // ReSharper disable once MethodHasAsyncOverload - var serialized = JsonConvert.SerializeObject(data); - var request = await CreateHttpRequestAsync(new HttpMethod("PATCH"), url, serialized, cancellation).ConfigureAwait(false); - request.Content = new StringContent(serialized, Encoding.UTF8, "application/json"); - await RunRequest(request, cancellation).ConfigureAwait(false); + public Task DeleteAsync([Localizable(false), NotNull] string url, [Localizable(false), NotNull] TData data, + CancellationToken cancellation = default) { + return DeleteAsync(url, data, cancellation); } + + public Task PostAsync([Localizable(false), NotNull] string url, [Localizable(false), CanBeNull] TData data, + CancellationToken cancellation = default) { + return RequestAsync(HttpMethod.Post, url, data, null, cancellation); + } + + public Task PatchAsync([Localizable(false), NotNull] string url, [Localizable(false), CanBeNull] TData data, + CancellationToken cancellation = default) { + return RequestAsync(new HttpMethod("PATCH"), url, data, null, cancellation); + } + + public Task PutAsync([Localizable(false), NotNull] string url, [Localizable(false), CanBeNull] TData data, + CancellationToken cancellation = default) { + return RequestAsync(HttpMethod.Put, url, data, null, cancellation); + } + + public Task DeleteAsync([Localizable(false), NotNull] string url, [Localizable(false), CanBeNull] TData data, + CancellationToken cancellation = default) { + return RequestAsync(HttpMethod.Delete, url, data, null, cancellation); + } + #endregion + + #region Simple file uploads + [ItemNotNull] + public async Task UploadAsync([NotNull] byte[] data, [NotNull] string fileName, + IProgress progress = null, CancellationToken cancellation = default) { + var checksum = CalculateUploadChecksum(data); + var uploaderParams = await PostAsync("/manage/urls", new { + size = data.Length, + name = fileName, + group = _currentGroup, + checksum + }, cancellation).ConfigureAwait(false); + + var uploaderId = uploaderParams["uploaderID"].ToString(); + if (uploaderId == "reuse/1") { + return ((JObject)uploaderParams["params"])["downloadURL"].ToString(); + } + + var uploader = WorkshopUploaderFactory.Create(uploaderId, (JObject)uploaderParams["params"]); + var result = await uploader.UploadAsync(data, _currentGroup, fileName, progress, cancellation).ConfigureAwait(false); + return (await PutAsync("/manage/urls", new { + checksum, + uploaderID = uploaderId, + size = result.Size, + tag = result.Tag + }))["downloadURL"].ToString(); + } + #endregion + + #region Authorization process + #endregion } } \ No newline at end of file diff --git a/AcManager.Workshop/WorkshopException.cs b/AcManager.Workshop/WorkshopException.cs new file mode 100644 index 000000000..2014d1978 --- /dev/null +++ b/AcManager.Workshop/WorkshopException.cs @@ -0,0 +1,21 @@ +using System; +using System.Net; +using JetBrains.Annotations; + +namespace AcManager.Workshop { + public class WorkshopException : Exception { + public HttpStatusCode Code { get; } + + [CanBeNull] + public string RemoteException { get; } + + [CanBeNull] + public string[] RemoteStackTrace { get; } + + public WorkshopException(HttpStatusCode code, string errorMesage, string remoteException, string[] remoteStackTrace) : base(errorMesage) { + Code = code; + RemoteException = remoteException; + RemoteStackTrace = remoteStackTrace; + } + } +} \ No newline at end of file diff --git a/AcManager.Workshop/WorkshopHolder.cs b/AcManager.Workshop/WorkshopHolder.cs new file mode 100644 index 000000000..4efa7a5f3 --- /dev/null +++ b/AcManager.Workshop/WorkshopHolder.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; + +namespace AcManager.Workshop { + public static class WorkshopHolder { + private static WorkshopClient _client; + private static WorkshopModel _model; + + [NotNull] + public static WorkshopClient Client => GetClient(); + + [NotNull] + public static WorkshopModel Model => GetModel(); + + private static void Initialize() { + if (_client == null) { + _client = new WorkshopClient("http://192.168.1.10:3000"); + _model = new WorkshopModel(_client); + } + } + + [NotNull] + public static WorkshopClient GetClient() { + Initialize(); + return _client; + } + + [NotNull] + public static WorkshopModel GetModel() { + Initialize(); + return _model; + } + } +} \ No newline at end of file diff --git a/AcManager.Workshop/WorkshopModel.cs b/AcManager.Workshop/WorkshopModel.cs new file mode 100644 index 000000000..6890cfb1f --- /dev/null +++ b/AcManager.Workshop/WorkshopModel.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AcManager.Tools.Helpers; +using AcManager.Workshop.Data; +using AcTools.Utils.Helpers; +using FirstFloor.ModernUI.Commands; +using FirstFloor.ModernUI.Dialogs; +using FirstFloor.ModernUI.Helpers; +using FirstFloor.ModernUI.Presentation; +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace AcManager.Workshop { + public class WorkshopModel : NotifyPropertyChanged { + private const string KeySteamId = "w/ui"; + private const string KeyUserPassword = "w/up"; + + [NotNull] + private readonly WorkshopClient _client; + + public WorkshopModel([NotNull] WorkshopClient client) { + _client = client ?? throw new ArgumentNullException(nameof(client)); + + if (ValuesStorage.GetEncrypted(KeySteamId) == null) { + ValuesStorage.SetEncrypted(KeySteamId, SteamIdHelper.Instance.Value); + } + + var steamId = ValuesStorage.GetEncrypted(KeySteamId); + AuthorizeAsync(steamId, ValuesStorage.GetEncrypted(KeyUserPassword, + WorkshopClient.GetPasswordChecksum(WorkshopClient.GetUserId(steamId), string.Empty))).Ignore(); + } + + private bool _isAuthorizing; + + public bool IsAuthorizing { + get => _isAuthorizing; + set => Apply(value, ref _isAuthorizing, () => _authorizeCommand?.RaiseCanExecuteChanged()); + } + + private string _steamId; + + public string SteamId { + get => _steamId ?? (_steamId = SteamIdHelper.Instance.Value); + set => Apply(value, ref _steamId, () => _authorizeCommand?.RaiseCanExecuteChanged()); + } + + private string GetDisplayError(IEnumerable message) { + return $"• {message.NonNull().JoinToString(";\n• ")}."; + } + + private async Task TryAsync(Func fn) { + IsWorkshopAvailable = true; + try { + await fn(); + } catch (Exception e) when (e.IsCancelled()) { + // Do nothing + } catch (HttpRequestException e) { + Logging.Warning(e); + IsWorkshopAvailable = false; + LastError = GetDisplayError(e.FlattenMessage()); + } catch (WorkshopException e) { + Logging.Warning(e); + LastError = GetDisplayError(new[] { + $"CM Workshop returned error {(int)e.Code} ({e.Message})", + e.RemoteException == null ? null + : e.RemoteStackTrace != null + ? $"{e.RemoteException}\n\t{e.RemoteStackTrace.Take(4).JoinToString("\n\t")}" + : $"{e.RemoteException}" + }); + } catch (Exception e) { + Logging.Warning(e); + LastError = GetDisplayError(e.FlattenMessage()); + } + } + + private bool _isWorkshopAvailable = true; + + public bool IsWorkshopAvailable { + get => _isWorkshopAvailable; + set => Apply(value, ref _isWorkshopAvailable); + } + + private UserInfo _loggedInAs; + + public UserInfo LoggedInAs { + get => _loggedInAs; + set => Apply(value, ref _loggedInAs, () => { + OnPropertyChanged(nameof(IsAuthorized)); + OnPropertyChanged(nameof(IsAccountVirtual)); + OnPropertyChanged(nameof(IsAbleToUploadContent)); + }); + } + + public bool IsAuthorized => _loggedInAs != null; + public bool IsAccountVirtual => _loggedInAs?.IsVirtual == true; + public bool IsAbleToUploadContent => _loggedInAs?.IsVirtual == false; + + private Task AuthorizeAsync(string steamId, string userPasswordChecksum) { + return TryAsync(async () => { + if (IsAuthorizing) { + throw new Exception("Already trying to authorize"); + } + + var userId = WorkshopClient.GetUserId(steamId); + // TODO: + //ValuesStorage.SetEncrypted(KeySteamId, steamId); + //ValuesStorage.SetEncrypted(KeyUserPassword, userPasswordChecksum); + + IsAuthorizing = true; + try { + _client.UserId = userId; + _client.UserPasswordChecksum = userPasswordChecksum; + LoggedInAs = await _client.PutAsync($"/users/{userId}", null); + // Reapplying values in case there is another Authorize request happening (which shouldn’t occur, but if + // it does, we don’t need a broken state) + _client.UserPasswordChecksum = userPasswordChecksum; + SteamId = steamId; + } catch { + _client.UserPasswordChecksum = null; + LoggedInAs = null; + throw; + } finally { + _client.UserId = userId; + IsAuthorizing = false; + } + }); + } + + private string _lastError; + + public string LastError { + get => _lastError; + set => Apply(value, ref _lastError); + } + + private AsyncCommand _refreshCommand; + + public AsyncCommand RefreshCommand => _refreshCommand ?? (_refreshCommand = new AsyncCommand( + () => AuthorizeAsync(SteamId, WorkshopClient.GetPasswordChecksum(WorkshopClient.GetUserId(SteamId), + ValuesStorage.GetEncrypted(KeyUserPassword, string.Empty))), + () => !string.IsNullOrEmpty(SteamId) && !IsAuthorizing)); + + private AsyncCommand _authorizeCommand; + + public AsyncCommand AuthorizeCommand => _authorizeCommand ?? (_authorizeCommand = new AsyncCommand( + password => AuthorizeAsync(SteamId, WorkshopClient.GetPasswordChecksum(WorkshopClient.GetUserId(SteamId), password)), + password => !string.IsNullOrEmpty(SteamId) && !IsAuthorizing && password != null)); + + private DelegateCommand _logOutCommand; + + public DelegateCommand LogOutCommand => _logOutCommand ?? (_logOutCommand = new DelegateCommand(() => { + _client.UserPasswordChecksum = null; + LoggedInAs = null; + })); + + private AsyncCommand _upgradeUserCommand; + private static int _upgradeRun; + + private async Task WaitForAuthenticationAsync(int upgradeRun, CancellationToken cancellation) { + var waitFor = TimeSpan.FromMinutes(10d); + var stepSize = TimeSpan.FromSeconds(1d); + for (int i = 0, t = (int)(waitFor.TotalSeconds / stepSize.TotalSeconds); i < t; i++) { + await Task.Delay(stepSize, cancellation); + if (_upgradeRun != upgradeRun || cancellation.IsCancellationRequested) break; + LoggedInAs = await _client.GetAsync($"/users/{_client.UserId}"); + Logging.Debug(JsonConvert.SerializeObject(LoggedInAs)); + if (!LoggedInAs.IsVirtual) return; + } + } + + public AsyncCommand UpgradeUserCommand => _upgradeUserCommand + ?? (_upgradeUserCommand = new AsyncCommand(c => { + return TryAsync(async () => { + var upgradeRun = ++_upgradeRun; + var password = Prompt.Show("Choose a new password for your CM Workshop account:", "Verify CM Workshop account", + comment: "After choosing a new password, you would need to verify it by passing Steam authentication.", + required: true, passwordMode: true); + if (string.IsNullOrEmpty(password)) return; + c?.ThrowIfCancellationRequested(); + + await UpgradeUserAsync(password); + c?.ThrowIfCancellationRequested(); + + await WaitForAuthenticationAsync(upgradeRun, c ?? default); + }); + })); + + public async Task UpgradeUserAsync(string userPassword) { + if (_client.UserId == null || LoggedInAs == null || !LoggedInAs.IsVirtual) throw new Exception("Can’t upgrade user"); + await _client.RequestAsync(new HttpMethod("PATCH"), $"/users/{_client.UserId}", new { + isVirtual = false, + passwordChecksum = WorkshopClient.GetPasswordChecksum(_client.UserId, userPassword) + }, headers => { + if (headers.Location != null) { + WindowsHelper.ViewInBrowser(headers.Location); + } + }); + } + + private AsyncCommand _resetPasswordCommand; + + public AsyncCommand ResetPasswordCommand + => _resetPasswordCommand ?? (_resetPasswordCommand = new AsyncCommand(c => { + return TryAsync(async () => { + var upgradeRun = ++_upgradeRun; + var password = Prompt.Show("Choose a new password for your CM Workshop account:", "Reset CM Workshop password", + comment: "After choosing a new password, you would need to verify it by passing Steam authentication.", + required: true, passwordMode: true); + if (string.IsNullOrEmpty(password)) return; + c?.ThrowIfCancellationRequested(); + + await SetPasswordAsync(password); + c?.ThrowIfCancellationRequested(); + + await WaitForAuthenticationAsync(upgradeRun, c ?? default); + }); + })); + + public async Task SetPasswordAsync([NotNull] string userPassword) { + if (_client.UserId == null) throw new Exception("Can’t reset password"); + await _client.RequestAsync(new HttpMethod("PATCH"), $"/users/{_client.UserId}", new { + passwordChecksum = WorkshopClient.GetPasswordChecksum(_client.UserId, userPassword) + }, headers => { + if (headers.Location != null) { + WindowsHelper.ViewInBrowser(headers.Location); + } + }); + } + } +} \ No newline at end of file diff --git a/AcManager/AcManager.csproj b/AcManager/AcManager.csproj index 40a9b27a3..a0586e103 100644 --- a/AcManager/AcManager.csproj +++ b/AcManager/AcManager.csproj @@ -429,6 +429,9 @@ CarSetupsDialog.xaml + + ImageEditor.xaml + TrackSkinTexturesDialog.xaml @@ -742,15 +745,47 @@ ShadersMainPage.xaml + + + + + + + + WorkshopCarBrandsHorizonal.xaml + + + WorkshopCarBrandsNarrrowColumns.xaml + + + WorkshopCarBrandsGrid.xaml + + + WorkshopCarBrandsBrandsRow.xaml + + + WorkshopContentMain.xaml + + + WorkshopContentCategories.xaml + + + WorkshopContentTags.xaml + WorkshopEditProfile.xaml + WorkshopManage.xaml + + WorkshopSelectedCar.xaml + WorkshopUpload.xaml + @@ -1123,6 +1158,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + MSBuild:Compile Designer @@ -1698,10 +1737,6 @@ Designer MSBuild:Compile - - Designer - MSBuild:Compile - Designer MSBuild:Compile @@ -1746,9 +1781,6 @@ CarSpecsEditor.xaml - - ImageEditor.xaml - UpgradeIconEditor.xaml @@ -1802,8 +1834,16 @@ Designer MSBuild:Compile + + + + + + + + MSBuild:Compile diff --git a/AcManager/App.xaml.cs b/AcManager/App.xaml.cs index 118266ee7..b7f4714a1 100644 --- a/AcManager/App.xaml.cs +++ b/AcManager/App.xaml.cs @@ -57,6 +57,7 @@ using AcManager.Tools.SemiGui; using AcManager.Tools.SharedMemory; using AcManager.Tools.Starters; +using AcManager.Workshop; using AcTools; using AcTools.AcdEncryption; using AcTools.AcdFile; @@ -103,6 +104,7 @@ public static void CreateAndRun(bool forceSoftwareRenderingMode) { Logging.Write($"App version: {BuildInformation.AppVersion} ({BuildInformation.Platform}, {WindowsVersionHelper.GetVersion()})"); } + Storage.TemporaryBackupsDirectory = FilesStorage.Instance.GetTemporaryDirectory("Storages Backups"); if (AppArguments.GetBool(AppFlag.DisableSaving)) { ValuesStorage.Initialize(); CacheStorage.Initialize(); @@ -329,7 +331,8 @@ private App() { AppArguments.Set(AppFlag.BenchmarkReplays, ref GameDialog.OptionBenchmarkReplays); AppArguments.Set(AppFlag.HideRaceCancelButton, ref GameDialog.OptionHideCancelButton); AppArguments.Set(AppFlag.PatchSupport, ref PatchHelper.OptionPatchSupport); - AppArguments.Set(AppFlag.CmWorkshop, ref WorkshopUpload.OptionAvailable); + AppArguments.Set(AppFlag.CmWorkshop, ref WorkshopClient.OptionUserAvailable); + AppArguments.Set(AppFlag.CmWorkshopCreator, ref WorkshopClient.OptionCreatorAvailable); // Shared memory, now as an app flag SettingsHolder.Drive.WatchForSharedMemory = !AppArguments.GetBool(AppFlag.DisableSharedMemory); @@ -392,7 +395,6 @@ private App() { } } - Storage.TemporaryBackupsDirectory = FilesStorage.Instance.GetTemporaryDirectory("Storages Backups"); CupClient.Initialize(); CupViewModel.Initialize(); Superintendent.Initialize(); @@ -503,6 +505,8 @@ private App() { } }; + WorkshopLinkCommands.Initialize(); + AppArguments.SetSize(AppFlag.ImagesCacheLimit, ref BetterImage.OptionCacheTotalSize); AppArguments.Set(AppFlag.ImagesMarkCached, ref BetterImage.OptionMarkCached); BetterImage.RemoteUserAgent = CmApiProvider.UserAgent; @@ -615,9 +619,7 @@ private App() { } // Check and apply FTH fix if necessary - if (SettingsHolder.Common.LaunchSteamAtStart) { - CheckFaultTolerantHeap().Ignore(); - } + CheckFaultTolerantHeap().Ignore(); // Let’s roll ShutdownMode = ShutdownMode.OnExplicitShutdown; @@ -630,8 +632,23 @@ private App() { } private static async Task CheckFaultTolerantHeap() { - await Task.Delay(500); - await FaultTolerantHeapFix.CheckAsync(); + try { + await Task.Delay(500); + if (ValuesStorage.Get(".fth.shown", false) && FaultTolerantHeapFix.Check()) { + NonfatalError.NotifyBackground("Performance issue detected", + "Assetto Corsa performance is negatively affected by FTH. Content Manager can try to fix it.", + solutions: new[] { + new NonfatalErrorSolution("Try to fix the issue", cancellation => { + FaultTolerantHeapFix.CheckAndFixAsync().Ignore(); + return Task.Delay(0); + }) + }); + } else { + FaultTolerantHeapFix.CheckAndFixAsync().Ignore(); + } + } catch (Exception e) { + Logging.Error(e); + } } private static async Task LaunchSteam() { @@ -674,14 +691,14 @@ public void Catch(DataFileBase file, int line) { if (file.Filename != null) { NonfatalError.NotifyBackground(string.Format(ToolsStrings.SyntaxError_Unpacked, Path.GetFileName(file.Filename), line), ToolsStrings.SyntaxError_Commentary, null, new[] { - new NonfatalErrorSolution(ToolsStrings.SyntaxError_Solution, null, token => { + new NonfatalErrorSolution(ToolsStrings.SyntaxError_Solution, token => { WindowsHelper.OpenFile(file.Filename); return Task.Delay(0, token); }) }); } else { NonfatalError.NotifyBackground(string.Format(ToolsStrings.SyntaxError_Packed, - $"{file.Name} ({Path.GetFileName(file.Data?.Location ?? "?")})", line), ToolsStrings.SyntaxError_Commentary); + $"{file.Name} ({Path.GetFileName((file.Data as IDataWrapper)?.Location ?? @"?")})", line), ToolsStrings.SyntaxError_Commentary); } } } diff --git a/AcManager/AppFlag.cs b/AcManager/AppFlag.cs index f801e19e5..7c1dc712f 100644 --- a/AcManager/AppFlag.cs +++ b/AcManager/AppFlag.cs @@ -6,10 +6,16 @@ namespace AcManager { public enum AppFlag { /// /// For development purposes. - /// Example: --patch-support. + /// Example: --cm-workshop. /// CmWorkshop, + /// + /// For development purposes. + /// Example: --cm-workshop-creator. + /// + CmWorkshopCreator, + /// /// For development purposes. /// Example: --patch-support. diff --git a/AcManager/AppStrings.Designer.cs b/AcManager/AppStrings.Designer.cs index ce0f40d57..274c8befd 100644 --- a/AcManager/AppStrings.Designer.cs +++ b/AcManager/AppStrings.Designer.cs @@ -7102,6 +7102,15 @@ public static string Main_Weather { } } + /// + /// Looks up a localized string similar to Workshop. + /// + public static string Main_Workshop { + get { + return ResourceManager.GetString("Main_Workshop", resourceCulture); + } + } + /// /// Looks up a localized string similar to Or, copy a link or a file and press Ctrl+V in Content Manager.. /// @@ -11393,7 +11402,7 @@ public static string Settings_QuickSwitches_AddedLabel { } /// - /// Looks up a localized string similar to Quick Switches — a small popup menu, which allows to change different settings from any place of the program. To open this menu, press Alt+~ or the right mouse button. Also, you can change settings by pressing Alt+1…Alt+9.[br][br]If you need some new option, [url="https://acstuff.ru/app/#contacts"]feel free to contact us[/url].. + /// Looks up a localized string similar to Quick Switches — a small popup menu, which allows to change different settings from any place of the program. To open this menu, press Alt+~ or click middle mouse button. Also, you can change settings by pressing Alt+1…Alt+9.[br][br]If you need some new option, [url="https://acstuff.ru/app/#contacts"]please contact us[/url].. /// public static string Settings_QuickSwitches_Description { get { diff --git a/AcManager/AppStrings.resx b/AcManager/AppStrings.resx index bc732c465..e54409147 100644 --- a/AcManager/AppStrings.resx +++ b/AcManager/AppStrings.resx @@ -436,6 +436,9 @@ Author: [b]{3}[/b] Content + + Workshop + About @@ -936,7 +939,7 @@ Working directory will be set to Assetto Corsa folder in Documents. Enable Quick Switches - Quick Switches — a small popup menu, which allows to change different settings from any place of the program. To open this menu, press Alt+~ or the right mouse button. Also, you can change settings by pressing Alt+1…Alt+9.[br][br]If you need some new option, [url="https://acstuff.ru/app/#contacts"]feel free to contact us[/url]. + Quick Switches — a small popup menu, which allows to change different settings from any place of the program. To open this menu, press Alt+~ or click middle mouse button. Also, you can change settings by pressing Alt+1…Alt+9.[br][br]If you need some new option, [url="https://acstuff.ru/app/#contacts"]please contact us[/url]. Added: diff --git a/AcManager/CustomShowroom/CmPreviewsFormWrapper.cs b/AcManager/CustomShowroom/CmPreviewsFormWrapper.cs index 21408ffb2..41d6744af 100644 --- a/AcManager/CustomShowroom/CmPreviewsFormWrapper.cs +++ b/AcManager/CustomShowroom/CmPreviewsFormWrapper.cs @@ -40,7 +40,7 @@ public IReadOnlyList GetErrors() { private static async Task> Run([NotNull] CarObject car, [CanBeNull] string skinId, [CanBeNull] IReadOnlyList toUpdate, [CanBeNull] string presetFilename) { var carKn5 = AcPaths.GetMainCarFilename(car.Location, car.AcdData, true); - if (!File.Exists(carKn5)) { + if (carKn5 == null || !File.Exists(carKn5)) { ModernDialog.ShowMessage("Model not found"); return null; } diff --git a/AcManager/CustomShowroom/CmPreviewsSettings.cs b/AcManager/CustomShowroom/CmPreviewsSettings.cs index b9451ee98..0c6c1113d 100644 --- a/AcManager/CustomShowroom/CmPreviewsSettings.cs +++ b/AcManager/CustomShowroom/CmPreviewsSettings.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using System.Windows.Forms; using System.Windows.Media; @@ -235,6 +236,11 @@ public static DarkPreviewsOptions GetSavedOptions(string presetFilename = null) } } + [NotNull] + public static DarkPreviewsOptions GetSerializedSavedOptions([NotNull] byte[] data) { + return (SaveHelper.LoadSerialized(Encoding.UTF8.GetString(data)) ?? new SaveableData()).ToPreviewsOptions(true); + } + protected new sealed class SaveableData : DarkRendererSettings.SaveableData { public override Color AmbientDownColor { get; set; } = Color.FromRgb(0x51, 0x5E, 0x6D); public override Color AmbientUpColor { get; set; } = Color.FromRgb(0xC6, 0xE6, 0xFF); diff --git a/AcManager/CustomShowroom/CmPreviewsTools.xaml.cs b/AcManager/CustomShowroom/CmPreviewsTools.xaml.cs index 50d34430d..d6c104823 100644 --- a/AcManager/CustomShowroom/CmPreviewsTools.xaml.cs +++ b/AcManager/CustomShowroom/CmPreviewsTools.xaml.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -372,6 +373,10 @@ private class Updater { private readonly List _errors = new List(); private DispatcherTimer _dispatcherTimer; + public Func DestinationOverrideCallback; + public IProgress Progress; + public CancellationToken CancellationToken = default(CancellationToken); + public Updater([NotNull] IReadOnlyList entries, [NotNull] DarkPreviewsOptions options, [CanBeNull] string presetName, [CanBeNull] DarkPreviewsUpdater updater) { _entries = entries; @@ -390,7 +395,7 @@ public Updater([NotNull] IReadOnlyList entries, [NotNull] DarkP public async Task> Run() { try { if (_options.Showroom != null && ShowroomsManager.Instance.GetById(_options.Showroom) == null) { - if (_options.Showroom == "at_previews" && MissingShowroomHelper != null) { + if (_options.Showroom == @"at_previews" && MissingShowroomHelper != null) { await MissingShowroomHelper.OfferToInstall("Kunos Previews Showroom (AT Previews Special)", "at_previews", "http://www.assettocorsa.net/assetto-corsa-v1-5-dev-diary-part-33/"); if (ShowroomsManager.Instance.GetById(_options.Showroom) != null) { @@ -414,7 +419,9 @@ await MissingShowroomHelper.OfferToInstall("Kunos Previews Showroom (AT Previews } } + [CanBeNull] private WaitingDialog _waiting; + private int _approximateSkinsPerCarCars = 1; private int _approximateSkinsPerCarSkins = 10; private Stopwatch _started; @@ -436,17 +443,21 @@ private async Task> RunReady() { _finished = false; _i = _j = 0; - _waiting = new WaitingDialog { CancellationText = "Stop" }; + _waiting = Progress != null ? null : new WaitingDialog { CancellationText = "Stop" }; + var progressReport = Progress ?? _waiting; + if (Progress == null) { + CancellationToken = _waiting?.CancellationToken ?? default(CancellationToken); + } var singleMode = _entries.Count == 1; _verySingleMode = singleMode && _entries[0].Skins?.Count == 1; var recycled = 0; if (!_verySingleMode) { - _waiting.SetImage(null); + _waiting?.SetImage(null); if (SettingsHolder.CustomShowroom.PreviewsRecycleOld) { - _waiting.SetMultiline(true); + _waiting?.SetMultiline(true); } } @@ -469,8 +480,8 @@ private async Task> RunReady() { _currentSkins = entry.Skins; if (_currentSkins == null) { - _waiting.Report(new AsyncProgressEntry("Loading skins…" + postfix, _verySingleMode ? 0d : progress)); - _waiting.SetDetails(GetDetails(_j, _currentCar, null, null)); + progressReport?.Report(new AsyncProgressEntry("Loading skins…" + postfix, _verySingleMode ? 0d : progress)); + _waiting?.SetDetails(GetDetails(_j, _currentCar, null, null)); await _currentCar.SkinsManager.EnsureLoadedAsync(); if (Cancel()) return _errors; @@ -484,21 +495,23 @@ private async Task> RunReady() { if (Cancel()) return _errors; _currentSkin = _currentSkins[_i]; - _waiting.SetDetails(GetDetails(_j, _currentCar, _currentSkin, _currentSkins.Count - _i)); + _waiting?.SetDetails(GetDetails(_j, _currentCar, _currentSkin, _currentSkins.Count - _i)); var subprogress = progress + step * (0.1 + 0.8 * _i / _currentSkins.Count); - var filename = Path.Combine(_currentSkin.Location, _options.PreviewName); - if (SettingsHolder.CustomShowroom.PreviewsRecycleOld && File.Exists(filename)) { - if (++recycled > 5) { - _recyclingWarning = true; + var filename = DestinationOverrideCallback?.Invoke(_currentSkin) ?? Path.Combine(_currentSkin.Location, _options.PreviewName); + if (DestinationOverrideCallback == null) { + if (SettingsHolder.CustomShowroom.PreviewsRecycleOld && File.Exists(filename)) { + if (++recycled > 5) { + _recyclingWarning = true; + } + + progressReport?.Report(new AsyncProgressEntry($"Recycling current preview for {_currentSkin.DisplayName}…" + postfix, + _verySingleMode ? 0d : subprogress)); + await Task.Run(() => FileUtils.Recycle(filename)); } - - _waiting.Report(new AsyncProgressEntry($"Recycling current preview for {_currentSkin.DisplayName}…" + postfix, - _verySingleMode ? 0d : subprogress)); - await Task.Run(() => FileUtils.Recycle(filename)); } - _waiting.Report(new AsyncProgressEntry($"Updating skin {_currentSkin.DisplayName}…" + postfix, + progressReport?.Report(new AsyncProgressEntry($"Updating skin {_currentSkin.DisplayName}…" + postfix, _verySingleMode ? 0d : subprogress + halfstep)); try { @@ -515,7 +528,7 @@ await _updater.ShotAsync(_currentCar.Id, _currentSkin.Id, filename, _currentCar. } _dispatcherTimer?.Stop(); - _waiting.Report(new AsyncProgressEntry("Saving…" + postfix, _verySingleMode ? 0d : 0.999999d)); + progressReport?.Report(new AsyncProgressEntry("Saving…" + postfix, _verySingleMode ? 0d : 0.999999d)); await _updater.WaitForProcessing(); _finished = true; @@ -536,12 +549,12 @@ private void PreviewReadyCallback() { private void UpdatePreviewImage() { if (!_finished) { - _waiting.SetImage(Path.Combine(_currentSkin.Location, _options.PreviewName)); + _waiting?.SetImage(Path.Combine(_currentSkin.Location, _options.PreviewName)); } } private bool Cancel() { - if (!_waiting.CancellationToken.IsCancellationRequested) return false; + if (!CancellationToken.IsCancellationRequested) return false; for (; _j < _entries.Count; _j++) { _errors.Add(new UpdatePreviewError(_entries[_j], ControlsStrings.Common_Cancelled, null)); } @@ -550,7 +563,7 @@ private bool Cancel() { private void TimerCallback(object sender, EventArgs args) { if (_currentCar == null || _currentSkins == null || _currentSkin == null) return; - _waiting.SetDetails(GetDetails(_j, _currentCar, _currentSkin, _currentSkins.Count - _i)); + _waiting?.SetDetails(GetDetails(_j, _currentCar, _currentSkin, _currentSkins.Count - _i)); } private void UpdateApproximate(int skinsPerCar) { @@ -588,9 +601,15 @@ private IEnumerable GetDetails(int currentIndex, CarObject car, CarSkinO } [ItemCanBeNull] - private static Task> UpdatePreviewAsync([NotNull] IReadOnlyList entries, - [NotNull] DarkPreviewsOptions options, string presetName = null, DarkPreviewsUpdater updater = null) { - return new Updater(entries, options, presetName, updater).Run(); + public static Task> UpdatePreviewAsync([NotNull] IReadOnlyList entries, + [NotNull] DarkPreviewsOptions options, string presetName = null, DarkPreviewsUpdater updater = null, + Func destinationOverrideCallback = null, IProgress progress = null, + CancellationToken cancellation = default(CancellationToken)) { + return new Updater(entries, options, presetName, updater) { + DestinationOverrideCallback = destinationOverrideCallback, + Progress = progress, + CancellationToken = cancellation + }.Run(); } /// diff --git a/AcManager/CustomShowroom/CustomShowroomWrapper.cs b/AcManager/CustomShowroom/CustomShowroomWrapper.cs index 2ac8cbd7e..77d3dcba5 100644 --- a/AcManager/CustomShowroom/CustomShowroomWrapper.cs +++ b/AcManager/CustomShowroom/CustomShowroomWrapper.cs @@ -32,7 +32,7 @@ private static bool IsSameDirectories(string a, string b) { private static bool _starting; - private static void SetProperties(BaseKn5FormWrapper wrapper, IKn5ObjectRenderer renderer) { + public static void SetProperties(BaseKn5FormWrapper wrapper, IKn5ObjectRenderer renderer) { if (!SettingsHolder.CustomShowroom.SmartCameraPivot) { wrapper.AutoAdjustTargetOnReset = false; renderer.AutoAdjustTarget = false; diff --git a/AcManager/CustomShowroom/DarkRendererSettings.cs b/AcManager/CustomShowroom/DarkRendererSettings.cs index 7ff3c0afa..4ed03177d 100644 --- a/AcManager/CustomShowroom/DarkRendererSettings.cs +++ b/AcManager/CustomShowroom/DarkRendererSettings.cs @@ -490,7 +490,7 @@ await CmPreviewsTools.MissingShowroomHelper.OfferToInstall("Kunos Previews Showr NonfatalError.Notify($"Can’t fully load the preset: showroom “{showroomId}” is missing", "Maybe you can find it online?", solutions: new[] { - new NonfatalErrorSolution("Search for showroom", null, token => { + new NonfatalErrorSolution("Search for showroom", token => { WindowsHelper.ViewInBrowser(SettingsHolder.Content.MissingContentSearch.GetUri(showroomId, SettingsHolder.MissingContentType.Showroom)); return Task.Delay(0); diff --git a/AcManager/Pages/Dialogs/AcRootDirectorySelector.xaml.cs b/AcManager/Pages/Dialogs/AcRootDirectorySelector.xaml.cs index f011930f3..a2836f967 100644 --- a/AcManager/Pages/Dialogs/AcRootDirectorySelector.xaml.cs +++ b/AcManager/Pages/Dialogs/AcRootDirectorySelector.xaml.cs @@ -176,7 +176,7 @@ private async void SetSteamId(string steamId) { SteamProfiles.Add(profile); SteamProfile = profile; - profile.ProfileName = await SteamIdHelper.GetSteamName(steamId); + profile.ProfileName = await SteamIdHelper.GetSteamNameAsync(steamId); } private CancellationTokenSource _cancellationTokenSource; diff --git a/AcManager/Pages/Dialogs/ImageEditor.xaml b/AcManager/Pages/Dialogs/ImageEditor.xaml index 7289a2473..1828dd963 100644 --- a/AcManager/Pages/Dialogs/ImageEditor.xaml +++ b/AcManager/Pages/Dialogs/ImageEditor.xaml @@ -6,12 +6,15 @@ MaxHeight="{x:Static mui:DpiAwareWindow.UnlimitedSize}" SizeToContent="Manual" ResizeMode="CanResizeWithGrip" Width="640" Height="640" LocationAndSizeKey=".imageEditor"> - 1 - -1 + + 1 + -1 + + - + diff --git a/AcManager/Pages/Drive/QuickDrive.xaml b/AcManager/Pages/Drive/QuickDrive.xaml index 6571eba04..f3024dd21 100644 --- a/AcManager/Pages/Drive/QuickDrive.xaml +++ b/AcManager/Pages/Drive/QuickDrive.xaml @@ -748,22 +748,25 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + { + var checksum = GetAcChecksum(CarsManager.Instance.GetById(carId)?.Location, fileName); + callback?.ExecuteAsync(checksum); + }).Ignore(); + } + public void getTrackFileChecksumAsync(string trackId, string layoutId, string fileName, IJavascriptCallback callback = null) { Task.Run(() => { - var checksum = GetAcChecksum(TracksManager.Instance.GetLayoutById(trackId, layoutId)?.DataDirectory, fileName); + var checksum = GetAcChecksum(TracksManager.Instance.GetLayoutById(trackId, layoutId)?.DataDirectory, FileUtils.NormalizePath(fileName)); callback?.ExecuteAsync(checksum); }).Ignore(); } public void getTrackGeneralFileChecksumAsync(string trackId, string layoutId, string fileName, IJavascriptCallback callback = null) { Task.Run(() => { - var checksum = GetAcChecksum(TracksManager.Instance.GetLayoutById(trackId, layoutId)?.Location, fileName); + var checksum = GetAcChecksum(TracksManager.Instance.GetLayoutById(trackId, layoutId)?.Location, FileUtils.NormalizePath(fileName)); callback?.ExecuteAsync(checksum); }).Ignore(); } @@ -170,7 +183,7 @@ public bool setCurrentTrack(string trackId, string layoutId = null) { }); } - public void startOnlineRace(string ip, int port, int httpPort, string password, string driverName, IJavascriptCallback callback = null) { + public void startOnlineRace(string paramsJson, IJavascriptCallback callback = null) { if (_car == null) { throw new Exception("Car is not set"); } @@ -179,6 +192,8 @@ public void startOnlineRace(string ip, int port, int httpPort, string password, throw new Exception("Track is not set"); } + var obj = JObject.Parse(paramsJson); + ActionExtension.InvokeInMainThreadAsync(async () => { var result = await GameWrapper.StartAsync(new Game.StartProperties { BasicProperties = new Game.BasicProperties { @@ -189,15 +204,18 @@ public void startOnlineRace(string ip, int port, int httpPort, string password, }, ModeProperties = new Game.OnlineProperties { Guid = SteamIdHelper.Instance.Value, - ServerIp = ip, - ServerPort = port, - ServerHttpPort = httpPort, + ServerIp = obj.GetStringValueOnly("ip") ?? throw new Exception(@"Field “ip” is required"), + ServerPort = obj.GetIntValueOnly("port") ?? throw new Exception(@"Field “port” is required"), + ServerHttpPort = obj.GetIntValueOnly("httpPort") ?? throw new Exception(@"Field “httpPort” is required"), RequestedCar = _car.Id, - Password = password + Password = obj.GetStringValueOnly("password") }, AdditionalPropertieses = { new WorldSimSeriesMark { - Name = driverName + Name = obj.GetStringValueOnly("driverName"), + Nationality = obj.GetStringValueOnly("driverNationality"), + NationCode = obj.GetStringValueOnly("driverNationCode"), + Team = obj.GetStringValueOnly("driverTeam"), } } }); diff --git a/AcManager/Pages/Lists/ScreenshotsListPage.xaml.cs b/AcManager/Pages/Lists/ScreenshotsListPage.xaml.cs index e1db85d0f..c95c6e9e2 100644 --- a/AcManager/Pages/Lists/ScreenshotsListPage.xaml.cs +++ b/AcManager/Pages/Lists/ScreenshotsListPage.xaml.cs @@ -162,7 +162,7 @@ private void OnItemClick(object sender, MouseButtonEventArgs e) { e.Handled = true; new ImageViewer(Model.Screenshots, Model.Screenshots.IndexOf(screenshot), - x => Task.FromResult((object)x.Filename), x => Path.GetFileNameWithoutExtension(x.Filename)) { + x => Task.FromResult((object)x?.Filename), x => Path.GetFileNameWithoutExtension(x?.Filename)) { MaxImageWidth = 3840, AutoHideDescriptionIfExpanded = true, Model = { ContextMenuCallback = ShowContextMenu } diff --git a/AcManager/Pages/Selected/SelectedCarPage_New.xaml b/AcManager/Pages/Selected/SelectedCarPage_New.xaml index 7ab0fc8d9..be7dda106 100644 --- a/AcManager/Pages/Selected/SelectedCarPage_New.xaml +++ b/AcManager/Pages/Selected/SelectedCarPage_New.xaml @@ -118,7 +118,8 @@ Visibility="{Binding DeveloperMode, Source={x:Static t:SettingsHolder.Common}, Converter={StaticResource BooleanToVisibilityConverter}}" />