diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..67f4d33 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: d2allgr +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: https://www.paypal.me/d2allgr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b28cc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,511 @@ + +# Created by https://www.gitignore.io/api/rider,jetbrains,dotnetcore,visualstudio,visualstudiocode +# Edit at https://www.gitignore.io/?templates=rider,jetbrains,dotnetcore,visualstudio,visualstudiocode + +### DotnetCore ### +# .NET Core build folders +/bin +/obj + +# Common node modules locations +/node_modules +/wwwroot/node_modules + + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +### VisualStudio ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# End of https://www.gitignore.io/api/rider,jetbrains,dotnetcore,visualstudio,visualstudiocode +/.idea/ +/.vscode/ +/Assets +/BannerLordLauncher/Assets \ No newline at end of file diff --git a/BannerLord.Common/BannerLord.Common.csproj b/BannerLord.Common/BannerLord.Common.csproj new file mode 100644 index 0000000..eab07a1 --- /dev/null +++ b/BannerLord.Common/BannerLord.Common.csproj @@ -0,0 +1,129 @@ + + + + + Debug + AnyCPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3} + Library + Properties + BannerLord.Common + BannerLord.Common + v4.8 + 512 + true + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + latest + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + latest + + + + ..\packages\AlphaFS.2.2.6\lib\net452\AlphaFS.dll + + + ..\packages\DynamicData.6.14.14\lib\net461\DynamicData.dll + + + ..\packages\MedallionTopologicalSort.1.0.0\lib\net45\MedallionTopologicalSort.dll + + + ..\packages\Mono.Cecil.0.11.2\lib\net40\Mono.Cecil.dll + + + ..\packages\Mono.Cecil.0.11.2\lib\net40\Mono.Cecil.Mdb.dll + + + ..\packages\Mono.Cecil.0.11.2\lib\net40\Mono.Cecil.Pdb.dll + + + ..\packages\Mono.Cecil.0.11.2\lib\net40\Mono.Cecil.Rocks.dll + + + ..\packages\ReactiveUI.11.3.8\lib\net461\ReactiveUI.dll + + + ..\packages\Splat.9.4.5\lib\net461\Splat.dll + + + + + ..\packages\System.Reactive.4.4.1\lib\net46\System.Reactive.dll + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + + ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + + + + ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + + + + + + + + + + + ..\packages\Trinet.Core.IO.Ntfs.4.1.1\lib\net462\Trinet.Core.IO.Ntfs.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {f2087de4-55fd-43de-944f-dc7c26cde545} + Steam.Common + + + + + + + \ No newline at end of file diff --git a/BannerLord.Common/Extensions/ComparerExtensions.cs b/BannerLord.Common/Extensions/ComparerExtensions.cs new file mode 100644 index 0000000..002f6b4 --- /dev/null +++ b/BannerLord.Common/Extensions/ComparerExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace BannerLord.Common.Extensions +{ + public static class ComparerExtensions + { + public static IComparer Create(params Func[] keyFunctions) + { + IComparer comparer = new FunctionComparer(keyFunctions[0]); + if (keyFunctions.Length == 1) + { + return comparer; + } + + for (var i = 1; i < keyFunctions.Length; i++) + { + comparer = comparer.ThenComparing(keyFunctions[i]); + } + + return comparer; + } + + public static IComparer ThenComparing(this IComparer comparator, Func thenComparing) + { + return new ChainedComparer(comparator, new FunctionComparer(thenComparing)); + } + + private sealed class ChainedComparer : IComparer + { + private readonly IComparer _comp1; + private readonly IComparer _comp2; + + internal ChainedComparer(IComparer comp1, IComparer comp2) + { + this._comp1 = comp1; + this._comp2 = comp2; + } + public int Compare(T x, T y) + { + var compare = this._comp1.Compare(x, y); + return compare == 0 ? this._comp2.Compare(x, y) : compare; + } + } + + private sealed class FunctionComparer : IComparer + { + private readonly Func _func; + + internal FunctionComparer(Func func) + { + this._func = func; + } + public int Compare(T x, T y) + { + return this._func(x).CompareTo(this._func(y)); + } + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Extensions/DictionaryExtensions.cs b/BannerLord.Common/Extensions/DictionaryExtensions.cs new file mode 100644 index 0000000..092d20f --- /dev/null +++ b/BannerLord.Common/Extensions/DictionaryExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace BannerLord.Common.Extensions +{ + public static class DictionaryExtensions + { + public static TV ComputeIfAbsent(this IDictionary dictionary, TK key, Func func) + { + if (!dictionary.ContainsKey(key)) + { + dictionary[key] = func(key); + } + + return dictionary[key]; + } + + public static void PutAll(this IDictionary dictionary, + IEnumerable> toPut) + { + foreach (var kv in toPut) + { + dictionary[kv.Key] = kv.Value; + } + } + + internal static TValue GetOrCreate(this Dictionary map, TKey key, Func ctor) + { + if (!map.ContainsKey(key)) + { + map[key] = ctor(key); + } + return map[key]; + } + + } +} \ No newline at end of file diff --git a/BannerLord.Common/Extensions/EnumerableExtensions.cs b/BannerLord.Common/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..e73da4a --- /dev/null +++ b/BannerLord.Common/Extensions/EnumerableExtensions.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace BannerLord.Common.Extensions +{ + public static class EnumerableExtensions + { + public static void ForEach(this IEnumerable enumerable, Action action) + { + foreach (var obj in enumerable) + { + action(obj); + } + } + + public static string StringJoin(this IEnumerable enumerable, string separator = ", ") + { + return string.Join(separator, enumerable); + } + + + public static IEnumerable NullToEmpty(this IEnumerable enumerable) + { + return enumerable ?? new T[] { }; + } + + public static int IndexOf(this IEnumerable source, TSource item, + IEqualityComparer itemComparer = null) + { + switch (source) + { + case null: + throw new ArgumentNullException(nameof(source)); + case IList listOfT: + return listOfT.IndexOf(item); + case IList list: + return list.IndexOf(item); + } + + if (itemComparer == null) + { + itemComparer = EqualityComparer.Default; + } + + var i = 0; + foreach (var possibleItem in source) + { + if (itemComparer.Equals(item, possibleItem)) + { + return i; + } + i++; + } + return -1; + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Extensions/ListExtensions.cs b/BannerLord.Common/Extensions/ListExtensions.cs new file mode 100644 index 0000000..a3b9794 --- /dev/null +++ b/BannerLord.Common/Extensions/ListExtensions.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace BannerLord.Common.Extensions +{ + public static class ListExtensions + { + public static void Swap(this IList list, int a, int b) + { + var tmp = list[a]; + list[a] = list[b]; + list[b] = tmp; + } + + public static void Splice(this List input, int start, int count, params T[] objects) + => input.Splice(start, count, (IEnumerable) objects); + + + public static void Splice(this List input, int start, int count, IEnumerable objects) + { + input.RemoveRange(start, count); + input.InsertRange(start, objects); + } + + } +} \ No newline at end of file diff --git a/BannerLord.Common/Extensions/ObservableCollectionExtensions.cs b/BannerLord.Common/Extensions/ObservableCollectionExtensions.cs new file mode 100644 index 0000000..82ffe3b --- /dev/null +++ b/BannerLord.Common/Extensions/ObservableCollectionExtensions.cs @@ -0,0 +1,39 @@ +using System.Collections.ObjectModel; + +namespace BannerLord.Common.Extensions +{ + public static class ObservableCollectionExtensions + { + internal static void MoveItemUp(this ObservableCollection baseCollection, int selectedIndex) + { + //# Check if move is possible + if (selectedIndex <= 0) + return; + + //# Move-Item + baseCollection.Move(selectedIndex - 1, selectedIndex); + } + + internal static void MoveItemDown(this ObservableCollection baseCollection, int selectedIndex) + { + //# Check if move is possible + if (selectedIndex < 0 || selectedIndex + 1 >= baseCollection.Count) + return; + + //# Move-Item + baseCollection.Move(selectedIndex + 1, selectedIndex); + } + + internal static void MoveItemDown(this ObservableCollection baseCollection, T selectedItem) + { + //# MoveDown based on Item + baseCollection.MoveItemDown(baseCollection.IndexOf(selectedItem)); + } + + internal static void MoveItemUp(this ObservableCollection baseCollection, T selectedItem) + { + //# MoveUp based on Item + baseCollection.MoveItemUp(baseCollection.IndexOf(selectedItem)); + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Extensions/StringExtensions.cs b/BannerLord.Common/Extensions/StringExtensions.cs new file mode 100644 index 0000000..afd0e38 --- /dev/null +++ b/BannerLord.Common/Extensions/StringExtensions.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace BannerLord.Common.Extensions +{ + public static class StringExtensions + { + internal static IEnumerable GetLines(this string text) + { + var newLine = text.IndexOf("\r", StringComparison.Ordinal) > -1 ? "\r\n" : "\n"; + return text.Split(new[] { newLine }, StringSplitOptions.None); + } + + } +} \ No newline at end of file diff --git a/BannerLord.Common/Helpers/ObservableHashSet.cs b/BannerLord.Common/Helpers/ObservableHashSet.cs new file mode 100644 index 0000000..653815c --- /dev/null +++ b/BannerLord.Common/Helpers/ObservableHashSet.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using BannerLord.Common.Extensions; + +namespace BannerLord.Common.Helpers +{ + /// + /// Represents an observable set of values. + /// + /// The type of elements in the hash set. + public sealed class ObservableHashSet : ISet, INotifyCollectionChanged, INotifyPropertyChanged, IDisposable + { + private SimpleMonitor _monitor = new SimpleMonitor(); + private readonly HashSet _hashSet; + + /// + /// Initializes a new instance of the class. + /// + public ObservableHashSet() + { + this._hashSet = new HashSet(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The collection whose elements are copied to the new set. + public ObservableHashSet(IEnumerable collection) + { + this._hashSet = new HashSet(collection); + } + + /// + /// Initializes a new instance of the class. + /// + /// The IEqualityComparer<T> implementation to use when comparing values in the set, or null to use the default EqualityComparer<T> implementation for the set type. + public ObservableHashSet(IEqualityComparer comparer) + { + this._hashSet = new HashSet(comparer); + } + + /// + /// Initializes a new instance of the class. + /// + /// The collection whose elements are copied to the new set. + /// The IEqualityComparer<T> implementation to use when comparing values in the set, or null to use the default EqualityComparer<T> implementation for the set type. + public ObservableHashSet(IEnumerable collection, IEqualityComparer comparer) + { + this._hashSet = new HashSet(collection, comparer); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + if (this._monitor == null) return; + this._monitor.Dispose(); + this._monitor = null; + } + + #region Properties + + /// + /// The property names used with INotifyPropertyChanged. + /// + [SuppressMessage("Microsoft.Design", "CA1034:NestedTypesShouldNotBeVisible", + Justification = "A container for constants used with INotifyPropertyChanged.")] + private static class PropertyNames + { + public const string Count = "Count"; + public const string IsReadOnly = "IsReadOnly"; + } + + + /// + /// Gets the IEqualityComparer<T> object that is used to determine equality for the values in the set. + /// + public IEqualityComparer Comparer => this._hashSet.Comparer; + + /// + /// Gets the number of elements contained in the . + /// + /// + /// The number of elements contained in the . + /// + public int Count => this._hashSet.Count; + + /// + /// Gets a value indicating whether the is read-only. + /// + /// true if the is read-only; otherwise, false. + /// + bool ICollection.IsReadOnly => ((ICollection) this._hashSet).IsReadOnly; + + #endregion + + #region Events + + /// + /// Raised when the collection changes. + /// + public event NotifyCollectionChangedEventHandler CollectionChanged; + + private void RaiseCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (this.CollectionChanged == null) return; + using (this.BlockReentrancy()) + { + this.CollectionChanged?.Invoke(this, e); + } + } + + /// + /// Raised when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged; + + private void RaisePropertyChanged(string propertyName) + { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + #endregion + + #region Methods + + /// + /// Adds the specified element to a set. + /// + /// The element to add to the set. + /// true if the element is added to the object; false if the element is already present. + public bool Add(T item) + { + this.CheckReentrancy(); + + var wasAdded = this._hashSet.Add(item); + + if (!wasAdded) return false; + var index = this._hashSet.IndexOf(item); + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + this.RaisePropertyChanged(PropertyNames.Count); + + return true; + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + /// + /// The is read-only. + /// + void ICollection.Add(T item) + { + this.Add(item ?? throw new ArgumentNullException(nameof(item))); + } + + /// + /// Removes all elements from a object. + /// + public void Clear() + { + this.CheckReentrancy(); + + if (this._hashSet.Count <= 0) return; + this._hashSet.Clear(); + + this.RaiseCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + this.RaisePropertyChanged(PropertyNames.Count); + } + + /// + /// Determines whether a object contains the specified element. + /// + /// The element to locate in the object. + /// true if the object contains the specified element; otherwise, false. + public bool Contains(T item) + { + return this._hashSet.Contains(item); + } + + + /// + /// Copies the elements of a collection to an array. + /// + /// The one-dimensional array that is the destination of the elements copied from the object. The array must have zero-based indexing. + public void CopyTo(T[] array) + { + this._hashSet.CopyTo(array); + } + + /// + /// Copies the elements of a collection to an array. + /// + /// The one-dimensional array that is the destination of the elements copied from the object. The array must have zero-based indexing. + /// The zero-based index in array at which copying begins. + public void CopyTo(T[] array, int arrayIndex) + { + this._hashSet.CopyTo(array, arrayIndex); + } + + /// + /// Copies the elements of a collection to an array. + /// + /// The one-dimensional array that is the destination of the elements copied from the object. The array must have zero-based indexing. + /// The zero-based index in array at which copying begins. + /// The number of elements to copy to array. + public void CopyTo(T[] array, int arrayIndex, int count) + { + this._hashSet.CopyTo(array, arrayIndex, count); + } + + /// + /// Removes all elements in the specified collection from the current object. + /// + /// The collection of items to remove from the object. + public void ExceptWith(IEnumerable other) + { + //VerifyArgument.IsNotNull("other", other); + + this.CheckReentrancy(); + + // I locate items in other that are in the hashset + var removedItems = other.Where(x => this._hashSet.Contains(x)).ToList(); + + this._hashSet.ExceptWith(other); + + if (removedItems.Count <= 0) return; + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItems)); + this.RaisePropertyChanged(PropertyNames.Count); + } + + /// + /// Returns an enumerator that iterates through a . + /// + /// A .Enumerator object for the object. + public IEnumerator GetEnumerator() + { + return this._hashSet.GetEnumerator(); + } + + /// + /// Modifies the current object to contain only elements that are present in that object and in the specified collection. + /// + /// The collection to compare to the current object. + public void IntersectWith(IEnumerable other) + { + //VerifyArgument.IsNotNull("other", other); + + this.CheckReentrancy(); + + // I locate the items in the hashset that are not in other + var removedItems = this._hashSet.Where(x => !other.Contains(x)).ToList(); + + this._hashSet.IntersectWith(other); + + if (removedItems.Count <= 0) return; + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItems)); + this.RaisePropertyChanged(PropertyNames.Count); + } + + /// + /// Determines whether a object is a proper subset of the specified collection. + /// + /// The collection to compare to the current object. + /// true if the object is a proper subset of other; otherwise, false. + public bool IsProperSubsetOf(IEnumerable other) + { + return this._hashSet.IsProperSubsetOf(other); + } + + /// + /// Determines whether a object is a proper subset of the specified collection. + /// + /// The collection to compare to the current object. + /// true if the object is a proper superset of other; otherwise, false. + public bool IsProperSupersetOf(IEnumerable other) + { + return this._hashSet.IsProperSupersetOf(other); + } + + /// + /// Determines whether a object is a subset of the specified collection. + /// + /// The collection to compare to the current object. + /// true if the object is a subset of other; otherwise, false. + public bool IsSubsetOf(IEnumerable other) + { + return this._hashSet.IsSubsetOf(other); + } + + /// + /// Determines whether a object is a superset of the specified collection. + /// + /// The collection to compare to the current object. + /// true if the object is a superset of other; otherwise, false. + public bool IsSupersetOf(IEnumerable other) + { + return this._hashSet.IsSupersetOf(other); + } + + /// + /// Determines whether the current object and a specified collection share common elements. + /// + /// The collection to compare to the current object. + /// true if the object and other share at least one common element; otherwise, false. + public bool Overlaps(IEnumerable other) + { + return this._hashSet.Overlaps(other); + } + + /// + /// Removes the specified element from a object. + /// + /// The element to remove. + /// true if the element is successfully found and removed; otherwise, false. This method returns false if item is not found in the object. + public bool Remove(T item) + { + var index = this._hashSet.IndexOf(item); + var wasRemoved = this._hashSet.Remove(item); + + if (!wasRemoved) return false; + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); + this.RaisePropertyChanged(PropertyNames.Count); + + return true; + } + + /// + /// Determines whether a object and the specified collection contain the same elements. + /// + /// The collection to compare to the current object. + /// true if the object is equal to other; otherwise, false. + public bool SetEquals(IEnumerable other) + { + return this._hashSet.SetEquals(other); + } + + /// + /// Modifies the current object to contain only elements that are present either in that object or in the specified collection, but not both. + /// + /// The collection to compare to the current object. + public void SymmetricExceptWith(IEnumerable other) + { + //VerifyArgument.IsNotNull("other", other); + this.CheckReentrancy(); + + // I locate the items in other that are not in the hashset + var addedItems = other.Where(x => !this._hashSet.Contains(x)).ToList(); + + // I locate items in other that are in the hashset + var removedItems = other.Where(x => this._hashSet.Contains(x)).ToList(); + + this._hashSet.SymmetricExceptWith(other); + + if (removedItems.Count > 0) + { + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, removedItems)); + this.RaisePropertyChanged(PropertyNames.Count); + } + + if (addedItems.Count > 0) + { + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, addedItems)); + } + + if (removedItems.Count > 0 || addedItems.Count > 0) + { + this.RaisePropertyChanged(PropertyNames.Count); + } + } + + /// + /// Sets the capacity of a object to the actual number of elements it contains, rounded up to a nearby, implementation-specific value. + /// + public void TrimExcess() + { + this._hashSet.TrimExcess(); + } + + /// + /// Modifies the current object to contain all elements that are present in itself, the specified collection, or both. + /// + /// The collection to compare to the current object. + public void UnionWith(IEnumerable other) + { + //VerifyArgument.IsNotNull("other", other); + this.CheckReentrancy(); + + // I locate the items in other that are not in the hashset + var addedItems = other.Where(x => !this._hashSet.Contains(x)).ToList(); + + this._hashSet.UnionWith(other); + + if (addedItems.Count <= 0) return; + this.RaiseCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, addedItems)); + this.RaisePropertyChanged(PropertyNames.Count); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// + /// An object that can be used to iterate through the collection. + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable) this._hashSet).GetEnumerator(); + } + + #endregion + + #region Reentrancy Methods + + private IDisposable BlockReentrancy() + { + this._monitor.Enter(); + return this._monitor; + } + + private void CheckReentrancy() + { + if ((this._monitor.Busy && (this.CollectionChanged != null)) && + (this.CollectionChanged.GetInvocationList().Length > 1)) + { + throw new InvalidOperationException( + "There are additional attempts to change this hash set during a CollectionChanged event."); + } + } + + #endregion + + #region Private Classes + + private sealed class SimpleMonitor : IDisposable + { + private int _busyCount; + + public void Dispose() + { + this._busyCount--; + } + + public void Enter() + { + this._busyCount++; + } + + public bool Busy => (this._busyCount > 0); + } + + #endregion + } +} \ No newline at end of file diff --git a/BannerLord.Common/IModManager.cs b/BannerLord.Common/IModManager.cs new file mode 100644 index 0000000..d0b7c5b --- /dev/null +++ b/BannerLord.Common/IModManager.cs @@ -0,0 +1,26 @@ +using Splat; + +namespace BannerLord.Common +{ + public interface IModManager : IEnableLogger + { + bool Initialize(string configPath, string gamePath, out string errorMessage); + + bool OpenConfig(out string errorMessage); + bool OpenModsFolder(out string errorMessage); + + bool Save(out string errorMessage); + bool Run(string gameExe, string extraGameArguments, out string errorMessage); + + bool MoveToTop(int idx, out string errorMessage); + bool MoveUp(int idx, out string errorMessage); + bool MoveDown(int idx, out string errorMessage); + bool MoveToBottom(int idx, out string errorMessage); + + bool CheckAll(out string errorMessage); + bool UncheckAll(out string errorMessage); + bool InvertCheck(out string errorMessage); + + bool Sort(out string errorMessage); + } +} diff --git a/BannerLord.Common/IModManagerClient.cs b/BannerLord.Common/IModManagerClient.cs new file mode 100644 index 0000000..f3f1405 --- /dev/null +++ b/BannerLord.Common/IModManagerClient.cs @@ -0,0 +1,23 @@ +using Splat; + +namespace BannerLord.Common +{ + public interface IModManagerClient : IEnableLogger + { + bool CanInitialize(string configPath, string gamePath); + + bool CanRun(string gameExe, string extraGameArguments); + bool CanSave(); + + bool CanMoveToTop(int idx); + bool CanMoveUp(int idx); + bool CanMoveDown(int idx); + bool CanMoveToBottom(int idx); + + bool CanCheckAll(); + bool CanUncheckAll(); + bool CanInvertCheck(); + + bool CanSort(); + } +} diff --git a/BannerLord.Common/LoadOrderConflict.cs b/BannerLord.Common/LoadOrderConflict.cs new file mode 100644 index 0000000..c96e1bc --- /dev/null +++ b/BannerLord.Common/LoadOrderConflict.cs @@ -0,0 +1,47 @@ +using ReactiveUI; + +namespace BannerLord.Common +{ + public sealed class LoadOrderConflict : ReactiveObject + { + private string _dependsOn; + + public string DependsOn + { + get => this._dependsOn; + set => this.RaiseAndSetIfChanged(ref this._dependsOn, value); + } + + private bool _isUp; + + public bool IsUp + { + get => this._isUp; + set => this.RaiseAndSetIfChanged(ref this._isUp, value); + } + + private bool _isDown; + + public bool IsDown + { + get => this._isDown; + set => this.RaiseAndSetIfChanged(ref this._isDown, value); + } + + private bool _isMissing; + + public bool IsMissing + { + get => this._isMissing; + set => this.RaiseAndSetIfChanged(ref this._isMissing, value); + } + + private bool _optional; + + public bool Optional + { + get => this._optional; + set => this.RaiseAndSetIfChanged(ref this._optional, value); + } + } +} diff --git a/BannerLord.Common/ModEntry.cs b/BannerLord.Common/ModEntry.cs new file mode 100644 index 0000000..0d22169 --- /dev/null +++ b/BannerLord.Common/ModEntry.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.ObjectModel; +using BannerLord.Common.Xml; +using ReactiveUI; + +namespace BannerLord.Common +{ + using System.Collections.Generic; + using System.Linq; + + public sealed class ModEntry : ReactiveObject, IEquatable + { + public bool Equals(ModEntry other) + { + return this == other; + } + + public override bool Equals(object obj) + { + return ReferenceEquals(this, obj) || obj is ModEntry other && this == other; + } + + public override int GetHashCode() + { + return this._module.Id.ToLowerInvariant().GetHashCode(); + } + + private Module _module; + private UserModData _userModData; + private bool _isChecked; + private int _originalSpot; + private bool _isPointerOver; + private ObservableCollection _loadOrderConflicts; + public HashSet MyAssemblies { get; } = new HashSet(); + public HashSet DependOnAssemblies { get; } = new HashSet(); + public HashSet DependsOn { get; } = new HashSet(); + + public bool IsCheckboxEnabled => this._module.Official == false && this._module.SingleplayerModule == true; + + public Module Module + { + get => this._module; + set => this.RaiseAndSetIfChanged(ref this._module, value); + } + + public void NotifyChanged(string property) + { + this.RaisePropertyChanged(property); + } + + public UserModData UserModData + { + get => this._userModData; + set + { + this.RaiseAndSetIfChanged(ref this._userModData, value); + this._isChecked = this._userModData.IsSelected; + } + } + + public ObservableCollection LoadOrderConflicts + { + get => this._loadOrderConflicts; + set => this.RaiseAndSetIfChanged(ref this._loadOrderConflicts, value); + } + + public bool HasConflicts => this._loadOrderConflicts.Any(x => !x.Optional); + + public string Conflicts + { + get + { + var ret = string.Empty; + foreach (var conflict in this._loadOrderConflicts) + { + if (!string.IsNullOrEmpty(ret)) ret += Environment.NewLine; + ret += conflict.Optional ? "(Optional)" : ""; + if (conflict.IsUp) ret += $"{conflict.DependsOn} depends on this"; + else if (conflict.IsDown) ret += $"This depends on {conflict.DependsOn}"; + else ret += $"{conflict.DependsOn} is missing"; + } + + return ret; + } + } + + public string DisplayName => this.Module.Name; + + public bool IsChecked + { + get => this._isChecked; + set + { + this.RaiseAndSetIfChanged(ref this._isChecked, value); + this._userModData.IsSelected = this._isChecked; + } + } + + public bool IsPointerOver + { + get => this._isPointerOver; + set => this.RaiseAndSetIfChanged(ref this._isPointerOver, value); + } + + public int OriginalSpot + { + get => this._originalSpot; + set => this.RaiseAndSetIfChanged(ref this._originalSpot, value); + } + + public static bool operator ==(ModEntry a, ModEntry b) + { + if (ReferenceEquals(a, null)) + { + return ReferenceEquals(b, null); + } + + if (ReferenceEquals(b, null)) return false; + + + return a.Module.Id.Equals(b.Module.Id, + StringComparison.OrdinalIgnoreCase); + } + + public static bool operator !=(ModEntry a, ModEntry b) + { + return !(a == b); + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/ModManager.cs b/BannerLord.Common/ModManager.cs new file mode 100644 index 0000000..fb7559d --- /dev/null +++ b/BannerLord.Common/ModManager.cs @@ -0,0 +1,616 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using BannerLord.Common.Xml; +using Medallion.Collections; +using Mono.Cecil; +using Splat; +using Trinet.Core.IO.Ntfs; +using Alphaleonis.Win32.Filesystem; + +namespace BannerLord.Common +{ + public sealed class ModManager : IModManager + { + private readonly IModManagerClient _client; + + private string _basePath; + + private string _modulePath; + + private readonly object _lock = new object(); + private bool _runValidation; + + public ModManager(IModManagerClient client) + { + this._client = client; + this.Mods.CollectionChanged += this.Mods_CollectionChanged; + } + + private void Mods_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + this.Validate(); + } + + public ObservableCollection Mods { get; } = new ObservableCollection(); + + public string GameExeFolder { get; private set; } + + public bool Initialize(string config, string game, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanInitialize(config, game)) return false; + + this._runValidation = false; + this._basePath = config; + if (!Directory.Exists(this._basePath)) + { + errorMessage = $"{this._basePath} does not exist"; + this.Log().Error(errorMessage); + return false; + } + + try + { + if (!Directory.Exists(Path.Combine(this._basePath, "BannerLordLauncher Backups"))) + Directory.CreateDirectory(Path.Combine(this._basePath, "BannerLordLauncher Backups")); + } + catch (Exception e) + { + this.Log().Error(e); + } + + var launcherData = UserData.Load(this, Path.Combine(this._basePath, "LauncherData.xml")) ?? new UserData(); + this._modulePath = Path.Combine(game, "Modules"); + this.GameExeFolder = Path.Combine(game, "bin", "Win64_Shipping_Client"); + var modulesFolder = Path.Combine(game, "Modules"); + if (!Directory.Exists(modulesFolder)) + { + errorMessage = $"{modulesFolder} does not exist"; + this.Log().Error(errorMessage); + return false; + } + + var modules = Directory.EnumerateDirectories(modulesFolder, "*",System.IO.SearchOption.TopDirectoryOnly) + .Select(dir => Module.Load(this, Path.GetFileName(dir), game)).Where(module => module != null).ToList(); + + if (launcherData.SingleplayerData?.ModDatas != null) + { + foreach (var mod in launcherData.SingleplayerData.ModDatas) + { + if (this.Mods.Any(x => x.UserModData.Id.Equals(mod.Id, StringComparison.OrdinalIgnoreCase))) + continue; + var module = modules.FirstOrDefault(x => x.Id == mod.Id); + if (module == null) + { + this.Log().Warn($"{mod.Id} could not be found in {modulesFolder}"); + continue; + } + + modules.Remove(module); + var modEntry = new ModEntry {Module = module, UserModData = mod}; + this.Mods.Add(modEntry); + if (modEntry.Module.Official) modEntry.IsChecked = true; + } + } + + foreach (var module in modules) + { + if (this.Mods.Any(x => x.Module.Id.Equals(module.Id, StringComparison.OrdinalIgnoreCase))) continue; + var modEntry = new ModEntry {Module = module, UserModData = new UserModData(module.Id, false)}; + this.Mods.Add(modEntry); + if (modEntry.Module.Official) modEntry.IsChecked = true; + } + + this.AnalyzeAssemblies(); + this.BuildDependencies(); + this._runValidation = true; + this.Validate(); + return true; + } + + public bool Run(string gameExe, string extraGameArguments, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanRun(gameExe, extraGameArguments)) return false; + + gameExe ??= "Bannerlord.exe"; + var actualGameExe = Path.Combine(this.GameExeFolder, gameExe); + if (string.IsNullOrEmpty(actualGameExe)) + { + errorMessage = "Game executable could not be detected"; + this.Log().Error(errorMessage); + return false; + } + + if (!File.Exists(actualGameExe)) + { + errorMessage = $"{actualGameExe} could not be found"; + this.Log().Error(errorMessage); + return false; + } + + foreach (var dll in this.GetAssemblies().Distinct()) + try + { + var fi = new System.IO.FileInfo(dll); + if (!fi.Exists) continue; + try + { + if (!fi.AlternateDataStreamExists("Zone.Identifier")) continue; + var s = fi.GetAlternateDataStream("Zone.Identifier", System.IO.FileMode.Open); + s.Delete(); + } + catch (Exception e) + { + this.Log().Error(e); + } + } + catch + { + // + } + + extraGameArguments ??= ""; + var args = extraGameArguments.Trim() + " " + this.GameArguments().Trim(); + this.Log().Warn($"Trying to execute: {actualGameExe} {args}"); + var info = new ProcessStartInfo + { + Arguments = args, + FileName = actualGameExe, + WorkingDirectory = Path.GetDirectoryName(actualGameExe) ?? throw new InvalidOperationException(), + UseShellExecute = false + }; + try + { + Process.Start(info); + } + catch (Exception e) + { + errorMessage = "Exception when trying to run the game. See the log for details"; + this.Log().Error(e); + return false; + } + + return true; + } + + public bool OpenConfig(out string errorMessage) + { + errorMessage = default; + try + { + if (string.IsNullOrEmpty(this._basePath) || !Directory.Exists(this._basePath)) + { + errorMessage = $"{this._basePath} is invalid"; + return false; + } + + var info = new ProcessStartInfo + { + Arguments = this._basePath, + FileName = "explorer.exe", + WorkingDirectory = this._basePath, + UseShellExecute = false + }; + Process.Start(info); + } + catch (Exception e) + { + this.Log().Error(e); + errorMessage = e.Message; + return false; + } + + return true; + } + public bool OpenModsFolder(out string errorMessage) + { + errorMessage = default; + try + { + if (string.IsNullOrEmpty(this._modulePath) || !Directory.Exists(this._modulePath)) + { + errorMessage = $"{this._modulePath} is invalid"; + return false; + } + + var info = new ProcessStartInfo + { + Arguments = this._modulePath, + FileName = "explorer.exe", + WorkingDirectory = this._modulePath, + UseShellExecute = false + }; + Process.Start(info); + } + catch (Exception e) + { + this.Log().Error(e); + errorMessage = e.Message; + return false; + } + + return true; + } + + public bool Save(out string errorMessage) + { + errorMessage = default; + if (!this._client.CanSave()) return false; + var launcherDataFile = Path.Combine(this._basePath, "LauncherData.xml"); + var launcherData = UserData.Load(this, launcherDataFile) ?? new UserData(); + if (launcherData.SingleplayerData == null) launcherData.SingleplayerData = new UserGameTypeData(); + launcherData.SingleplayerData.ModDatas.Clear(); + foreach (var mod in this.Mods) launcherData.SingleplayerData.ModDatas.Add(mod.UserModData); + + this.BackupFile(launcherDataFile); + try + { + launcherData.Save(launcherDataFile); + } + catch (Exception e) + { + errorMessage = "Exception when trying to save the mod list. See the log for details"; + this.Log().Error(e); + return false; + } + + this.AnalyzeAssemblies(); + + return true; + } + + public bool MoveToTop(int selectedIndex, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanMoveToTop(selectedIndex)) return false; + if (selectedIndex <= 0 || selectedIndex >= this.Mods.Count) + { + errorMessage = "Index out of bounds"; + return false; + } + + this.Mods.Move(selectedIndex, 0); + this.Validate(); + return true; + } + + public bool MoveUp(int selectedIndex, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanMoveUp(selectedIndex)) return false; + if (selectedIndex <= 0 || selectedIndex >= this.Mods.Count) + { + errorMessage = "Index out of bounds"; + return false; + } + + this.Mods.Move(selectedIndex, selectedIndex - 1); + this.Validate(); + return true; + } + + public bool MoveDown(int selectedIndex, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanMoveDown(selectedIndex)) return false; + if (selectedIndex < 0 || selectedIndex >= this.Mods.Count - 1) + { + errorMessage = "Index out of bounds"; + return false; + } + + this.Mods.Move(selectedIndex, selectedIndex + 1); + this.Validate(); + return true; + } + + public bool MoveToBottom(int selectedIndex, out string errorMessage) + { + errorMessage = default; + if (!this._client.CanMoveToBottom(selectedIndex)) return false; + if (selectedIndex < 0 || selectedIndex >= this.Mods.Count - 1) + { + errorMessage = "Index out of bounds"; + return false; + } + + this.Mods.Move(selectedIndex, this.Mods.Count - 1); + this.Validate(); + return true; + } + + public bool CheckAll(out string errorMessage) + { + errorMessage = default; + if (!this._client.CanCheckAll()) return false; + this._runValidation = false; + foreach (var modEntry in this.Mods.Where(x => x.IsCheckboxEnabled)) modEntry.IsChecked = true; + this._runValidation = true; + this.Validate(); + return true; + } + + public bool UncheckAll(out string errorMessage) + { + errorMessage = default; + if (!this._client.CanUncheckAll()) return false; + this._runValidation = false; + foreach (var modEntry in this.Mods.Where(x => x.IsCheckboxEnabled)) modEntry.IsChecked = false; + this._runValidation = true; + this.Validate(); + return true; + } + + public bool InvertCheck(out string errorMessage) + { + errorMessage = default; + if (!this._client.CanInvertCheck()) return false; + this._runValidation = false; + foreach (var modEntry in this.Mods.Where(x => x.IsCheckboxEnabled)) + modEntry.IsChecked = !modEntry.IsChecked; + this._runValidation = true; + this.Validate(); + return true; + } + + public bool Sort(out string errorMessage) + { + errorMessage = default; + if (!this._client.CanSort()) return false; + this._runValidation = false; + + try + { + var mods = this.Mods.ToArray(); + this.Mods.Clear(); + var sorted2 = mods.StableOrderTopologicallyBy(x => x.DependsOn); + foreach (var mod in sorted2) this.Mods.Add(mod); + } + catch (Exception e) + { + this.Log().Error(e, "TopologicalSort"); + errorMessage = e.Message; + this._runValidation = true; + this.Validate(); + return false; + } + + this._runValidation = true; + this.Validate(); + return false; + } + + private string EnabledMods() + { + return "_MODULES_" + string.Join( + "", + this.Mods.Where(x => x.UserModData.IsSelected).Select(x => "*" + x.Module.Id)) + "*_MODULES_"; + } + + private string GameArguments() + { + return $"/singleplayer {this.EnabledMods()}"; + } + + private void BackupFile(string file) + { + var path = Path.Combine(this._basePath, "BannerLordLauncher Backups"); + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + catch (Exception e) + { + this.Log().Error(e); + return; + } + + if (!File.Exists(file)) return; + var ext = Path.GetExtension(file); + var i = 0; + var newFile = Path.ChangeExtension(file, $"{ext}.{i:D3}"); + Debug.Assert(newFile != null, nameof(newFile) + " != null"); + newFile = Path.Combine(path, Path.GetFileName(newFile)); + while (File.Exists(newFile)) + { + i++; + newFile = Path.ChangeExtension(file, $"{ext}.{i:D3}"); + Debug.Assert(newFile != null, nameof(newFile) + " != null"); + newFile = Path.Combine(path, Path.GetFileName(newFile)); + } + + if (i > 999) return; + try + { + Debug.Assert(file != null, nameof(file) + " != null"); + File.Move(file, newFile); + } + catch (Exception e) + { + this.Log().Error(e); + } + } + + private IEnumerable GetAssemblies(ModEntry module) + { + var path = Path.Combine(this._modulePath, module.Module.DirectoryName, "bin", "Win64_Shipping_Client"); + if (!Directory.Exists(path)) yield break; + + foreach (var subModule in module.Module.SubModules) + { + foreach (var assembly in subModule.Assemblies ?? Enumerable.Empty()) + { + var file = Path.Combine(path, assembly); + if (File.Exists(file)) yield return file; + } + + if (string.IsNullOrEmpty(subModule.DLLName)) continue; + { + var file = Path.Combine(path, subModule.DLLName); + if (File.Exists(file)) yield return file; + } + } + + foreach (var subModule in module.Module.DelayedSubModules) + { + foreach (var assembly in subModule.Assemblies ?? Enumerable.Empty()) + { + var file = Path.Combine(path, assembly); + if (File.Exists(file)) yield return file; + } + + if (string.IsNullOrEmpty(subModule.DLLName)) continue; + { + var file = Path.Combine(path, subModule.DLLName); + if (File.Exists(file)) yield return file; + } + } + } + + private IEnumerable GetAssemblies() + { + foreach (var module in this.Mods.Where(x => x.IsChecked)) + { + foreach (var a in this.GetAssemblies(module)) + yield return a; + } + } + + private void Validate() + { + if (!this._runValidation) return; + lock (this._lock) + { + foreach (var entry in this.Mods) + { + if (entry.LoadOrderConflicts == null) + entry.LoadOrderConflicts = new ObservableCollection(); + entry.LoadOrderConflicts.Clear(); + } + + for (var i = 0; i < this.Mods.Count; i++) + { + var modEntry = this.Mods[i]; + foreach (var dependsOn in modEntry.Module.DependedModules) + { + var found = this.Mods.FirstOrDefault(x => x.Module.Id == dependsOn && x.UserModData.IsSelected); + var isDown = false; + var isMissing = found == null; + if (found != null) + { + var foundIdx = this.Mods.IndexOf(found); + if (foundIdx > i) + { + isDown = true; + found.LoadOrderConflicts.Add( + new LoadOrderConflict + {IsUp = true, DependsOn = modEntry.Module.Id, Optional = false}); + } + } + + if (!isDown && !isMissing) continue; + var conflict = new LoadOrderConflict + { + IsUp = false, + IsDown = isDown, + IsMissing = isMissing, + DependsOn = dependsOn, + Optional = false + }; + modEntry.LoadOrderConflicts.Add(conflict); + } + + foreach (var dependsOn in modEntry.Module.OptionalDependModules) + { + var found = this.Mods.FirstOrDefault(x => x.Module.Id == dependsOn && x.UserModData.IsSelected); + var isDown = false; + var isMissing = found == null; + if (found != null) + { + var foundIdx = this.Mods.IndexOf(found); + if (foundIdx > i) + { + isDown = true; + found.LoadOrderConflicts.Add( + new LoadOrderConflict + {IsUp = true, DependsOn = modEntry.Module.Id, Optional = true}); + } + } + + if (!isDown && !isMissing) continue; + var conflict = new LoadOrderConflict + { + IsUp = false, + IsDown = isDown, + IsMissing = isMissing, + DependsOn = dependsOn, + Optional = true + }; + modEntry.LoadOrderConflicts.Add(conflict); + } + } + + foreach (var entry in this.Mods) + { + entry.NotifyChanged("HasConflicts"); + entry.NotifyChanged("Conflicts"); + } + } + } + + private void BuildDependencies() + { + foreach (var module in this.Mods) + { + foreach (var moduleId in module.Module.DependedModules) + { + var found = this.Mods.FirstOrDefault( + x => x.Module.Id.Equals(moduleId, StringComparison.OrdinalIgnoreCase)); + if (found == null) continue; + module.DependsOn.Add(found); + } + + foreach (var assembly in module.DependOnAssemblies) + { + var found = this.Mods.FirstOrDefault(x => x.MyAssemblies.Contains(assembly)); + if (found == null) continue; + if (found == module) continue; + module.DependsOn.Add(found); + } + + foreach (var moduleId in module.Module.OptionalDependModules) + { + var found = this.Mods.FirstOrDefault( + x => x.Module.Id.Equals(moduleId, StringComparison.OrdinalIgnoreCase)); + if (found == null) continue; + module.DependsOn.Add(found); + } + } + } + + private void AnalyzeAssemblies() + { + foreach (var module in this.Mods) + { + module.MyAssemblies.Clear(); + foreach (var assemblyFile in this.GetAssemblies(module)) + try + { + var assemblyDefinition = AssemblyDefinition.ReadAssembly(assemblyFile); + module.MyAssemblies.Add(assemblyDefinition.Name.FullName); + foreach (var d in assemblyDefinition.MainModule.AssemblyReferences) + module.DependOnAssemblies.Add(d.FullName); + } + catch (Exception e) + { + this.Log().Error(e); + } + } + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Properties/AssemblyInfo.cs b/BannerLord.Common/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..3978a9b --- /dev/null +++ b/BannerLord.Common/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("BannerLord.Common")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("BannerLord.Common")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("EEC8F786-3A4D-4077-BCC3-14A3B602A0A3")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// 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.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/BannerLord.Common/UACChecker.cs b/BannerLord.Common/UACChecker.cs new file mode 100644 index 0000000..1b0fb57 --- /dev/null +++ b/BannerLord.Common/UACChecker.cs @@ -0,0 +1,115 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace BannerLord.Common +{ + public static class UACChecker + { + private const int ERROR_ELEVATION_REQUIRED = 740; + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreateProcess(string lpApplicationName, + string lpCommandLine, ref SECURITY_ATTRIBUTES lpProcessAttributes, + ref SECURITY_ATTRIBUTES lpThreadAttributes, bool bInheritHandles, + CreationFlags dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, + [In] ref STARTUPINFO lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public Int32 cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public Int32 dwX; + public Int32 dwY; + public Int32 dwXSize; + public Int32 dwYSize; + public Int32 dwXCountChars; + public Int32 dwYCountChars; + public Int32 dwFillAttribute; + public Int32 dwFlags; + public Int16 wShowWindow; + public Int16 cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public int bInheritHandle; + } + + [Flags] + private enum CreationFlags : uint + { + CREATE_SUSPENDED = 0x4 + } + + public static bool RequiresElevation(string Filename) + { + var requiresElevation = false; + var win32error = 0; + + // These are struct's required by CreateProcess API + //var pInfo = new PROCESS_INFORMATION(); + var sInfo = new STARTUPINFO(); + var pSec = new SECURITY_ATTRIBUTES(); + var tSec = new SECURITY_ATTRIBUTES(); + + pSec.nLength = Marshal.SizeOf(pSec); + tSec.nLength = Marshal.SizeOf(tSec); + + // Attempt to start "Filename" in a suspended state + var success = CreateProcess(null, Filename, + ref pSec, ref tSec, false, CreationFlags.CREATE_SUSPENDED, + IntPtr.Zero, null, ref sInfo, out var pInfo); + + if (success) + requiresElevation = false; // "Filename" started, so no elevation is required (maybe we are already elevated) + else + { + // An error occurred, find out what it is. + win32error = Marshal.GetLastWin32Error(); + if (win32error == ERROR_ELEVATION_REQUIRED) + requiresElevation = true; // The error states that "Filename" could not start because it requires elevation + } + + + // We don't actually want "Filename" to run, so kill the process and close the handles in pInfo + TerminateProcess(pInfo.hProcess, 0); + CloseHandle(pInfo.hThread); + CloseHandle(pInfo.hProcess); + + // If there was an error, and that error was NOT elevation is required then throw an exception + if ((win32error != 0) && (win32error != ERROR_ELEVATION_REQUIRED)) + throw new Win32Exception(win32error); + + return requiresElevation; + } + } +} diff --git a/BannerLord.Common/UacUtil.cs b/BannerLord.Common/UacUtil.cs new file mode 100644 index 0000000..09961a5 --- /dev/null +++ b/BannerLord.Common/UacUtil.cs @@ -0,0 +1,310 @@ +/* + * Copied from https://github.com/Nexus-Mods/Nexus-Mod-Manager + */ +using System; +using System.Runtime.InteropServices; + +namespace BannerLord.Common +{ + + /// + /// Utility class for getting information about UAC. + /// + public class UacUtil + { + /// + /// The TOKEN_ELEVATION enum. + /// + private struct TokenElevation + { + /// + /// Indicates if the token is elevated. + /// + public uint TokenIsElevated; + } + + /// + /// The TOKEN_INFORMATION_CLASS enum. + /// + private enum TokenInformationClass + { + /// + /// The information class contains the user account of the token. + /// + TokenUser = 1, + + /// + /// The information class contains the group accounts of the token. + /// + TokenGroups = 2, + + /// + /// The information class contains the privileges of the token. + /// + TokenPrivileges = 3, + + /// + /// The information class contains the owner security identifier for newly created objects. + /// + TokenOwner = 4, + + /// + /// The information class contains the primary group security identifier for newly created objects. + /// + TokenPrimaryGroup = 5, + + /// + /// The information class contains the default DACL for newly created objects. + /// + TokenDefaultDacl = 6, + + /// + /// The information class contains the source of the token. + /// + TokenSource = 7, + + /// + /// The information class contains a value that indicates the type of the token. + /// + TokenType = 8, + + /// + /// The information class contains a value that indicates the impersonation level of the token. + /// + TokenImpersonationLevel = 9, + + /// + /// The information class contains token statistics. + /// + TokenStatistics = 10, + + /// + /// The information class contains the list of restricting security identifiers in a token. + /// + TokenRestrictedSids = 11, + + /// + /// The information class contains a value that indicates the session id associated with the token. + /// + TokenSessionId = 12, + + /// + /// The information class contains the user groups and privileges associated with the token. + /// + TokenGroupsAndPrivileges = 13, + + /// + /// Reserved. + /// + TokenSessionReference = 14, + + /// + /// The information class contains a value that indicates whether the token includes the SANDBOX_INERT flag. + /// + TokenSandBoxInert = 15, + + /// + /// Reserved. + /// + TokenAuditPolicy = 16, + + /// + /// The information class contains a value describing the origin of the token. + /// + TokenOrigin = 17, + + /// + /// The information class contains a value that specifies the elevation level of the token. + /// + TokenElevationType = 18, + + /// + /// The information class contains a handle to a token linked to this token. + /// + TokenLinkedToken = 19, + + /// + /// The information class contains a value that specifies whether the token is elevated. + /// + TokenElevation = 20, + + /// + /// The information class contains a value that specified if the token has ever been filtered. + /// + TokenHasRestrictions = 21, + + /// + /// The information class contains a value that specifies security information in the token. + /// + TokenAccessInformation = 22, + + /// + /// The information class contains a value that specifies if virtualization is allowed. + /// + TokenVirtualizationAllowed = 23, + + /// + /// The information class contains a value that specifies if virtualization is enabled. + /// + TokenVirtualizationEnabled = 24, + + /// + /// The information class contains a value that specifies the integrity level of the token. + /// + TokenIntegrityLevel = 25, + + /// + /// The information class contains a value that specifies whether the token includes the UIAccess flag. + /// + TokenUiAccess = 26, + + /// + /// The information class contains a value that specifies the token's mandatory policy. + /// + TokenMandatoryPolicy = 27, + + /// + /// The information class contains the token's logon security identifier. + /// + TokenLogonSid = 28, + + /// + /// The maximum value. + /// + MaxTokenInfoClass = 29 + } + + /// + /// Constant from the Windows SDK. + /// + private const uint TokenQuery = 0x0008; + + /// + /// Opens the access token of a process. + /// + /// The process whose token is to be opened. + /// The desired access token we wish to open. + /// The output parameter for the opened token. + /// true if the desired token was opened; + /// false otherwise. + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle); + + /// + /// Gets the current process's handle. + /// + /// The cur + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetCurrentProcess(); + + /// + /// Gets information about the specified process access token. + /// + /// The handle to the token about which to get the information. + /// The type of infromation we want. + /// The structure into which the information will be copied. + /// The length of the information data structure. + /// The length of the return information. + /// true if the information was successfully retrieved; + /// false otherwise. + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation( + IntPtr tokenHandle, + TokenInformationClass tokenInformationClass, + IntPtr tokenInformation, + uint tokenInformationLength, + out uint returnLength); + + /// + /// Closes the given handle. + /// + /// The handle to close. + /// true if the was closed; + /// false otherwise. + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr hObject); + + /// + /// Loads the specified library. + /// + /// The library to load. + /// A handle to the loaded library. + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = false)] + private static extern IntPtr LoadLibrary(string lpFileName); + + /// + /// Gets the address of the specified process. + /// + /// The handle of the module. + /// The process name. + /// The address of the specified process. + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true)] + private static extern IntPtr GetProcAddress(IntPtr hmodule, string procName); + + /// + /// Gets whether the OS has UAC. + /// + /// Whether the OS has UAC. + private static bool IsUacOperatingSystem => Environment.OSVersion.Version >= new Version("6.0"); + + /// + /// Gets whether or not the current process is elevated. + /// + /// + /// This return true if: + /// The current OS supports UAC, UAC is on, and the process is being run as an elevated user. + /// OR + /// The current OS supports UAC, UAC is off, and the process is being run by an administrator. + /// OR + /// The current OS doesn't support UAC. + /// + /// Otherwise, this returns false. + /// + /// Whether or not the current process is elevated. + public static bool IsElevated + { + get + { + if (!IsUacOperatingSystem) + return true; + + var ptrProcessHandle = GetCurrentProcess(); + if (ptrProcessHandle == IntPtr.Zero) + throw new Exception("Could not get handle to current process."); + + if (!(OpenProcessToken(ptrProcessHandle, TokenQuery, out var hToken))) + throw new Exception("Could not open process token."); + + try + { + TokenElevation tevTokenElevation; + tevTokenElevation.TokenIsElevated = 0; + + var intTokenElevationSize = Marshal.SizeOf(tevTokenElevation); + var pteTokenElevation = Marshal.AllocHGlobal(intTokenElevationSize); + try + { + Marshal.StructureToPtr(tevTokenElevation, pteTokenElevation, true); + var booCallSucceeded = GetTokenInformation(hToken, TokenInformationClass.TokenElevation, pteTokenElevation, (uint)intTokenElevationSize, out var uintReturnLength); + if ((!booCallSucceeded) || (intTokenElevationSize != uintReturnLength)) + throw new Exception("Could not get token information."); + tevTokenElevation = (TokenElevation)Marshal.PtrToStructure(pteTokenElevation, typeof(TokenElevation)); + } + finally + { + Marshal.FreeHGlobal(pteTokenElevation); + } + + return (tevTokenElevation.TokenIsElevated != 0); + } + finally + { + CloseHandle(hToken); + } + } + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/GameType.cs b/BannerLord.Common/Xml/GameType.cs new file mode 100644 index 0000000..b6320ad --- /dev/null +++ b/BannerLord.Common/Xml/GameType.cs @@ -0,0 +1,8 @@ +namespace BannerLord.Common.Xml +{ + public enum GameType + { + Singleplayer, + Multiplayer + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/Module.cs b/BannerLord.Common/Xml/Module.cs new file mode 100644 index 0000000..31e187e --- /dev/null +++ b/BannerLord.Common/Xml/Module.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Alphaleonis.Win32.Filesystem; +using System.Xml; +using Splat; + +namespace BannerLord.Common.Xml +{ + public class Module + { + public string DirectoryName { get; set; } + public string Name { get; set; } + public string Id { get; set; } + public ModuleVersion Version { get; set; } + public bool Official { get; set; } + public bool DefaultModule { get; set; } + public bool SingleplayerModule { get; set; } + public bool MultiplayerModule { get; set; } + public List DependedModules { get; set; } + public List SubModules { get; set; } + public List DelayedSubModules { get; set; } + public List OptionalDependModules { get; set; } + + public Module() + { + this.DependedModules = new List(); + this.SubModules = new List(); + this.DelayedSubModules = new List(); + this.OptionalDependModules = new List(); + this.Version = ModuleVersion.Empty; + } + + public static Module Load(ModManager manager, string directoryName, string gamePath) + { + var file = Path.Combine(gamePath, "Modules", directoryName, "SubModule.xml"); + if (!File.Exists(file)) + { + manager.Log().Warn($"File {file} does not exist"); + return null; + } + + var document = new XmlDocument(); + try + { + document.Load(file); + } + catch (Exception ex) + { + manager.Log().Error($"Error while loading {file}", ex); + + return null; + } + + var module = document.SelectSingleNode("Module"); + if (module != null) + { + var ret = new Module(); + ret.DirectoryName = directoryName; + ret.Name = module.SelectSingleNode("Name")?.Attributes["value"]?.InnerText; + ret.Id = module.SelectSingleNode("Id")?.Attributes["value"]?.InnerText; + if (string.IsNullOrEmpty(ret.Name) || string.IsNullOrWhiteSpace(ret.Id)) + { + if (string.IsNullOrEmpty(ret.Name)) manager.Log().Error($"Invalid module name in {file}"); + if (string.IsNullOrEmpty(ret.Id)) manager.Log().Error($"Invalid module id in {file}"); + return null; + } + + try + { + ret.Version = ModuleVersion.FromString(module.SelectSingleNode("Version")?.Attributes["value"]?.InnerText); + } + catch (Exception e) + { + manager.Log().Error(e, $"Version parsing issue in {file}"); + ret.Version = ModuleVersion.Empty; + } + + ret.Official = string.Equals(module.SelectSingleNode("Official")?.Attributes["value"]?.InnerText, "true", StringComparison.OrdinalIgnoreCase); + ret.DefaultModule = string.Equals(module.SelectSingleNode("DefaultModule")?.Attributes["value"]?.InnerText, "true", StringComparison.OrdinalIgnoreCase); + ret.SingleplayerModule = string.Equals(module.SelectSingleNode("SingleplayerModule")?.Attributes["value"]?.InnerText, "true", StringComparison.OrdinalIgnoreCase); + ret.MultiplayerModule = string.Equals(module.SelectSingleNode("MultiplayerModule")?.Attributes["value"]?.InnerText, "true", StringComparison.OrdinalIgnoreCase); + var dependedModules = module.SelectSingleNode("DependedModules"); + if (dependedModules != null) + { + var dependedModulesList = dependedModules.SelectNodes("DependedModule"); + for (var i = 0; i < dependedModulesList.Count; i++) + { + var value = dependedModulesList[i].Attributes["Id"]?.InnerText; + if (!string.IsNullOrEmpty(value)) ret.DependedModules.Add(value); + } + dependedModulesList = dependedModules.SelectNodes("OptionalDependModule"); + for (var i = 0; i < dependedModulesList.Count; i++) + { + var value = dependedModulesList[i].Attributes["Id"]?.InnerText; + if (!string.IsNullOrEmpty(value)) ret.OptionalDependModules.Add(value); + } + } + var subModules = module.SelectSingleNode("SubModules"); + if (subModules != null) + { + var subModulesList = subModules.SelectNodes("SubModule"); + for (var i = 0; i < subModulesList.Count; i++) + { + var subModule = SubModule.Load(subModulesList[i]); + if (subModule != null) ret.SubModules.Add(subModule); + } + } + + var delayedSubModules = module.SelectSingleNode("DelayedSubModules"); + if (delayedSubModules != null) + { + var subModulesList = delayedSubModules.SelectNodes("SubModule"); + for (var i = 0; i < subModulesList.Count; i++) + { + var subModule = SubModule.Load(subModulesList[i]); + if (subModule != null) ret.DelayedSubModules.Add(subModule); + } + subModulesList = delayedSubModules.SelectNodes("DelayedSubModule"); + for (var i = 0; i < subModulesList.Count; i++) + { + var subModule = SubModule.Load(subModulesList[i]); + if (subModule != null) ret.DelayedSubModules.Add(subModule); + } + + } + var optionalDependModules = module.SelectSingleNode("OptionalDependModules"); + if (optionalDependModules != null) + { + var optionalDependModuleList = optionalDependModules.SelectNodes("OptionalDependModule"); + for (var i = 0; i < optionalDependModuleList.Count; i++) + { + var value = optionalDependModuleList[i].Attributes["Id"]?.InnerText; + if (!string.IsNullOrEmpty(value)) ret.OptionalDependModules.Add(value); + } + optionalDependModuleList = dependedModules.SelectNodes("DependModule"); + for (var i = 0; i < optionalDependModuleList.Count; i++) + { + var value = optionalDependModuleList[i].Attributes["Id"]?.InnerText; + if (!string.IsNullOrEmpty(value)) ret.OptionalDependModules.Add(value); + } + } + + return ret; + } + manager.Log().Error($"Could not find module node in {file}"); + + return null; + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/ModuleVersion.cs b/BannerLord.Common/Xml/ModuleVersion.cs new file mode 100644 index 0000000..cede88d --- /dev/null +++ b/BannerLord.Common/Xml/ModuleVersion.cs @@ -0,0 +1,157 @@ +using System; + +namespace BannerLord.Common.Xml +{ + public struct ModuleVersion: IEquatable, IComparable + { + private static readonly ModuleVersionTypeComparer TypeComparer; + private const int DefaultChangeSet = 226961; + + static ModuleVersion() + { + TypeComparer = new ModuleVersionTypeComparer(); + } + + public ModuleVersionType Type { get; } + public int Major { get; } + public int Minor { get; } + public int Revision { get; } + public int ChangeSet { get; } + + public string AsString + { + get + { + var changeSet = (this.ChangeSet == DefaultChangeSet) ? string.Empty : $".{this.ChangeSet}"; + return $"{GetPrefix(this.Type)}{this.Major}.{this.Minor}.{this.Revision}{changeSet}"; + } + } + + public static readonly ModuleVersion Empty = new ModuleVersion(ModuleVersionType.Invalid, -1, -1, -1, -1); + + public ModuleVersion(ModuleVersionType type, int major, int minor, int revision, int changeSet) + { + this.Type = type; + this.Major = major; + this.Minor = minor; + this.Revision = revision; + this.ChangeSet = changeSet; + } + + public static ModuleVersion FromString(string input) + { + var array = input.Split(new char[] + { + '.' + }); + if (array.Length != 3 && array.Length != 4) + { + throw new Exception("Wrong version as string"); + } + var applicationVersionType = ModuleVersion.ApplicationVersionTypeFromString(array[0][0].ToString()); + var value = array[0].Substring(1); + var value2 = array[1]; + var value3 = array[2]; + var major = Convert.ToInt32(value); + var minor = Convert.ToInt32(value2); + var revision = Convert.ToInt32(value3); + var changeSet = (array.Length > 3) ? Convert.ToInt32(array[3]) : DefaultChangeSet; + return new ModuleVersion(applicationVersionType, major, minor, revision, changeSet); + } + + private static ModuleVersionType ApplicationVersionTypeFromString(string applicationVersionTypeAsString) + { + return applicationVersionTypeAsString switch + { + "a" => ModuleVersionType.Alpha, + "b" => ModuleVersionType.Beta, + "e" => ModuleVersionType.EarlyAccess, + "v" => ModuleVersionType.Release, + "d" => ModuleVersionType.Development, + _ => ModuleVersionType.Invalid + }; + } + + public static bool operator ==(ModuleVersion a, ModuleVersion b) + { + return TypeComparer.Equals(a.Type, b.Type) && a.Major == b.Major && a.Minor == b.Minor && a.Revision == b.Revision && + a.ChangeSet == b.ChangeSet; + } + + public static bool operator !=(ModuleVersion a, ModuleVersion b) + { + return !TypeComparer.Equals(a.Type, b.Type) || a.Major != b.Major || a.Minor != b.Minor || a.Revision != b.Revision || + a.ChangeSet != b.ChangeSet; + } + + public bool Equals(ModuleVersion other) + { + return this == other; + } + + public int CompareTo(ModuleVersion other) + { + var res = TypeComparer.Compare(this.Type, other.Type); + if (res != 0) return res; + res = this.Major.CompareTo(other.Major); + if (res != 0) return res; + res = this.Minor.CompareTo(other.Minor); + if (res != 0) return res; + res = this.Revision.CompareTo(other.Revision); + if (res != 0) return res; + res = this.ChangeSet.CompareTo(other.ChangeSet); + return res; + } + + public override bool Equals(object obj) + { + return obj is ModuleVersion other && this.Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (int) this.Type; + hashCode = (hashCode * 397) ^ this.Major; + hashCode = (hashCode * 397) ^ this.Minor; + hashCode = (hashCode * 397) ^ this.Revision; + hashCode = (hashCode * 397) ^ this.ChangeSet; + return hashCode; + } + } + + public static bool operator <(ModuleVersion left, ModuleVersion right) + { + return left.CompareTo(right) < 0; + } + + public static bool operator >(ModuleVersion left, ModuleVersion right) + { + return left.CompareTo(right) > 0; + } + + public static bool operator <=(ModuleVersion left, ModuleVersion right) + { + return left.CompareTo(right) <= 0; + } + + public static bool operator >=(ModuleVersion left, ModuleVersion right) + { + return left.CompareTo(right) >= 0; + } + + private static string GetPrefix(ModuleVersionType applicationVersionType) + { + return applicationVersionType switch + { + ModuleVersionType.Alpha => "a", + ModuleVersionType.Beta => "b", + ModuleVersionType.EarlyAccess => "e", + ModuleVersionType.Release => "v", + ModuleVersionType.Development => "d", + _ => "i" + }; + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/ModuleVersionType.cs b/BannerLord.Common/Xml/ModuleVersionType.cs new file mode 100644 index 0000000..fff4c87 --- /dev/null +++ b/BannerLord.Common/Xml/ModuleVersionType.cs @@ -0,0 +1,12 @@ +namespace BannerLord.Common.Xml +{ + public enum ModuleVersionType + { + Invalid = -1, + Alpha, + Beta, + EarlyAccess, + Release, + Development + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/ModuleVersionTypeComparer.cs b/BannerLord.Common/Xml/ModuleVersionTypeComparer.cs new file mode 100644 index 0000000..95bda9d --- /dev/null +++ b/BannerLord.Common/Xml/ModuleVersionTypeComparer.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace BannerLord.Common.Xml +{ + public class ModuleVersionTypeComparer : IComparer, IEqualityComparer { + + // ReSharper disable AssignNullToNotNullAttribute + public int Compare(ModuleVersionType x, ModuleVersionType y) + => + this.GetHashCode(x) - this.GetHashCode(y); + // ReSharper restore AssignNullToNotNullAttribute + + public bool Equals(ModuleVersionType x, ModuleVersionType y) + => + this.Compare(x, y) == 0; + + public int GetHashCode(ModuleVersionType obj) + => (int) obj + 1 % 5; + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/SubModule.cs b/BannerLord.Common/Xml/SubModule.cs new file mode 100644 index 0000000..262e6b5 --- /dev/null +++ b/BannerLord.Common/Xml/SubModule.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Xml; + +namespace BannerLord.Common.Xml +{ + public class SubModule + { + public string Name { get; set; } + public string DLLName { get; set; } + public string SubModuleClassType { get; set; } + public List<(SubModuleTags Key, string Value)> Tags { get; set; } + public List Assemblies { get; set; } + + public SubModule() + { + this.Tags = new List<(SubModuleTags Key, string Value)>(); + this.Assemblies = new List(); + } + + public static SubModule Load(XmlNode node) + { + if (node == null) return null; + var ret = new SubModule(); + ret.Name = node.SelectSingleNode("Name")?.Attributes["value"]?.InnerText; + ret.DLLName = node.SelectSingleNode("DLLName")?.Attributes["value"]?.InnerText; + ret.SubModuleClassType = node.SelectSingleNode("SubModuleClassType")?.Attributes["value"]?.InnerText; + var assemblies = node.SelectSingleNode("Assemblies"); + if (assemblies != null) + { + var assembliesList = assemblies.SelectNodes("Assembly"); + for (var i = 0; i < assembliesList.Count; i++) + { + var value = assembliesList[i].Attributes["value"]?.InnerText; + if(!string.IsNullOrEmpty(value)) ret.Assemblies.Add(value); + } + } + var tags = node.SelectSingleNode("Tags"); + if (tags != null) + { + var tagsList = tags.SelectNodes("Tag"); + for (var i = 0; i < tagsList.Count; i++) + { + var key = tagsList[i].Attributes["key"]?.InnerText; + if (!string.IsNullOrEmpty(key) && Enum.TryParse(key, out var keyEnum)) + { + var value = tagsList[i].Attributes["value"]?.InnerText; + ret.Tags.Add((keyEnum, value)); + } + } + } + return ret; + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/SubModuleTags.cs b/BannerLord.Common/Xml/SubModuleTags.cs new file mode 100644 index 0000000..1970e6b --- /dev/null +++ b/BannerLord.Common/Xml/SubModuleTags.cs @@ -0,0 +1,11 @@ +namespace BannerLord.Common.Xml +{ + public enum SubModuleTags + { + RejectedPlatform, + ExclusivePlatform, + DedicatedServerType, + IsNoRenderModeElement, + DependantRuntimeLibrary + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/UserData.cs b/BannerLord.Common/Xml/UserData.cs new file mode 100644 index 0000000..14ca47e --- /dev/null +++ b/BannerLord.Common/Xml/UserData.cs @@ -0,0 +1,60 @@ +using System; +using Alphaleonis.Win32.Filesystem; +using System.Xml; +using System.Xml.Serialization; +using Splat; + +namespace BannerLord.Common.Xml +{ + public class UserData : IEnableLogger + { + public GameType GameType { get; set; } + public UserGameTypeData SingleplayerData { get; set; } + public UserGameTypeData MultiplayerData { get; set; } + + public UserData() + { + this.GameType = GameType.Singleplayer; + this.SingleplayerData = new UserGameTypeData(); + this.MultiplayerData = new UserGameTypeData(); + } + + public static UserData Load(ModManager manager, string file) + { + if (!File.Exists(file)) + { + manager.Log().Warn($"File {file} does not exist"); + return null; + } + var xmlSerializer = new XmlSerializer(typeof(UserData)); + try + { + using var xmlReader = XmlReader.Create(file); + return (UserData)xmlSerializer.Deserialize(xmlReader); + } + catch (Exception value) + { + manager.Log().Error($"Error loading {file}", value); + return null; + } + } + + public void Save(string file) + { + var xmlSerializer = new XmlSerializer(typeof(UserData)); + try + { + if (File.Exists(file)) File.Delete(file); + using var xmlWriter = XmlWriter.Create(file, new XmlWriterSettings + { + Indent = true + }); + xmlSerializer.Serialize(xmlWriter, this); + } + catch (Exception value) + { + this.Log().Error($"Error saving {file}", value); + } + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/UserGameTypeData.cs b/BannerLord.Common/Xml/UserGameTypeData.cs new file mode 100644 index 0000000..d95dcf8 --- /dev/null +++ b/BannerLord.Common/Xml/UserGameTypeData.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace BannerLord.Common.Xml +{ + public class UserGameTypeData + { + public List ModDatas { get; set; } + + public UserGameTypeData() + { + this.ModDatas = new List(); + } + } +} \ No newline at end of file diff --git a/BannerLord.Common/Xml/UserModData.cs b/BannerLord.Common/Xml/UserModData.cs new file mode 100644 index 0000000..178e9aa --- /dev/null +++ b/BannerLord.Common/Xml/UserModData.cs @@ -0,0 +1,17 @@ +namespace BannerLord.Common.Xml +{ + public class UserModData + { + public UserModData() {} + + public UserModData(string id, bool isSelected) + { + this.Id = id; + this.IsSelected = isSelected; + } + + public string Id { get; set; } + public bool IsSelected { get; set; } + + } +} \ No newline at end of file diff --git a/BannerLord.Common/packages.config b/BannerLord.Common/packages.config new file mode 100644 index 0000000..3934b03 --- /dev/null +++ b/BannerLord.Common/packages.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BannerLordLauncher.sln b/BannerLordLauncher.sln new file mode 100644 index 0000000..3edc198 --- /dev/null +++ b/BannerLordLauncher.sln @@ -0,0 +1,58 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29806.167 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BannerLord.Common", "BannerLord.Common\BannerLord.Common.csproj", "{EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Steam.Common", "Steam.Common\Steam.Common.csproj", "{F2087DE4-55FD-43DE-944F-DC7C26CDE545}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BannerLordLauncher", "BannerLordLauncher\BannerLordLauncher.csproj", "{B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2C0D2AA8-551A-475C-B2C5-0E7B874086A9}" + ProjectSection(SolutionItems) = preProject + BuildCommon.targets = BuildCommon.targets + package.bat = package.bat + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Debug|x64.Build.0 = Debug|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Release|Any CPU.Build.0 = Release|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Release|x64.ActiveCfg = Release|Any CPU + {EEC8F786-3A4D-4077-BCC3-14A3B602A0A3}.Release|x64.Build.0 = Release|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Debug|x64.Build.0 = Debug|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Release|Any CPU.Build.0 = Release|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Release|x64.ActiveCfg = Release|Any CPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545}.Release|x64.Build.0 = Release|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Debug|x64.ActiveCfg = Debug|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Debug|x64.Build.0 = Debug|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Release|Any CPU.Build.0 = Release|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Release|x64.ActiveCfg = Release|Any CPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC}.Release|x64.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0969332D-6A7E-46C1-911C-A541120DFCEB} + EndGlobalSection +EndGlobal diff --git a/BannerLordLauncher/App.config b/BannerLordLauncher/App.config new file mode 100644 index 0000000..cfbcfcc --- /dev/null +++ b/BannerLordLauncher/App.config @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BannerLordLauncher/App.xaml b/BannerLordLauncher/App.xaml new file mode 100644 index 0000000..c95951d --- /dev/null +++ b/BannerLordLauncher/App.xaml @@ -0,0 +1,6 @@ + + diff --git a/BannerLordLauncher/App.xaml.cs b/BannerLordLauncher/App.xaml.cs new file mode 100644 index 0000000..c33f075 --- /dev/null +++ b/BannerLordLauncher/App.xaml.cs @@ -0,0 +1,76 @@ +using System.Windows; +using Serilog; +using Serilog.Exceptions; +using Splat; +using Splat.Serilog; + +namespace BannerLordLauncher +{ + using System; + using System.Threading.Tasks; + + /// + /// Interaction logic for App.xaml + /// + public partial class App + { + protected override void OnStartup(StartupEventArgs e) + { + InitializeLogging(); + + if (Program.Configuration?.Version != null && Program.Configuration.SubmitCrashLogs == false) + { + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + this.Dispatcher.UnhandledException += Dispatcher_UnhandledException; + Current.DispatcherUnhandledException += Current_DispatcherUnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + } + + base.OnStartup(e); + } + + private void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) + { + Log.Fatal(e.Exception, ""); + e.SetObserved(); + this.Shutdown(); + } + + private void Current_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) + { + Log.Fatal(e.Exception, ""); + e.Handled = true; + this.Shutdown(); + } + + private void Dispatcher_UnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) + { + Log.Fatal(e.Exception, ""); + e.Handled = true; + this.Shutdown(); + } + + private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception x) + Log.Fatal(x, ""); + if (!e.IsTerminating) this.Shutdown(); + } + + private static void InitializeLogging() + { + Log.Logger = new LoggerConfiguration() + .Enrich.WithExceptionDetails() // + +#if DEBUG + .MinimumLevel.Debug() // +#else + .MinimumLevel.Warning()// +#endif + .Enrich.FromLogContext() // + .WriteTo.File("app.log") // + .CreateLogger(); + Locator.CurrentMutable.UseSerilogFullLogger(); + } + } +} diff --git a/BannerLordLauncher/AppConfig.cs b/BannerLordLauncher/AppConfig.cs new file mode 100644 index 0000000..de8808c --- /dev/null +++ b/BannerLordLauncher/AppConfig.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BannerLordLauncher +{ + public class AppConfig + { + [JsonProperty("version")] + public int? Version { get; set; } + + [JsonProperty("gamePath")] + public string GamePath { get; set; } + + [JsonProperty("placement")] + public WindowPlacement.Data Placement { get; set; } + + [JsonProperty("configPath")] + public string ConfigPath { get; set; } + + [JsonProperty("checkForUpdates")] + public bool CheckForUpdates { get; set; } + + [JsonProperty("submitCrashLogs")] + public bool SubmitCrashLogs { get; set; } + + [JsonProperty("closeWhenRunningGame")] + public bool CloseWhenRunningGame { get; set; } + + [JsonProperty("warnOnConflict")] + public bool WarnOnConflict { get; set; } + + [JsonProperty("extraGameArguments")] + public string ExtraGameArguments { get; set; } + + [JsonProperty("gameExeId")] + public int GameExeId { get; set; } + + [JsonProperty("sorting")] + public List Sorting { get; set; } + + public void CopyFrom(AppConfig other) + { + this.Version = other.Version; + this.GamePath = other.GamePath; + this.ConfigPath = other.ConfigPath; + this.CheckForUpdates = other.CheckForUpdates; + this.CloseWhenRunningGame = other.CloseWhenRunningGame; + this.SubmitCrashLogs = other.SubmitCrashLogs; + this.WarnOnConflict = other.WarnOnConflict; + this.ExtraGameArguments = other.ExtraGameArguments; + this.GameExeId = other.GameExeId; + + if(this.Sorting == null) this.Sorting = new List(); + this.Sorting.Clear(); + if (other.Sorting == null) return; + foreach (var s in other.Sorting) + { + this.Sorting.Add(s.Clone()); + } + } + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum SortType + { + [EnumMember(Value = "id")] + Id = 0, + [EnumMember(Value = "name")] + Name = 1, + [EnumMember(Value = "version")] + Version = 2, + [EnumMember(Value = "official")] + Official = 3, + [EnumMember(Value = "native")] + Native = 4, + [EnumMember(Value = "selected")] + Selected = 5 + } + + public class Sorting + { + [JsonProperty("type")] + public SortType Type { get; set; } + [JsonProperty("ascending")] + public bool Ascending { get; set; } + + public Sorting Clone() => new Sorting{Type = this.Type, Ascending = this.Ascending}; + } +} \ No newline at end of file diff --git a/BannerLordLauncher/BannerLordLauncher.csproj b/BannerLordLauncher/BannerLordLauncher.csproj new file mode 100644 index 0000000..94c766c --- /dev/null +++ b/BannerLordLauncher/BannerLordLauncher.csproj @@ -0,0 +1,338 @@ + + + + + + Debug + AnyCPU + {B74E9353-C7E3-4C28-AB7A-49B0B57D15CC} + WinExe + BannerLordLauncher + BannerLordLauncher + v4.8 + 512 + true + true + BannerLordLauncher.Program + + false + publish\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + ML.ico + + + + ..\packages\AlphaFS.2.2.6\lib\net452\AlphaFS.dll + + + ..\packages\Costura.Fody.4.1.0\lib\net40\Costura.dll + True + + + ..\packages\DynamicData.6.14.14\lib\net461\DynamicData.dll + True + + + ..\packages\gong-wpf-dragdrop.2.2.0\lib\net47\GongSolutions.WPF.DragDrop.dll + True + + + ..\packages\MahApps.Metro.IconPacks.RPGAwesome.4.0.0\lib\net47\MahApps.Metro.IconPacks.Core.dll + + + ..\packages\MahApps.Metro.IconPacks.Material.4.0.0\lib\net47\MahApps.Metro.IconPacks.Material.dll + True + + + ..\packages\MahApps.Metro.IconPacks.RPGAwesome.4.0.0\lib\net47\MahApps.Metro.IconPacks.RPGAwesome.dll + + + + ..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll + True + + + ..\packages\Octokit.0.47.0\lib\net46\Octokit.dll + True + + + ..\packages\Ookii.Dialogs.Wpf.1.1.0\lib\net45\Ookii.Dialogs.Wpf.dll + True + + + ..\packages\ReactiveUI.11.3.8\lib\net461\ReactiveUI.dll + + + ..\packages\ReactiveUI.WPF.11.3.8\lib\net461\ReactiveUI.WPF.dll + + + ..\packages\Sentry.2.1.1\lib\net461\Sentry.dll + True + + + ..\packages\Sentry.PlatformAbstractions.1.1.0\lib\net471\Sentry.PlatformAbstractions.dll + True + + + ..\packages\Sentry.Protocol.2.1.1\lib\net46\Sentry.Protocol.dll + True + + + ..\packages\Serilog.2.9.0\lib\net46\Serilog.dll + True + + + ..\packages\Serilog.Exceptions.5.4.0\lib\net472\Serilog.Exceptions.dll + True + + + ..\packages\Serilog.Sinks.File.4.1.0\lib\net45\Serilog.Sinks.File.dll + True + + + ..\packages\Splat.9.4.5\lib\net461\Splat.dll + True + + + ..\packages\Splat.Serilog.9.4.5\lib\net461\Splat.Serilog.dll + True + + + + ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + ..\packages\System.Collections.Immutable.1.7.0\lib\netstandard2.0\System.Collections.Immutable.dll + True + + + + + + ..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll + + + + ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + + + ..\packages\System.Reactive.4.4.1\lib\net46\System.Reactive.dll + True + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.7.1\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + + + ..\packages\System.Runtime.Serialization.Primitives.4.3.0\lib\net46\System.Runtime.Serialization.Primitives.dll + True + + + + ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + True + + + + ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + True + + + + + + + + + + + + 4.0 + + + + + + ..\packages\WpfAnimatedGif.2.0.0\lib\net40\WpfAnimatedGif.dll + True + + + + + + + + + + + + + + + App.xaml + Code + + + Designer + MSBuild:Compile + + + UserControl1.xaml + + + Designer + MSBuild:Compile + + + MyMessageBoxView.xaml + + + Designer + MSBuild:Compile + + + MainWindow.xaml + Code + + + Designer + MSBuild:Compile + + + OptionsDialog.xaml + Code + + + Designer + MSBuild:Compile + + + + MSBuild:Compile + Designer + + + MSBuild:Compile + Designer + + + + + + + + + + + Code + + + True + True + Resources.resx + + + True + Settings.settings + True + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + + + + + + + + + {eec8f786-3a4d-4077-bcc3-14a3b602a0a3} + BannerLord.Common + + + {f2087de4-55fd-43de-944f-dc7c26cde545} + Steam.Common + + + + + + + + + + + + + + + + + + + + + False + Microsoft .NET Framework 4.7.2 %28x86 and x64%29 + true + + + False + .NET Framework 3.5 SP1 + false + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}. + + + + + \ No newline at end of file diff --git a/BannerLordLauncher/Controls/BoolToVisibleOrHidden.cs b/BannerLordLauncher/Controls/BoolToVisibleOrHidden.cs new file mode 100644 index 0000000..985dabf --- /dev/null +++ b/BannerLordLauncher/Controls/BoolToVisibleOrHidden.cs @@ -0,0 +1,51 @@ +using System; + +namespace BannerLordLauncher.Controls +{ + using System.Windows; + using System.Windows.Data; + + public class BoolToVisibleOrHidden : IValueConverter + { + #region Constructors + /// + /// The default constructor + /// + public BoolToVisibleOrHidden() { } + #endregion + + #region Properties + public bool Collapse { get; set; } + public bool Reverse { get; set; } + #endregion + + #region IValueConverter Members + public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + bool bValue = (bool)value; + + if (bValue != Reverse) + { + return Visibility.Visible; + } + else + { + if (Collapse) + return Visibility.Collapsed; + else + return Visibility.Hidden; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) + { + Visibility visibility = (Visibility)value; + + if (visibility == Visibility.Visible) + return !Reverse; + else + return Reverse; + } + #endregion + } +} diff --git a/BannerLordLauncher/Controls/MessageBox/MyMessageBox.cs b/BannerLordLauncher/Controls/MessageBox/MyMessageBox.cs new file mode 100644 index 0000000..4766f17 --- /dev/null +++ b/BannerLordLauncher/Controls/MessageBox/MyMessageBox.cs @@ -0,0 +1,25 @@ +namespace BannerLordLauncher.Controls.MessageBox +{ + using System.Windows; + + public static class MyMessageBox + { + public static MessageBoxResult Show(Window parent, string message, string caption = "", MessageBoxButton buttons = MessageBoxButton.OK) + { + var dialog = new MyMessageBoxView + { + Title = caption, + tbMessage = { Text = message }, + Buttons = buttons, + Owner = parent, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false + }; + + dialog.ShowDialog(); + var result = dialog.Result; + + return result; + } + } +} diff --git a/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml b/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml new file mode 100644 index 0000000..291fcc6 --- /dev/null +++ b/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml.cs b/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml.cs new file mode 100644 index 0000000..d4d8779 --- /dev/null +++ b/BannerLordLauncher/Controls/MessageBox/MyMessageBoxView.xaml.cs @@ -0,0 +1,115 @@ +namespace BannerLordLauncher.Controls.MessageBox +{ + using System; + using System.Windows; + using System.Windows.Input; + + public partial class MyMessageBoxView + { + #region Constructor + public MyMessageBoxView() + { + this.InitializeComponent(); + } + #endregion + + #region Private Variables + private MessageBoxButton _Buttons; + + #endregion + + #region internal Properties + internal MessageBoxButton Buttons + { + get => this._Buttons; + set + { + this._Buttons = value; + // Set all Buttons Visibility Properties + this.SetButtonsVisibility(); + } + } + + internal MessageBoxResult Result { get; private set; } = MessageBoxResult.None; + + #endregion + + #region SetButtonsVisibility Method + + private void SetButtonsVisibility() + { + switch (this._Buttons) + { + case MessageBoxButton.OK: + this.btnOk.Visibility = Visibility.Visible; + this.btnCancel.Visibility = Visibility.Collapsed; + this.btnYes.Visibility = Visibility.Collapsed; + this.btnNo.Visibility = Visibility.Collapsed; + break; + case MessageBoxButton.OKCancel: + this.btnOk.Visibility = Visibility.Visible; + this.btnCancel.Visibility = Visibility.Visible; + this.btnYes.Visibility = Visibility.Collapsed; + this.btnNo.Visibility = Visibility.Collapsed; + break; + case MessageBoxButton.YesNo: + this.btnOk.Visibility = Visibility.Collapsed; + this.btnCancel.Visibility = Visibility.Collapsed; + this.btnYes.Visibility = Visibility.Visible; + this.btnNo.Visibility = Visibility.Visible; + break; + case MessageBoxButton.YesNoCancel: + this.btnOk.Visibility = Visibility.Collapsed; + this.btnCancel.Visibility = Visibility.Visible; + this.btnYes.Visibility = Visibility.Visible; + this.btnNo.Visibility = Visibility.Visible; + break; + } + } + #endregion + + #region Button Click Events + private void btnYes_Click(object sender, RoutedEventArgs e) + { + this.Result = MessageBoxResult.Yes; + this.Close(); + } + + private void btnNo_Click(object sender, RoutedEventArgs e) + { + this.Result = MessageBoxResult.No; + this.Close(); + } + + private void btnCancel_Click(object sender, RoutedEventArgs e) + { + this.Result = MessageBoxResult.Cancel; + this.Close(); + } + + private void btnOk_Click(object sender, RoutedEventArgs e) + { + this.Result = MessageBoxResult.OK; + this.Close(); + } + #endregion + + #region Windows Drag Event + private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ButtonState == MouseButtonState.Pressed) + this.DragMove(); + } + #endregion + + #region Deactivated Event + private void Window_Deactivated(object sender, EventArgs e) + { + // If only an OK button is displayed, + // allow the user to just move away from this dialog box + /*if (this.Buttons == MessageBoxButton.OK) + this.Close();*/ + } + #endregion + } +} diff --git a/BannerLordLauncher/Controls/UserControl1.xaml b/BannerLordLauncher/Controls/UserControl1.xaml new file mode 100644 index 0000000..1a4efdf --- /dev/null +++ b/BannerLordLauncher/Controls/UserControl1.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/BannerLordLauncher/Controls/UserControl1.xaml.cs b/BannerLordLauncher/Controls/UserControl1.xaml.cs new file mode 100644 index 0000000..61856ed --- /dev/null +++ b/BannerLordLauncher/Controls/UserControl1.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace BannerLordLauncher.Controls +{ + /// + /// Interaction logic for UserControl1.xaml + /// + public partial class UserControl1 : UserControl + { + public UserControl1() + { + InitializeComponent(); + } + } +} diff --git a/BannerLordLauncher/Dictionary.xaml b/BannerLordLauncher/Dictionary.xaml new file mode 100644 index 0000000..2859467 --- /dev/null +++ b/BannerLordLauncher/Dictionary.xaml @@ -0,0 +1,590 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BannerLordLauncher/FodyWeavers.xml b/BannerLordLauncher/FodyWeavers.xml new file mode 100644 index 0000000..5029e70 --- /dev/null +++ b/BannerLordLauncher/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/BannerLordLauncher/FodyWeavers.xsd b/BannerLordLauncher/FodyWeavers.xsd new file mode 100644 index 0000000..44a5374 --- /dev/null +++ b/BannerLordLauncher/FodyWeavers.xsd @@ -0,0 +1,111 @@ + + + + + + + + + + + + A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks + + + + + A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks. + + + + + A list of unmanaged 32 bit assembly names to include, delimited with line breaks. + + + + + A list of unmanaged 64 bit assembly names to include, delimited with line breaks. + + + + + The order of preloaded assemblies, delimited with line breaks. + + + + + + This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file. + + + + + Controls if .pdbs for reference assemblies are also embedded. + + + + + Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option. + + + + + As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off. + + + + + Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code. + + + + + Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior. + + + + + A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with | + + + + + A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |. + + + + + A list of unmanaged 32 bit assembly names to include, delimited with |. + + + + + A list of unmanaged 64 bit assembly names to include, delimited with |. + + + + + The order of preloaded assemblies, delimited with |. + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/BannerLordLauncher/ML.ico b/BannerLordLauncher/ML.ico new file mode 100644 index 0000000..0775e4c Binary files /dev/null and b/BannerLordLauncher/ML.ico differ diff --git a/BannerLordLauncher/Program.cs b/BannerLordLauncher/Program.cs new file mode 100644 index 0000000..b08e736 --- /dev/null +++ b/BannerLordLauncher/Program.cs @@ -0,0 +1,57 @@ +namespace BannerLordLauncher +{ + using System; + using System.Diagnostics; + using System.Threading.Tasks; + using Alphaleonis.Win32.Filesystem; + using Newtonsoft.Json; + using Sentry; + + public static class Program + { + internal static AppConfig Configuration; + + internal static string ConfigurationFilePath; + [STAThread] + public static void Main(string[] args) + { + ConfigurationFilePath = Path.Combine(GetApplicationRoot(), "configuration.json"); + Configuration = null; + try + { + if (File.Exists(ConfigurationFilePath)) + { + Configuration = + JsonConvert.DeserializeObject(File.ReadAllText(ConfigurationFilePath)); + } + } + catch + { + Configuration = null; + } + + var submit = true; + if (Configuration?.Version != null) + { + submit = Configuration.SubmitCrashLogs; + } + + if (submit) + { + using (SentrySdk.Init("http://fb8a882e37f14a13be3a802570dfd640@d2allgr.duckdns.org:9001/2")) + { + App.Main(); + } + } + else + { + App.Main(); + } + } + + private static string GetApplicationRoot() + { + return Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + } + } +} diff --git a/BannerLordLauncher/Properties/AssemblyInfo.cs b/BannerLordLauncher/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b6d25a7 --- /dev/null +++ b/BannerLordLauncher/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("BannerLordLauncher")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("BannerLordLauncher")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("B74E9353-C7E3-4C28-AB7A-49B0B57D15CC")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// 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.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/BannerLordLauncher/Properties/Resources.Designer.cs b/BannerLordLauncher/Properties/Resources.Designer.cs new file mode 100644 index 0000000..d6b6db9 --- /dev/null +++ b/BannerLordLauncher/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace BannerLordLauncher.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("BannerLordLauncher.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/BannerLordLauncher/Properties/Resources.resx b/BannerLordLauncher/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/BannerLordLauncher/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/BannerLordLauncher/Properties/Settings.Designer.cs b/BannerLordLauncher/Properties/Settings.Designer.cs new file mode 100644 index 0000000..49d2b54 --- /dev/null +++ b/BannerLordLauncher/Properties/Settings.Designer.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace BannerLordLauncher.Properties { + + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.5.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); + + public static Settings Default { + get { + return defaultInstance; + } + } + } +} diff --git a/BannerLordLauncher/Properties/Settings.settings b/BannerLordLauncher/Properties/Settings.settings new file mode 100644 index 0000000..033d7a5 --- /dev/null +++ b/BannerLordLauncher/Properties/Settings.settings @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/BannerLordLauncher/Scrollbar.xaml b/BannerLordLauncher/Scrollbar.xaml new file mode 100644 index 0000000..da4c1b4 --- /dev/null +++ b/BannerLordLauncher/Scrollbar.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BannerLordLauncher/Simple Styles.xaml b/BannerLordLauncher/Simple Styles.xaml new file mode 100644 index 0000000..6cb1c07 --- /dev/null +++ b/BannerLordLauncher/Simple Styles.xaml @@ -0,0 +1,1121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BannerLordLauncher/ViewModels/MainWindowViewModel.cs b/BannerLordLauncher/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..0700a0b --- /dev/null +++ b/BannerLordLauncher/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,387 @@ +using System; +using Alphaleonis.Win32.Filesystem; +using System.Reactive.Linq; +using System.Windows.Input; +using BannerLord.Common; +using BannerLordLauncher.Views; +using ReactiveUI; +using System.Diagnostics; +using System.Windows; +using System.Linq; +using Splat; +using Octokit; +using Application = System.Windows.Application; +using System.Windows.Threading; + +namespace BannerLordLauncher.ViewModels +{ + using BannerLordLauncher.Controls.MessageBox; + + public sealed class MainWindowViewModel : ViewModelBase, IModManagerClient + { + public ModManager Manager { get; } + private readonly MainWindow _window; + private int _selectedIndex = -1; + + public int SelectedIndex + { + get => this._selectedIndex; + set => this.RaiseAndSetIfChanged(ref this._selectedIndex, value); + } + + // ReSharper disable MemberCanBePrivate.Global + // ReSharper disable UnusedAutoPropertyAccessor.Global + public ICommand Config { get; } + public ICommand ModsFolder { get; } + public ICommand Run { get; } + public ICommand Save { get; } + public ICommand Sort { get; } + public ICommand MoveToTop { get; } + public ICommand MoveUp { get; } + public ICommand MoveDown { get; } + public ICommand MoveToBottom { get; } + public ICommand CheckAll { get; } + public ICommand UncheckAll { get; } + public ICommand InvertCheck { get; } + public ICommand Copy { get; } + public ICommand CopyChecked { get; } + + private string _windowTitle; + public string WindowTitle + { + get => this._windowTitle; + set => this.RaiseAndSetIfChanged(ref this._windowTitle, value); + } + // ReSharper restore UnusedAutoPropertyAccessor.Global + // ReSharper restore MemberCanBePrivate.Global + + public MainWindowViewModel(MainWindow window) + { + this._window = window; + this.Manager = new ModManager(this); + + var moveUp = this.WhenAnyValue(x => x.SelectedIndex).Select(x => x > 0); + var moveDown = this.WhenAnyValue(x => x.SelectedIndex).Select(x => x >= 0 && x < this.Manager.Mods.Count - 1); + + this.Save = ReactiveCommand.Create(this.SaveCmd); + this.Sort = ReactiveCommand.Create(this.SortCmd); + this.MoveToTop = ReactiveCommand.Create(this.MoveToTopCmd, moveUp.Select(x => x)); + this.MoveUp = ReactiveCommand.Create(this.MoveUpCmd, moveUp.Select(x => x)); + this.MoveDown = ReactiveCommand.Create(this.MoveDownCmd, moveDown.Select(x => x)); + this.MoveToBottom = ReactiveCommand.Create(this.MoveToBottomCmd, moveDown.Select(x => x)); + this.CheckAll = ReactiveCommand.Create(this.CheckAllCmd); + this.UncheckAll = ReactiveCommand.Create(this.UncheckAllCmd); + this.InvertCheck = ReactiveCommand.Create(this.InvertCheckCmd); + this.Run = ReactiveCommand.Create(this.RunCmd); + this.Config = ReactiveCommand.Create(this.OpenConfigCmd); + this.ModsFolder = ReactiveCommand.Create(this.OpenModsFolderCmd); + this.Copy = ReactiveCommand.Create(() => Clipboard.SetText(string.Join(Environment.NewLine, this.Manager.Mods.Select(x => x.Module.Id)))); + this.CopyChecked = ReactiveCommand.Create(() => Clipboard.SetText(string.Join(Environment.NewLine, this.Manager.Mods.Where(x => x.UserModData.IsSelected).Select(x => x.Module.Id)))); + } + + private static void SafeMessage(string message) + { + Debug.Assert(Application.Current.Dispatcher != null, "Application.Current.Dispatcher != null"); + if (Application.Current.Dispatcher.CheckAccess()) + { + MyMessageBox.Show(Application.Current.MainWindow, message); + } + else + { + Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, new Action(() => MyMessageBox.Show(Application.Current.MainWindow, message))); + } + } + + private async void CheckForUpdates() + { + try + { + var github = new GitHubClient(new ProductHeaderValue("BannerLordLauncher")); + var currentVersion = typeof(MainWindowViewModel).Assembly.GetName().Version; + var result = await github.Repository.Release.GetAll("tstavrianos", "BannerLordLauncher") + .ConfigureAwait(false); + var latestRelease = result.OrderByDescending(x => x.CreatedAt).FirstOrDefault(); + if (latestRelease == null) return; + this.WindowTitle = currentVersion.ToString(); + var latestReleaseVersion = new Version(latestRelease.TagName); + if (latestReleaseVersion <= currentVersion) return; + var message = $"Version {latestReleaseVersion} is available to download"; + SafeMessage(message); + } + catch (Exception e) + { + this.Log().Error(e); + } + } + + private void CheckAllCmd() + { + if (this.Manager.CheckAll(out var error)) return; + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + private void UncheckAllCmd() + { + if (this.Manager.UncheckAll(out var error)) return; + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + private void InvertCheckCmd() + { + if (this.Manager.InvertCheck(out var error)) return; + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + + private void SortCmd() + { + var idx = this._window.ModList.SelectedIndex; + ModEntry it = null; + if (idx != -1) + it = this.Manager.Mods[idx]; + this._window.ModList.SelectedIndex = -1; + + if(Program.Configuration.Sorting != null && Program.Configuration.Sorting.Count > 0){ + var mods = this.Manager.Mods.ToArray(); + this.Manager.Mods.Clear(); + + var sorted = mods.OrderBy(x => 0); + foreach (var sorting in Program.Configuration.Sorting) + { + switch (sorting.Type) + { + case SortType.Id: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.Module.Id) : sorted.ThenByDescending(x => x.Module.Id); + break; + case SortType.Name: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.Module.Name) : sorted.ThenByDescending(x => x.Module.Name); + break; + case SortType.Version: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.Module.Version) : sorted.ThenByDescending(x => x.Module.Version); + break; + case SortType.Official: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.Module.Official ? 0 : 1) : sorted.ThenBy(x => x.Module.Official ? 1 : 0); + break; + case SortType.Native: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.Module.Id == "Native" ? 0 : 1) : sorted.ThenBy(x => x.Module.Id == "Native" ? 1 : 0); + break; + case SortType.Selected: + sorted = sorting.Ascending ? sorted.ThenBy(x => x.UserModData.IsSelected ? 0 : 1) : sorted.ThenBy(x => x.UserModData.IsSelected ? 1 : 0); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + foreach (var m in sorted) + { + this.Manager.Mods.Add(m); + } + } + + if (!this.Manager.Sort(out var errorMessage)) + { + this._window.ModList.SelectedIndex = idx; + if (!string.IsNullOrEmpty(errorMessage)) SafeMessage(errorMessage); + return; + } + if (idx != -1) this._window.ModList.SelectedIndex = this.Manager.Mods.IndexOf(it); + } + + private void MoveToTopCmd() + { + var idx = this._window.ModList.SelectedIndex; + var it = this.Manager.Mods[idx]; + this._window.ModList.SelectedIndex = -1; + if (!this.Manager.MoveToTop(idx, out var errorMessage)) + { + this._window.ModList.SelectedIndex = idx; + if (!string.IsNullOrEmpty(errorMessage)) SafeMessage(errorMessage); + return; + } + this._window.ModList.SelectedIndex = this.Manager.Mods.IndexOf(it); + } + + private void MoveUpCmd() + { + var idx = this._window.ModList.SelectedIndex; + var it = this.Manager.Mods[idx]; + this._window.ModList.SelectedIndex = -1; + if (!this.Manager.MoveUp(idx, out var errorMessage)) + { + this._window.ModList.SelectedIndex = idx; + if (!string.IsNullOrEmpty(errorMessage)) SafeMessage(errorMessage); + return; + } + this._window.ModList.SelectedIndex = this.Manager.Mods.IndexOf(it); + } + + private void MoveDownCmd() + { + var idx = this._window.ModList.SelectedIndex; + var it = this.Manager.Mods[idx]; + this._window.ModList.SelectedIndex = -1; + if (!this.Manager.MoveDown(idx, out var errorMessage)) + { + this._window.ModList.SelectedIndex = idx; + if (!string.IsNullOrEmpty(errorMessage)) SafeMessage(errorMessage); + return; + } + this._window.ModList.SelectedIndex = this.Manager.Mods.IndexOf(it); + } + + private void MoveToBottomCmd() + { + var idx = this._window.ModList.SelectedIndex; + var it = this.Manager.Mods[idx]; + this._window.ModList.SelectedIndex = -1; + if (!this.Manager.MoveToBottom(idx, out var errorMessage)) + { + this._window.ModList.SelectedIndex = idx; + if (!string.IsNullOrEmpty(errorMessage)) SafeMessage(errorMessage); + return; + } + this._window.ModList.SelectedIndex = this.Manager.Mods.IndexOf(it); + } + + public void Initialize() + { + if (!this.Manager.Initialize(Program.Configuration.ConfigPath, Program.Configuration.GamePath, out var error)) + { + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + + if (Program.Configuration.CheckForUpdates) this.CheckForUpdates(); + } + + private void RunCmd() + { + if (!this.Manager.Run(Program.Configuration.GameExeId == 1 ? "Bannerlord.Native.exe" : "Bannerlord.exe", Program.Configuration.ExtraGameArguments, out var error)) + { + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + return; + } + + if (Program.Configuration.CloseWhenRunningGame) Application.Current.Shutdown(); + } + + private void SaveCmd() + { + + if (!this.Manager.Save(out var error)) + { + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + SafeMessage("Saved successfully"); + } + + private void OpenConfigCmd() + { + + if (this.Manager.OpenConfig(out var error)) return; + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + private void OpenModsFolderCmd() + { + + if (this.Manager.OpenModsFolder(out var error)) return; + if (!string.IsNullOrEmpty(error)) SafeMessage(error); + } + + public bool CanInitialize(string configPath, string gamePath) + { + return true; + } + + public bool CanRun(string gameExe, string extraGameArguments) + { + if (this.Manager.Mods.Any(x => x.HasConflicts) && Program.Configuration.WarnOnConflict) + { + if (MyMessageBox.Show( + this._window, + "Your mod list has existing conflicts, are you sure that you want to run the game?", + "Warning", + MessageBoxButton.YesNo + ) == MessageBoxResult.No) + { + return false; + } + } + + var acutalGameExe = Path.Combine(this.Manager.GameExeFolder, gameExe); + if (!File.Exists(acutalGameExe)) + { + this.Log().Error($"{acutalGameExe} could not be found"); + return false; + } + + try + { + if (UACChecker.RequiresElevation(acutalGameExe)) + { + if (!UacUtil.IsElevated) + { + SafeMessage("The application must be run as admin, to allow launching the game"); + return false; + } + } + } + catch (Exception e) + { + this.Log().Error(e); + SafeMessage(e.Message); + } + + return true; + } + + public bool CanSave() + { + if (!this.Manager.Mods.Any(x => x.HasConflicts)) return true; + if (!Program.Configuration.WarnOnConflict) return true; + return MyMessageBox.Show( + this._window, + "Your mod list has existing conflicts, are you sure that you want to save it?", + "Warning", + MessageBoxButton.YesNo + ) != MessageBoxResult.No; + } + + public bool CanMoveToTop(int idx) + { + return idx > 0; + } + + public bool CanMoveUp(int idx) + { + return idx > 0; + } + + public bool CanMoveDown(int idx) + { + return idx >= 0 && idx < this.Manager.Mods.Count - 1; + } + + public bool CanMoveToBottom(int idx) + { + return idx >= 0 && idx < this.Manager.Mods.Count - 1; + } + + public bool CanCheckAll() + { + return this.Manager.Mods.Count > 0; + } + + public bool CanUncheckAll() + { + return this.Manager.Mods.Count > 0; + } + + public bool CanInvertCheck() + { + return this.Manager.Mods.Count > 0; + } + + public bool CanSort() + { + return this.Manager.Mods.Count > 0; + } + } +} diff --git a/BannerLordLauncher/ViewModels/ViewModelBase.cs b/BannerLordLauncher/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..a51a92e --- /dev/null +++ b/BannerLordLauncher/ViewModels/ViewModelBase.cs @@ -0,0 +1,8 @@ +using ReactiveUI; + +namespace BannerLordLauncher.ViewModels +{ + public abstract class ViewModelBase : ReactiveObject + { + } +} \ No newline at end of file diff --git a/BannerLordLauncher/Views/MainWindow.xaml b/BannerLordLauncher/Views/MainWindow.xaml new file mode 100644 index 0000000..b14f641 --- /dev/null +++ b/BannerLordLauncher/Views/MainWindow.xaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BannerLordLauncher/Views/MainWindow.xaml.cs b/BannerLordLauncher/Views/MainWindow.xaml.cs new file mode 100644 index 0000000..845ce98 --- /dev/null +++ b/BannerLordLauncher/Views/MainWindow.xaml.cs @@ -0,0 +1,115 @@ +using Alphaleonis.Win32.Filesystem; +using BannerLordLauncher.ViewModels; +using Newtonsoft.Json; +using System; +using System.ComponentModel; +using System.Windows; + +namespace BannerLordLauncher.Views +{ + using Serilog; + + public partial class MainWindow + { + public MainWindow() + { + this.InitializeComponent(); + + var model = new MainWindowViewModel(this); + this.DataContext = model; + } + + private void OnActivated(object sender, EventArgs eventArgs) + { + if (Program.Configuration?.Version == null) + { + var o = new OptionsDialog(); + o.Owner = this; + o.ShowDialog(); + if (o.Result) + { + Program.Configuration = o.Config; + } + else + { + Environment.Exit(0); + } + } + (this.DataContext as MainWindowViewModel).Initialize(); + this.SetPlacement(Program.Configuration.Placement); + this.Activated -= this.OnActivated; + } + + private static string GetApplicationRoot() + { + return Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName); + } + + protected override void OnClosing(CancelEventArgs e) + { + base.OnClosing(e); + Program.Configuration.Placement = this.GetPlacement(); + var settings = new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.Indented }; + try + { + File.WriteAllText( + Program.ConfigurationFilePath, + JsonConvert.SerializeObject(Program.Configuration, settings)); + } + catch (Exception ex) + { + Log.Error(ex, "Error writing the configuration to disk"); + } + } + + private void ButtonBase_OnClickCog(object sender, RoutedEventArgs e) + { + var w = new OptionsDialog(); + w.Owner = this; + w.ShowDialog(); + if (!w.Result) return; + Program.Configuration.CopyFrom(w.Config); + } + + private void RefreshMaximizeRestoreButton() + { + if (this.WindowState == System.Windows.WindowState.Maximized) + { + this.maximizeButton.Visibility = System.Windows.Visibility.Collapsed; + this.restoreButton.Visibility = System.Windows.Visibility.Visible; + } + else + { + this.maximizeButton.Visibility = System.Windows.Visibility.Visible; + this.restoreButton.Visibility = System.Windows.Visibility.Collapsed; + } + } + + private void Window_StateChanged(object sender, EventArgs e) + { + this.RefreshMaximizeRestoreButton(); + } + + private void OnMinimizeButtonClick(object sender, RoutedEventArgs e) + { + this.WindowState = System.Windows.WindowState.Minimized; + } + + private void OnMaximizeRestoreButtonClick(object sender, RoutedEventArgs e) + { + if (this.WindowState == System.Windows.WindowState.Maximized) + { + this.WindowState = System.Windows.WindowState.Normal; + } + else + { + this.WindowState = System.Windows.WindowState.Maximized; + } + } + + private void OnCloseButtonClick(object sender, RoutedEventArgs e) + { + this.Close(); + } + } +} diff --git a/BannerLordLauncher/Views/OptionsDialog.xaml b/BannerLordLauncher/Views/OptionsDialog.xaml new file mode 100644 index 0000000..bece9e8 --- /dev/null +++ b/BannerLordLauncher/Views/OptionsDialog.xaml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BannerLord.exe + BannerLord.Native.exe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BannerLordLauncher/Views/OptionsDialog.xaml.cs b/BannerLordLauncher/Views/OptionsDialog.xaml.cs new file mode 100644 index 0000000..8d72d7e --- /dev/null +++ b/BannerLordLauncher/Views/OptionsDialog.xaml.cs @@ -0,0 +1,268 @@ +using System; +using System.Windows; +using ReactiveUI; + +namespace BannerLordLauncher.Views +{ + using System.Diagnostics; + using System.Linq; + using Alphaleonis.Win32.Filesystem; + using System.Windows.Threading; + using System.Collections.Generic; + + using BannerLordLauncher.Controls.MessageBox; + + using Ookii.Dialogs.Wpf; + + using Steam.Common; + using System.Collections.ObjectModel; + + public class SortingModel: ReactiveObject + { + private bool _ascending; + + public bool Ascending + { + get => this._ascending; + set + { + this.RaiseAndSetIfChanged(ref this._ascending, value); + this.RaisePropertyChanged("AscendingName"); + this.RaisePropertyChanged("Tip"); + } + } + + public SortingModel() {} + + public string AscendingName => this.Ascending ? "Ascending" : "Descending"; + + public SortType Type { get; set; } + public string Name { + get + { + switch (this.Type) + { + case SortType.Id: return "Id"; + case SortType.Name: return "Name"; + case SortType.Version: return "Version"; + case SortType.Official: return "Official"; + case SortType.Native: return "Native"; + case SortType.Selected: return "Selected"; + } + return null; + } + } + + public string Tip + { + get + { + switch (this.Type) + { + case SortType.Id: return $"Alphabetical by Id, {this.AscendingName}"; + case SortType.Name: return $"Alphabetical by Id, {this.AscendingName}"; + case SortType.Version: return $"By Version, {this.AscendingName}"; + case SortType.Official: return "Official mods, " + (this.Ascending ? "first" : "last"); + case SortType.Native: return "Native mod, " + (this.Ascending ? "first" : "last"); + case SortType.Selected: return "Selected mods, " + (this.Ascending ? "first" : "last"); + } + + return string.Empty; + } + } + + public override string ToString() + { + return this.Name; + } + } + /// + /// Interaction logic for OptionsDialog.xaml + /// + public partial class OptionsDialog : Window + { + public AppConfig Config { get; } + + public bool Result { get; private set; } + public ObservableCollection Sorting {get;} + public ObservableCollection Sorting2 {get;} + + public OptionsDialog() + { + this.Config = new AppConfig(); + if (Program.Configuration != null) + this.Config.CopyFrom(Program.Configuration); + if (this.Config?.Version == null) + { + var basePath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var config = string.Empty; + if (Directory.Exists(basePath)) + { + basePath = Path.Combine(basePath, "Mount and Blade II Bannerlord"); + if (Directory.Exists(basePath)) + { + basePath = Path.Combine(basePath, "Configs"); + if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath); + config = basePath; + } + } + + var game = string.Empty; + var steamFinder = new SteamFinder(); + if (steamFinder.FindSteam()) + { + game = steamFinder.FindGameFolder(261550); + if (string.IsNullOrEmpty(game) || !Directory.Exists(game)) + { + game = null; + } + } + this.Config = new AppConfig + { + Version = 1, + Placement = new WindowPlacement.Data + { + normalPosition = new WindowPlacement.Rect(0, 0, 604, 730) + }, + CheckForUpdates = true, + CloseWhenRunningGame = true, + SubmitCrashLogs = true, + WarnOnConflict = true, + ConfigPath = config, + GamePath = game, + Sorting = new List() + }; + } + this.InitializeComponent(); + this.DataContext = this; + this.Result = false; + this.Sorting = new ObservableCollection(); + this.Sorting2 = new ObservableCollection(); + this.Sorting.Add(new SortingModel{Type = SortType.Id, Ascending = true}); + this.Sorting.Add(new SortingModel{Type = SortType.Name, Ascending = true}); + this.Sorting.Add(new SortingModel{Type = SortType.Native, Ascending = true}); + this.Sorting.Add(new SortingModel{Type = SortType.Official, Ascending = true}); + this.Sorting.Add(new SortingModel{Type = SortType.Version, Ascending = true}); + this.Sorting.Add(new SortingModel{Type = SortType.Selected, Ascending = true}); + + if(this.Config.Sorting != null) { + foreach(var s in this.Config.Sorting) + { + var found = this.Sorting.FirstOrDefault(x => x.Type == s.Type); + if(found == null) continue; + this.Sorting.Remove(found); + this.Sorting2.Add(new SortingModel{Type = s.Type, Ascending = s.Ascending}); + } + } + } + + private static string GetApplicationRoot() + { + return Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + } + + private void ButtonBase_OnClickCancel(object sender, RoutedEventArgs e) + { + this.Result = false; + this.Close(); + } + + private void ButtonBase_OnClickOk(object sender, RoutedEventArgs e) + { + if (!ValidConfigFolder(this.Config.ConfigPath)) + { + var r = this.FindConfigFolder(); + if (r == null) return; + this.Config.ConfigPath = r; + } + if (!ValidGameFolder(this.Config.GamePath)) + { + var r = this.FindGameFolder(); + if (r == null) return; + this.Config.GamePath = r; + } + + if (!ValidConfigFolder(this.Config.ConfigPath) || !ValidGameFolder(this.Config.GamePath)) return; + + this.Config.Sorting.Clear(); + foreach (var sorting in this.Sorting2) + { + this.Config.Sorting.Add(new Sorting{Type = sorting.Type, Ascending = sorting.Ascending}); + } + this.Result = true; + this.Close(); + } + + private void SafeMessage(string message) + { + Debug.Assert(Application.Current.Dispatcher != null, "Application.Current.Dispatcher != null"); + if (Application.Current.Dispatcher.CheckAccess()) + { + MyMessageBox.Show(this, message); + } + else + { + Application.Current.Dispatcher.Invoke(DispatcherPriority.Normal, new Action(() => MyMessageBox.Show(this, message))); + } + } + + private string FindGameFolder() + { + while (true) + { + var dialog = new VistaFolderBrowserDialog + { + Description = "Select game root folder", + UseDescriptionForTitle = true, + SelectedPath = this.Config.GamePath + }; + var result = dialog.ShowDialog(this); + if (result is null) return null; + if (!ValidGameFolder(dialog.SelectedPath)) continue; + return dialog.SelectedPath; + } + } + + private static bool ValidGameFolder(string selectedPath) + { + return (Directory.Exists(selectedPath) && File.Exists(Path.Combine(selectedPath, "bin", "Win64_Shipping_Client", "Bannerlord.exe"))); + } + + private static bool ValidConfigFolder(string selectedPath) + { + return Directory.Exists(selectedPath); + + } + + private string FindConfigFolder() + { + while (true) + { + var dialog = new VistaFolderBrowserDialog + { + Description = "Select game config folder, in documents", + UseDescriptionForTitle = true, + SelectedPath = this.Config.ConfigPath + }; + var result = dialog.ShowDialog(this); + if (result is null) return null; + if (!ValidConfigFolder(dialog.SelectedPath)) continue; + return dialog.SelectedPath; + } + } + + private void ButtonBase_OnClickGame(object sender, RoutedEventArgs e) + { + var r = this.FindGameFolder(); + if (r == null) return; + this.Config.GamePath = r; + } + + private void ButtonBase_OnClickConfig(object sender, RoutedEventArgs e) + { + var r = this.FindConfigFolder(); + if (r == null) return; + this.Config.ConfigPath = r; + } + } +} diff --git a/BannerLordLauncher/WindowPlacement.cs b/BannerLordLauncher/WindowPlacement.cs new file mode 100644 index 0000000..691c3ec --- /dev/null +++ b/BannerLordLauncher/WindowPlacement.cs @@ -0,0 +1,109 @@ +using System; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using System.Windows; +using System.Windows.Interop; + +namespace BannerLordLauncher +{ + + public static class WindowPlacement + { + // Rect structure required by Data structure + [StructLayout(LayoutKind.Sequential)] + public struct Rect + { + [JsonProperty("left")] + public int Left; + [JsonProperty("top")] + public int Top; + [JsonProperty("right")] + public int Right; + [JsonProperty("bottom")] + public int Bottom; + + public Rect(int left, int top, int right, int bottom) + { + this.Left = left; + this.Top = top; + this.Right = right; + this.Bottom = bottom; + } + } + + // Point structure required by Data structure + [StructLayout(LayoutKind.Sequential)] + public struct Point + { + [JsonProperty("y")] + public int X; + [JsonProperty("x")] + public int Y; + + public Point(int x, int y) + { + this.X = x; + this.Y = y; + } + } + + // Data stores the position, size, and state of a window + [StructLayout(LayoutKind.Sequential)] + public struct Data + { + [JsonIgnore] + public int length; + [JsonIgnore] + public int flags; + [JsonProperty("showCmd")] + public int showCmd; + [JsonProperty("minPosition")] + public Point minPosition; + [JsonProperty("maxPosition")] + public Point maxPosition; + [JsonProperty("normalPosition")] + public Rect normalPosition; + } + + [DllImport("user32.dll")] + private static extern bool SetWindowPlacement(IntPtr hWnd, [In] ref Data lpwndpl); + + [DllImport("user32.dll")] + private static extern bool GetWindowPlacement(IntPtr hWnd, out Data lpwndpl); + + private const int SW_SHOWNORMAL = 1; + private const int SW_SHOWMINIMIZED = 2; + + private static void SetPlacement(IntPtr windowHandle, Data placement) + { + try + { + placement.length = Marshal.SizeOf(typeof(Data)); + placement.flags = 0; + placement.showCmd = (placement.showCmd == SW_SHOWMINIMIZED ? SW_SHOWNORMAL : placement.showCmd); + SetWindowPlacement(windowHandle, ref placement); + } + catch (InvalidOperationException) + { + } + } + + private static Data GetPlacement(IntPtr windowHandle) + { + GetWindowPlacement(windowHandle, out var placement); + return placement; + } + + public static void SetPlacement(this Window window, Data placement) + { + WindowPlacement.SetPlacement(new WindowInteropHelper(window).Handle, placement); + } + + public static Data GetPlacement(this Window window) + { + return WindowPlacement.GetPlacement(new WindowInteropHelper(window).Handle); + } + + + } +} diff --git a/BannerLordLauncher/packages.config b/BannerLordLauncher/packages.config new file mode 100644 index 0000000..6d1f15f --- /dev/null +++ b/BannerLordLauncher/packages.config @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BuildCommon.targets b/BuildCommon.targets new file mode 100644 index 0000000..ab1a14b --- /dev/null +++ b/BuildCommon.targets @@ -0,0 +1,76 @@ + + + + + + CommonBuildDefineModifiedAssemblyVersion; + $(CompileDependsOn); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..11670a3 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +### Alternative Launcher for Mount & Blade II: BannerLord + +###### **Don't extract it into your game directory because you will most certainly break something.** + +An extremely simple alternative to the standard launcher. +* Can read/write your LauncherData.xml, without breaking compatibility with the standard launcher. +* Supports only single player for now. +* Does some basic validation: official mods cannot be deselected, non single player mods cannot be selected, indicators when you place a mod before its dependencies.? + +I am learning xaml (and the changes required for Avalonia) as I go, so my design choices might appear questionable. + +As with all my projects, this will be maintained for as long as I am still interested in the game, feel free to fork or submit pull requests if you find me being too slow to react. + + +How it looks like: +![How it looks like](https://i.imgur.com/2L0iW02.png) + +Main Creator links: +https://www.nexusmods.com/mountandblade2bannerlord/mods/265 +https://github.com/tstavrianos/BannerLordLauncher \ No newline at end of file diff --git a/Steam.Common/Extensions.cs b/Steam.Common/Extensions.cs new file mode 100644 index 0000000..5a52525 --- /dev/null +++ b/Steam.Common/Extensions.cs @@ -0,0 +1,18 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Steam.Common +{ + public static class Extensions + { + public static bool ExpiredSince(this DateTime dateTime, int minutes) + { + return (dateTime - DateTime.Now).TotalMinutes < minutes; + } + + public static string ToJson(this object obj, bool indented = true) { + return JsonConvert.SerializeObject(obj, (indented ? Formatting.Indented : Formatting.None), new StringEnumConverter()); + } + } +} \ No newline at end of file diff --git a/Steam.Common/Properties/AssemblyInfo.cs b/Steam.Common/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..9bbcf39 --- /dev/null +++ b/Steam.Common/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Steam.Common")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Steam.Common")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("F2087DE4-55FD-43DE-944F-DC7C26CDE545")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// 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.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Steam.Common/README.md b/Steam.Common/README.md new file mode 100644 index 0000000..dd8fed2 --- /dev/null +++ b/Steam.Common/README.md @@ -0,0 +1,16 @@ +Copied from https://github.com/Bluscream/Serious-Sam-Fusion-Mod-Manager, with changes made as needed. + + + +### Alternative Launcher for Mount & Blade II: BannerLord + +###### **Don't extract it into your game directory because you will most certainly break something.** + +An extremely simple alternative to the standard launcher. +* Can read/write your LauncherData.xml, without breaking compatibility with the standard launcher. +* Supports only single player for now. +* Does some basic validation: official mods cannot be deselected, non single player mods cannot be selected, indicators when you place a mod before its dependencies.? + +I am learning xaml (and the changes required for Avalonia) as I go, so my design choices might appear questionable. + +As with all my projects, this will be maintained for as long as I am still interested in the game, feel free to fork or submit pull requests if you find me being too slow to react. \ No newline at end of file diff --git a/Steam.Common/Steam.Common.csproj b/Steam.Common/Steam.Common.csproj new file mode 100644 index 0000000..3cd5c3b --- /dev/null +++ b/Steam.Common/Steam.Common.csproj @@ -0,0 +1,75 @@ + + + + + Debug + AnyCPU + {F2087DE4-55FD-43DE-944F-DC7C26CDE545} + Library + Properties + Steam.Common + Steam.Common + v4.8 + 512 + true + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\AlphaFS.2.2.6\lib\net452\AlphaFS.dll + + + ..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll + True + + + ..\packages\Splat.9.4.5\lib\net461\Splat.dll + + + + + + ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Steam.Common/SteamFinder.cs b/Steam.Common/SteamFinder.cs new file mode 100644 index 0000000..0931a6b --- /dev/null +++ b/Steam.Common/SteamFinder.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using Alphaleonis.Win32.Filesystem; +using System.Linq; +using System.Text.RegularExpressions; +using Splat; + +namespace Steam.Common +{ + using Microsoft.Win32; + + /// + /// Steam installation path and Steam games folder finder. + /// + public sealed class SteamFinder : IEnableLogger + { + public string SteamPath { get; private set; } + public string[] Libraries { get; private set; } + + /// + /// Tries to find the Steam folder and its libraries on the system. + /// + /// Returns true if a valid Steam installation folder path was found. + public bool FindSteam() + { + this.SteamPath = null; + + switch (Environment.OSVersion.Platform) + { + case PlatformID.Win32NT: + case PlatformID.Win32S: + case PlatformID.Win32Windows: + case PlatformID.WinCE: + this.SteamPath = FindWindowsSteamPath(); + break; + default: + if (IsUnix()) this.SteamPath = FindUnixSteamPath(); + break; + } + + return this.SteamPath != null && this.FindLibraries(); + } + + /// + /// Retrieves the game folder by reading the game's Steam manifest. The game needs to be marked as installed on Steam. + /// Returns null if not found. + /// + /// The game's app id on Steam. + /// The path to the game folder. + public string FindGameFolder(int appId) + { + if (this.Libraries == null) + { + this.Log().Error("Steam must be found first."); + return null; + } + + foreach (var library in this.Libraries) + { + var gameManifestPath = GetManifestFilePath(library, appId); + if (gameManifestPath == null) + continue; + + var gameFolderName = ReadInstallDirFromManifest(gameManifestPath); + if (gameFolderName == null) + continue; + + return Path.Combine(library, "common", gameFolderName); + } + + return null; + } + + /// + /// Searches for a game directory that has the specified name in known libraries. + /// + /// The game's folder name inside the Steam library. + /// The game folders path in the libraries. + public IEnumerable FindGameFolders(string gameFolderName) + { + if (this.Libraries == null) + { + this.Log().Error("Steam must be found first."); + yield break; + } + + gameFolderName = gameFolderName.ToLowerInvariant(); + + foreach (var library in this.Libraries) + { + var folder = Directory.EnumerateDirectories(library) + .FirstOrDefault(f => Path.GetFileName(f).ToLowerInvariant() == gameFolderName); + + if (folder != null) + yield return folder; + } + } + + private bool FindLibraries() + { + var steamLibraries = new List(); + var steamDefaultLibrary = Path.Combine(this.SteamPath, "steamapps"); + if (!Directory.Exists(steamDefaultLibrary)) + return false; + + steamLibraries.Add(steamDefaultLibrary); + + /* + * Get library folders paths from libraryfolders.vdf + * + * Libraries are listed like this: + * "id" "library folder path" + * + * Examples: + * "1" "D:\\Games\\SteamLibraryOnD" + * "2" "E:\\Games\\steam_games" + */ + var regex = new Regex(@"""\d+""\s+""(.+)"""); + var libraryFoldersFilePath = Path.Combine(steamDefaultLibrary, "libraryfolders.vdf"); + if (File.Exists(libraryFoldersFilePath)) + { + foreach (var line in File.ReadAllLines(libraryFoldersFilePath)) + { + var match = regex.Match(line); + if (!match.Success) + continue; + + var libPath = match.Groups[1].Value; + libPath = libPath.Replace("\\\\", "\\"); // unescape the backslashes + libPath = Path.Combine(libPath, "steamapps"); + if (Directory.Exists(libPath)) + steamLibraries.Add(libPath); + } + } + + this.Libraries = steamLibraries.ToArray(); + return true; + } + + private static string GetManifestFilePath(string libraryPath, int appId) + { + var manifestPath = Path.Combine(libraryPath, $"appmanifest_{appId}.acf"); + return File.Exists(manifestPath) ? manifestPath : null; + } + + private static string ReadInstallDirFromManifest(string manifestFilePath) + { + var regex = new Regex(@"""installdir""\s+""(.+)"""); + foreach (var line in File.ReadAllLines(manifestFilePath)) + { + var match = regex.Match(line); + if (!match.Success) + continue; + + return match.Groups[1].Value; + } + + return null; + } + + private static string FindWindowsSteamPath() + { + var regPath = Environment.Is64BitOperatingSystem + ? @"SOFTWARE\Wow6432Node\Valve\Steam" + : @"SOFTWARE\Valve\Steam"; + try + { + var subRegKey = Registry.LocalMachine.OpenSubKey(regPath); + var path = subRegKey?.GetValue("InstallPath").ToString() + .Replace('/', '\\'); // not actually required, just for consistency's sake + + return Directory.Exists(path) ? path : null; + } + catch + { + return null; + } + } + + private static string FindUnixSteamPath() + { + string path; + if (Directory.Exists(path = GetDefaultLinuxSteamPath()) + || Directory.Exists(path = GetDefaultMacOsSteamPath())) + { + return path; + } + + return null; + } + + private static string GetDefaultLinuxSteamPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), + ".local/share/Steam/" + ); + } + + private static string GetDefaultMacOsSteamPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), + "Library/Application Support/Steam" + ); + } + + // https://stackoverflow.com/questions/5116977 + private static bool IsUnix() + { + var p = (int)Environment.OSVersion.Platform; + return p == 4 || p == 6 || p == 128; + } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Requests/ISteamRemoteStorage.cs b/Steam.Common/WebAPI/Requests/ISteamRemoteStorage.cs new file mode 100644 index 0000000..0eccf2e --- /dev/null +++ b/Steam.Common/WebAPI/Requests/ISteamRemoteStorage.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Steam.Common.WebAPI.Responses.ISteamRemoteStorage; + +namespace Steam.Common.WebAPI.Requests +{ + public interface ISteamRemoteStorage + { + Task GetPublishedFileDetailsAsync(string fileId); + Task GetPublishedFileDetailsAsync(ulong fileId); + Task GetPublishedFileDetailsAsync(IEnumerable fileIds); + Task GetPublishedFileDetailsAsync(IReadOnlyList fileIds); + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Requests/SteamRemoteStorage.cs b/Steam.Common/WebAPI/Requests/SteamRemoteStorage.cs new file mode 100644 index 0000000..222c8a6 --- /dev/null +++ b/Steam.Common/WebAPI/Requests/SteamRemoteStorage.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using Alphaleonis.Win32.Filesystem; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Splat; +using Steam.Common.WebAPI.Responses; +using Steam.Common.WebAPI.Responses.ISteamRemoteStorage; + +namespace Steam.Common.WebAPI.Requests +{ + public sealed class SteamRemoteStorage : ISteamRemoteStorage, IEnableLogger + { + private readonly HttpClient _httpClient; + private readonly bool _cached; + private List> _publishedFileDetailsCached; + private readonly FileInfo _publishedFileDetailsCacheFile; + + public SteamRemoteStorage(HttpClient httpClient, bool cached = true, string publishedFileDetailsCacheFile = "steam.publishedFileDetails.cache.json") + { + this._httpClient = httpClient ?? new HttpClient(); + this._publishedFileDetailsCacheFile = new FileInfo(publishedFileDetailsCacheFile); + this._cached = cached; + } + + private void CheckCache(ref List> property, FileSystemInfo file) + { + if (!(property is null)) return; + if (file.Exists) + { + try + { + property = JsonConvert.DeserializeObject>>(File.ReadAllText(file.FullName)); + } + catch (Exception ex) + { + this.Log().Error(ex, "[Steam] Unable to load cache"); + property = new List>(); + } + } + else + { + property = new List>(); + } + } + + public Task GetPublishedFileDetailsAsync(string fileId) + { + return this.GetPublishedFileDetailsAsync(new[] { fileId }); + } + + public Task GetPublishedFileDetailsAsync(ulong fileId) + { + return this.GetPublishedFileDetailsAsync(new[] { fileId }); + } + + public Task GetPublishedFileDetailsAsync(IEnumerable fileIds) + { + return this.GetPublishedFileDetailsAsync(fileIds.Select(x => $"{x}").ToString()); + } + + public async Task GetPublishedFileDetailsAsync(IReadOnlyList fileIds) + { + var parsedResponse = new GetPublishedFileDetailsResponse(); + if (fileIds.Count < 1) return parsedResponse; + if (this._cached) + { + this.CheckCache(ref this._publishedFileDetailsCached, this._publishedFileDetailsCacheFile); + if (this._publishedFileDetailsCacheFile.Exists && + (!this._publishedFileDetailsCacheFile.LastWriteTime.ExpiredSince(10))) + { + foreach (var item in fileIds.Select(fileId => this._publishedFileDetailsCached.FirstOrDefault(x => + $"{x.Response.PublishedFileId}" == fileId)).Where(item => item != null)) + { + parsedResponse.Response.PublishedFileDetails.Add(item.Response); + } + + if (parsedResponse.Response.PublishedFileDetails.Count >= fileIds.Count) + return parsedResponse; + } + } + + var values = new Dictionary { { "itemcount", fileIds.Count.ToString() } }; + for (var i = 0; i < fileIds.Count; i++) + { + values.Add($"publishedfileids[{i}]", fileIds[i]); + } + var content = new FormUrlEncodedContent(values); + var url = new Uri("https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"); + this.Log().Debug($"[Steam] POST to {url} with payload {content.ToJson(false)} and values {values.ToJson(false)}"); + var response = await this._httpClient.PostAsync(url, content).ConfigureAwait(false); + var responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + try + { + parsedResponse = JsonConvert.DeserializeObject(responseString); + } + catch (Exception ex) + { + this.Log().Error(ex, $"[Steam] Could not deserialize response: {responseString}"); + } + + if (!this._cached) return parsedResponse; + foreach (var item in parsedResponse.Response.PublishedFileDetails) + { + this._publishedFileDetailsCached.RemoveAll(x => x.Response.PublishedFileId == item.PublishedFileId); + this._publishedFileDetailsCached.Add(CachedResponse.FromResponse(item)); + } + + File.WriteAllText(this._publishedFileDetailsCacheFile.FullName, + JsonConvert.SerializeObject(this._publishedFileDetailsCached)); + + return parsedResponse; + } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/CachedResponse.cs b/Steam.Common/WebAPI/Responses/CachedResponse.cs new file mode 100644 index 0000000..223e562 --- /dev/null +++ b/Steam.Common/WebAPI/Responses/CachedResponse.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace Steam.Common.WebAPI.Responses +{ + public sealed class CachedResponse + { + [JsonProperty("lastFetched")] + public DateTime LastFetched { get; set; } + [JsonProperty("response")] + public T Response { get; private set; } + + public static CachedResponse FromResponse(T response) + { + return new CachedResponse{LastFetched = DateTime.Now, Response = response}; + } + + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/GetPublishedFileDetailsResponse.cs b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/GetPublishedFileDetailsResponse.cs new file mode 100644 index 0000000..7f77e29 --- /dev/null +++ b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/GetPublishedFileDetailsResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Steam.Common.WebAPI.Responses.ISteamRemoteStorage +{ + public sealed class GetPublishedFileDetailsResponse + { + [JsonProperty("response")] + public PublishedFileDetailResponse Response { get; set; } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetail.cs b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetail.cs new file mode 100644 index 0000000..e7e32cd --- /dev/null +++ b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetail.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Steam.Common.WebAPI.Responses.ISteamRemoteStorage +{ + public sealed class PublishedFileDetail + { + [JsonProperty("publishedfileid")] + public ulong PublishedFileId { get; set; } + [JsonProperty("result")] + public uint Result { get; set; } + [JsonProperty("creator")] + public string Creator { get; set; } + [JsonProperty("creator_app_id")] + public uint CreatorAppId { get; set; } + [JsonProperty("consumer_app_id")] + public uint ConsumerAppId { get; set; } + [JsonProperty("filename")] + public string Filename { get; set; } + [JsonProperty("file_size")] + public uint FileSize { get; set; } + [JsonProperty("file_url")] + public string FileUrl { get; set; } + [JsonProperty("hcontent_file")] + public string HContentFile { get; set; } + [JsonProperty("preview_url")] + public string PreviewUrl { get; set; } + [JsonProperty("hcontent_preview")] + public string HContentPreview { get; set; } + [JsonProperty("title")] + public string Title { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("time_created")] + public DateTime TimeCreated { get; set; } + [JsonProperty("time_updated")] + public DateTime TimeUpdated { get; set; } + [JsonProperty("visibility")] + public PublishedFileVisibility Visibility { get; set; } + [JsonProperty("banned")] + public bool Banned { get; set; } + [JsonProperty("ban_reason")] + public string BanReason { get; set; } + [JsonProperty("subscriptions")] + public ulong Subscriptions { get; set; } + [JsonProperty("favorited")] + public ulong Favorited { get; set; } + [JsonProperty("lifetime_subscriptions")] + public ulong LifetimeSubscriptions { get; set; } + [JsonProperty("lifetime_favorited")] + public ulong LifetimeFavorited { get; set; } + [JsonProperty("views")] + public ulong Views { get; set; } + [JsonProperty("tags")] + public List Tags { get; set; } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailResponse.cs b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailResponse.cs new file mode 100644 index 0000000..4a6cb35 --- /dev/null +++ b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailResponse.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Steam.Common.WebAPI.Responses.ISteamRemoteStorage +{ + public sealed class PublishedFileDetailResponse + { + [JsonProperty("result")] + public int Result { get; set; } + [JsonProperty("resultcount")] + public int ResultCount { get; set; } + [JsonProperty("publishedfiledetails")] + public IList PublishedFileDetails { get; set; } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailTag.cs b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailTag.cs new file mode 100644 index 0000000..9e445ea --- /dev/null +++ b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileDetailTag.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Steam.Common.WebAPI.Responses.ISteamRemoteStorage +{ + public sealed class PublishedFileDetailTag + { + [JsonProperty("tag")] + public string Value { get; set; } + } +} \ No newline at end of file diff --git a/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileVisibility.cs b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileVisibility.cs new file mode 100644 index 0000000..5283541 --- /dev/null +++ b/Steam.Common/WebAPI/Responses/ISteamRemoteStorage/PublishedFileVisibility.cs @@ -0,0 +1,9 @@ +namespace Steam.Common.WebAPI.Responses.ISteamRemoteStorage +{ + public enum PublishedFileVisibility + { + Public = 0, + FriendsOnly = 1, + Private = 2 + } +} \ No newline at end of file diff --git a/Steam.Common/packages.config b/Steam.Common/packages.config new file mode 100644 index 0000000..5c88582 --- /dev/null +++ b/Steam.Common/packages.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/package.bat b/package.bat new file mode 100644 index 0000000..a6768e5 --- /dev/null +++ b/package.bat @@ -0,0 +1,7 @@ +@echo off +msbuild BannerLordLauncher.sln /p:Configuration=Release;VersionAssembly=%1.0;Platform="Any CPU" +cd BannerLordLauncher\bin\Release\ +if exist BannerLordLauncher.7z del BannerLordLauncher.7z +if exist BannerLordLauncher.exe "c:\Program Files\7-Zip\7z.exe" a BannerLordLauncher.7z BannerLordLauncher.exe BannerLordLauncher.pdb +cd ..\..\.. +pause