From 929dccb75a1405737975d76648e015a3e7c00177 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 7 Oct 2017 23:07:10 -0400 Subject: reorganise repo structure --- src/SMAPI.Installer/InteractiveInstaller.cs | 740 ++++++++++++++++++++++++++++ 1 file changed, 740 insertions(+) create mode 100644 src/SMAPI.Installer/InteractiveInstaller.cs (limited to 'src/SMAPI.Installer/InteractiveInstaller.cs') diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs new file mode 100644 index 00000000..1a132e54 --- /dev/null +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -0,0 +1,740 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using Microsoft.Win32; +using StardewModdingApi.Installer.Enums; + +namespace StardewModdingApi.Installer +{ + /// Interactively performs the install and uninstall logic. + internal class InteractiveInstaller + { + /********* + ** Properties + *********/ + /// The value that represents Windows 7. + private readonly Version Windows7Version = new Version(6, 1); + + /// The default file paths where Stardew Valley can be installed. + /// The target platform. + /// Derived from the crossplatform mod config: https://github.com/Pathoschild/Stardew.ModBuildConfig. + private IEnumerable GetDefaultInstallPaths(Platform platform) + { + switch (platform) + { + case Platform.Mono: + { + string home = Environment.GetEnvironmentVariable("HOME"); + + // Linux + yield return $"{home}/GOG Games/Stardew Valley/game"; + yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") + ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" + : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; + + // Mac + yield return "/Applications/Stardew Valley.app/Contents/MacOS"; + yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; + } + break; + + case Platform.Windows: + { + // Windows + yield return @"C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley"; + yield return @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley"; + + // Windows registry + IDictionary registryKeys = new Dictionary + { + [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150"] = "InstallLocation", // Steam + [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows + }; + foreach (var pair in registryKeys) + { + string path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); + if (!string.IsNullOrWhiteSpace(path)) + yield return path; + } + } + break; + + default: + throw new InvalidOperationException($"Unknown platform '{platform}'."); + } + } + + /// Get the absolute file or folder paths to remove when uninstalling SMAPI. + /// The folder for Stardew Valley and SMAPI. + /// The folder for SMAPI mods. + private IEnumerable GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) + { + string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); + + // common + yield return GetInstallPath("Mono.Cecil.dll"); + yield return GetInstallPath("Newtonsoft.Json.dll"); + yield return GetInstallPath("StardewModdingAPI.exe"); + yield return GetInstallPath("StardewModdingAPI.config.json"); + yield return GetInstallPath("StardewModdingAPI.data.json"); + yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); + yield return GetInstallPath("System.ValueTuple.dll"); + yield return GetInstallPath("steam_appid.txt"); + + // Linux/Mac only + yield return GetInstallPath("libgdiplus.dylib"); + yield return GetInstallPath("StardewModdingAPI"); + yield return GetInstallPath("StardewModdingAPI.exe.mdb"); + yield return GetInstallPath("System.Numerics.dll"); + yield return GetInstallPath("System.Runtime.Caching.dll"); + + // Windows only + yield return GetInstallPath("StardewModdingAPI.pdb"); + + // obsolete + yield return GetInstallPath("Mods/.cache"); // 1.3-1.4 + yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 + yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4 + if (modsDir.Exists) + { + foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories()) + yield return Path.Combine(modDir.FullName, ".cache"); // 1.4–1.7 + } + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files + } + + /// Whether the current console supports color formatting. + private static readonly bool ConsoleSupportsColor = InteractiveInstaller.GetConsoleSupportsColor(); + + + /********* + ** Public methods + *********/ + /// Run the install or uninstall script. + /// The command line arguments. + /// + /// Initialisation 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/Mac: 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/Mac: 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) + { + /**** + ** read command-line arguments + ****/ + // get action from CLI + 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."); + Console.ReadLine(); + 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]; + } + + /**** + ** collect details + ****/ + // get platform + Platform platform = this.DetectPlatform(); + this.PrintDebug($"Platform: {(platform == Platform.Windows ? "Windows" : "Linux or Mac")}."); + + // get game path + DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); + if (installDir == null) + { + this.PrintError("Failed finding your game path."); + Console.ReadLine(); + return; + } + + // get folders + DirectoryInfo packageDir = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", platform.ToString())); + DirectoryInfo modsDir = new DirectoryInfo(Path.Combine(installDir.FullName, "Mods")); + var paths = new + { + executable = Path.Combine(installDir.FullName, platform == Platform.Mono ? "StardewValley.exe" : "Stardew Valley.exe"), + unixSmapiLauncher = Path.Combine(installDir.FullName, "StardewModdingAPI"), + unixLauncher = Path.Combine(installDir.FullName, "StardewValley"), + unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original") + }; + this.PrintDebug($"Install path: {installDir}."); + + /**** + ** validate assumptions + ****/ + if (!packageDir.Exists) + { + this.PrintError(platform == Platform.Windows && packageDir.FullName.Contains(Path.GetTempPath()) && packageDir.FullName.Contains(".zip") + ? "The installer is missing some files. It looks like you're running the installer from inside the downloaded zip; make sure you unzip the downloaded file first, then run the installer from the unzipped folder." + : $"The 'internal/{platform}' package folder is missing (should be at {packageDir})." + ); + Console.ReadLine(); + return; + } + if (!File.Exists(paths.executable)) + { + this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + Console.ReadLine(); + return; + } + + /**** + ** validate Windows dependencies + ****/ + if (platform == Platform.Windows) + { + // .NET Framework 4.5+ + if (!this.HasNetFramework45(platform)) + { + this.PrintError(Environment.OSVersion.Version >= this.Windows7Version + ? "Please install the latest version of .NET Framework before installing SMAPI." // Windows 7+ + : "Please install .NET Framework 4.5 before installing SMAPI." // Windows Vista or earlier + ); + this.PrintError("See the download page at https://www.microsoft.com/net/download/framework for details."); + Console.ReadLine(); + return; + } + if (!this.HasXNA(platform)) + { + this.PrintError("You don't seem to have XNA Framework installed. Please run the game at least once before installing SMAPI, so it can perform its first-time setup."); + Console.ReadLine(); + return; + } + } + + Console.WriteLine(); + + /**** + ** ask user what to do + ****/ + ScriptAction action; + + if (installArg) + action = ScriptAction.Install; + else if (uninstallArg) + action = ScriptAction.Uninstall; + else + { + Console.WriteLine("You can...."); + Console.WriteLine("[1] Install SMAPI."); + Console.WriteLine("[2] Uninstall SMAPI."); + Console.WriteLine(); + + string choice = this.InteractivelyChoose("What do you want to do? 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.WriteLine(); + } + + /**** + ** Always uninstall old files + ****/ + // restore game launcher + if (platform == Platform.Mono && File.Exists(paths.unixLauncherBackup)) + { + this.PrintDebug("Removing SMAPI launcher..."); + this.InteractivelyDelete(paths.unixLauncher); + File.Move(paths.unixLauncherBackup, paths.unixLauncher); + } + + // remove old files + string[] removePaths = this.GetUninstallPaths(installDir, 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); + } + + /**** + ** Install new files + ****/ + if (action == ScriptAction.Install) + { + // copy SMAPI files to game dir + this.PrintDebug("Adding SMAPI files..."); + foreach (FileInfo sourceFile in packageDir.EnumerateFiles()) + { + string targetPath = Path.Combine(installDir.FullName, sourceFile.Name); + this.InteractivelyDelete(targetPath); + sourceFile.CopyTo(targetPath); + } + + // replace mod launcher (if possible) + if (platform == Platform.Mono) + { + this.PrintDebug("Safely replacing game launcher..."); + if (!File.Exists(paths.unixLauncherBackup)) + File.Move(paths.unixLauncher, paths.unixLauncherBackup); + else if (File.Exists(paths.unixLauncher)) + this.InteractivelyDelete(paths.unixLauncher); + + File.Move(paths.unixSmapiLauncher, paths.unixLauncher); + } + + // create mods directory (if needed) + if (!modsDir.Exists) + { + this.PrintDebug("Creating mods directory..."); + modsDir.Create(); + } + + // add or replace bundled mods + Directory.CreateDirectory(Path.Combine(installDir.FullName, "Mods")); + DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "Mods")); + if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any()) + { + this.PrintDebug("Adding bundled mods..."); + foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories()) + { + this.PrintDebug($" adding {sourceDir.Name}..."); + + // initialise target dir + DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(modsDir.FullName, sourceDir.Name)); + this.InteractivelyDelete(targetDir.FullName); + targetDir.Create(); + + // copy files + foreach (FileInfo sourceFile in sourceDir.EnumerateFiles()) + sourceFile.CopyTo(Path.Combine(targetDir.FullName, sourceFile.Name)); + } + } + + // remove obsolete appdata mods + this.InteractivelyRemoveAppDataMods(platform, modsDir, packagedModsDir); + } + Console.WriteLine(); + + /**** + ** exit + ****/ + this.PrintColor("Done!", ConsoleColor.DarkGreen); + if (platform == Platform.Windows) + { + this.PrintColor( + action == ScriptAction.Install + ? "Don't forget to launch StardewModdingAPI.exe instead of the normal game executable. See the readme.txt for details." + : "If you manually changed shortcuts or Steam to launch SMAPI, don't forget to change those back.", + ConsoleColor.DarkGreen + ); + } + else if (action == ScriptAction.Install) + this.PrintColor("You can launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen); + Console.ReadKey(); + } + + + /********* + ** Private methods + *********/ + /// Detect the game's platform. + /// The platform is not supported. + private Platform DetectPlatform() + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.MacOSX: + case PlatformID.Unix: + return Platform.Mono; + + default: + return Platform.Windows; + } + } + + /// Test whether the current console supports color formatting. + private static bool GetConsoleSupportsColor() + { + try + { + Console.ForegroundColor = Console.ForegroundColor; + return true; + } + catch (Exception) + { + return false; // Mono bug + } + } + + /// Get the value of a key in the Windows registry. + /// The full path of the registry key relative to HKLM. + /// The name of the value. + private string GetLocalMachineRegistryValue(string key, string name) + { + RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; + RegistryKey openKey = localMachine.OpenSubKey(key); + if (openKey == null) + return null; + using (openKey) + return (string)openKey.GetValue(name); + } + + /// Print a debug message. + /// The text to print. + private void PrintDebug(string text) + { + this.PrintColor(text, ConsoleColor.DarkGray); + } + + /// Print a warning message. + /// The text to print. + private void PrintWarning(string text) + { + this.PrintColor(text, ConsoleColor.DarkYellow); + } + + /// Print a warning message. + /// The text to print. + private void PrintError(string text) + { + this.PrintColor(text, ConsoleColor.Red); + } + + /// Print a message to the console. + /// The message text. + /// The text foreground color. + private void PrintColor(string text, ConsoleColor color) + { + if (InteractiveInstaller.ConsoleSupportsColor) + { + Console.ForegroundColor = color; + Console.WriteLine(text); + Console.ResetColor(); + } + else + Console.WriteLine(text); + } + + /// Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows. + /// The current platform. + /// The current platform is not Windows. + private bool HasNetFramework45(Platform platform) + { + switch (platform) + { + case Platform.Windows: + using (RegistryKey versionKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full")) + return versionKey?.GetValue("Release") != null; // .NET Framework 4.5+ + + default: + throw new NotSupportedException("The installed .NET Framework version can only be checked on Windows."); + } + } + + /// Get whether the current system has XNA Framework installed. This only applies on Windows. + /// The current platform. + /// The current platform is not Windows. + private bool HasXNA(Platform platform) + { + switch (platform) + { + case Platform.Windows: + using (RegistryKey key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\XNA\Framework")) + return key != null; // XNA Framework 4.0+ + + default: + throw new NotSupportedException("The installed XNA Framework version can only be checked on Windows."); + } + } + + /// Interactively delete a file or folder path, and block until deletion completes. + /// The file or folder path. + private void InteractivelyDelete(string path) + { + while (true) + { + try + { + this.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)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."); + Console.ReadKey(); + } + } + } + + /// Delete a file or folder regardless of file permissions, and block until deletion completes. + /// The file or folder to reset. + private void ForceDelete(FileSystemInfo entry) + { + // ignore if already deleted + entry.Refresh(); + if (!entry.Exists) + return; + + // delete children + var folder = entry as DirectoryInfo; + if (folder != null) + { + foreach (FileSystemInfo child in folder.GetFileSystemInfos()) + this.ForceDelete(child); + } + + // reset permissions & delete + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + + // wait for deletion to finish + for (int i = 0; i < 10; i++) + { + entry.Refresh(); + if (entry.Exists) + Thread.Sleep(500); + } + + // throw exception if deletion didn't happen before timeout + entry.Refresh(); + if (entry.Exists) + throw new IOException($"Timed out trying to delete {entry.FullName}"); + } + + /// Interactively ask the user to choose a value. + /// The message to print. + /// The allowed options (not case sensitive). + private string InteractivelyChoose(string message, params string[] options) + { + while (true) + { + Console.WriteLine(message); + string input = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (!options.Contains(input)) + { + Console.WriteLine("That's not a valid option."); + continue; + } + return input; + } + } + + /// Interactively locate the game install path to update. + /// The current platform. + /// The path specified as a command-line argument (if any), which should override automatic path detection. + private DirectoryInfo InteractivelyGetInstallPath(Platform platform, string specifiedPath) + { + // get executable name + string executableFilename = platform == Platform.Windows + ? "Stardew Valley.exe" + : "StardewValley.exe"; + + // validate specified path + if (specifiedPath != null) + { + var dir = new DirectoryInfo(specifiedPath); + if (!dir.Exists) + { + this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't exist."); + return null; + } + if (!dir.EnumerateFiles(executableFilename).Any()) + { + this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't contain the Stardew Valley executable."); + return null; + } + return dir; + } + + // get installed paths + DirectoryInfo[] defaultPaths = + ( + from path in this.GetDefaultInstallPaths(platform).Distinct(StringComparer.InvariantCultureIgnoreCase) + let dir = new DirectoryInfo(path) + where dir.Exists && dir.EnumerateFiles(executableFilename).Any() + select dir + ) + .ToArray(); + + // choose where to install + if (defaultPaths.Any()) + { + // only one path + if (defaultPaths.Length == 1) + return defaultPaths.First(); + + // let user choose path + Console.WriteLine(); + Console.WriteLine("Found multiple copies of the game:"); + for (int i = 0; i < defaultPaths.Length; i++) + Console.WriteLine($"[{i + 1}] {defaultPaths[i].FullName}"); + Console.WriteLine(); + + string[] validOptions = Enumerable.Range(1, defaultPaths.Length).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); + string choice = this.InteractivelyChoose("Where do you want to add/remove SMAPI? Type the number next to your choice, then press enter.", validOptions); + int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; + return defaultPaths[index]; + } + + // ask user + Console.WriteLine("Oops, couldn't find the game automatically."); + while (true) + { + // get path from user + Console.WriteLine($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter."); + string path = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(path)) + { + Console.WriteLine(" You must specify a directory path to continue."); + continue; + } + + // normalise path + if (platform == Platform.Windows) + path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path + if (platform == Platform.Mono) + path = path.Replace("\\ ", " "); // in Linux/Mac, 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 + if (File.Exists(path)) + path = Path.GetDirectoryName(path); + DirectoryInfo directory = new DirectoryInfo(path); + + // validate path + if (!directory.Exists) + { + Console.WriteLine(" That directory doesn't seem to exist."); + continue; + } + if (!directory.EnumerateFiles(executableFilename).Any()) + { + Console.WriteLine(" That directory doesn't contain a Stardew Valley executable."); + continue; + } + + // looks OK + Console.WriteLine(" OK!"); + return directory; + } + } + + /// Interactively move mods out of the appdata directory. + /// The current platform. + /// The directory which should contain all mods. + /// The installer directory containing packaged mods. + private void InteractivelyRemoveAppDataMods(Platform platform, DirectoryInfo properModsDir, DirectoryInfo packagedModsDir) + { + // get packaged mods to delete + string[] packagedModNames = packagedModsDir.GetDirectories().Select(p => p.Name).ToArray(); + + // get path + string homePath = platform == Platform.Windows + ? Environment.GetEnvironmentVariable("APPDATA") + : Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".config"); + string appDataPath = Path.Combine(homePath, "StardewValley"); + DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods")); + + // check if migration needed + if (!modDir.Exists) + return; + this.PrintDebug($"Found an obsolete mod path: {modDir.FullName}"); + this.PrintDebug(" Support for mods here was dropped in SMAPI 1.0 (it was never officially supported)."); + + // move mods if no conflicts (else warn) + foreach (FileSystemInfo entry in modDir.EnumerateFileSystemInfos()) + { + // get type + bool isDir = entry is DirectoryInfo; + if (!isDir && !(entry is FileInfo)) + continue; // should never happen + + // delete packaged mods (newer version bundled into SMAPI) + if (isDir && packagedModNames.Contains(entry.Name, StringComparer.InvariantCultureIgnoreCase)) + { + this.PrintDebug($" Deleting {entry.Name} because it's bundled into SMAPI..."); + this.InteractivelyDelete(entry.FullName); + continue; + } + + // check paths + string newPath = Path.Combine(properModsDir.FullName, entry.Name); + if (isDir ? Directory.Exists(newPath) : File.Exists(newPath)) + { + this.PrintWarning($" Can't move {entry.Name} because it already exists in your game's mod directory."); + continue; + } + + // move into mods + this.PrintDebug($" Moving {entry.Name} into the game's mod directory..."); + this.Move(entry, newPath); + } + + // delete if empty + if (modDir.EnumerateFileSystemInfos().Any()) + this.PrintWarning(" You have files in this folder which couldn't be moved automatically. These will be ignored by SMAPI."); + else + { + this.PrintDebug(" Deleted empty directory."); + modDir.Delete(); + } + } + + /// Move a filesystem entry to a new parent directory. + /// The filesystem entry to move. + /// The destination path. + /// We can't use or , because those don't work across partitions. + private void Move(FileSystemInfo entry, string newPath) + { + // file + if (entry is FileInfo file) + { + file.CopyTo(newPath); + file.Delete(); + } + + // directory + else + { + Directory.CreateDirectory(newPath); + + DirectoryInfo directory = (DirectoryInfo)entry; + foreach (FileSystemInfo child in directory.EnumerateFileSystemInfos()) + this.Move(child, Path.Combine(newPath, child.Name)); + + directory.Delete(); + } + } + } +} -- cgit