diff --git a/CUE4Parse b/CUE4Parse index 4e955153..455b72e5 160000 --- a/CUE4Parse +++ b/CUE4Parse @@ -1 +1 @@ -Subproject commit 4e955153559be8dc156d15fc93ff8c1016d3ebfe +Subproject commit 455b72e5e38bfe9476b5823bc642fc8ef488347f diff --git a/FModel/App.xaml.cs b/FModel/App.xaml.cs index e5161508..4781e951 100644 --- a/FModel/App.xaml.cs +++ b/FModel/App.xaml.cs @@ -97,14 +97,16 @@ protected override void OnStartup(StartupEventArgs e) Directory.CreateDirectory(Path.Combine(UserSettings.Default.OutputDirectory, ".data")); #if DEBUG - Log.Logger = new LoggerConfiguration().WriteTo.Console(theme: AnsiConsoleTheme.Literate).CreateLogger(); + Log.Logger = new LoggerConfiguration().WriteTo.Console(theme: AnsiConsoleTheme.Literate).WriteTo.File( + Path.Combine(UserSettings.Default.OutputDirectory, "Logs", $"FModel-Debug-Log-{DateTime.Now:yyyy-MM-dd}.txt"), + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [FModel] [{Level:u3}] {Message:lj}{NewLine}{Exception}").CreateLogger(); #else Log.Logger = new LoggerConfiguration().WriteTo.Console(theme: AnsiConsoleTheme.Literate).WriteTo.File( Path.Combine(UserSettings.Default.OutputDirectory, "Logs", $"FModel-Log-{DateTime.Now:yyyy-MM-dd}.txt"), outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [FModel] [{Level:u3}] {Message:lj}{NewLine}{Exception}").CreateLogger(); #endif - Log.Information("Version {Version}", Constants.APP_VERSION); + Log.Information("Version {Version} ({CommitId})", Constants.APP_VERSION, Constants.APP_COMMIT_ID); Log.Information("{OS}", GetOperatingSystemProductName()); Log.Information("{RuntimeVer}", RuntimeInformation.FrameworkDescription); Log.Information("Culture {SysLang}", CultureInfo.CurrentCulture); @@ -140,7 +142,7 @@ private void OnUnhandledException(object sender, DispatcherUnhandledExceptionEve if (messageBox.Result == MessageBoxResult.Custom && (EErrorKind) messageBox.ButtonPressed.Id != EErrorKind.Ignore) { if ((EErrorKind) messageBox.ButtonPressed.Id == EErrorKind.ResetSettings) - UserSettings.Default = new UserSettings(); + UserSettings.Delete(); ApplicationService.ApplicationView.Restart(); } diff --git a/FModel/Constants.cs b/FModel/Constants.cs index a0bf4597..48507954 100644 --- a/FModel/Constants.cs +++ b/FModel/Constants.cs @@ -1,12 +1,20 @@ -using System.Numerics; +using System; +using System.Diagnostics; +using System.IO; +using System.Numerics; using System.Reflection; using CUE4Parse.UE4.Objects.Core.Misc; +using FModel.Extensions; namespace FModel; public static class Constants { - public static readonly string APP_VERSION = Assembly.GetExecutingAssembly().GetName().Version?.ToString(); + public static readonly string APP_PATH = Path.GetFullPath(Environment.GetCommandLineArgs()[0]); + public static readonly string APP_VERSION = FileVersionInfo.GetVersionInfo(APP_PATH).FileVersion; + public static readonly string APP_COMMIT_ID = FileVersionInfo.GetVersionInfo(APP_PATH).ProductVersion.SubstringAfter('+'); + public static readonly string APP_SHORT_COMMIT_ID = APP_COMMIT_ID[..7]; + public const string ZERO_64_CHAR = "0000000000000000000000000000000000000000000000000000000000000000"; public static readonly FGuid ZERO_GUID = new(0U); @@ -21,6 +29,9 @@ public static class Constants public const string BLUE = "#528BCC"; public const string ISSUE_LINK = "https://github.com/4sval/FModel/discussions/categories/q-a"; + public const string GH_REPO = "https://api.github.com/repos/4sval/FModel"; + public const string GH_COMMITS_HISTORY = GH_REPO + "/commits"; + public const string GH_RELEASES = GH_REPO + "/releases"; public const string DONATE_LINK = "https://fmodel.app/donate"; public const string DISCORD_LINK = "https://fmodel.app/discord"; diff --git a/FModel/Creator/Bases/FN/BaseIcon.cs b/FModel/Creator/Bases/FN/BaseIcon.cs index 9c5f2d30..1ea4ce2c 100644 --- a/FModel/Creator/Bases/FN/BaseIcon.cs +++ b/FModel/Creator/Bases/FN/BaseIcon.cs @@ -31,25 +31,33 @@ public void ParseForReward(bool isUsingDisplayAsset) { // rarity if (Object.TryGetValue(out FPackageIndex series, "Series")) GetSeries(series); - else if (Object.TryGetValue(out FInstancedStruct[] dataList, "DataList")) GetSeries(dataList); else if (Object.TryGetValue(out FStructFallback componentContainer, "ComponentContainer")) GetSeries(componentContainer); else GetRarity(Object.GetOrDefault("Rarity", EFortRarity.Uncommon)); // default is uncommon + if (Object.TryGetValue(out FInstancedStruct[] dataList, "DataList")) + { + GetSeries(dataList); + Preview = Utils.GetBitmap(dataList); + } + // preview - if (isUsingDisplayAsset && Utils.TryGetDisplayAsset(Object, out var preview)) - Preview = preview; - else if (Object.TryGetValue(out FPackageIndex itemDefinition, "HeroDefinition", "WeaponDefinition")) - Preview = Utils.GetBitmap(itemDefinition); - else if (Object.TryGetValue(out FSoftObjectPath largePreview, "LargePreviewImage", "EntryListIcon", "SmallPreviewImage", "BundleImage", "ItemDisplayAsset", "LargeIcon", "ToastIcon", "SmallIcon")) - Preview = Utils.GetBitmap(largePreview); - else if (Object.TryGetValue(out string s, "LargePreviewImage") && !string.IsNullOrEmpty(s)) - Preview = Utils.GetBitmap(s); - else if (Object.TryGetValue(out FPackageIndex otherPreview, "SmallPreviewImage", "ToastIcon", "access_item")) - Preview = Utils.GetBitmap(otherPreview); - else if (Object.TryGetValue(out UMaterialInstanceConstant materialInstancePreview, "EventCalloutImage")) - Preview = Utils.GetBitmap(materialInstancePreview); - else if (Object.TryGetValue(out FStructFallback brush, "IconBrush") && brush.TryGetValue(out UTexture2D res, "ResourceObject")) - Preview = Utils.GetBitmap(res); + if (Preview is null) + { + if (isUsingDisplayAsset && Utils.TryGetDisplayAsset(Object, out var preview)) + Preview = preview; + else if (Object.TryGetValue(out FPackageIndex itemDefinition, "HeroDefinition", "WeaponDefinition")) + Preview = Utils.GetBitmap(itemDefinition); + else if (Object.TryGetValue(out FSoftObjectPath largePreview, "LargePreviewImage", "EntryListIcon", "SmallPreviewImage", "BundleImage", "ItemDisplayAsset", "LargeIcon", "ToastIcon", "SmallIcon")) + Preview = Utils.GetBitmap(largePreview); + else if (Object.TryGetValue(out string s, "LargePreviewImage") && !string.IsNullOrEmpty(s)) + Preview = Utils.GetBitmap(s); + else if (Object.TryGetValue(out FPackageIndex otherPreview, "SmallPreviewImage", "ToastIcon", "access_item")) + Preview = Utils.GetBitmap(otherPreview); + else if (Object.TryGetValue(out UMaterialInstanceConstant materialInstancePreview, "EventCalloutImage")) + Preview = Utils.GetBitmap(materialInstancePreview); + else if (Object.TryGetValue(out FStructFallback brush, "IconBrush") && brush.TryGetValue(out UTexture2D res, "ResourceObject")) + Preview = Utils.GetBitmap(res); + } // text if (Object.TryGetValue(out FText displayName, "DisplayName", "ItemName", "BundleName", "DefaultHeaderText", "UIDisplayName", "EntryName", "EventCalloutTitle")) diff --git a/FModel/Creator/Bases/FN/BaseMaterialInstance.cs b/FModel/Creator/Bases/FN/BaseMaterialInstance.cs index c9776b8b..e761fff9 100644 --- a/FModel/Creator/Bases/FN/BaseMaterialInstance.cs +++ b/FModel/Creator/Bases/FN/BaseMaterialInstance.cs @@ -31,6 +31,7 @@ public override void ParseForInfo() case "TextureA": case "TextureB": case "OfferImage": + case "CarTexture": Preview = Utils.GetBitmap(texture); break; } @@ -88,4 +89,4 @@ public override SKBitmap[] Draw() return new[] { ret }; } -} \ No newline at end of file +} diff --git a/FModel/Creator/Bases/FN/BaseOfferDisplayData.cs b/FModel/Creator/Bases/FN/BaseOfferDisplayData.cs index 250a6ab3..721c53fe 100644 --- a/FModel/Creator/Bases/FN/BaseOfferDisplayData.cs +++ b/FModel/Creator/Bases/FN/BaseOfferDisplayData.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using CUE4Parse.UE4.Assets.Exports; using CUE4Parse.UE4.Assets.Exports.Material; using CUE4Parse.UE4.Assets.Objects; @@ -8,10 +9,11 @@ namespace FModel.Creator.Bases.FN; public class BaseOfferDisplayData : UCreator { - private BaseMaterialInstance[] _offerImages; + private readonly List _offerImages; public BaseOfferDisplayData(UObject uObject, EIconStyle style) : base(uObject, style) { + _offerImages = new List(); } public override void ParseForInfo() @@ -19,24 +21,23 @@ public override void ParseForInfo() if (!Object.TryGetValue(out FStructFallback[] contextualPresentations, "ContextualPresentations")) return; - _offerImages = new BaseMaterialInstance[contextualPresentations.Length]; - for (var i = 0; i < _offerImages.Length; i++) + for (var i = 0; i < contextualPresentations.Length; i++) { if (!contextualPresentations[i].TryGetValue(out FSoftObjectPath material, "Material") || !material.TryLoad(out UMaterialInterface presentation)) continue; var offerImage = new BaseMaterialInstance(presentation, Style); offerImage.ParseForInfo(); - _offerImages[i] = offerImage; + _offerImages.Add(offerImage); } } public override SKBitmap[] Draw() { - var ret = new SKBitmap[_offerImages.Length]; + var ret = new SKBitmap[_offerImages.Count]; for (var i = 0; i < ret.Length; i++) { - ret[i] = _offerImages[i].Draw()[0]; + ret[i] = _offerImages[i]?.Draw()[0]; } return ret; diff --git a/FModel/Creator/Bases/FN/BasePlaylist.cs b/FModel/Creator/Bases/FN/BasePlaylist.cs index fe7fd7c9..9110158c 100644 --- a/FModel/Creator/Bases/FN/BasePlaylist.cs +++ b/FModel/Creator/Bases/FN/BasePlaylist.cs @@ -34,7 +34,7 @@ public override void ParseForInfo() return; var playlist = _apiEndpointView.FortniteApi.GetPlaylist(playlistName.Text); - if (!playlist.IsSuccess || !playlist.Data.Images.HasShowcase || + if (!playlist.IsSuccess || playlist.Data.Images is not { HasShowcase: true } || !_apiEndpointView.FortniteApi.TryGetBytes(playlist.Data.Images.Showcase, out var image)) return; @@ -74,4 +74,4 @@ private void DrawMissionIcon(SKCanvas c) if (_missionIcon == null) return; c.DrawBitmap(_missionIcon, new SKPoint(5, 5), ImagePaint); } -} \ No newline at end of file +} diff --git a/FModel/Creator/CreatorPackage.cs b/FModel/Creator/CreatorPackage.cs index 4a10cde2..d61cf4bf 100644 --- a/FModel/Creator/CreatorPackage.cs +++ b/FModel/Creator/CreatorPackage.cs @@ -67,6 +67,8 @@ public bool TryConstructCreator(out UCreator creator) case "FortBadgeItemDefinition": case "SparksMicItemDefinition": case "FortAwardItemDefinition": + case "FortStackItemDefinition": + case "FortWorldItemDefinition": case "SparksAuraItemDefinition": case "SparksDrumItemDefinition": case "SparksBassItemDefinition": @@ -76,6 +78,8 @@ public bool TryConstructCreator(out UCreator creator) case "FortGiftBoxItemDefinition": case "FortOutpostItemDefinition": case "FortVehicleItemDefinition": + case "FortMissionItemDefinition": + case "FortAccountItemDefinition": case "SparksGuitarItemDefinition": case "FortCardPackItemDefinition": case "FortDefenderItemDefinition": @@ -83,28 +87,34 @@ public bool TryConstructCreator(out UCreator creator) case "FortResourceItemDefinition": case "FortBackpackItemDefinition": case "FortEventQuestMapDataAsset": + case "FortBuildingItemDefinition": case "FortWeaponModItemDefinition": case "FortCodeTokenItemDefinition": case "FortSchematicItemDefinition": + case "FortAlterableItemDefinition": case "SparksKeyboardItemDefinition": case "FortWorldMultiItemDefinition": case "FortAlterationItemDefinition": case "FortExpeditionItemDefinition": case "FortIngredientItemDefinition": + case "FortConsumableItemDefinition": case "StWFortAccoladeItemDefinition": case "FortAccountBuffItemDefinition": case "FortWeaponMeleeItemDefinition": case "FortPlayerPerksItemDefinition": case "FortPlaysetPropItemDefinition": + case "FortPrerollDataItemDefinition": case "JunoRecipeBundleItemDefinition": case "FortHomebaseNodeItemDefinition": case "FortNeverPersistItemDefinition": case "FortPlayerAugmentItemDefinition": case "FortSmartBuildingItemDefinition": + case "FortGiftBoxUnlockItemDefinition": case "FortWeaponModItemDefinitionOptic": case "RadioContentSourceItemDefinition": case "FortPlaysetGrenadeItemDefinition": case "JunoWeaponCreatureItemDefinition": + case "FortEventDependentItemDefinition": case "FortPersonalVehicleItemDefinition": case "FortGameplayModifierItemDefinition": case "FortHardcoreModifierItemDefinition": @@ -113,11 +123,13 @@ public bool TryConstructCreator(out UCreator creator) case "FortConversionControlItemDefinition": case "FortAccountBuffCreditItemDefinition": case "JunoBuildInstructionsItemDefinition": + case "FortCharacterCosmeticItemDefinition": case "JunoBuildingSetAccountItemDefinition": case "FortEventCurrencyItemDefinitionRedir": case "FortPersistentResourceItemDefinition": case "FortWeaponMeleeOffhandItemDefinition": case "FortHomebaseBannerIconItemDefinition": + case "FortVehicleCosmeticsVariantTokenType": case "JunoBuildingPropAccountItemDefinition": case "FortCampaignHeroLoadoutItemDefinition": case "FortConditionalResourceItemDefinition": @@ -129,6 +141,7 @@ public bool TryConstructCreator(out UCreator creator) case "FortVehicleCosmeticsItemDefinition_Skin": case "FortVehicleCosmeticsItemDefinition_Wheel": case "FortCreativeRealEstatePlotItemDefinition": + case "FortDeployableBaseCloudSaveItemDefinition": case "FortVehicleCosmeticsItemDefinition_Booster": case "AthenaDanceItemDefinition_AdHocSquadsJoin_C": case "FortVehicleCosmeticsItemDefinition_DriftSmoke": diff --git a/FModel/Creator/Utils.cs b/FModel/Creator/Utils.cs index 57bc1213..9d79ae31 100644 --- a/FModel/Creator/Utils.cs +++ b/FModel/Creator/Utils.cs @@ -11,6 +11,7 @@ using CUE4Parse.UE4.Objects.UObject; using CUE4Parse.UE4.Versions; using CUE4Parse_Conversion.Textures; +using CUE4Parse.UE4.Assets.Objects; using FModel.Framework; using FModel.Extensions; using FModel.Services; @@ -71,6 +72,7 @@ public static SKBitmap GetBitmap(FPackageIndex packageIndex) return GetBitmap(material); default: { + if (export.TryGetValue(out FInstancedStruct[] dataList, "DataList")) return GetBitmap(dataList); if (export.TryGetValue(out FSoftObjectPath previewImage, "LargePreviewImage", "SmallPreviewImage")) return GetBitmap(previewImage); if (export.TryGetValue(out string largePreview, "LargePreviewImage")) return GetBitmap(largePreview); if (export.TryGetValue(out FPackageIndex smallPreview, "SmallPreviewImage")) @@ -85,6 +87,21 @@ public static SKBitmap GetBitmap(FPackageIndex packageIndex) } } + public static SKBitmap GetBitmap(FInstancedStruct[] structs) + { + if (structs.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out FSoftObjectPath p, "LargeIcon") == true && !p.AssetPathName.IsNone) is { NonConstStruct: not null } isl) + { + return GetBitmap(isl.NonConstStruct.Get("LargeIcon")); + } + + if (structs.FirstOrDefault(d => d.NonConstStruct?.TryGetValue(out FSoftObjectPath p, "Icon") == true && !p.AssetPathName.IsNone) is { NonConstStruct: not null } isi) + { + return GetBitmap(isi.NonConstStruct.Get("Icon")); + } + + return null; + } + public static SKBitmap GetBitmap(UMaterialInstanceConstant material) { if (material == null) return null; @@ -400,4 +417,4 @@ public static List SplitLines(string text, SKPaint paint, float maxWidth return ret; } -} +} \ No newline at end of file diff --git a/FModel/Enums.cs b/FModel/Enums.cs index 8231f7b6..1412b013 100644 --- a/FModel/Enums.cs +++ b/FModel/Enums.cs @@ -20,8 +20,7 @@ public enum EErrorKind public enum SettingsOut { ReloadLocres, - ReloadMappings, - CheckForUpdates + ReloadMappings } public enum EStatusKind @@ -64,15 +63,15 @@ public enum ELoadingMode AllButModified } -public enum EUpdateMode -{ - [Description("Stable")] - Stable, - [Description("Beta")] - Beta, - [Description("QA Testing")] - Qa -} +// public enum EUpdateMode +// { +// [Description("Stable")] +// Stable, +// [Description("Beta")] +// Beta, +// [Description("QA Testing")] +// Qa +// } public enum ECompressedAudio { diff --git a/FModel/FModel.csproj b/FModel/FModel.csproj index 1094a287..dbae3fed 100644 --- a/FModel/FModel.csproj +++ b/FModel/FModel.csproj @@ -5,9 +5,9 @@ net8.0-windows true FModel.ico - 4.4.3.6 - 4.4.3.6 - 4.4.3.6 + 4.4.4.0 + 4.4.4.0 + 4.4.4.0 false true win-x64 @@ -148,21 +148,21 @@ - + - - + + - - - - + + + + diff --git a/FModel/Framework/FRestRequest.cs b/FModel/Framework/FRestRequest.cs index 549a00e3..b09cf30c 100644 --- a/FModel/Framework/FRestRequest.cs +++ b/FModel/Framework/FRestRequest.cs @@ -1,19 +1,19 @@ -using System; +using System; using RestSharp; namespace FModel.Framework; public class FRestRequest : RestRequest { - private const int _timeout = 3 * 1000; + private const int TimeoutSeconds = 5; public FRestRequest(string url, Method method = Method.Get) : base(url, method) { - Timeout = _timeout; + Timeout = TimeSpan.FromSeconds(TimeoutSeconds); } public FRestRequest(Uri uri, Method method = Method.Get) : base(uri, method) { - Timeout = _timeout; + Timeout = TimeSpan.FromSeconds(TimeoutSeconds); } } diff --git a/FModel/MainWindow.xaml b/FModel/MainWindow.xaml index b574a4f2..57d1b200 100644 --- a/FModel/MainWindow.xaml +++ b/FModel/MainWindow.xaml @@ -147,11 +147,11 @@ - + - + @@ -349,7 +349,7 @@ - + @@ -517,7 +517,7 @@ - + @@ -797,13 +797,17 @@ + + + + diff --git a/FModel/MainWindow.xaml.cs b/FModel/MainWindow.xaml.cs index 7714cd4e..1fe3623a 100644 --- a/FModel/MainWindow.xaml.cs +++ b/FModel/MainWindow.xaml.cs @@ -47,7 +47,7 @@ private async void OnLoaded(object sender, RoutedEventArgs e) { var newOrUpdated = UserSettings.Default.ShowChangelog; #if !DEBUG - ApplicationService.ApiEndpointView.FModelApi.CheckForUpdates(UserSettings.Default.UpdateMode, true); + ApplicationService.ApiEndpointView.FModelApi.CheckForUpdates(true); #endif switch (UserSettings.Default.AesReload) @@ -85,7 +85,7 @@ await Task.WhenAll( #if DEBUG // await _threadWorkerView.Begin(cancellationToken => // _applicationView.CUE4Parse.Extract(cancellationToken, - // "fortnitegame/Content/Characters/Player/Female/Large/Bodies/F_LRG_BunnyBR/Meshes/F_LRG_BunnyBR.uasset")); + // "FortniteGame/Content/Athena/Apollo/Maps/UI/Apollo_Terrain_Minimap.uasset")); // await _threadWorkerView.Begin(cancellationToken => // _applicationView.CUE4Parse.Extract(cancellationToken, // "FortniteGame/Content/Environments/Helios/Props/GlacierHotel/GlacierHotel_Globe_A/Meshes/SM_GlacierHotel_Globe_A.uasset")); diff --git a/FModel/Resources/outline.vert b/FModel/Resources/outline.vert index 396b6b14..0a930758 100644 --- a/FModel/Resources/outline.vert +++ b/FModel/Resources/outline.vert @@ -27,10 +27,18 @@ vec2 unpackBoneIDsAndWeights(int packedData) return vec2(float((packedData >> 16) & 0xFFFF), float(packedData & 0xFFFF)); } +vec4 calculateScale(vec4 bindPos, vec4 bindNormal) +{ + vec4 worldPos = vInstanceMatrix * bindPos; + float scaleFactor = length(uViewPos - worldPos.xyz) * 0.0035; + return transpose(inverse(vInstanceMatrix)) * bindNormal * scaleFactor; +} + void main() { vec4 bindPos = vec4(mix(vPos, vMorphTargetPos, uMorphTime), 1.0); vec4 bindNormal = vec4(vNormal, 1.0); + bindPos.xyz += calculateScale(bindPos, bindNormal).xyz; vec4 finalPos = vec4(0.0); vec4 finalNormal = vec4(0.0); @@ -53,8 +61,6 @@ void main() finalNormal += transpose(inverse(boneMatrix)) * bindNormal * weight; } } - finalPos = normalize(finalPos); - finalNormal = normalize(finalNormal); } else { @@ -62,10 +68,5 @@ void main() finalNormal = bindNormal; } - vec4 worldPos = vInstanceMatrix * finalPos; - float scaleFactor = length(uViewPos - worldPos.xyz) * 0.0035; - vec4 nor = transpose(inverse(vInstanceMatrix)) * finalNormal * scaleFactor; - finalPos.xyz += nor.xyz; - gl_Position = uProjection * uView * vInstanceMatrix * finalPos; } diff --git a/FModel/Settings/DirectorySettings.cs b/FModel/Settings/DirectorySettings.cs index d96a735b..6b977cbb 100644 --- a/FModel/Settings/DirectorySettings.cs +++ b/FModel/Settings/DirectorySettings.cs @@ -113,6 +113,11 @@ public override int GetHashCode() return HashCode.Combine(GameDirectory, (int) UeVersion); } + public override string ToString() + { + return GameName; + } + public object Clone() { return this.MemberwiseClone(); diff --git a/FModel/Settings/UserSettings.cs b/FModel/Settings/UserSettings.cs index b5eb8e3e..f6d31036 100644 --- a/FModel/Settings/UserSettings.cs +++ b/FModel/Settings/UserSettings.cs @@ -32,16 +32,21 @@ static UserSettings() Default = new UserSettings(); } + private static bool _bSave = true; public static void Save() { - if (Default == null) return; + if (!_bSave || Default == null) return; Default.PerDirectory[Default.CurrentDir.GameDirectory] = Default.CurrentDir; File.WriteAllText(FilePath, JsonConvert.SerializeObject(Default, Formatting.Indented)); } public static void Delete() { - if (File.Exists(FilePath)) File.Delete(FilePath); + if (File.Exists(FilePath)) + { + _bSave = false; + File.Delete(FilePath); + } } public static bool IsEndpointValid(EEndpointType type, out EndpointSettings endpoint) @@ -174,18 +179,18 @@ public ELoadingMode LoadingMode set => SetProperty(ref _loadingMode, value); } - private EUpdateMode _updateMode = EUpdateMode.Beta; - public EUpdateMode UpdateMode + private DateTime _lastUpdateCheck = DateTime.MinValue; + public DateTime LastUpdateCheck { - get => _updateMode; - set => SetProperty(ref _updateMode, value); + get => _lastUpdateCheck; + set => SetProperty(ref _lastUpdateCheck, value); } - private string _commitHash = Constants.APP_VERSION; - public string CommitHash + private DateTime _nextUpdateCheck = DateTime.Now; + public DateTime NextUpdateCheck { - get => _commitHash; - set => SetProperty(ref _commitHash, value); + get => _nextUpdateCheck; + set => SetProperty(ref _nextUpdateCheck, value); } private bool _keepDirectoryStructure = true; @@ -260,8 +265,6 @@ public IDictionary PerDirectory [JsonIgnore] public DirectorySettings CurrentDir { get; set; } - [JsonIgnore] - public string ShortCommitHash => CommitHash[..7]; /// /// TO DELETEEEEEEEEEEEEE diff --git a/FModel/ViewModels/ApiEndpointViewModel.cs b/FModel/ViewModels/ApiEndpointViewModel.cs index 00927593..6276567a 100644 --- a/FModel/ViewModels/ApiEndpointViewModel.cs +++ b/FModel/ViewModels/ApiEndpointViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using FModel.Framework; @@ -12,7 +12,7 @@ public class ApiEndpointViewModel private readonly RestClient _client = new (new RestClientOptions { UserAgent = $"FModel/{Constants.APP_VERSION}", - MaxTimeout = 3 * 1000 + Timeout = TimeSpan.FromSeconds(5) }, configureSerialization: s => s.UseSerializer()); public FortniteApiEndpoint FortniteApi { get; } @@ -20,6 +20,7 @@ public class ApiEndpointViewModel public FortniteCentralApiEndpoint CentralApi { get; } public EpicApiEndpoint EpicApi { get; } public FModelApiEndpoint FModelApi { get; } + public GitHubApiEndpoint GitHubApi { get; } public DynamicApiEndpoint DynamicApi { get; } public ApiEndpointViewModel() @@ -29,6 +30,7 @@ public ApiEndpointViewModel() CentralApi = new FortniteCentralApiEndpoint(_client); EpicApi = new EpicApiEndpoint(_client); FModelApi = new FModelApiEndpoint(_client); + GitHubApi = new GitHubApiEndpoint(_client); DynamicApi = new DynamicApiEndpoint(_client); } diff --git a/FModel/ViewModels/ApiEndpoints/FModelApiEndpoint.cs b/FModel/ViewModels/ApiEndpoints/FModelApiEndpoint.cs index 1fbe18f0..34fcca98 100644 --- a/FModel/ViewModels/ApiEndpoints/FModelApiEndpoint.cs +++ b/FModel/ViewModels/ApiEndpoints/FModelApiEndpoint.cs @@ -10,13 +10,13 @@ using FModel.Services; using FModel.Settings; using FModel.ViewModels.ApiEndpoints.Models; +using FModel.Views; using Newtonsoft.Json; using RestSharp; using Serilog; using MessageBox = AdonisUI.Controls.MessageBox; using MessageBoxButton = AdonisUI.Controls.MessageBoxButton; using MessageBoxImage = AdonisUI.Controls.MessageBoxImage; -using MessageBoxResult = AdonisUI.Controls.MessageBoxResult; namespace FModel.ViewModels.ApiEndpoints; @@ -46,19 +46,6 @@ public News GetNews(CancellationToken token, string game) return _news ??= GetNewsAsync(token, game).GetAwaiter().GetResult(); } - public async Task GetInfosAsync(CancellationToken token, EUpdateMode updateMode) - { - var request = new FRestRequest($"https://api.fmodel.app/v1/infos/{updateMode}"); - var response = await _client.ExecuteAsync(request, token).ConfigureAwait(false); - Log.Information("[{Method}] [{Status}({StatusCode})] '{Resource}'", request.Method, response.StatusDescription, (int) response.StatusCode, response.ResponseUri?.OriginalString); - return response.Data; - } - - public Info GetInfos(CancellationToken token, EUpdateMode updateMode) - { - return _infos ?? GetInfosAsync(token, updateMode).GetAwaiter().GetResult(); - } - public async Task GetDonatorsAsync() { var request = new FRestRequest($"https://api.fmodel.app/v1/donations/donators"); @@ -116,14 +103,16 @@ public CommunityDesign GetDesign(string designName) return communityDesign; } - public void CheckForUpdates(EUpdateMode updateMode, bool launch = false) + public void CheckForUpdates(bool launch = false) { + if (DateTime.Now < UserSettings.Default.NextUpdateCheck) return; + if (launch) { AutoUpdater.ParseUpdateInfoEvent += ParseUpdateInfoEvent; AutoUpdater.CheckForUpdateEvent += CheckForUpdateEvent; } - AutoUpdater.Start($"https://api.fmodel.app/v1/infos/{updateMode}"); + AutoUpdater.Start("https://api.fmodel.app/v1/infos/Qa"); } private void ParseUpdateInfoEvent(ParseUpdateInfoEventArgs args) @@ -138,7 +127,6 @@ private void ParseUpdateInfoEvent(ParseUpdateInfoEventArgs args) DownloadURL = _infos.DownloadUrl, Mandatory = new CustomMandatory { - Value = UserSettings.Default.UpdateMode == EUpdateMode.Qa, CommitHash = _infos.Version.SubstringAfter('+') } }; @@ -149,43 +137,21 @@ private void CheckForUpdateEvent(UpdateInfoEventArgs args) { if (args is { CurrentVersion: { } }) { - var qa = (CustomMandatory) args.Mandatory; - var currentVersion = new System.Version(args.CurrentVersion); - if ((qa.Value && qa.CommitHash == UserSettings.Default.CommitHash) || // qa branch : same commit id - (!qa.Value && currentVersion == args.InstalledVersion && args.CurrentVersion == UserSettings.Default.CommitHash)) // stable - beta branch : same version + commit id = version + UserSettings.Default.LastUpdateCheck = DateTime.Now; + + if (((CustomMandatory)args.Mandatory).CommitHash == Constants.APP_COMMIT_ID) { if (UserSettings.Default.ShowChangelog) ShowChangelog(args); + return; } - var downgrade = currentVersion < args.InstalledVersion; - var messageBox = new MessageBoxModel - { - Text = $"The latest version of FModel {UserSettings.Default.UpdateMode.GetDescription()} is {(qa.Value ? qa.ShortCommitHash : args.CurrentVersion)}. You are using version {(qa.Value ? UserSettings.Default.ShortCommitHash : args.InstalledVersion)}. Do you want to {(downgrade ? "downgrade" : "update")} the application now?", - Caption = $"{(downgrade ? "Downgrade" : "Update")} Available", - Icon = MessageBoxImage.Question, - Buttons = MessageBoxButtons.YesNo(), - IsSoundEnabled = false - }; - - MessageBox.Show(messageBox); - if (messageBox.Result != MessageBoxResult.Yes) return; + var currentVersion = new System.Version(args.CurrentVersion); + UserSettings.Default.ShowChangelog = currentVersion != args.InstalledVersion; - try - { - if (AutoUpdater.DownloadUpdate(args)) - { - UserSettings.Default.ShowChangelog = currentVersion != args.InstalledVersion; - UserSettings.Default.CommitHash = qa.CommitHash; - Application.Current.Shutdown(); - } - } - catch (Exception exception) - { - UserSettings.Default.ShowChangelog = false; - MessageBox.Show(exception.Message, exception.GetType().ToString(), MessageBoxButton.OK, MessageBoxImage.Error); - } + const string message = "A new update is available!"; + Helper.OpenWindow(message, () => new UpdateView { Title = message, ResizeMode = ResizeMode.NoResize }.ShowDialog()); } else { @@ -199,7 +165,7 @@ private void ShowChangelog(UpdateInfoEventArgs args) { var request = new FRestRequest(args.ChangelogURL); var response = _client.Execute(request); - if (string.IsNullOrEmpty(response.Content)) return; + if (!response.IsSuccessful || string.IsNullOrEmpty(response.Content)) return; _applicationView.CUE4Parse.TabControl.AddTab($"Release Notes: {args.CurrentVersion}"); _applicationView.CUE4Parse.TabControl.SelectedTab.Highlighter = AvalonExtensions.HighlighterSelector("changelog"); diff --git a/FModel/ViewModels/ApiEndpoints/GitHubApiEndpoint.cs b/FModel/ViewModels/ApiEndpoints/GitHubApiEndpoint.cs new file mode 100644 index 00000000..94fbb075 --- /dev/null +++ b/FModel/ViewModels/ApiEndpoints/GitHubApiEndpoint.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using FModel.Framework; +using FModel.ViewModels.ApiEndpoints.Models; +using RestSharp; + +namespace FModel.ViewModels.ApiEndpoints; + +public class GitHubApiEndpoint : AbstractApiProvider +{ + public GitHubApiEndpoint(RestClient client) : base(client) { } + + public async Task GetCommitHistoryAsync(string branch = "dev", int page = 1, int limit = 20) + { + var request = new FRestRequest(Constants.GH_COMMITS_HISTORY); + request.AddParameter("sha", branch); + request.AddParameter("page", page); + request.AddParameter("per_page", limit); + var response = await _client.ExecuteAsync(request).ConfigureAwait(false); + return response.Data; + } + + public async Task GetReleaseAsync(string tag) + { + var request = new FRestRequest($"{Constants.GH_RELEASES}/tags/{tag}"); + var response = await _client.ExecuteAsync(request).ConfigureAwait(false); + return response.Data; + } +} diff --git a/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs b/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs new file mode 100644 index 00000000..c2c15d5d --- /dev/null +++ b/FModel/ViewModels/ApiEndpoints/Models/GitHubResponse.cs @@ -0,0 +1,125 @@ +using System; +using System.Windows; +using AdonisUI.Controls; +using AutoUpdaterDotNET; +using FModel.Framework; +using FModel.Settings; +using MessageBox = AdonisUI.Controls.MessageBox; +using MessageBoxButton = AdonisUI.Controls.MessageBoxButton; +using MessageBoxImage = AdonisUI.Controls.MessageBoxImage; +using MessageBoxResult = AdonisUI.Controls.MessageBoxResult; +using J = Newtonsoft.Json.JsonPropertyAttribute; + +namespace FModel.ViewModels.ApiEndpoints.Models; + +public class GitHubRelease +{ + [J("assets")] public GitHubAsset[] Assets { get; private set; } +} + +public class GitHubAsset : ViewModel +{ + [J("name")] public string Name { get; private set; } + [J("size")] public int Size { get; private set; } + [J("download_count")] public int DownloadCount { get; private set; } + [J("browser_download_url")] public string BrowserDownloadUrl { get; private set; } + [J("created_at")] public DateTime CreatedAt { get; private set; } + [J("uploader")] public Author Uploader { get; private set; } + + private bool _isLatest; + public bool IsLatest + { + get => _isLatest; + set => SetProperty(ref _isLatest, value); + } +} + +public class GitHubCommit : ViewModel +{ + private string _sha; + [J("sha")] + public string Sha + { + get => _sha; + set + { + SetProperty(ref _sha, value); + RaisePropertyChanged(nameof(IsCurrent)); + RaisePropertyChanged(nameof(ShortSha)); + } + } + + [J("commit")] public Commit Commit { get; set; } + [J("author")] public Author Author { get; set; } + + private GitHubAsset _asset; + public GitHubAsset Asset + { + get => _asset; + set + { + SetProperty(ref _asset, value); + RaisePropertyChanged(nameof(IsDownloadable)); + } + } + + public bool IsCurrent => Sha == Constants.APP_COMMIT_ID; + public string ShortSha => Sha[..7]; + public bool IsDownloadable => Asset != null; + + public void Download() + { + if (IsCurrent) + { + MessageBox.Show(new MessageBoxModel + { + Text = "You are already on the latest version.", + Caption = "Update FModel", + Icon = MessageBoxImage.Information, + Buttons = [MessageBoxButtons.Ok()], + IsSoundEnabled = false + }); + return; + } + + var messageBox = new MessageBoxModel + { + Text = $"Are you sure you want to update to version '{ShortSha}'?{(!Asset.IsLatest ? "\nThis is not the latest version." : "")}", + Caption = "Update FModel", + Icon = MessageBoxImage.Question, + Buttons = MessageBoxButtons.YesNo(), + IsSoundEnabled = false + }; + + MessageBox.Show(messageBox); + if (messageBox.Result != MessageBoxResult.Yes) return; + + try + { + if (AutoUpdater.DownloadUpdate(new UpdateInfoEventArgs { DownloadURL = Asset.BrowserDownloadUrl })) + { + Application.Current.Shutdown(); + } + } + catch (Exception exception) + { + UserSettings.Default.ShowChangelog = false; + MessageBox.Show(exception.Message, exception.GetType().ToString(), MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} + +public class Commit +{ + [J("author")] public Author Author { get; set; } + [J("message")] public string Message { get; set; } +} + +public class Author +{ + [J("name")] public string Name { get; set; } + [J("login")] public string Login { get; set; } + [J("date")] public DateTime Date { get; set; } + [J("avatar_url")] public string AvatarUrl { get; set; } + [J("html_url")] public string HtmlUrl { get; set; } +} diff --git a/FModel/ViewModels/ApiEndpoints/ValorantApiEndpoint.cs b/FModel/ViewModels/ApiEndpoints/ValorantApiEndpoint.cs index 8c827867..031b5e82 100644 --- a/FModel/ViewModels/ApiEndpoints/ValorantApiEndpoint.cs +++ b/FModel/ViewModels/ApiEndpoints/ValorantApiEndpoint.cs @@ -15,7 +15,7 @@ using FModel.Framework; using FModel.Settings; - +using OffiUtils; using RestSharp; namespace FModel.ViewModels.ApiEndpoints; @@ -117,7 +117,7 @@ public async Task GetChunkBytes(VChunk chunk, CancellationToken cancella return chunkBytes; } - public Stream GetPakStream(int index) => new VPakStream(this, index); + public VPakStream GetPakStream(int index) => new VPakStream(this, index); } public readonly struct VHeader @@ -179,7 +179,7 @@ public readonly struct VChunk public string GetUrl() => $"https://fmodel.fortnite-api.com/valorant/v2/chunks/{Id}"; } -public class VPakStream : Stream, ICloneable +public class VPakStream : Stream, IRandomAccessStream, ICloneable { private readonly VManifest _manifest; private readonly int _pakIndex; @@ -203,11 +203,22 @@ public VPakStream(VManifest manifest, int pakIndex, long position = 0L) public object Clone() => new VPakStream(_manifest, _pakIndex, _position); - public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + + public int ReadAt(long position, byte[] buffer, int offset, int count) => + ReadAtAsync(position, buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - var (i, startPos) = GetChunkIndex(_position); + var bytesRead = await ReadAtAsync(_position, buffer, offset, count, cancellationToken); + _position += bytesRead; + return bytesRead; + } + + public async Task ReadAtAsync(long position, byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var (i, startPos) = GetChunkIndex(position); if (i == -1) return 0; await PrefetchAsync(i, startPos, count, cancellationToken).ConfigureAwait(false); @@ -234,10 +245,14 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, if (++i == _chunks.Length) break; } - _position += bytesRead; return bytesRead; } + public Task ReadAtAsync(long position, Memory memory, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + private async Task PrefetchAsync(int i, uint startPos, long count, CancellationToken cancellationToken, int concurrentDownloads = 4) { var tasks = new List(); diff --git a/FModel/ViewModels/ApplicationViewModel.cs b/FModel/ViewModels/ApplicationViewModel.cs index 3e5bef8f..a67df7ad 100644 --- a/FModel/ViewModels/ApplicationViewModel.cs +++ b/FModel/ViewModels/ApplicationViewModel.cs @@ -10,7 +10,6 @@ using CUE4Parse.Encryption.Aes; using CUE4Parse.UE4.Objects.Core.Misc; using CUE4Parse.UE4.VirtualFileSystem; -using FModel.Extensions; using FModel.Framework; using FModel.Services; using FModel.Settings; @@ -50,7 +49,7 @@ public FStatus Status public CopyCommand CopyCommand => _copyCommand ??= new CopyCommand(this); private CopyCommand _copyCommand; - public string InitialWindowTitle => $"FModel {UserSettings.Default.UpdateMode.GetDescription()}"; + public string InitialWindowTitle => $"FModel ({Constants.APP_SHORT_COMMIT_ID})"; public string GameDisplayName => CUE4Parse.Provider.GameDisplayName ?? "Unknown"; public string TitleExtra => $"({UserSettings.Default.CurrentDir.UeVersion}){(Build != EBuildKind.Release ? $" ({Build})" : "")}"; @@ -144,7 +143,7 @@ public void Restart() StartInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"\"{Path.GetFullPath(Environment.GetCommandLineArgs()[0])}\"", + Arguments = $"\"{path}\"", UseShellExecute = false, RedirectStandardOutput = false, RedirectStandardError = false, @@ -208,7 +207,7 @@ public static async Task InitVgmStream() foreach (var entry in zip.Entries) { var entryPath = Path.Combine(zipDir, entry.FullName); - await using var entryFs = File.OpenRead(entryPath); + await using var entryFs = File.Create(entryPath); await using var entryStream = entry.Open(); await entryStream.CopyToAsync(entryFs); } diff --git a/FModel/ViewModels/BackupManagerViewModel.cs b/FModel/ViewModels/BackupManagerViewModel.cs index 14497692..814258c1 100644 --- a/FModel/ViewModels/BackupManagerViewModel.cs +++ b/FModel/ViewModels/BackupManagerViewModel.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Data; +using CUE4Parse.FileProvider.Objects; using CUE4Parse.UE4.VirtualFileSystem; using FModel.Framework; using FModel.Services; @@ -20,6 +21,8 @@ namespace FModel.ViewModels; public class BackupManagerViewModel : ViewModel { + public const uint FBKP_MAGIC = 0x504B4246; + private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView; private ApplicationViewModel _applicationView => ApplicationService.ApplicationView; @@ -64,23 +67,21 @@ await _threadWorkerView.Begin(_ => var backupFolder = Path.Combine(UserSettings.Default.OutputDirectory, "Backups"); var fileName = $"{_gameName}_{DateTime.Now:MM'_'dd'_'yyyy}.fbkp"; var fullPath = Path.Combine(backupFolder, fileName); + var func = new Func(x => !x.Path.EndsWith(".uexp") && !x.Path.EndsWith(".ubulk") && !x.Path.EndsWith(".uptnl")); using var fileStream = new FileStream(fullPath, FileMode.Create); using var compressedStream = LZ4Stream.Encode(fileStream, LZ4Level.L00_FAST); using var writer = new BinaryWriter(compressedStream); + writer.Write(FBKP_MAGIC); + writer.Write((byte) EBackupVersion.Latest); + writer.Write(_applicationView.CUE4Parse.Provider.Files.Values.Count(func)); + foreach (var asset in _applicationView.CUE4Parse.Provider.Files.Values) { - if (asset is not VfsEntry entry || entry.Path.EndsWith(".uexp") || - entry.Path.EndsWith(".ubulk") || entry.Path.EndsWith(".uptnl")) - continue; - - writer.Write((long) 0); - writer.Write((long) 0); - writer.Write(entry.Size); - writer.Write(entry.IsEncrypted); - writer.Write(0); - writer.Write($"/{entry.Path.ToLower()}"); - writer.Write(0); + if (!func(asset)) continue; + writer.Write(asset.Size); + writer.Write(asset.IsEncrypted); + writer.Write($"/{asset.Path.ToLower()}"); } SaveCheck(fullPath, fileName, "created", "create"); @@ -116,3 +117,12 @@ private void SaveCheck(string fullPath, string fileName, string type1, string ty } } } + +public enum EBackupVersion : byte +{ + BeforeVersionWasAdded = 0, + Initial, + + LatestPlusOne, + Latest = LatestPlusOne - 1 +} diff --git a/FModel/ViewModels/CUE4ParseViewModel.cs b/FModel/ViewModels/CUE4ParseViewModel.cs index 18ebeb30..55eb71f1 100644 --- a/FModel/ViewModels/CUE4ParseViewModel.cs +++ b/FModel/ViewModels/CUE4ParseViewModel.cs @@ -8,11 +8,26 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; + using AdonisUI.Controls; + using CUE4Parse.Compression; using CUE4Parse.Encryption.Aes; using CUE4Parse.FileProvider; using CUE4Parse.FileProvider.Vfs; +using CUE4Parse.GameTypes.ApexMobile.Encryption.Aes; +using CUE4Parse.GameTypes.DBD.Encryption.Aes; +using CUE4Parse.GameTypes.DeltaForce.Encryption.Aes; +using CUE4Parse.GameTypes.DreamStar.Encryption.Aes; +using CUE4Parse.GameTypes.FSR.Encryption.Aes; +using CUE4Parse.GameTypes.FunkoFusion.Encryption.Aes; +using CUE4Parse.GameTypes.MJS.Encryption.Aes; +using CUE4Parse.GameTypes.NetEase.MAR.Encryption.Aes; +using CUE4Parse.GameTypes.PAXDEI.Encryption.Aes; +using CUE4Parse.GameTypes.Rennsport.Encryption.Aes; +using CUE4Parse.GameTypes.Snowbreak.Encryption.Aes; +using CUE4Parse.GameTypes.UDWN.Encryption.Aes; +using CUE4Parse.GameTypes.THPS.Encryption.Aes; using CUE4Parse.MappingsProvider; using CUE4Parse.UE4.AssetRegistry; using CUE4Parse.UE4.Assets.Exports; @@ -26,6 +41,7 @@ using CUE4Parse.UE4.Assets.Exports.Wwise; using CUE4Parse.UE4.IO; using CUE4Parse.UE4.Localization; +using CUE4Parse.UE4.Objects.Core.Misc; using CUE4Parse.UE4.Objects.Core.Serialization; using CUE4Parse.UE4.Objects.Engine; using CUE4Parse.UE4.Oodle.Objects; @@ -33,16 +49,11 @@ using CUE4Parse.UE4.Shaders; using CUE4Parse.UE4.Versions; using CUE4Parse.UE4.Wwise; + using CUE4Parse_Conversion; using CUE4Parse_Conversion.Sounds; -using CUE4Parse.GameTypes.UDWN.Encryption.Aes; -using CUE4Parse.GameTypes.DBD.Encryption.Aes; -using CUE4Parse.GameTypes.DreamStar.Encryption.Aes; -using CUE4Parse.GameTypes.PAXDEI.Encryption.Aes; -using CUE4Parse.GameTypes.NetEase.MAR.Encryption.Aes; -using CUE4Parse.GameTypes.FSR.Encryption.Aes; -using CUE4Parse.UE4.Objects.Core.Misc; using EpicManifestParser; + using FModel.Creator; using FModel.Extensions; using FModel.Framework; @@ -51,13 +62,18 @@ using FModel.Views; using FModel.Views.Resources.Controls; using FModel.Views.Snooper; + using Newtonsoft.Json; -using Ookii.Dialogs.Wpf; +using OffiUtils; using OpenTK.Windowing.Common; using OpenTK.Windowing.Desktop; + using Serilog; + using SkiaSharp; + using UE4Config.Parsing; + using Application = System.Windows.Application; namespace FModel.ViewModels; @@ -66,7 +82,7 @@ public class CUE4ParseViewModel : ViewModel { private ThreadWorkerViewModel _threadWorkerView => ApplicationService.ThreadWorkerView; private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView; - private readonly Regex _fnLive = new(@"^FortniteGame(/|\\)Content(/|\\)Paks(/|\\)", + private readonly Regex _fnLive = new(@"^FortniteGame[/\\]Content[/\\]Paks[/\\]", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private string _internalGameName; @@ -177,12 +193,19 @@ public CUE4ParseViewModel() Provider.ReadScriptData = UserSettings.Default.ReadScriptData; Provider.CustomEncryption = Provider.Versions.Game switch { + EGame.GAME_ApexLegendsMobile => ApexLegendsMobileAes.DecryptApexMobile, + EGame.GAME_Snowbreak => SnowbreakAes.SnowbreakDecrypt, EGame.GAME_MarvelRivals => MarvelAes.MarvelDecrypt, EGame.GAME_Undawn => ToaaAes.ToaaDecrypt, EGame.GAME_DeadByDaylight => DBDAes.DbDDecrypt, EGame.GAME_PaxDei => PaxDeiAes.PaxDeiDecrypt, EGame.GAME_3on3FreeStyleRebound => FreeStyleReboundAes.FSRDecrypt, EGame.GAME_DreamStar => DreamStarAes.DreamStarDecrypt, + EGame.GAME_DeltaForceHawkOps => DeltaForceAes.DeltaForceDecrypt, + EGame.GAME_MonsterJamShowdown => MonsterJamShowdownAes.MonsterJamShowdownDecrypt, + EGame.GAME_Rennsport => RennsportAes.RennsportDecrypt, + EGame.GAME_FunkoFusion => FunkoFusionAes.FunkoFusionDecrypt, + EGame.GAME_TonyHawkProSkater12 => THPS12Aes.THPS12Decrypt, _ => Provider.CustomEncryption }; @@ -216,26 +239,32 @@ await _threadWorkerView.Begin(cancellationToken => ChunkCacheDirectory = cacheDir, ManifestCacheDirectory = cacheDir, ChunkBaseUrl = "http://epicgames-download1.akamaized.net/Builds/Fortnite/CloudDir/", - Zlibng = ZlibHelper.Instance + Zlibng = ZlibHelper.Instance, + CacheChunksAsIs = false }; var startTs = Stopwatch.GetTimestamp(); var (manifest, _) = manifestInfo.DownloadAndParseAsync(manifestOptions, - cancellationToken: cancellationToken).GetAwaiter().GetResult(); + cancellationToken: cancellationToken, + elementManifestPredicate: x => x.Uri.Host is ("epicgames-download1.akamaized.net" or "download.epicgames.com") + ).GetAwaiter().GetResult(); var parseTime = Stopwatch.GetElapsedTime(startTs); - const bool cacheChunksAsIs = false; foreach (var fileManifest in manifest.FileManifestList) { if (fileManifest.FileName.Equals("Cloud/IoStoreOnDemand.ini", StringComparison.OrdinalIgnoreCase)) { - IoStoreOnDemand.Read(new StreamReader(fileManifest.GetStream(cacheChunksAsIs))); + IoStoreOnDemand.Read(new StreamReader(fileManifest.GetStream())); continue; } - if (!_fnLive.IsMatch(fileManifest.FileName)) continue; - p.RegisterVfs(fileManifest.FileName, [fileManifest.GetStream(cacheChunksAsIs)] - , it => new FStreamArchive(it, manifest.FileManifestList.First(x => x.FileName.Equals(it)).GetStream(cacheChunksAsIs), p.Versions)); + if (!_fnLive.IsMatch(fileManifest.FileName)) + { + continue; + } + + p.RegisterVfs(fileManifest.FileName, [(IRandomAccessStream)fileManifest.GetStream()] + , it => new FRandomAccessStreamArchive(it, manifest.FileManifestList.First(x => x.FileName.Equals(it)).GetStream(), p.Versions)); } FLogger.Append(ELog.Information, () => @@ -252,7 +281,7 @@ await _threadWorkerView.Begin(cancellationToken => for (var i = 0; i < manifestInfo.Paks.Length; i++) { - p.RegisterVfs(manifestInfo.Paks[i].GetFullName(), [manifestInfo.GetPakStream(i)]); + p.RegisterVfs(manifestInfo.Paks[i].GetFullName(), [(IRandomAccessStream)manifestInfo.GetPakStream(i)]); } FLogger.Append(ELog.Information, () => @@ -661,6 +690,16 @@ public void Extract(CancellationToken cancellationToken, string fullPath, bool a break; } + case "bin" when fileName.Contains("GlobalShaderCache", StringComparison.OrdinalIgnoreCase): + { + if (Provider.TryCreateReader(fullPath, out var archive)) + { + var registry = new FGlobalShaderCache(archive); + TabControl.SelectedTab.SetDocumentText(JsonConvert.SerializeObject(registry, Formatting.Indented), saveProperties, updateUi); + } + + break; + } case "bnk": case "pck": { @@ -812,6 +851,7 @@ public void ExtractAndScroll(CancellationToken cancellationToken, string fullPat "JunoBuildingPropAccountItemDefinition" => true, _ => false }: + case UPaperSprite when isNone && UserSettings.Default.PreviewMaterials: case UStaticMesh when isNone && UserSettings.Default.PreviewStaticMeshes: case USkeletalMesh when isNone && UserSettings.Default.PreviewSkeletalMeshes: case USkeleton when isNone && UserSettings.Default.SaveSkeletonAsMesh: @@ -847,7 +887,7 @@ public void ExtractAndScroll(CancellationToken cancellationToken, string fullPat case UAnimMontage when HasFlag(bulk, EBulkType.Animations): case UAnimComposite when HasFlag(bulk, EBulkType.Animations): { - SaveExport(export, HasFlag(bulk, EBulkType.Auto)); + SaveExport(export, updateUi); return true; } default: @@ -870,7 +910,7 @@ private void SaveAndPlaySound(string fullPath, string ext, byte[] data) { if (fullPath.StartsWith("/")) fullPath = fullPath[1..]; var savedAudioPath = Path.Combine(UserSettings.Default.AudioDirectory, - UserSettings.Default.KeepDirectoryStructure ? fullPath : fullPath.SubstringAfterLast('/')).Replace('\\', '/') + $".{ext.ToLower()}"; + UserSettings.Default.KeepDirectoryStructure ? fullPath : fullPath.SubstringAfterLast('/')).Replace('\\', '/') + $".{ext.ToLowerInvariant()}"; if (!UserSettings.Default.IsAutoOpenSounds) { @@ -893,29 +933,21 @@ private void SaveAndPlaySound(string fullPath, string ext, byte[] data) }); } - private void SaveExport(UObject export, bool auto) + private void SaveExport(UObject export, bool updateUi = true) { var toSave = new Exporter(export, UserSettings.Default.ExportOptions); - - string dir; - if (!auto) - { - var folderBrowser = new VistaFolderBrowserDialog(); - if (folderBrowser.ShowDialog() == true) - dir = folderBrowser.SelectedPath; - else return; - } - else dir = UserSettings.Default.ModelDirectory; - - var toSaveDirectory = new DirectoryInfo(dir); + var toSaveDirectory = new DirectoryInfo(UserSettings.Default.ModelDirectory); if (toSave.TryWriteToDir(toSaveDirectory, out var label, out var savedFilePath)) { Log.Information("Successfully saved {FilePath}", savedFilePath); - FLogger.Append(ELog.Information, () => + if (updateUi) { - FLogger.Text("Successfully saved ", Constants.WHITE); - FLogger.Link(label, savedFilePath, true); - }); + FLogger.Append(ELog.Information, () => + { + FLogger.Text("Successfully saved ", Constants.WHITE); + FLogger.Link(label, savedFilePath, true); + }); + } } else { diff --git a/FModel/ViewModels/Commands/LoadCommand.cs b/FModel/ViewModels/Commands/LoadCommand.cs index 3dfa13b1..2bb6a989 100644 --- a/FModel/ViewModels/Commands/LoadCommand.cs +++ b/FModel/ViewModels/Commands/LoadCommand.cs @@ -154,7 +154,16 @@ private void FilterNewOrModifiedFilesToDisplay(CancellationToken cancellationTok FLogger.Append(ELog.Information, () => FLogger.Text($"Backup file older than current game is '{openFileDialog.FileName.SubstringAfterLast("\\")}'", Constants.WHITE, true)); - using var fileStream = new FileStream(openFileDialog.FileName, FileMode.Open); + var mode = UserSettings.Default.LoadingMode; + var entries = ParseBackup(openFileDialog.FileName, mode, cancellationToken); + + _applicationView.Status.UpdateStatusLabel($"{mode.ToString()[6..]} Folders & Packages"); + _applicationView.CUE4Parse.AssetsFolder.BulkPopulate(entries); + } + + private List ParseBackup(string path, ELoadingMode mode, CancellationToken cancellationToken = default) + { + using var fileStream = new FileStream(path, FileMode.Open); using var memoryStream = new MemoryStream(); if (fileStream.ReadUInt32() == _IS_LZ4) @@ -169,25 +178,41 @@ private void FilterNewOrModifiedFilesToDisplay(CancellationToken cancellationTok using var archive = new FStreamArchive(fileStream.Name, memoryStream); var entries = new List(); - var mode = UserSettings.Default.LoadingMode; switch (mode) { case ELoadingMode.AllButNew: { - var paths = new Dictionary(); - while (archive.Position < archive.Length) + var paths = new HashSet(); + var magic = archive.Read(); + if (magic != BackupManagerViewModel.FBKP_MAGIC) { - cancellationToken.ThrowIfCancellationRequested(); + archive.Position -= sizeof(uint); + while (archive.Position < archive.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + + archive.Position += 29; + paths.Add(archive.ReadString().ToLower()[1..]); + archive.Position += 4; + } + } + else + { + var version = archive.Read(); + var count = archive.Read(); + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); - archive.Position += 29; - paths[archive.ReadString().ToLower()[1..]] = 0; - archive.Position += 4; + archive.Position += sizeof(long) + sizeof(byte); + paths.Add(archive.ReadString().ToLower()[1..]); + } } foreach (var (key, value) in _applicationView.CUE4Parse.Provider.Files) { cancellationToken.ThrowIfCancellationRequested(); - if (value is not VfsEntry entry || paths.ContainsKey(key) || entry.Path.EndsWith(".uexp") || + if (value is not VfsEntry entry || paths.Contains(key) || entry.Path.EndsWith(".uexp") || entry.Path.EndsWith(".ubulk") || entry.Path.EndsWith(".uptnl")) continue; entries.Add(entry); @@ -198,31 +223,54 @@ private void FilterNewOrModifiedFilesToDisplay(CancellationToken cancellationTok } case ELoadingMode.AllButModified: { - while (archive.Position < archive.Length) + var magic = archive.Read(); + if (magic != BackupManagerViewModel.FBKP_MAGIC) { - cancellationToken.ThrowIfCancellationRequested(); - - archive.Position += 16; - var uncompressedSize = archive.Read(); - var isEncrypted = archive.ReadFlag(); - archive.Position += 4; - var fullPath = archive.ReadString().ToLower()[1..]; - archive.Position += 4; + archive.Position -= sizeof(uint); + while (archive.Position < archive.Length) + { + cancellationToken.ThrowIfCancellationRequested(); - if (fullPath.EndsWith(".uexp") || fullPath.EndsWith(".ubulk") || fullPath.EndsWith(".uptnl") || - !_applicationView.CUE4Parse.Provider.Files.TryGetValue(fullPath, out var asset) || asset is not VfsEntry entry || - entry.Size == uncompressedSize && entry.IsEncrypted == isEncrypted) - continue; + archive.Position += 16; + var uncompressedSize = archive.Read(); + var isEncrypted = archive.ReadFlag(); + archive.Position += 4; + var fullPath = archive.ReadString().ToLower()[1..]; + archive.Position += 4; - entries.Add(entry); - _applicationView.Status.UpdateStatusLabel(entry.Vfs.Name); + AddEntry(fullPath, uncompressedSize, isEncrypted, entries); + } } + else + { + var version = archive.Read(); + var count = archive.Read(); + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var uncompressedSize = archive.Read(); + var isEncrypted = archive.ReadFlag(); + var fullPath = archive.ReadString().ToLower()[1..]; + + AddEntry(fullPath, uncompressedSize, isEncrypted, entries); + } + } break; } } - _applicationView.Status.UpdateStatusLabel($"{mode.ToString()[6..]} Folders & Packages"); - _applicationView.CUE4Parse.AssetsFolder.BulkPopulate(entries); + return entries; + } + + private void AddEntry(string path, long uncompressedSize, bool isEncrypted, List entries) + { + if (path.EndsWith(".uexp") || path.EndsWith(".ubulk") || path.EndsWith(".uptnl") || + !_applicationView.CUE4Parse.Provider.Files.TryGetValue(path, out var asset) || asset is not VfsEntry entry || + entry.Size == uncompressedSize && entry.IsEncrypted == isEncrypted) + return; + + entries.Add(entry); + _applicationView.Status.UpdateStatusLabel(entry.Vfs.Name); } } diff --git a/FModel/ViewModels/Commands/MenuCommand.cs b/FModel/ViewModels/Commands/MenuCommand.cs index 7a510915..14b54904 100644 --- a/FModel/ViewModels/Commands/MenuCommand.cs +++ b/FModel/ViewModels/Commands/MenuCommand.cs @@ -54,9 +54,8 @@ public override async void Execute(ApplicationViewModel contextViewModel, object case "Help_Donate": Process.Start(new ProcessStartInfo { FileName = Constants.DONATE_LINK, UseShellExecute = true }); break; - case "Help_Changelog": - UserSettings.Default.ShowChangelog = true; - ApplicationService.ApiEndpointView.FModelApi.CheckForUpdates(UserSettings.Default.UpdateMode); + case "Help_Releases": + Helper.OpenWindow("Releases", () => new UpdateView().Show()); break; case "Help_BugsReport": Process.Start(new ProcessStartInfo { FileName = Constants.ISSUE_LINK, UseShellExecute = true }); diff --git a/FModel/ViewModels/Commands/RemindMeCommand.cs b/FModel/ViewModels/Commands/RemindMeCommand.cs new file mode 100644 index 00000000..21464761 --- /dev/null +++ b/FModel/ViewModels/Commands/RemindMeCommand.cs @@ -0,0 +1,40 @@ +using System; +using FModel.Framework; +using FModel.Settings; + +namespace FModel.ViewModels.Commands; + +public class RemindMeCommand : ViewModelCommand +{ + public RemindMeCommand(UpdateViewModel contextViewModel) : base(contextViewModel) + { + } + + public override void Execute(UpdateViewModel contextViewModel, object parameter) + { + switch (parameter) + { + case "Days": + // check for update in 3 days + UserSettings.Default.NextUpdateCheck = DateTime.Now.AddDays(3); + break; + case "Week": + // check for update next week (a week starts on Monday) + var delay = (DayOfWeek.Monday - DateTime.Now.DayOfWeek + 7) % 7; + UserSettings.Default.NextUpdateCheck = DateTime.Now.AddDays(delay == 0 ? 7 : delay); + break; + case "Month": + // check for update next month (if today is 31st, it will be 1st of next month) + UserSettings.Default.NextUpdateCheck = DateTime.Now.AddDays(1 - DateTime.Now.Day).AddMonths(1); + break; + case "Never": + // never check for updates + UserSettings.Default.NextUpdateCheck = DateTime.MaxValue; + break; + default: + // reset + UserSettings.Default.NextUpdateCheck = DateTime.Now; + break; + } + } +} diff --git a/FModel/ViewModels/Commands/RightClickMenuCommand.cs b/FModel/ViewModels/Commands/RightClickMenuCommand.cs index ab009a4b..acb48897 100644 --- a/FModel/ViewModels/Commands/RightClickMenuCommand.cs +++ b/FModel/ViewModels/Commands/RightClickMenuCommand.cs @@ -22,6 +22,7 @@ public override async void Execute(ApplicationViewModel contextViewModel, object var assetItems = ((IList) parameters[1]).Cast().ToArray(); if (!assetItems.Any()) return; + var updateUi = assetItems.Length > 1 ? EBulkType.Auto : EBulkType.None; await _threadWorkerView.Begin(cancellationToken => { switch (trigger) @@ -47,7 +48,7 @@ await _threadWorkerView.Begin(cancellationToken => { Thread.Yield(); cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Properties); + contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Properties | updateUi); } break; case "Assets_Save_Textures": @@ -55,7 +56,7 @@ await _threadWorkerView.Begin(cancellationToken => { Thread.Yield(); cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Textures); + contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Textures | updateUi); } break; case "Assets_Save_Models": @@ -63,7 +64,7 @@ await _threadWorkerView.Begin(cancellationToken => { Thread.Yield(); cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Meshes | EBulkType.Auto); + contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Meshes | updateUi); } break; case "Assets_Save_Animations": @@ -71,7 +72,7 @@ await _threadWorkerView.Begin(cancellationToken => { Thread.Yield(); cancellationToken.ThrowIfCancellationRequested(); - contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Animations | EBulkType.Auto); + contextViewModel.CUE4Parse.Extract(cancellationToken, asset.FullPath, false, EBulkType.Animations | updateUi); } break; } diff --git a/FModel/ViewModels/GameSelectorViewModel.cs b/FModel/ViewModels/GameSelectorViewModel.cs index 273248b2..c8bdd253 100644 --- a/FModel/ViewModels/GameSelectorViewModel.cs +++ b/FModel/ViewModels/GameSelectorViewModel.cs @@ -33,28 +33,16 @@ public class DetectedGame public IList CustomDirectories { get; set; } } - private bool _useCustomEGames; - public bool UseCustomEGames - { - get => _useCustomEGames; - set => SetProperty(ref _useCustomEGames, value); - } - private DirectorySettings _selectedDirectory; public DirectorySettings SelectedDirectory { get => _selectedDirectory; - set - { - SetProperty(ref _selectedDirectory, value); - if (_selectedDirectory != null) UseCustomEGames = EnumerateUeGames().ElementAt(1).Contains(_selectedDirectory.UeVersion); - } + set => SetProperty(ref _selectedDirectory, value); } private readonly ObservableCollection _detectedDirectories; public ReadOnlyObservableCollection DetectedDirectories { get; } public ReadOnlyObservableCollection UeGames { get; } - public ReadOnlyObservableCollection CustomUeGames { get; } public GameSelectorViewModel(string gameDirectory) { @@ -73,9 +61,7 @@ public GameSelectorViewModel(string gameDirectory) else SelectedDirectory = DetectedDirectories.FirstOrDefault(); - var ueGames = EnumerateUeGames().ToArray(); - UeGames = new ReadOnlyObservableCollection(new ObservableCollection(ueGames[0])); - CustomUeGames = new ReadOnlyObservableCollection(new ObservableCollection(ueGames[1])); + UeGames = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateUeGames())); } public void AddUndetectedDir(string gameDirectory) => AddUndetectedDir(gameDirectory.SubstringAfterLast('\\'), gameDirectory); @@ -94,11 +80,11 @@ public void DeleteSelectedGame() SelectedDirectory = DetectedDirectories.Last(); } - private IEnumerable> EnumerateUeGames() + private IEnumerable EnumerateUeGames() => Enum.GetValues() .GroupBy(value => (int)value) .Select(group => group.First()) - .GroupBy(value => (int)value == ((int)value & ~0xF)); + .OrderBy(value => (int)value == ((int)value & ~0xF)); private IEnumerable EnumerateDetectedGames() { yield return GetUnrealEngineGame("Fortnite", "\\FortniteGame\\Content\\Paks", EGame.GAME_UE5_5); diff --git a/FModel/ViewModels/SettingsViewModel.cs b/FModel/ViewModels/SettingsViewModel.cs index e76c59b5..c5601724 100644 --- a/FModel/ViewModels/SettingsViewModel.cs +++ b/FModel/ViewModels/SettingsViewModel.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Windows; using CUE4Parse.UE4.Assets.Exports.Texture; using CUE4Parse.UE4.Objects.Core.Serialization; using CUE4Parse.UE4.Versions; @@ -27,20 +26,6 @@ public bool UseCustomOutputFolders set => SetProperty(ref _useCustomOutputFolders, value); } - private bool _useCustomEGames; - public bool UseCustomEGames - { - get => _useCustomEGames; - set => SetProperty(ref _useCustomEGames, value); - } - - private EUpdateMode _selectedUpdateMode; - public EUpdateMode SelectedUpdateMode - { - get => _selectedUpdateMode; - set => SetProperty(ref _selectedUpdateMode, value); - } - private ETexturePlatform _selectedUePlatform; public ETexturePlatform SelectedUePlatform { @@ -175,9 +160,7 @@ public ETextureFormat SelectedTextureExportFormat public bool SocketSettingsEnabled => SelectedMeshExportFormat == EMeshFormat.ActorX; public bool CompressionSettingsEnabled => SelectedMeshExportFormat == EMeshFormat.UEFormat; - public ReadOnlyObservableCollection UpdateModes { get; private set; } public ReadOnlyObservableCollection UeGames { get; private set; } - public ReadOnlyObservableCollection CustomUeGames { get; private set; } public ReadOnlyObservableCollection AssetLanguages { get; private set; } public ReadOnlyObservableCollection AesReloads { get; private set; } public ReadOnlyObservableCollection DiscordRpcs { get; private set; } @@ -198,7 +181,6 @@ public ETextureFormat SelectedTextureExportFormat private string _audioSnapshot; private string _modelSnapshot; private string _gameSnapshot; - private EUpdateMode _updateModeSnapshot; private ETexturePlatform _uePlatformSnapshot; private EGame _ueGameSnapshot; private IList _customVersionsSnapshot; @@ -230,7 +212,6 @@ public void Initialize() _audioSnapshot = UserSettings.Default.AudioDirectory; _modelSnapshot = UserSettings.Default.ModelDirectory; _gameSnapshot = UserSettings.Default.GameDirectory; - _updateModeSnapshot = UserSettings.Default.UpdateMode; _uePlatformSnapshot = UserSettings.Default.CurrentDir.TexturePlatform; _ueGameSnapshot = UserSettings.Default.CurrentDir.UeVersion; _customVersionsSnapshot = UserSettings.Default.CurrentDir.Versioning.CustomVersions; @@ -255,7 +236,6 @@ public void Initialize() _materialExportFormatSnapshot = UserSettings.Default.MaterialExportFormat; _textureExportFormatSnapshot = UserSettings.Default.TextureExportFormat; - SelectedUpdateMode = _updateModeSnapshot; SelectedUePlatform = _uePlatformSnapshot; SelectedUeGame = _ueGameSnapshot; SelectedCustomVersions = _customVersionsSnapshot; @@ -273,12 +253,7 @@ public void Initialize() SelectedAesReload = UserSettings.Default.AesReload; SelectedDiscordRpc = UserSettings.Default.DiscordRpc; - var ueGames = EnumerateUeGames().ToArray(); - UseCustomEGames = ueGames[1].Contains(SelectedUeGame); - - UpdateModes = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateUpdateModes())); - UeGames = new ReadOnlyObservableCollection(new ObservableCollection(ueGames[0])); - CustomUeGames = new ReadOnlyObservableCollection(new ObservableCollection(ueGames[1])); + UeGames = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateUeGames())); AssetLanguages = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateAssetLanguages())); AesReloads = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateAesReloads())); DiscordRpcs = new ReadOnlyObservableCollection(new ObservableCollection(EnumerateDiscordRpcs())); @@ -302,8 +277,6 @@ public bool Save(out List whatShouldIDo) whatShouldIDo.Add(SettingsOut.ReloadLocres); if (_mappingsUpdate) whatShouldIDo.Add(SettingsOut.ReloadMappings); - if (_updateModeSnapshot != SelectedUpdateMode) - whatShouldIDo.Add(SettingsOut.CheckForUpdates); if (_ueGameSnapshot != SelectedUeGame || _customVersionsSnapshot != SelectedCustomVersions || _uePlatformSnapshot != SelectedUePlatform || _optionsSnapshot != SelectedOptions || // combobox @@ -317,7 +290,6 @@ public bool Save(out List whatShouldIDo) _gameSnapshot != UserSettings.Default.GameDirectory) // textbox restart = true; - UserSettings.Default.UpdateMode = SelectedUpdateMode; UserSettings.Default.CurrentDir.UeVersion = SelectedUeGame; UserSettings.Default.CurrentDir.TexturePlatform = SelectedUePlatform; UserSettings.Default.CurrentDir.Versioning.CustomVersions = SelectedCustomVersions; @@ -342,12 +314,11 @@ public bool Save(out List whatShouldIDo) return restart; } - private IEnumerable EnumerateUpdateModes() => Enum.GetValues(); - private IEnumerable> EnumerateUeGames() + private IEnumerable EnumerateUeGames() => Enum.GetValues() .GroupBy(value => (int)value) .Select(group => group.First()) - .GroupBy(value => (int)value == ((int)value & ~0xF)); + .OrderBy(value => (int)value == ((int)value & ~0xF)); private IEnumerable EnumerateAssetLanguages() => Enum.GetValues(); private IEnumerable EnumerateAesReloads() => Enum.GetValues(); private IEnumerable EnumerateDiscordRpcs() => Enum.GetValues(); diff --git a/FModel/ViewModels/TabControlViewModel.cs b/FModel/ViewModels/TabControlViewModel.cs index 27924c4d..1afe5c77 100644 --- a/FModel/ViewModels/TabControlViewModel.cs +++ b/FModel/ViewModels/TabControlViewModel.cs @@ -68,9 +68,13 @@ private void SetImage(SKBitmap bitmap) Image = null; return; } + _bmp = bitmap; - using var data = _bmp.Encode(NoAlpha ? SKEncodedImageFormat.Jpeg : SKEncodedImageFormat.Png, 100); + using var data = _bmp.Encode(NoAlpha ? ETextureFormat.Jpeg : UserSettings.Default.TextureExportFormat, 100); using var stream = new MemoryStream(ImageBuffer = data.ToArray(), false); + if (UserSettings.Default.TextureExportFormat == ETextureFormat.Tga) + return; + var image = new BitmapImage(); image.BeginInit(); image.CacheOption = BitmapCacheOption.OnLoad; @@ -240,18 +244,31 @@ public void SoftReset(string header, string directory) public void AddImage(UTexture texture, bool save, bool updateUi) { - var img = texture.Decode(UserSettings.Default.CurrentDir.TexturePlatform); - if (texture is UTextureCube) + var appendLayerNumber = false; + var img = new SKBitmap[1]; + if (texture is UTexture2DArray textureArray) { - img = img?.ToPanorama(); + img = textureArray.DecodeTextureArray(UserSettings.Default.CurrentDir.TexturePlatform); + appendLayerNumber = true; + } + else + { + img[0] = texture.Decode(UserSettings.Default.CurrentDir.TexturePlatform); + if (texture is UTextureCube) + { + img[0] = img[0]?.ToPanorama(); + } } - AddImage(texture.Name, texture.RenderNearestNeighbor, img, save, updateUi); + AddImage(texture.Name, texture.RenderNearestNeighbor, img, save, updateUi, appendLayerNumber); } - public void AddImage(string name, bool rnn, SKBitmap[] img, bool save, bool updateUi) + public void AddImage(string name, bool rnn, SKBitmap[] img, bool save, bool updateUi, bool appendLayerNumber = false) { - foreach (var i in img) AddImage(name, rnn, i, save, updateUi); + for (var i = 0; i < img.Length; i++) + { + AddImage($"{name}{(appendLayerNumber ? $"_{i}" : "")}", rnn, img[i], save, updateUi); + } } public void AddImage(string name, bool rnn, SKBitmap img, bool save, bool updateUi) @@ -288,7 +305,16 @@ public void SetDocumentText(string text, bool save, bool updateUi) private void SaveImage(TabImage image, bool updateUi) { if (image == null) return; - var fileName = $"{image.ExportName}.png"; + + var ext = UserSettings.Default.TextureExportFormat switch + { + ETextureFormat.Png => ".png", + ETextureFormat.Jpeg => ".jpg", + ETextureFormat.Tga => ".tga", + _ => ".png" + }; + + var fileName = image.ExportName + ext; var path = Path.Combine(UserSettings.Default.TextureDirectory, UserSettings.Default.KeepDirectoryStructure ? Directory : "", fileName!).Replace('\\', '/'); diff --git a/FModel/ViewModels/UpdateViewModel.cs b/FModel/ViewModels/UpdateViewModel.cs new file mode 100644 index 00000000..f1ee7567 --- /dev/null +++ b/FModel/ViewModels/UpdateViewModel.cs @@ -0,0 +1,74 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Data; +using FModel.Extensions; +using FModel.Framework; +using FModel.Services; +using FModel.Settings; +using FModel.ViewModels.ApiEndpoints.Models; +using FModel.ViewModels.Commands; +using FModel.Views.Resources.Converters; + +namespace FModel.ViewModels; + +public class UpdateViewModel : ViewModel +{ + private ApiEndpointViewModel _apiEndpointView => ApplicationService.ApiEndpointView; + + private RemindMeCommand _remindMeCommand; + public RemindMeCommand RemindMeCommand => _remindMeCommand ??= new RemindMeCommand(this); + + public RangeObservableCollection Commits { get; } + public ICollectionView CommitsView { get; } + + public UpdateViewModel() + { + Commits = new RangeObservableCollection(); + CommitsView = new ListCollectionView(Commits) + { + GroupDescriptions = { new PropertyGroupDescription("Commit.Author.Date", new DateTimeToDateConverter()) } + }; + + if (UserSettings.Default.NextUpdateCheck < DateTime.Now) + RemindMeCommand.Execute(this, null); + } + + public async Task Load() + { + Commits.AddRange(await _apiEndpointView.GitHubApi.GetCommitHistoryAsync()); + + var qa = await _apiEndpointView.GitHubApi.GetReleaseAsync("qa"); + qa.Assets.OrderByDescending(x => x.CreatedAt).First().IsLatest = true; + + foreach (var asset in qa.Assets) + { + var commitSha = asset.Name.SubstringBeforeLast(".zip"); + var commit = Commits.FirstOrDefault(x => x.Sha == commitSha); + if (commit != null) + { + commit.Asset = asset; + } + else + { + Commits.Add(new GitHubCommit + { + Sha = commitSha, + Commit = new Commit + { + Message = $"FModel ({commitSha[..7]})", + Author = new Author { Name = asset.Uploader.Login, Date = asset.CreatedAt } + }, + Author = asset.Uploader, + Asset = asset + }); + } + } + } + + public void DownloadLatest() + { + Commits.FirstOrDefault(x => x.Asset.IsLatest)?.Download(); + } +} diff --git a/FModel/Views/DirectorySelector.xaml b/FModel/Views/DirectorySelector.xaml index badcfc23..f268e0a1 100644 --- a/FModel/Views/DirectorySelector.xaml +++ b/FModel/Views/DirectorySelector.xaml @@ -1,6 +1,7 @@  - + - + - - - - + - - + diff --git a/FModel/Views/DirectorySelector.xaml.cs b/FModel/Views/DirectorySelector.xaml.cs index 9605352f..cef51ef3 100644 --- a/FModel/Views/DirectorySelector.xaml.cs +++ b/FModel/Views/DirectorySelector.xaml.cs @@ -1,4 +1,7 @@ -using FModel.ViewModels; +using System; +using System.IO; +using System.Linq; +using FModel.ViewModels; using Ookii.Dialogs.Wpf; using System.Windows; using CUE4Parse.Utils; @@ -39,8 +42,29 @@ private void OnBrowseManualDirectories(object sender, RoutedEventArgs e) var folderBrowser = new VistaFolderBrowserDialog {ShowNewFolderButton = false}; if (folderBrowser.ShowDialog() == true) { - HelloMyNameIsGame.Text = folderBrowser.SelectedPath.SubstringAfterLast('\\'); HelloGameMyNameIsDirectory.Text = folderBrowser.SelectedPath; + + // install_folder/ + // ├─ Engine/ + // ├─ GameName/ + // │ ├─ Binaries/ + // │ ├─ Content/ + // │ │ ├─ Paks/ + // our goal is to get the GameName folder + var currentFolder = folderBrowser.SelectedPath.SubstringAfterLast('\\'); + if (currentFolder.Equals("Paks", StringComparison.InvariantCulture)) + { + var dir = new DirectoryInfo(folderBrowser.SelectedPath); + if (dir.Parent is { Parent: not null } && + dir.Parent.Name.Equals("Content", StringComparison.InvariantCulture) && + dir.Parent.Parent.GetDirectories().Any(x => x.Name == "Binaries")) + { + HelloMyNameIsGame.Text = dir.Parent.Parent.Name; + return; + } + } + + HelloMyNameIsGame.Text = folderBrowser.SelectedPath.SubstringAfterLast('\\'); } } diff --git a/FModel/Views/Resources/Controls/CommitControl.xaml b/FModel/Views/Resources/Controls/CommitControl.xaml new file mode 100644 index 00000000..6d597d9c --- /dev/null +++ b/FModel/Views/Resources/Controls/CommitControl.xaml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FModel/Views/Resources/Controls/CommitControl.xaml.cs b/FModel/Views/Resources/Controls/CommitControl.xaml.cs new file mode 100644 index 00000000..198c05ae --- /dev/null +++ b/FModel/Views/Resources/Controls/CommitControl.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace FModel.Views.Resources.Controls; + +public partial class CommitControl : UserControl +{ + public CommitControl() + { + InitializeComponent(); + } +} + diff --git a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml new file mode 100644 index 00000000..954d64e7 --- /dev/null +++ b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs new file mode 100644 index 00000000..b779543a --- /dev/null +++ b/FModel/Views/Resources/Controls/CommitDownloaderControl.xaml.cs @@ -0,0 +1,28 @@ +using System.Windows; +using System.Windows.Controls; +using FModel.ViewModels.ApiEndpoints.Models; + +namespace FModel.Views.Resources.Controls; + +public partial class CommitDownloaderControl : UserControl +{ + public CommitDownloaderControl() + { + InitializeComponent(); + } + + public static readonly DependencyProperty CommitProperty = + DependencyProperty.Register(nameof(Commit), typeof(GitHubCommit), typeof(CommitDownloaderControl), new PropertyMetadata(null)); + + public GitHubCommit Commit + { + get { return (GitHubCommit)GetValue(CommitProperty); } + set { SetValue(CommitProperty, value); } + } + + private void OnDownload(object sender, RoutedEventArgs e) + { + Commit.Download(); + } +} + diff --git a/FModel/Views/Resources/Controls/FilterableComboBox.cs b/FModel/Views/Resources/Controls/FilterableComboBox.cs new file mode 100644 index 00000000..4679311a --- /dev/null +++ b/FModel/Views/Resources/Controls/FilterableComboBox.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; + +namespace FModel.Views.Resources.Controls; + +/// +/// https://stackoverflow.com/a/58066259/13389331 +/// +public class FilterableComboBox : ComboBox +{ + /// + /// If true, on lost focus or enter key pressed, checks the text in the combobox. If the text is not present + /// in the list, it leaves it blank. + /// + public bool OnlyValuesInList { + get => (bool)GetValue(OnlyValuesInListProperty); + set => SetValue(OnlyValuesInListProperty, value); + } + public static readonly DependencyProperty OnlyValuesInListProperty = + DependencyProperty.Register(nameof(OnlyValuesInList), typeof(bool), typeof(FilterableComboBox)); + + /// + /// Selected item, changes only on lost focus or enter key pressed + /// + public object EffectivelySelectedItem { + get => (bool)GetValue(EffectivelySelectedItemProperty); + set => SetValue(EffectivelySelectedItemProperty, value); + } + public static readonly DependencyProperty EffectivelySelectedItemProperty = + DependencyProperty.Register(nameof(EffectivelySelectedItem), typeof(object), typeof(FilterableComboBox)); + + private string CurrentFilter = string.Empty; + private bool TextBoxFreezed; + protected TextBox EditableTextBox => GetTemplateChild("PART_EditableTextBox") as TextBox; + private UserChange IsDropDownOpenUC; + + /// + /// Triggers on lost focus or enter key pressed, if the selected item changed since the last time focus was lost or enter was pressed. + /// + public event Action SelectionEffectivelyChanged; + + public FilterableComboBox() + { + IsDropDownOpenUC = new UserChange(v => IsDropDownOpen = v); + DropDownOpened += FilteredComboBox_DropDownOpened; + + Focusable = true; + IsEditable = true; + IsTextSearchEnabled = true; + StaysOpenOnEdit = true; + IsReadOnly = false; + + Loaded += (s, e) => { + if (EditableTextBox != null) + new TextBoxBaseUserChangeTracker(EditableTextBox).UserTextChanged += FilteredComboBox_UserTextChange; + }; + + SelectionChanged += (_, __) => shouldTriggerSelectedItemChanged = true; + + SelectionEffectivelyChanged += (_, o) => EffectivelySelectedItem = o; + } + + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + base.OnPreviewKeyDown(e); + if (e.Key == Key.Down && !IsDropDownOpen) { + IsDropDownOpen = true; + e.Handled = true; + } + else if (e.Key == Key.Escape) { + ClearFilter(); + Text = ""; + IsDropDownOpen = true; + } + else if (e.Key == Key.Enter || e.Key == Key.Tab) { + CheckSelectedItem(); + TriggerSelectedItemChanged(); + } + } + + protected override void OnPreviewLostKeyboardFocus(KeyboardFocusChangedEventArgs e) + { + base.OnPreviewLostKeyboardFocus(e); + CheckSelectedItem(); + if ((e.OldFocus == this || e.OldFocus == EditableTextBox) && e.NewFocus != this && e.NewFocus != EditableTextBox) + TriggerSelectedItemChanged(); + } + + private void CheckSelectedItem() + { + if (OnlyValuesInList) + Text = SelectedItem?.ToString() ?? ""; + } + + private bool shouldTriggerSelectedItemChanged = false; + private void TriggerSelectedItemChanged() + { + if (shouldTriggerSelectedItemChanged) { + SelectionEffectivelyChanged?.Invoke(this, SelectedItem); + shouldTriggerSelectedItemChanged = false; + } + } + + public void ClearFilter() + { + if (string.IsNullOrEmpty(CurrentFilter)) return; + CurrentFilter = ""; + CollectionViewSource.GetDefaultView(ItemsSource).Refresh(); + } + + private void FilteredComboBox_DropDownOpened(object sender, EventArgs e) + { + if (IsDropDownOpenUC.IsUserChange) + ClearFilter(); + } + + private void FilteredComboBox_UserTextChange(object sender, EventArgs e) + { + if (TextBoxFreezed) return; + var tb = EditableTextBox; + if (tb.SelectionStart + tb.SelectionLength == tb.Text.Length) + CurrentFilter = tb.Text.Substring(0, tb.SelectionStart).ToLower(); + else + CurrentFilter = tb.Text.ToLower(); + RefreshFilter(); + } + + protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) + { + if (newValue != null) { + var view = CollectionViewSource.GetDefaultView(newValue); + view.Filter += FilterItem; + } + + if (oldValue != null) { + var view = CollectionViewSource.GetDefaultView(oldValue); + if (view != null) view.Filter -= FilterItem; + } + + base.OnItemsSourceChanged(oldValue, newValue); + } + + private void RefreshFilter() + { + if (ItemsSource == null) return; + + var view = CollectionViewSource.GetDefaultView(ItemsSource); + FreezTextBoxState(() => { + var isDropDownOpen = IsDropDownOpen; + //always hide because showing it enables the user to pick with up and down keys, otherwise it's not working because of the glitch in view.Refresh() + IsDropDownOpenUC.Set(false); + view.Refresh(); + + if (!string.IsNullOrEmpty(CurrentFilter) || isDropDownOpen) + IsDropDownOpenUC.Set(true); + + if (SelectedItem == null) { + foreach (var itm in ItemsSource) + if (itm.ToString() == Text) { + SelectedItem = itm; + break; + } + } + }); + } + + private void FreezTextBoxState(Action action) + { + TextBoxFreezed = true; + var tb = EditableTextBox; + var text = Text; + var selStart = tb.SelectionStart; + var selLen = tb.SelectionLength; + action(); + Text = text; + tb.SelectionStart = selStart; + tb.SelectionLength = selLen; + TextBoxFreezed = false; + } + + private bool FilterItem(object value) + { + if (value == null) return false; + if (CurrentFilter.Length == 0) return true; + + return value.ToString().ToLower().Contains(CurrentFilter); + } + + private class TextBoxBaseUserChangeTracker + { + private bool IsTextInput { get; set; } + + public TextBox TextBoxBase { get; set; } + private List PressedKeys = new List(); + public event EventHandler UserTextChanged; + private string LastText; + + public TextBoxBaseUserChangeTracker(TextBox textBoxBase) + { + TextBoxBase = textBoxBase; + LastText = TextBoxBase.ToString(); + + textBoxBase.PreviewTextInput += (s, e) => { + IsTextInput = true; + }; + + textBoxBase.TextChanged += (s, e) => { + var isUserChange = PressedKeys.Count > 0 || IsTextInput || LastText == TextBoxBase.ToString(); + IsTextInput = false; + LastText = TextBoxBase.ToString(); + if (isUserChange) + UserTextChanged?.Invoke(this, e); + }; + + textBoxBase.PreviewKeyDown += (s, e) => { + switch (e.Key) { + case Key.Back: + case Key.Space: + if (!PressedKeys.Contains(e.Key)) + PressedKeys.Add(e.Key); + break; + } + if (e.Key == Key.Back) { + var textBox = textBoxBase as TextBox; + if (textBox.SelectionStart > 0 && textBox.SelectionLength > 0 && (textBox.SelectionStart + textBox.SelectionLength) == textBox.Text.Length) { + textBox.SelectionStart--; + textBox.SelectionLength++; + e.Handled = true; + UserTextChanged?.Invoke(this, e); + } + } + }; + + textBoxBase.PreviewKeyUp += (s, e) => { + if (PressedKeys.Contains(e.Key)) + PressedKeys.Remove(e.Key); + }; + + textBoxBase.LostFocus += (s, e) => { + PressedKeys.Clear(); + IsTextInput = false; + }; + } + } + + private class UserChange + { + private Action action; + + public bool IsUserChange { get; private set; } = true; + + public UserChange(Action action) + { + this.action = action; + } + + public void Set(T val) + { + try { + IsUserChange = false; + action(val); + } + finally { + IsUserChange = true; + } + } + } +} diff --git a/FModel/Views/Resources/Converters/CommitMessageConverter.cs b/FModel/Views/Resources/Converters/CommitMessageConverter.cs new file mode 100644 index 00000000..22b32fd3 --- /dev/null +++ b/FModel/Views/Resources/Converters/CommitMessageConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace FModel.Views.Resources.Converters; + +public class CommitMessageConverter : IValueConverter +{ + public static readonly CommitMessageConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string commitMessage) + { + var parts = commitMessage.Split("\n\n"); + return parameter?.ToString() == "Title" ? parts[0] : parts.Length > 1 ? parts[1] : string.Empty; + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/FModel/Views/Resources/Converters/DateTimeToDateConverter.cs b/FModel/Views/Resources/Converters/DateTimeToDateConverter.cs new file mode 100644 index 00000000..1c5bea71 --- /dev/null +++ b/FModel/Views/Resources/Converters/DateTimeToDateConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace FModel.Views.Resources.Converters; + +public class DateTimeToDateConverter : IValueConverter +{ + public static readonly DateTimeToDateConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is DateTime dateTime) + { + return DateOnly.FromDateTime(dateTime); + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/FModel/Views/Resources/Converters/InvertBooleanConverter.cs b/FModel/Views/Resources/Converters/InvertBooleanConverter.cs new file mode 100644 index 00000000..90b9c264 --- /dev/null +++ b/FModel/Views/Resources/Converters/InvertBooleanConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace FModel.Views.Resources.Converters; + +public class InvertBooleanConverter : IValueConverter +{ + public static readonly InvertBooleanConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolean) + { + return !boolean; + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/FModel/Views/Resources/Converters/RelativeDateTimeConverter.cs b/FModel/Views/Resources/Converters/RelativeDateTimeConverter.cs new file mode 100644 index 00000000..ac9aaa34 --- /dev/null +++ b/FModel/Views/Resources/Converters/RelativeDateTimeConverter.cs @@ -0,0 +1,65 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace FModel.Views.Resources.Converters; + +public class RelativeDateTimeConverter : IValueConverter +{ + public static readonly RelativeDateTimeConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is DateTime dateTime) + { + var timeSpan = DateTime.Now - dateTime.ToLocalTime(); + + int time; + string unit; + if (timeSpan.TotalSeconds < 30) + return "Just now"; + + if (timeSpan.TotalMinutes < 1) + { + time = timeSpan.Seconds; + unit = "second"; + } + else if (timeSpan.TotalHours < 1) + { + time = timeSpan.Minutes; + unit = "minute"; + } + else switch (timeSpan.TotalDays) + { + case < 1: + time = timeSpan.Hours; + unit = "hour"; + break; + case < 7: + time = timeSpan.Days; + unit = "day"; + break; + case < 30: + time = timeSpan.Days / 7; + unit = "week"; + break; + case < 365: + time = timeSpan.Days / 30; + unit = "month"; + break; + default: + time = timeSpan.Days / 365; + unit = "year"; + break; + } + + return $"{time} {unit}{(time > 1 ? "s" : string.Empty)} ago"; + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/FModel/Views/Resources/Resources.xaml b/FModel/Views/Resources/Resources.xaml index 4cd0164e..03e061d9 100644 --- a/FModel/Views/Resources/Resources.xaml +++ b/FModel/Views/Resources/Resources.xaml @@ -68,6 +68,8 @@ M12 5.83l2.46 2.46c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L12.7 3.7c-.39-.39-1.02-.39-1.41 0L8.12 6.88c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 5.83zm0 12.34l-2.46-2.46c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l3.17 3.18c.39.39 1.02.39 1.41 0l3.17-3.17c.39-.39.39-1.02 0-1.41-.39-.39-1.02-.39-1.41 0L12 18.17z M11.71,17.99C8.53,17.84,6,15.22,6,12c0-3.31,2.69-6,6-6c3.22,0,5.84,2.53,5.99,5.71l-2.1-0.63C15.48,9.31,13.89,8,12,8 c-2.21,0-4,1.79-4,4c0,1.89,1.31,3.48,3.08,3.89L11.71,17.99z M22,12c0,0.3-0.01,0.6-0.04,0.9l-1.97-0.59C20,12.21,20,12.1,20,12 c0-4.42-3.58-8-8-8s-8,3.58-8,8s3.58,8,8,8c0.1,0,0.21,0,0.31-0.01l0.59,1.97C12.6,21.99,12.3,22,12,22C6.48,22,2,17.52,2,12 C2,6.48,6.48,2,12,2S22,6.48,22,12z M18.23,16.26l2.27-0.76c0.46-0.15,0.45-0.81-0.01-0.95l-7.6-2.28 c-0.38-0.11-0.74,0.24-0.62,0.62l2.28,7.6c0.14,0.47,0.8,0.48,0.95,0.01l0.76-2.27l3.91,3.91c0.2,0.2,0.51,0.2,0.71,0l1.27-1.27 c0.2-0.2,0.2-0.51,0-0.71L18.23,16.26z M1.8 6q-.525 0-.887-.35Q.55 5.3.55 4.8V4q0-1.425 1.012-2.438Q2.575.55 4 .55h.8q.5 0 .85.362.35.363.35.888 0 .5-.35.85T4.8 3H4q-.425 0-.712.287Q3 3.575 3 4v.8q0 .5-.35.85T1.8 6ZM4 23.45q-1.425 0-2.438-1.012Q.55 21.425.55 20v-.8q0-.5.363-.85.362-.35.887-.35.5 0 .85.35t.35.85v.8q0 .425.288.712Q3.575 21 4 21h.8q.5 0 .85.35t.35.85q0 .525-.35.887-.35.363-.85.363Zm15.2 0q-.5 0-.85-.363-.35-.362-.35-.887 0-.5.35-.85t.85-.35h.8q.425 0 .712-.288Q21 20.425 21 20v-.8q0-.5.35-.85t.85-.35q.525 0 .888.35.362.35.362.85v.8q0 1.425-1.012 2.438Q21.425 23.45 20 23.45ZM22.2 6q-.5 0-.85-.35T21 4.8V4q0-.425-.288-.713Q20.425 3 20 3h-.8q-.5 0-.85-.35T18 1.8q0-.525.35-.888.35-.362.85-.362h.8q1.425 0 2.438 1.012Q23.45 2.575 23.45 4v.8q0 .5-.362.85-.363.35-.888.35ZM12 17.35l1-.575v-4.1l3.55-2.075V9.425l-1-.575L12 10.925 8.45 8.85l-1 .575V10.6L11 12.675v4.1Zm-1.325 2.325-4.55-2.65q-.625-.35-.975-.963-.35-.612-.35-1.337V9.45q0-.725.35-1.337.35-.613.975-.963l4.55-2.65Q11.3 4.15 12 4.15t1.325.35l4.55 2.65q.625.35.975.963.35.612.35 1.337v5.275q0 .725-.35 1.337-.35.613-.975.963l-4.55 2.65q-.625.35-1.325.35t-1.325-.35Z + M3.5 1.75v11.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 2 13.25V1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.185 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 12.25 15h-.5a.75.75 0 0 1 0-1.5h.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177L9.513 1.573a.25.25 0 0 0-.177-.073H7.25a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5h-3a.25.25 0 0 0-.25.25Zm3.75 8.75h.5c.966 0 1.75.784 1.75 1.75v3a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75v-3c0-.966.784-1.75 1.75-1.75ZM6 5.25a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 6 5.25Zm.75 2.25h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 6.75A.75.75 0 0 1 8.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 6.75ZM8.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 9.75A.75.75 0 0 1 8.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 9.75Zm-1 2.5v2.25h1v-2.25a.25.25 0 0 0-.25-.25h-.5a.25.25 0 0 0-.25.25Z + M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z + + diff --git a/FModel/Views/SearchView.xaml b/FModel/Views/SearchView.xaml index c038c114..340b2584 100644 --- a/FModel/Views/SearchView.xaml +++ b/FModel/Views/SearchView.xaml @@ -185,7 +185,7 @@ - + diff --git a/FModel/Views/SettingsView.xaml b/FModel/Views/SettingsView.xaml index 991e945e..650542a5 100644 --- a/FModel/Views/SettingsView.xaml +++ b/FModel/Views/SettingsView.xaml @@ -42,7 +42,6 @@ - @@ -55,7 +54,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FModel/Views/UpdateView.xaml.cs b/FModel/Views/UpdateView.xaml.cs new file mode 100644 index 00000000..3b9ca455 --- /dev/null +++ b/FModel/Views/UpdateView.xaml.cs @@ -0,0 +1,27 @@ +using System.Windows; +using FModel.ViewModels; +using FModel.Views.Resources.Controls; + +namespace FModel.Views; + +public partial class UpdateView +{ + public UpdateView() + { + DataContext = new UpdateViewModel(); + InitializeComponent(); + } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + if (DataContext is not UpdateViewModel viewModel) return; + await viewModel.Load(); + } + + private void OnDownloadLatest(object sender, RoutedEventArgs e) + { + if (DataContext is not UpdateViewModel viewModel) return; + viewModel.DownloadLatest(); + } +} +