using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using StardewModdingApi.Installer.Enums; using StardewModdingAPI.Installer.Framework; using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Framework.GameScanning; using StardewModdingAPI.Toolkit.Framework.ModScanning; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingApi.Installer; /// Interactively performs the install and uninstall logic. internal class InteractiveInstaller { /********* ** Fields *********/ /// The absolute path to the directory containing the files to copy into the game folder. private readonly string BundlePath; /// The mod IDs which the installer should allow as bundled mods. private readonly string[] BundledModIds = ["SMAPI.SaveBackup", "SMAPI.ConsoleCommands"]; /// Get the absolute file or folder paths to remove when uninstalling SMAPI. /// The folder for Stardew Valley and SMAPI. /// The folder for SMAPI mods. [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid file names.")] private IEnumerable GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) { string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); // installed files yield return GetInstallPath("StardewModdingAPI"); // Linux/macOS only yield return GetInstallPath("StardewModdingAPI.deps.json"); yield return GetInstallPath("StardewModdingAPI.dll"); yield return GetInstallPath("StardewModdingAPI.exe"); yield return GetInstallPath("StardewModdingAPI.exe.config"); yield return GetInstallPath("StardewModdingAPI.exe.mdb"); // before 3.18.4 (Linux/macOS only) yield return GetInstallPath("StardewModdingAPI.pdb"); // before 3.18.4 (Windows only) yield return GetInstallPath("StardewModdingAPI.runtimeconfig.json"); yield return GetInstallPath("StardewModdingAPI.xml"); yield return GetInstallPath("smapi-internal"); yield return GetInstallPath("steam_appid.txt"); // obsolete files yield return GetInstallPath("libgdiplus.dylib"); // before 3.13 (macOS only) yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 yield return GetInstallPath(Path.Combine("Mods", "ErrorHandler")); // before 4.0 (no longer needed) yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // before 2.0 (renamed to ConsoleCommands) yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3-1.8 yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5 yield return GetInstallPath("0Harmony.dll"); // moved in 2.8 yield return GetInstallPath("0Harmony.pdb"); // moved in 2.8 yield return GetInstallPath("Mono.Cecil.dll"); // moved in 2.8 yield return GetInstallPath("Newtonsoft.Json.dll"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.config.json"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.crash.marker"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.metadata.json"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.update.marker"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.dll"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.xml"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI-x64.exe"); // before 3.13 // old log files yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); } /// Handles writing text to the console. private IConsoleWriter ConsoleWriter; /********* ** Public methods *********/ /// Construct an instance. /// The absolute path to the directory containing the files to copy into the game folder. public InteractiveInstaller(string bundlePath) { this.BundlePath = bundlePath; this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform()); } /// Run the install or uninstall script. /// The command line arguments. /// /// Initialization flow: /// 1. Collect information (mainly OS and install path) and validate it. /// 2. Ask the user whether to install or uninstall. /// /// Uninstall logic: /// 1. On Linux/macOS: if a backup of the launcher exists, delete the launcher and restore the backup. /// 2. Delete all files and folders in the game directory matching one of the values returned by . /// /// Install flow: /// 1. Run the uninstall flow. /// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory. /// 3. On Linux/macOS: back up the game launcher and replace it with the SMAPI launcher. (This isn't possible on Windows, so the user needs to configure it manually.) /// 4. Create the 'Mods' directory. /// 5. Copy the bundled mods into the 'Mods' directory (deleting any existing versions). /// 6. Move any mods from app data into game's mods directory. /// public void Run(string[] args) { /********* ** Step 1: initial setup *********/ /**** ** Get basic info & set window title ****/ ModToolkit toolkit = new(); var context = new InstallerContext(); Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}"; Console.WriteLine(); /**** ** read command-line arguments ****/ // get input mode bool allowUserInput = !args.Contains("--no-prompt"); // get action bool installArg = args.Contains("--install"); bool uninstallArg = args.Contains("--uninstall"); if (installArg && uninstallArg) { this.PrintError("You can't specify both --install and --uninstall command-line flags."); this.AwaitConfirmation(allowUserInput); return; } if (!allowUserInput && !installArg && !uninstallArg) { this.PrintError("You must specify --install or --uninstall when running with --no-prompt."); return; } // get game path from CLI string? gamePathArg = null; { int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; if (pathIndex >= 1 && args.Length >= pathIndex) gamePathArg = args[pathIndex]; } /**** ** Check if correct installer ****/ #if SMAPI_FOR_WINDOWS if (context.IsUnix) { this.PrintError($"This is the installer for Windows. Run the 'install on {context.Platform}.{(context.Platform == Platform.Mac ? "command" : "sh")}' file instead."); this.AwaitConfirmation(allowUserInput); return; } #else if (context.IsWindows) { this.PrintError($"This is the installer for Linux/macOS. Run the 'install on Windows.exe' file instead."); this.AwaitConfirmation(allowUserInput); return; } #endif /********* ** Step 2: choose a theme (can't auto-detect on Linux/macOS) *********/ MonitorColorScheme scheme = MonitorColorScheme.AutoDetect; if (context.IsUnix && allowUserInput) { /**** ** print header ****/ this.PrintPlain("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); this.PrintPlain("----------------------------------------------------------------------------"); Console.WriteLine(); /**** ** show theme selector ****/ // get theme writers ColorfulConsoleWriter lightBackgroundWriter = new(context.Platform, MonitorColorScheme.LightBackground, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); ColorfulConsoleWriter darkBackgroundWriter = new(context.Platform, MonitorColorScheme.DarkBackground, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); // print question this.PrintPlain("Which text looks more readable?"); Console.WriteLine(); Console.Write(" [1] "); lightBackgroundWriter.WriteLine("Dark text on light background", ConsoleLogLevel.Info); Console.Write(" [2] "); darkBackgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); Console.WriteLine(); // handle choice string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", ["1", "2"], printLine: Console.WriteLine); switch (choice) { case "1": scheme = MonitorColorScheme.LightBackground; this.ConsoleWriter = lightBackgroundWriter; break; case "2": scheme = MonitorColorScheme.DarkBackground; this.ConsoleWriter = darkBackgroundWriter; break; default: throw new InvalidOperationException($"Unexpected action key '{choice}'."); } } Console.Clear(); /********* ** Step 3: find game folder *********/ InstallerPaths paths; { /**** ** print header ****/ this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); this.PrintDebug("----------------------------------------------------------------------------"); Console.WriteLine(); /**** ** collect details ****/ // get game path DirectoryInfo? installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg); if (installDir == null) { this.PrintError("Failed finding your game path."); this.AwaitConfirmation(allowUserInput); return; } // get folders DirectoryInfo bundleDir = new(this.BundlePath); paths = new InstallerPaths(bundleDir, installDir); } /********* ** Step 4: validate assumptions *********/ // executable exists if (!File.Exists(paths.GameDllPath)) { this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); this.AwaitConfirmation(allowUserInput); return; } Console.Clear(); /********* ** Step 5: ask what to do *********/ ScriptAction action; { /**** ** print header ****/ this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just one question first."); this.PrintDebug($"Game path: {paths.GamePath}"); this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); this.PrintDebug("----------------------------------------------------------------------------"); Console.WriteLine(); /**** ** ask what to do ****/ if (installArg) action = ScriptAction.Install; else if (uninstallArg) action = ScriptAction.Uninstall; else { this.PrintInfo("What do you want to do?"); Console.WriteLine(); this.PrintInfo("[1] Install SMAPI."); this.PrintInfo("[2] Uninstall SMAPI."); Console.WriteLine(); string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", ["1", "2"]); switch (choice) { case "1": action = ScriptAction.Install; break; case "2": action = ScriptAction.Uninstall; break; default: throw new InvalidOperationException($"Unexpected action key '{choice}'."); } } } Console.Clear(); /********* ** Step 6: apply *********/ { /**** ** print header ****/ this.PrintInfo($"That's all I need! I'll {action.ToString().ToLower()} SMAPI now."); this.PrintDebug($"Game path: {paths.GamePath}"); this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); this.PrintDebug("----------------------------------------------------------------------------"); Console.WriteLine(); /**** ** Back up user settings ****/ if (File.Exists(paths.ApiUserConfigPath)) File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath); /**** ** Always uninstall old files ****/ // restore game launcher if (context.IsUnix && File.Exists(paths.BackupLaunchScriptPath)) { this.PrintDebug("Removing SMAPI launcher..."); this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); File.Move(paths.BackupLaunchScriptPath, paths.VanillaLaunchScriptPath); } // remove old files string[] removePaths = this.GetUninstallPaths(paths.GameDir, paths.ModsDir) .Where(path => Directory.Exists(path) || File.Exists(path)) .ToArray(); if (removePaths.Any()) { this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); foreach (string path in removePaths) this.InteractivelyDelete(path, allowUserInput); } // move global save data folder (changed in 3.2) { string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); DirectoryInfo oldDir = new(Path.Combine(dataPath, "Saves", ".smapi")); DirectoryInfo newDir = new(Path.Combine(dataPath, ".smapi")); if (oldDir.Exists) { if (newDir.Exists) this.InteractivelyDelete(oldDir.FullName, allowUserInput); else oldDir.MoveTo(newDir.FullName); } } /**** ** Install new files ****/ if (action == ScriptAction.Install) { // copy SMAPI files to game dir this.PrintDebug("Adding SMAPI files..."); foreach (FileSystemInfo sourceEntry in paths.BundleDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) { this.InteractivelyDelete(Path.Combine(paths.GameDir.FullName, sourceEntry.Name), allowUserInput); this.RecursiveCopy(sourceEntry, paths.GameDir); } // replace mod launcher (if possible) if (context.IsUnix) { this.PrintDebug("Safely replacing game launcher..."); // back up & remove current launcher if (File.Exists(paths.VanillaLaunchScriptPath)) { if (!File.Exists(paths.BackupLaunchScriptPath)) File.Move(paths.VanillaLaunchScriptPath, paths.BackupLaunchScriptPath); else this.InteractivelyDelete(paths.VanillaLaunchScriptPath, allowUserInput); } // add new launcher File.Move(paths.NewLaunchScriptPath, paths.VanillaLaunchScriptPath); // mark files executable // (MSBuild doesn't keep permission flags for files zipped in a build task.) foreach (string path in new[] { paths.VanillaLaunchScriptPath, paths.UnixSmapiExecutablePath }) { new Process { StartInfo = new ProcessStartInfo { FileName = "chmod", Arguments = $"755 \"{path}\"", CreateNoWindow = true } }.Start(); } } // copy the game's deps.json file // (This is needed to resolve native DLLs like libSkiaSharp.) File.Copy( sourceFileName: Path.Combine(paths.GamePath, "Stardew Valley.deps.json"), destFileName: Path.Combine(paths.GamePath, "StardewModdingAPI.deps.json"), overwrite: true ); // create mods directory (if needed) if (!paths.ModsDir.Exists) { this.PrintDebug("Creating mods directory..."); paths.ModsDir.Create(); } // add or replace bundled mods DirectoryInfo bundledModsDir = new(Path.Combine(paths.BundlePath, "Mods")); if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any()) { this.PrintDebug("Adding bundled mods..."); ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath, useCaseInsensitiveFilePaths: true).ToArray(); foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName, useCaseInsensitiveFilePaths: true)) { // validate source mod if (sourceMod.Manifest == null) { this.PrintWarning($" ignored invalid bundled mod {sourceMod.DisplayName}: {sourceMod.ManifestParseError}"); continue; } if (!this.BundledModIds.Contains(sourceMod.Manifest.UniqueID)) { this.PrintWarning($" ignored unknown '{sourceMod.DisplayName}' mod in the installer folder. To add mods, put them here instead: {paths.ModsPath}"); continue; } // get mod info string modId = sourceMod.Manifest.UniqueID; string modName = sourceMod.Manifest.Name; DirectoryInfo fromDir = sourceMod.Directory; // get target path // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- avoid error if the Mods folder has invalid mods, since they're not validated yet ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(modId, StringComparison.OrdinalIgnoreCase) == true); DirectoryInfo targetDir = new(Path.Combine(paths.ModsPath, fromDir.Name)); // replace existing folder if possible // if we found the mod in a custom location, replace that copy if (targetMod != null && targetMod.Directory.FullName != targetDir.FullName) { targetDir = targetMod.Directory; DirectoryInfo parentDir = targetDir.Parent!; this.PrintDebug($" adding {modName} to {Path.Combine(paths.ModsDir.Name, PathUtilities.GetRelativePath(paths.ModsPath, targetDir.FullName))}..."); if (targetDir.Name != fromDir.Name) { // in case the user does weird things like swap folder names, rename the bundled // mod in a unique staging folder within the temporary package: string stagingPath = Path.Combine(fromDir.Parent!.Parent!.FullName, $"renamed-mod-{fromDir.Name}"); Directory.CreateDirectory(stagingPath); fromDir.MoveTo( Path.Combine(stagingPath, targetDir.Name) ); } this.InteractivelyDelete(targetDir.FullName, allowUserInput); this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); } // else add it to default location else { DirectoryInfo parentDir = targetDir.Parent!; this.PrintDebug($" adding {modName}..."); if (targetDir.Exists) this.InteractivelyDelete(targetDir.FullName, allowUserInput); this.RecursiveCopy(fromDir, parentDir, filter: this.ShouldCopy); } } } // set SMAPI's color scheme if defined if (scheme != MonitorColorScheme.AutoDetect) { string text = File .ReadAllText(paths.ApiConfigPath) .Replace(@"""UseScheme"": ""AutoDetect""", $@"""UseScheme"": ""{scheme}"""); File.WriteAllText(paths.ApiConfigPath, text); } } } Console.WriteLine(); Console.WriteLine(); /********* ** Step 7: final instructions *********/ if (context.IsWindows) { if (action == ScriptAction.Install) { this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):"); this.PrintSuccess($" \"{Path.Combine(paths.GamePath, "StardewModdingAPI.exe")}\" %command%"); Console.WriteLine(); this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods."); } else this.PrintSuccess("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options."); } else { this.PrintSuccess(action == ScriptAction.Install ? "SMAPI is installed! Launch the game the same way as before to play with mods." : "SMAPI is removed! Launch the game the same way as before to play without mods." ); } this.AwaitConfirmation(allowUserInput); } /********* ** Private methods *********/ /// Get the display text for a color scheme. /// The color scheme. private string GetDisplayText(MonitorColorScheme scheme) { switch (scheme) { case MonitorColorScheme.AutoDetect: return "auto-detect"; case MonitorColorScheme.DarkBackground: return "light text on dark background"; case MonitorColorScheme.LightBackground: return "dark text on light background"; default: return scheme.ToString(); } } /// Print a message without formatting. /// The text to print. private void PrintPlain(string text) { Console.WriteLine(text); } /// Print a debug message. /// The text to print. private void PrintDebug(string text) { this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug); } /// Print a debug message. /// The text to print. private void PrintInfo(string text) { this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info); } /// Print a warning message. /// The text to print. private void PrintWarning(string text) { this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn); } /// Print a warning message. /// The text to print. private void PrintError(string text) { this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error); } /// Print a success message. /// The text to print. private void PrintSuccess(string text) { this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success); } /// Interactively delete a file or folder path, and block until deletion completes. /// The file or folder path. /// Whether the installer can ask for user input from the terminal. private void InteractivelyDelete(string path, bool allowUserInput) { while (true) { try { FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path)); break; } catch (Exception ex) { this.PrintError($"Oops! The installer couldn't delete {path}: [{ex.GetType().Name}] {ex.Message}."); this.PrintError("Try rebooting your computer and then run the installer again. If that doesn't work, try deleting it yourself then press any key to retry."); this.AwaitConfirmation(allowUserInput); } } } /// Recursively copy a directory or file. /// The file or folder to copy. /// The folder to copy into. /// A filter which matches directories and files to copy, or null to match all. private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func? filter = null) { if (filter != null && !filter(source)) return; if (!targetFolder.Exists) targetFolder.Create(); switch (source) { case FileInfo sourceFile: sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); break; case DirectoryInfo sourceDir: DirectoryInfo targetSubfolder = new(Path.Combine(targetFolder.FullName, sourceDir.Name)); foreach (FileSystemInfo entry in sourceDir.EnumerateFileSystemInfos()) this.RecursiveCopy(entry, targetSubfolder, filter); break; default: throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); } } /// Interactively ask the user to choose a value. /// A callback which prints a message to the console. /// The message to print. /// The allowed options (not case sensitive). /// The indentation to prefix to output. private string InteractivelyChoose(string message, string[] options, string indent = "", Action? printLine = null) { printLine ??= this.PrintInfo; while (true) { printLine(indent + message); Console.Write(indent); string? input = Console.ReadLine()?.Trim().ToLowerInvariant(); if (input == null || !options.Contains(input)) { printLine($"{indent}That's not a valid option."); continue; } return input; } } /// Interactively locate the game install path to update. /// The mod toolkit. /// The installer context. /// The path specified as a command-line argument (if any), which should override automatic path detection. private DirectoryInfo? InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string? specifiedPath) { // use specified path if (specifiedPath != null) { string errorPrefix = $"You specified --game-path \"{specifiedPath}\", but"; var dir = new DirectoryInfo(specifiedPath); if (!dir.Exists) { this.PrintError($"{errorPrefix} that folder doesn't exist."); return null; } GameFolderType type = context.GetGameFolderType(dir); switch (type) { case GameFolderType.Valid: return dir; default: foreach (string message in this.GetInvalidFolderWarning(type)) this.PrintWarning(message); return null; } } // get valid install paths & log invalid ones List defaultPaths = []; foreach ((DirectoryInfo dir, GameFolderType type) in this.DetectGameFolders(toolkit, context)) { if (type is GameFolderType.Valid) { defaultPaths.Add(dir); continue; } this.PrintDebug($"Ignored game folder: {dir.FullName}"); foreach (string message in this.GetInvalidFolderWarning(type)) this.PrintDebug(message); this.PrintDebug("\n"); } // let user choose detected path if (defaultPaths.Any()) { this.PrintInfo("Where do you want to add or remove SMAPI?"); Console.WriteLine(); for (int i = 0; i < defaultPaths.Count; i++) this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}"); this.PrintInfo($"[{defaultPaths.Count + 1}] Enter a custom game path."); Console.WriteLine(); string[] validOptions = Enumerable.Range(1, defaultPaths.Count + 1).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); string choice = this.InteractivelyChoose("Type the number next to your choice, then press enter.", validOptions); int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; if (index < defaultPaths.Count) return defaultPaths[index]; } else this.PrintInfo("Oops, couldn't find the game automatically."); // let user enter manual path while (true) { // get path from user Console.WriteLine(); this.PrintInfo($"Type the file path to the game directory (the one containing '{Constants.GameDllName}'), then press enter."); string? path = Console.ReadLine()?.Trim(); if (string.IsNullOrWhiteSpace(path)) { this.PrintWarning("You must specify a directory path to continue."); continue; } // normalize path path = context.IsWindows ? path.Replace("\"", "") // in Windows, quotes are used to escape spaces and aren't part of the file path : path.Replace("\\ ", " "); // in Linux/macOS, spaces in paths may be escaped if copied from the command line if (path.StartsWith("~/")) { string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")!; path = Path.Combine(home, path.Substring(2)); } // get directory DirectoryInfo directory = new(path); if (!directory.Exists && (path.EndsWith(".dll") || path.EndsWith(".exe") || File.Exists(path)) && directory.Parent is { Exists: true }) directory = directory.Parent; // validate path if (!directory.Exists) { this.PrintWarning("That directory doesn't seem to exist."); continue; } GameFolderType type = context.GetGameFolderType(directory); switch (type) { case GameFolderType.Valid: this.PrintInfo(" OK!"); return directory; default: foreach (string message in this.GetInvalidFolderWarning(type)) this.PrintWarning(message); continue; } } } /// Get the possible game paths to update. /// The mod toolkit. /// The installer context. private IEnumerable<(DirectoryInfo, GameFolderType)> DetectGameFolders(ModToolkit toolkit, InstallerContext context) { HashSet foundPaths = []; // game folder which contains the installer, if any { DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; while (curPath?.Parent != null) // must be in a folder (not at the root) { if (context.GetGameFolderType(curPath) == GameFolderType.Valid) { foundPaths.Add(curPath.FullName); yield return (curPath, GameFolderType.Valid); break; } curPath = curPath.Parent; } } // game paths detected by toolkit foreach ((DirectoryInfo, GameFolderType) pair in toolkit.GetGameFoldersIncludingInvalid()) { if (foundPaths.Add(pair.Item1.FullName)) yield return pair; } } private string[] GetInvalidFolderWarning(GameFolderType type) { switch (type) { case GameFolderType.Valid: return ["OK!"]; // should never happen case GameFolderType.LegacyVersion: return [ "That directory seems to have Stardew Valley 1.5.6 or earlier.", "Please update your game to the latest version to use SMAPI." ]; case GameFolderType.LegacyCompatibilityBranch: return [ "That directory seems to have the Stardew Valley legacy 'compatibility' branch.", "Unfortunately SMAPI is only compatible with the modern version of the game.", "Please update your game to the main branch to use SMAPI." ]; case GameFolderType.NoGameFound: return ["That directory doesn't contain a Stardew Valley executable."]; default: return ["That directory doesn't seem to contain a valid game install."]; } } /// Get whether a file or folder should be copied from the installer files. /// The file or folder info. private bool ShouldCopy(FileSystemInfo entry) { return entry.Name switch { "mcs" => false, // ignore macOS symlink "Mods" => false, // Mods folder handled separately _ => true }; } /// Wait until the user presses enter to confirm, if user input is allowed. /// Whether the installer can ask for user input from the terminal. private void AwaitConfirmation(bool allowUserInput) { if (allowUserInput) Console.ReadLine(); } }