diff options
Diffstat (limited to 'src/SMAPI/Program.cs')
-rw-r--r-- | src/SMAPI/Program.cs | 746 |
1 files changed, 432 insertions, 314 deletions
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index ff4e9a50..6012b15a 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -7,29 +8,37 @@ using System.Net; using System.Reflection; using System.Runtime.ExceptionServices; using System.Security; +using System.Text; using System.Text.RegularExpressions; using System.Threading; +using Microsoft.Xna.Framework.Input; #if SMAPI_FOR_WINDOWS -using System.Management; using System.Windows.Forms; #endif using Newtonsoft.Json; -using StardewModdingAPI.Common.Models; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Logging; -using StardewModdingAPI.Framework.ModData; using StardewModdingAPI.Framework.Models; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Serialisation; -using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using Keys = Microsoft.Xna.Framework.Input.Keys; using Monitor = StardewModdingAPI.Framework.Monitor; using SObject = StardewValley.Object; +using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI { @@ -54,15 +63,14 @@ namespace StardewModdingAPI /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection = new Reflector(); + /// <summary>The SMAPI configuration settings.</summary> + private readonly SConfig Settings; + /// <summary>The underlying game instance.</summary> private SGame GameInstance; /// <summary>The underlying content manager.</summary> - private ContentCore ContentCore => this.GameInstance.ContentCore; - - /// <summary>The SMAPI configuration settings.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private SConfig Settings; + private ContentCoordinator ContentCore => this.GameInstance.ContentCore; /// <summary>Tracks the installed mods.</summary> /// <remarks>This is initialised after the game starts.</remarks> @@ -72,10 +80,6 @@ namespace StardewModdingAPI /// <remarks>This is initialised after the game starts.</remarks> private DeprecationManager DeprecationManager; - /// <summary>Manages console commands.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private CommandManager CommandManager; - /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager EventManager; @@ -89,11 +93,15 @@ namespace StardewModdingAPI private readonly Regex[] SuppressConsolePatterns = { new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant) + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^Multiplayer auth success$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) }; - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper = new JsonHelper(); + /// <summary>The mod toolkit used for generic mod interactions.</summary> + private readonly ModToolkit Toolkit = new ModToolkit(); /********* @@ -108,48 +116,57 @@ namespace StardewModdingAPI // get flags from arguments bool writeToConsole = !args.Contains("--no-terminal"); - // get log path from arguments - string logPath = null; - { - int pathIndex = Array.LastIndexOf(args, "--log-path") + 1; - if (pathIndex >= 1 && args.Length >= pathIndex) - { - logPath = args[pathIndex]; - if (!Path.IsPathRooted(logPath)) - logPath = Path.Combine(Constants.LogDir, logPath); - } - } - if (string.IsNullOrWhiteSpace(logPath)) - logPath = Constants.DefaultLogPath; - // load SMAPI - using (Program program = new Program(writeToConsole, logPath)) + using (Program program = new Program(writeToConsole)) program.RunInteractively(); } /// <summary>Construct an instance.</summary> /// <param name="writeToConsole">Whether to output log messages to the console.</param> - /// <param name="logPath">The full file path to which to write log messages.</param> - public Program(bool writeToConsole, string logPath) + public Program(bool writeToConsole) { + // init paths + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); + + // init log file + this.PurgeLogFiles(); + string logPath = this.GetLogPath(); + // init basics + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole }; + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme) + { + WriteToConsole = writeToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; this.EventManager = new EventManager(this.Monitor, this.ModRegistry); - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + + // validate game version + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) + { + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + { + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // apply game patches + new GamePatcher(this.Monitor).Apply( + // new GameLocationPatch() + ); } /// <summary>Launch SMAPI.</summary> @@ -159,28 +176,32 @@ namespace StardewModdingAPI // initialise SMAPI try { - // init logging - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info); - this.Monitor.Log($"Mods go here: {Constants.ModPath}"); - this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); - - // validate paths - this.VerifyPath(Constants.ModPath); - this.VerifyPath(Constants.LogDir); - - // validate game version - if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) - { - this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } - if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) - { - this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI: https://smapi.io.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } + // hook up events + ContentEvents.Init(this.EventManager); + ControlEvents.Init(this.EventManager); + GameEvents.Init(this.EventManager); + GraphicsEvents.Init(this.EventManager); + InputEvents.Init(this.EventManager); + LocationEvents.Init(this.EventManager); + MenuEvents.Init(this.EventManager); + MineEvents.Init(this.EventManager); + MultiplayerEvents.Init(this.EventManager); + PlayerEvents.Init(this.EventManager); + SaveEvents.Init(this.EventManager); + SpecialisedEvents.Init(this.EventManager); + TimeEvents.Init(this.EventManager); + + // init JSON parser + JsonConverter[] converters = { + new StringEnumConverter<Buttons>(), + new StringEnumConverter<Keys>(), + new StringEnumConverter<SButton>(), + new ColorConverter(), + new PointConverter(), + new RectangleConverter() + }; + foreach (JsonConverter converter in converters) + this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); // add error handlers #if SMAPI_FOR_WINDOWS @@ -189,10 +210,13 @@ namespace StardewModdingAPI #endif AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + // add more leniant assembly resolvers + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + // override game SGame.MonitorDuringInitialisation = this.Monitor; SGame.ReflectorDuringInitialisation = this.Reflection; - this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart); + this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose); StardewValley.Program.gamePtr = this.GameInstance; // add exit handler @@ -216,10 +240,6 @@ namespace StardewModdingAPI }).Start(); // hook into game events -#if SMAPI_FOR_WINDOWS - ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose(); -#endif - this.GameInstance.Exiting += (sender, e) => this.Dispose(); ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); // set window titles @@ -233,11 +253,28 @@ namespace StardewModdingAPI return; } + // check update marker + if (File.Exists(Constants.UpdateMarker)) + { + string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); + if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) + { + if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) + { + this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); + this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); + Console.ReadKey(); + } + } + File.Delete(Constants.UpdateMarker); + } + // show details if game crashed during last session if (File.Exists(Constants.FatalCrashMarker)) { this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error); - this.Monitor.Log($"If you ask for help, make sure to attach this file: {Constants.FatalCrashLog}", LogLevel.Error); + this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error); this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); Console.ReadKey(); File.Delete(Constants.FatalCrashLog); @@ -245,13 +282,19 @@ namespace StardewModdingAPI } // start game - this.Monitor.Log("Starting game...", LogLevel.Trace); + this.Monitor.Log("Starting game...", LogLevel.Debug); try { this.IsGameRunning = true; StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window this.GameInstance.Run(); } + catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) + { + this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); + this.PressAnyKeyToExit(); + } catch (Exception ex) { this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); @@ -266,12 +309,11 @@ namespace StardewModdingAPI /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Monitor.Log("Disposing...", LogLevel.Trace); - // skip if already disposed if (this.IsDisposed) return; this.IsDisposed = true; + this.Monitor.Log("Disposing...", LogLevel.Trace); // dispose mod data foreach (IModMetadata mod in this.ModRegistry.GetAll()) @@ -293,6 +335,9 @@ namespace StardewModdingAPI this.CancellationTokenSource?.Dispose(); this.GameInstance?.Dispose(); this.LogFile?.Dispose(); + + // end game (moved from Game1.OnExiting to let us clean up first) + Process.GetCurrentProcess().Kill(); } @@ -309,14 +354,7 @@ namespace StardewModdingAPI Console.ResetColor(); Program.PressAnyKeyToExit(showMessage: true); } - - // get game assembly name - const string gameAssemblyName = -#if SMAPI_FOR_WINDOWS - "Stardew Valley"; -#else - "StardewValley"; -#endif + string gameAssemblyName = Constants.GameAssemblyName; // game not present if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) @@ -345,27 +383,21 @@ namespace StardewModdingAPI private void InitialiseAfterGameStart() { // load settings - this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; // load core components this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); - this.CommandManager = new CommandManager(); // redirect direct console output { - Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + Monitor monitor = this.GetSecondaryMonitor("game"); if (monitor.WriteToConsole) this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); } // add headers if (this.Settings.DeveloperMode) - { - this.Monitor.ShowTraceInConsole = true; - this.Monitor.ShowFullStampInConsole = true; this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - } if (!this.Settings.CheckForUpdates) this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); if (!this.Monitor.WriteToConsole) @@ -377,7 +409,8 @@ namespace StardewModdingAPI this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // load mod data - ModDatabase modDatabase = new ModDatabase(this.Settings.ModData, Constants.GetUpdateUrl); + ModToolkit toolkit = new ModToolkit(); + ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); // load mods { @@ -385,14 +418,28 @@ namespace StardewModdingAPI ModResolver resolver = new ModResolver(); // load manifests - IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, this.JsonHelper, modDatabase).ToArray(); - resolver.ValidateManifests(mods, Constants.ApiVersion, Constants.GetUpdateUrl); + IModMetadata[] mods = resolver.ReadManifests(toolkit, Constants.ModPath, modDatabase).ToArray(); + resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); // process dependencies mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); // load mods - this.LoadMods(mods, this.JsonHelper, this.ContentCore, modDatabase); + this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); + + // write metadata file + if (this.Settings.DumpMetadata) + { + ModFolderExport export = new ModFolderExport + { + Exported = DateTime.UtcNow.ToString("O"), + ApiVersion = Constants.ApiVersion.ToString(), + GameVersion = Constants.GameVersion.ToString(), + ModFolderPath = Constants.ModPath, + Mods = mods + }; + this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export); + } // check for updates this.CheckForUpdatesAsync(mods); @@ -430,8 +477,8 @@ namespace StardewModdingAPI { // prepare console this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); - this.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); + this.GameInstance.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); + this.GameInstance.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); // start handling command line input Thread inputThread = new Thread(() => @@ -443,16 +490,9 @@ namespace StardewModdingAPI if (string.IsNullOrWhiteSpace(input)) continue; - // parse input - try - { - if (!this.CommandManager.Trigger(input)) - this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); - } - catch (Exception ex) - { - this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); - } + // handle command + this.Monitor.LogUserInput(input); + this.GameInstance.CommandQueue.Enqueue(input); } }); inputThread.Start(); @@ -529,22 +569,36 @@ namespace StardewModdingAPI new Thread(() => { // create client - WebApiClient client = new WebApiClient(this.Settings.WebApiBaseUrl, Constants.ApiVersion); + string url = this.Settings.WebApiBaseUrl; +#if !SMAPI_FOR_WINDOWS + url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac +#endif + WebApiClient client = new WebApiClient(url, Constants.ApiVersion); this.Monitor.Log("Checking for updates...", LogLevel.Trace); // check SMAPI version + ISemanticVersion updateFound = null; try { - ModInfoModel response = client.GetModInfo($"GitHub:{this.Settings.GitHubProjectName}").Single().Value; - if (response.Error != null) + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; + ISemanticVersion latestStable = response.Main?.Version; + ISemanticVersion latestBeta = response.Optional?.Version; + + if (latestStable == null && response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {response.Error}"); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) + { + updateFound = latestBeta; + this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) + { + updateFound = latestStable; + this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); } - else if (this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.Version))) - this.Monitor.Log($"You can update SMAPI to {response.Version}: {Constants.HomePageUrl}", LogLevel.Alert); - else if (response.PreviewVersion != null && this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.PreviewVersion))) - this.Monitor.Log($"You can update SMAPI to {response.PreviewVersion}: {Constants.HomePageUrl}", LogLevel.Alert); else this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } @@ -557,94 +611,85 @@ namespace StardewModdingAPI ); } + // show update message on next launch + if (updateFound != null) + File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); + // check mod versions if (mods.Any()) { try { - // prepare update keys - Dictionary<string, IModMetadata[]> modsByKey = - ( - from mod in mods - where mod.Manifest?.UpdateKeys != null - from key in mod.Manifest.UpdateKeys - select new { key, mod } - ) - .GroupBy(p => p.key, StringComparer.InvariantCultureIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(p => p.mod).ToArray(), - StringComparer.InvariantCultureIgnoreCase - ); - - // report update keys + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); + + // prepare search model + List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>(); + foreach (IModMetadata mod in mods) { - IModMetadata[] modsWithoutKeys = ( - from mod in mods - where - mod.Manifest != null - && (mod.Manifest.UpdateKeys == null || !mod.Manifest.UpdateKeys.Any()) - && (mod.Manifest?.UniqueID != "SMAPI.ConsoleCommands" && mod.Manifest?.UniqueID != "SMAPI.TrainerMod") - orderby mod.DisplayName - select mod - ).ToArray(); - - string message = $"Checking {modsByKey.Count} mod update keys."; - if (modsWithoutKeys.Any()) - message += $" {modsWithoutKeys.Length} mods have no update keys: {string.Join(", ", modsWithoutKeys.Select(p => p.DisplayName))}."; - this.Monitor.Log($" {message}", LogLevel.Trace); + if (!mod.HasID()) + continue; + + string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0]; + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray())); } // fetch results - var results = - ( - from entry in client.GetModInfo(modsByKey.Keys.ToArray()) - from mod in modsByKey[entry.Key] - orderby mod.DisplayName - select new { entry.Key, Mod = mod, Info = entry.Value } - ) - .ToArray(); - - // extract latest versions - IDictionary<IModMetadata, ModInfoModel> updatesByMod = new Dictionary<IModMetadata, ModInfoModel>(); - foreach (var result in results) - { - IModMetadata mod = result.Mod; - ModInfoModel remoteInfo = result.Info; + this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); - // handle error - if (remoteInfo.Error != null) - { - this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: {remoteInfo.Error}", LogLevel.Trace); + // extract update alerts & errors + var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); + var errors = new StringBuilder(); + foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) + { + // link to update-check data + if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) continue; - } + mod.SetUpdateData(result); - // normalise versions - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - if (!SemanticVersion.TryParse(mod.DataRecord?.GetRemoteVersionForUpdateChecks(remoteInfo.Version) ?? remoteInfo.Version, out ISemanticVersion remoteVersion)) + // handle errors + if (result.Errors != null && result.Errors.Any()) { - this.Monitor.Log($" {mod.DisplayName} ({result.Key}): update error: Mod has invalid version {remoteInfo.Version}", LogLevel.Trace); - continue; + errors.AppendLine(result.Errors.Length == 1 + ? $" {mod.DisplayName}: {result.Errors[0]}" + : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}" + ); } - // compare versions - bool isUpdate = remoteVersion.IsNewerThan(localVersion); - this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {remoteInfo.Version}" : "okay")}."); - if (isUpdate) - { - if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || remoteVersion.IsNewerThan(other.Version)) - updatesByMod[mod] = remoteInfo; - } + // parse versions + ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; + ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; + ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; + ISemanticVersion unofficialVersion = result.Unofficial?.Version; + + // show update alerts + if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) + updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); + else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) + updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); + else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) + updates.Add(Tuple.Create(mod, unofficialVersion, result.Unofficial?.Url)); } - // output - if (updatesByMod.Any()) + // show update errors + if (errors.Length != 0) + this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); + + // show update alerts + if (updates.Any()) { this.Monitor.Newline(); - this.Monitor.Log($"You can update {updatesByMod.Count} mod{(updatesByMod.Count != 1 ? "s" : "")}:", LogLevel.Alert); - foreach (var entry in updatesByMod.OrderBy(p => p.Key.DisplayName)) - this.Monitor.Log($" {entry.Key.DisplayName} {entry.Value.Version}: {entry.Value.Url}", LogLevel.Alert); + this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); + foreach (var entry in updates) + { + IModMetadata mod = entry.Item1; + ISemanticVersion newVersion = entry.Item2; + string newUrl = entry.Item3; + this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); + } } + else + this.Monitor.Log(" All mods up to date.", LogLevel.Trace); } catch (Exception ex) { @@ -661,22 +706,13 @@ namespace StardewModdingAPI /// <summary>Get whether a given version should be offered to the user as an update.</summary> /// <param name="currentVersion">The current semantic version.</param> /// <param name="newVersion">The target semantic version.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion) + /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> + private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) { - // basic eligibility - bool isNewer = newVersion.IsNewerThan(currentVersion); - bool isPrerelease = newVersion.Build != null; - bool isEquallyStable = !isPrerelease || currentVersion.Build != null; // don't update stable => prerelease - if (!isNewer || !isEquallyStable) - return false; - if (!isPrerelease) - return true; - - // prerelease eligible if same version (excluding prerelease tag) return - newVersion.MajorVersion == currentVersion.MajorVersion - && newVersion.MinorVersion == currentVersion.MinorVersion - && newVersion.PatchVersion == currentVersion.PatchVersion; + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); } /// <summary>Create a directory path if it doesn't exist.</summary> @@ -699,32 +735,35 @@ namespace StardewModdingAPI /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> /// <param name="contentCore">The content manager to use for mod content.</param> /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> - private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCore contentCore, ModDatabase modDatabase) + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) { this.Monitor.Log("Loading mods...", LogLevel.Trace); + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>(); void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase }; // load content packs foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) { - // get basic info - IManifest manifest = metadata.Manifest; - this.Monitor.Log($"Loading {metadata.DisplayName} from {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)} (content pack)...", LogLevel.Trace); + this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)})...", LogLevel.Trace); + + // show warning for missing update key + if (metadata.HasManifest() && !metadata.HasUpdateKeys()) + metadata.SetWarning(ModWarning.NoUpdateKeys); // validate status if (metadata.Status == ModMetadataStatus.Failed) { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); TrackSkip(metadata, metadata.Error); continue; } // load mod as content pack + IManifest manifest = metadata.Manifest; IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - ContentManagerShim contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", metadata.DirectoryPath); - IContentHelper contentHelper = new ContentHelper(this.ContentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); metadata.SetMod(contentPack, monitor); this.ModRegistry.Add(metadata); @@ -743,100 +782,103 @@ namespace StardewModdingAPI StringComparer.InvariantCultureIgnoreCase ); - // get assembly loaders - AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode); - AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); - InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - - // load from metadata - foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) + // load mods from metadata + using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor)) { - // get basic info - IManifest manifest = metadata.Manifest; - this.Monitor.Log(metadata.Manifest?.EntryDll != null - ? $"Loading {metadata.DisplayName} from {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll}..." // don't use Path.Combine here, since EntryDLL might not be valid - : $"Loading {metadata.DisplayName}...", LogLevel.Trace); - - // validate status - if (metadata.Status == ModMetadataStatus.Failed) + InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); + foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); - TrackSkip(metadata, metadata.Error); - continue; - } - - // load mod - string assemblyPath = metadata.Manifest?.EntryDll != null - ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) - : null; - Assembly modAssembly; - try - { - modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible); - } - catch (IncompatibleInstructionException) // details already in trace logs - { - string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray(); + // get basic info + IManifest manifest = metadata.Manifest; + this.Monitor.Log(metadata.Manifest?.EntryDll != null + ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid + : $" {metadata.DisplayName}...", LogLevel.Trace); + + // show warnings + if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID)) + metadata.SetWarning(ModWarning.NoUpdateKeys); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } - TrackSkip(metadata, $"it's outdated. Please check for a new version at {string.Join(" or ", updateUrls)}."); - continue; - } - catch (SAssemblyLoadFailedException ex) - { - TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}"); - continue; - } - catch (Exception ex) - { - TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}"); - continue; - } + // load mod + string assemblyPath = metadata.Manifest?.EntryDll != null + ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) + : null; + Assembly modAssembly; + try + { + modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible); + } + catch (IncompatibleInstructionException) // details already in trace logs + { + string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray(); - // initialise mod - try - { - // get content packs - if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) - contentPacks = new IContentPack[0]; + TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}."); + continue; + } + catch (SAssemblyLoadFailedException ex) + { + TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}"); + continue; + } + catch (Exception ex) + { + TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}"); + continue; + } - // init mod helpers - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - IModHelper modHelper; + // initialise mod + try { - ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); - ContentManagerShim contentManager = this.ContentCore.CreateContentManager($"Mods.{metadata.Manifest.UniqueID}", metadata.DirectoryPath); - IContentHelper contentHelper = new ContentHelper(contentCore, contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); - IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - ContentManagerShim packContentManager = this.ContentCore.CreateContentManager($"Mods.{packManifest.UniqueID}", packDirPath); - IContentHelper packContentHelper = new ContentHelper(contentCore, packContentManager, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.JsonHelper); - } + // get mod instance + if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod)) + continue; - modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager); - } + // get content packs + if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) + contentPacks = new IContentPack[0]; - // get mod instance - if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod)) - continue; + // init mod helpers + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + IModHelper modHelper; + { + IModEvents events = new ModEvents(metadata, this.EventManager); + ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); + + IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); + } + + modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager); + } - // init mod - mod.ModManifest = manifest; - mod.Helper = modHelper; - mod.Monitor = monitor; + // init mod + mod.ModManifest = manifest; + mod.Helper = modHelper; + mod.Monitor = monitor; - // track mod - metadata.SetMod(mod); - this.ModRegistry.Add(metadata); - } - catch (Exception ex) - { - TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); + // track mod + metadata.SetMod(mod); + this.ModRegistry.Add(metadata); + } + catch (Exception ex) + { + TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); + } } } } @@ -894,6 +936,28 @@ namespace StardewModdingAPI this.Monitor.Newline(); } + // log warnings + { + IModMetadata[] modsWithWarnings = this.ModRegistry.GetAll().Where(p => p.Warnings != ModWarning.None).ToArray(); + if (modsWithWarnings.Any()) + { + this.Monitor.Log($"Found issues with {modsWithWarnings.Length} mods:", LogLevel.Warn); + foreach (IModMetadata metadata in modsWithWarnings) + { + string[] warnings = this.GetWarningText(metadata.Warnings).ToArray(); + if (warnings.Length == 1) + this.Monitor.Log($" {metadata.DisplayName} {warnings[0]}", LogLevel.Warn); + else + { + this.Monitor.Log($" {metadata.DisplayName}:", LogLevel.Warn); + foreach (string warning in warnings) + this.Monitor.Log(" - " + warning, LogLevel.Warn); + } + } + this.Monitor.Newline(); + } + } + // initialise translations this.ReloadTranslations(loadedMods); @@ -983,6 +1047,25 @@ namespace StardewModdingAPI this.ModRegistry.AreAllModsInitialised = true; } + /// <summary>Get the warning text for a mod warning bit mask.</summary> + /// <param name="mask">The mod warning bit mask.</param> + private IEnumerable<string> GetWarningText(ModWarning mask) + { + if (mask.HasFlag(ModWarning.BrokenCodeLoaded)) + yield return "has broken code, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly."; + if (mask.HasFlag(ModWarning.ChangesSaveSerialiser)) + yield return "accesses the save serialiser and may break your saves."; + if (mask.HasFlag(ModWarning.PatchesGame)) + yield return "patches the game. This may cause errors or bugs in-game. If you have issues, try removing this mod first."; + if (mask.HasFlag(ModWarning.UsesUnvalidatedUpdateTick)) + yield return "bypasses normal SMAPI event protections. This may cause errors or save corruption. If you have issues, try removing this mod first."; + if (mask.HasFlag(ModWarning.UsesDynamic)) + yield return "uses the 'dynamic' keyword. This won't work on Linux/Mac."; + if (mask.HasFlag(ModWarning.NoUpdateKeys)) + yield return "has no update keys in its manifest. SMAPI won't show update alerts for this mod."; + + } + /// <summary>Load a mod's entry class.</summary> /// <param name="modAssembly">The mod assembly.</param> /// <param name="onError">A callback invoked when loading fails.</param> @@ -1019,7 +1102,7 @@ namespace StardewModdingAPI /// <param name="mods">The mods for which to reload translations.</param> private void ReloadTranslations(IEnumerable<IModMetadata> mods) { - JsonHelper jsonHelper = new JsonHelper(); + JsonHelper jsonHelper = this.Toolkit.JsonHelper; foreach (IModMetadata metadata in mods) { if (metadata.IsContentPack) @@ -1045,8 +1128,17 @@ namespace StardewModdingAPI } // validate translations - foreach (string locale in translations.Keys) + foreach (string locale in translations.Keys.ToArray()) { + // skip empty files + if (translations[locale] == null || !translations[locale].Keys.Any()) + { + metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); + translations.Remove(locale); + continue; + } + + // handle duplicates HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); foreach (string key in translations[locale].Keys.ToArray()) @@ -1057,7 +1149,6 @@ namespace StardewModdingAPI translations[locale].Remove(key); } } - if (duplicateKeys.Any()) metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); } @@ -1078,7 +1169,7 @@ namespace StardewModdingAPI case "help": if (arguments.Any()) { - Command result = this.CommandManager.Get(arguments[0]); + Command result = this.GameInstance.CommandManager.Get(arguments[0]); if (result == null) this.Monitor.Log("There's no command with that name.", LogLevel.Error); else @@ -1087,7 +1178,7 @@ namespace StardewModdingAPI else { string message = "The following commands are registered:\n"; - IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray(); + IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray(); foreach (var group in groups) { string modName = group.Key; @@ -1148,7 +1239,7 @@ namespace StardewModdingAPI /// <param name="name">The name of the module which will log messages with this instance.</param> private Monitor GetSecondaryMonitor(string name) { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource) + return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, @@ -1156,24 +1247,6 @@ namespace StardewModdingAPI }; } - /// <summary>Get a human-readable name for the current platform.</summary> - [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] - private string GetFriendlyPlatformName() - { -#if SMAPI_FOR_WINDOWS - try - { - return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") - .Get() - .Cast<ManagementObject>() - .Select(entry => entry.GetPropertyValue("Caption").ToString()) - .FirstOrDefault(); - } - catch { } -#endif - return Environment.OSVersion.ToString(); - } - /// <summary>Log a message if verbose mode is enabled.</summary> /// <param name="message">The message to log.</param> private void VerboseLog(string message) @@ -1181,5 +1254,50 @@ namespace StardewModdingAPI if (this.Settings.VerboseLogging) this.Monitor.Log(message, LogLevel.Trace); } + + /// <summary>Get the absolute path to the next available log file.</summary> + private string GetLogPath() + { + // default path + { + FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.{Constants.LogNameExtension}")); + if (!defaultFile.Exists) + return defaultFile.FullName; + } + + // get first disambiguated path + for (int i = 2; i < int.MaxValue; i++) + { + FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.player-{i}.{Constants.LogNameExtension}")); + if (!file.Exists) + return file.FullName; + } + + // should never happen + throw new InvalidOperationException("Could not find an available log path."); + } + + /// <summary>Delete all log files created by SMAPI.</summary> + private void PurgeLogFiles() + { + DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); + if (!logsDir.Exists) + return; + + foreach (FileInfo logFile in logsDir.EnumerateFiles()) + { + if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase)) + { + try + { + FileUtilities.ForceDelete(logFile); + } + catch (IOException) + { + // ignore file if it's in use + } + } + } + } } } |