diff --git a/CHANGELOG.md b/CHANGELOG.md index 78be2dbbc4..c056a46e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file. - [Multiple] Store GitHub Discussions links and display in UIs (#4111 by: HebaruSan) - [GUI] Chinese translation fixes (#4115, #4116, #4131 by: zhouyiqing0304; reviewed: HebaruSan) - [Multiple] Visually indicate to users that they should click Refresh (#4133 by: HebaruSan) +- [Multiple] Option to clone smaller instances with junction points (Windows) or symbolic links (Unix) (#4129 by: HebaruSan) ### Bugfixes diff --git a/Cmdline/Action/GameInstance.cs b/Cmdline/Action/GameInstance.cs index 2690379f85..afcdabe4f0 100644 --- a/Cmdline/Action/GameInstance.cs +++ b/Cmdline/Action/GameInstance.cs @@ -97,6 +97,9 @@ internal class AddOptions : CommonOptions internal class CloneOptions : CommonOptions { + [Option("share-stock", DefaultValue = false, HelpText = "Use junction points (Windows) or symbolic links (Unix) for stock dirs instead of copying")] + public bool shareStock { get; set; } + [ValueOption(0)] public string nameOrPath { get; set; } [ValueOption(1)] public string new_name { get; set; } [ValueOption(2)] public string new_path { get; set; } @@ -341,7 +344,7 @@ private int CloneInstall(CloneOptions options) if (instance.Name == instanceNameOrPath) { // Found it, now clone it. - Manager.CloneInstance(instance, newName, newPath); + Manager.CloneInstance(instance, newName, newPath, options.shareStock); break; } } @@ -350,7 +353,7 @@ private int CloneInstall(CloneOptions options) // If it's valid, go on. else if (Manager.InstanceAt(instanceNameOrPath) is CKAN.GameInstance instance && instance.Valid) { - Manager.CloneInstance(instance, newName, newPath); + Manager.CloneInstance(instance, newName, newPath, options.shareStock); } // There is no instance with this name or at this path. else diff --git a/Core/DirectoryLink.cs b/Core/DirectoryLink.cs new file mode 100644 index 0000000000..a2b8820bfe --- /dev/null +++ b/Core/DirectoryLink.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +using ChinhDo.Transactions.FileManager; + +namespace CKAN +{ + /// + /// Junctions on Windows, symbolic links on Unix + /// + public static class DirectoryLink + { + public static void Create(string target, string link, TxFileManager txMgr) + { + if (!CreateImpl(target, link, txMgr)) + { + throw new Kraken(Platform.IsWindows + ? $"Failed to create junction at {link}: {Marshal.GetLastWin32Error()}" + : $"Failed to create symbolic link at {link}"); + } + } + + private static bool CreateImpl(string target, string link, TxFileManager txMgr) + => Platform.IsWindows ? CreateJunction(link, target, txMgr) + : symlink(target, link) == 0; + + [DllImport("libc")] + private static extern int symlink(string target, string link); + + private static bool CreateJunction(string link, string target, TxFileManager txMgr) + { + // A junction is a directory with some extra magic attached + if (!txMgr.DirectoryExists(link)) + { + txMgr.CreateDirectory(link); + } + using (var h = CreateFile(link, GenericWrite, FileShare.Read | FileShare.Write, IntPtr.Zero, + FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero)) + { + if (!h.IsInvalid) + { + var junctionInfo = ReparseDataBuffer.FromPath(target, out int byteCount); + return DeviceIoControl(h, FSCTL_SET_REPARSE_POINT, + ref junctionInfo, byteCount + 20, + null, 0, + out _, IntPtr.Zero); + } + } + return false; + } + + public static bool TryGetTarget(string link, out string target) + { + target = null; + var fi = new DirectoryInfo(link); + if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + if (Platform.IsWindows) + { + var h = CreateFile(link, 0, FileShare.Read, IntPtr.Zero, + FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero); + if (!h.IsInvalid) + { + if (DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, + null, 0, + out ReparseDataBuffer junctionInfo, Marshal.SizeOf(typeof(ReparseDataBuffer)), + out _, IntPtr.Zero)) + { + if (junctionInfo.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT) + { + target = junctionInfo.PathBuffer.TrimStart("\\\\?\\"); + } + } + h.Close(); + } + } + else + { + var bytes = new byte[1024]; + var result = readlink(link, bytes, bytes.Length); + if (result > 0) + { + target = Encoding.UTF8.GetString(bytes); + } + } + } + return !string.IsNullOrEmpty(target); + } + + private const uint GenericWrite = 0x40000000u; + private const uint BackupSemantics = 0x02000000u; + private const uint OpenReparsePoint = 0x00200000u; + private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4u; + private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003u; + private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8u; + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool DeviceIoControl(SafeFileHandle hDevice, + uint IoControlCode, + ref ReparseDataBuffer InBuffer, + int nInBufferSize, + byte[] OutBuffer, + int nOutBufferSize, + out int pBytesReturned, + IntPtr Overlapped); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool DeviceIoControl(SafeFileHandle hDevice, + uint IoControlCode, + byte[] InBuffer, + int nInBufferSize, + out ReparseDataBuffer OutBuffer, + int nOutBufferSize, + out int pBytesReturned, + IntPtr Overlapped); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct ReparseDataBuffer + { + public uint ReparseTag; + public ushort ReparseDataLength; + private readonly ushort Reserved; + public ushort SubstituteNameOffset; + public ushort SubstituteNameLength; + public ushort PrintNameOffset; + public ushort PrintNameLength; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 8184)] + public string PathBuffer; + + public static ReparseDataBuffer FromPath(string target, out int byteCount) + { + var fullTarget = $@"\??\{Path.GetFullPath(target)}"; + byteCount = Encoding.Unicode.GetByteCount(fullTarget); + return new ReparseDataBuffer + { + ReparseTag = IO_REPARSE_TAG_MOUNT_POINT, + ReparseDataLength = (ushort)(byteCount + 12), + SubstituteNameOffset = 0, + SubstituteNameLength = (ushort)byteCount, + PrintNameOffset = (ushort)(byteCount + 2), + PrintNameLength = 0, + PathBuffer = fullTarget, + }; + } + } + + [DllImport("libc")] + private static extern int readlink(string link, byte[] buf, int bufsize); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern SafeFileHandle CreateFile([MarshalAs(UnmanagedType.LPTStr)] string filename, + [MarshalAs(UnmanagedType.U4)] uint access, + [MarshalAs(UnmanagedType.U4)] FileShare share, + IntPtr securityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition, + [MarshalAs(UnmanagedType.U4)] uint flagsAndAttributes, + IntPtr templateFile); + + private static string TrimStart(this string orig, string toRemove) + => orig.StartsWith(toRemove) ? orig.Remove(0, toRemove.Length) + : orig; + + } +} diff --git a/Core/GameInstanceManager.cs b/Core/GameInstanceManager.cs index 7fc0ec9dd2..f8ccd759a2 100644 --- a/Core/GameInstanceManager.cs +++ b/Core/GameInstanceManager.cs @@ -239,7 +239,10 @@ public GameInstance AddInstance(string path, string name, IUser user) /// Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here. /// Thrown by CopyDirectory() if the target folder already exists and is not empty. /// Thrown by CopyDirectory() if something goes wrong during the process. - public void CloneInstance(GameInstance existingInstance, string newName, string newPath) + public void CloneInstance(GameInstance existingInstance, + string newName, + string newPath, + bool shareStockFolders = false) { if (HasInstance(newName)) { @@ -252,11 +255,13 @@ public void CloneInstance(GameInstance existingInstance, string newName, string } log.Debug("Copying directory."); - Utilities.CopyDirectory(existingInstance.GameDir(), newPath, true); + Utilities.CopyDirectory(existingInstance.GameDir(), newPath, + shareStockFolders ? existingInstance.game.StockFolders + : Array.Empty(), + existingInstance.game.LeaveEmptyInClones); // Add the new instance to the config - GameInstance new_instance = new GameInstance(existingInstance.game, newPath, newName, User); - AddInstance(new_instance); + AddInstance(new GameInstance(existingInstance.game, newPath, newName, User)); } /// diff --git a/Core/Games/IGame.cs b/Core/Games/IGame.cs index 58ff2f6a91..80244a2647 100644 --- a/Core/Games/IGame.cs +++ b/Core/Games/IGame.cs @@ -24,10 +24,11 @@ public interface IGame string PrimaryModDirectoryRelative { get; } string[] AlternateModDirectoriesRelative { get; } string PrimaryModDirectory(GameInstance inst); - string[] StockFolders { get; } - string[] ReservedPaths { get; } - string[] CreateableDirs { get; } - string[] AutoRemovableDirs { get; } + string[] StockFolders { get; } + string[] LeaveEmptyInClones { get; } + string[] ReservedPaths { get; } + string[] CreateableDirs { get; } + string[] AutoRemovableDirs { get; } bool IsReservedDirectory(GameInstance inst, string path); bool AllowInstallationIn(string name, out string path); void RebuildSubdirectories(string absGameRoot); diff --git a/Core/Games/KerbalSpaceProgram.cs b/Core/Games/KerbalSpaceProgram.cs index e29eee84b6..e6208b4a45 100644 --- a/Core/Games/KerbalSpaceProgram.cs +++ b/Core/Games/KerbalSpaceProgram.cs @@ -65,6 +65,17 @@ public string PrimaryModDirectory(GameInstance inst) "PDLauncher", }; + public string[] LeaveEmptyInClones => new string[] + { + "saves", + "Screenshots", + "thumbs", + "Missions", + "Logs", + "CKAN/history", + "CKAN/downloads", + }; + public string[] ReservedPaths => new string[] { "GameData", "Ships", "Missions" diff --git a/Core/Games/KerbalSpaceProgram2.cs b/Core/Games/KerbalSpaceProgram2.cs index a10029b1a1..640df65000 100644 --- a/Core/Games/KerbalSpaceProgram2.cs +++ b/Core/Games/KerbalSpaceProgram2.cs @@ -59,6 +59,12 @@ public string PrimaryModDirectory(GameInstance inst) "PDLauncher", }; + public string[] LeaveEmptyInClones => new string[] + { + "CKAN/history", + "CKAN/downloads", + }; + public string[] ReservedPaths => Array.Empty(); public string[] CreateableDirs => new string[] diff --git a/Core/Utilities.cs b/Core/Utilities.cs index f687cdb4c9..923a5a4125 100644 --- a/Core/Utilities.cs +++ b/Core/Utilities.cs @@ -1,7 +1,8 @@ using System; +using System.Linq; using System.IO; using System.Diagnostics; -using System.Transactions; +using System.Collections.Generic; using ChinhDo.Transactions.FileManager; using log4net; @@ -40,34 +41,39 @@ public static T DefaultIfThrows(Func func) } /// - /// Copies a directory and optionally its subdirectories as a transaction. + /// Copies a directory and its subdirectories as a transaction /// - /// Source directory path. - /// Destination directory path. - /// Copy sub dirs recursively if set to true. - public static void CopyDirectory(string sourceDirPath, string destDirPath, bool copySubDirs) + /// Source directory path + /// Destination directory path + /// Relative subdirs that should be symlinked to the originals instead of copied + public static void CopyDirectory(string sourceDirPath, + string destDirPath, + string[] subFolderRelPathsToSymlink, + string[] subFolderRelPathsToLeaveEmpty) { - TxFileManager file_transaction = new TxFileManager(); - using (TransactionScope transaction = CkanTransaction.CreateTransactionScope()) + using (var transaction = CkanTransaction.CreateTransactionScope()) { - _CopyDirectory(sourceDirPath, destDirPath, copySubDirs, file_transaction); + CopyDirectory(sourceDirPath, destDirPath, new TxFileManager(), + subFolderRelPathsToSymlink, subFolderRelPathsToLeaveEmpty); transaction.Complete(); } } - - private static void _CopyDirectory(string sourceDirPath, string destDirPath, bool copySubDirs, TxFileManager file_transaction) + private static void CopyDirectory(string sourceDirPath, + string destDirPath, + TxFileManager file_transaction, + string[] subFolderRelPathsToSymlink, + string[] subFolderRelPathsToLeaveEmpty) { - DirectoryInfo sourceDir = new DirectoryInfo(sourceDirPath); - + var sourceDir = new DirectoryInfo(sourceDirPath); if (!sourceDir.Exists) { throw new DirectoryNotFoundKraken( sourceDirPath, - "Source directory does not exist or could not be found."); + $"Source directory {sourceDirPath} does not exist or could not be found."); } - // If the destination directory doesn't exist, create it. + // If the destination directory doesn't exist, create it if (!Directory.Exists(destDirPath)) { file_transaction.CreateDirectory(destDirPath); @@ -77,9 +83,8 @@ private static void _CopyDirectory(string sourceDirPath, string destDirPath, boo throw new PathErrorKraken(destDirPath, "Directory not empty: "); } - // Get the files in the directory and copy them to the new location. - FileInfo[] files = sourceDir.GetFiles(); - foreach (FileInfo file in files) + // Get the files in the directory and copy them to the new location + foreach (var file in sourceDir.GetFiles()) { if (file.Name == "registry.locked") { @@ -91,26 +96,45 @@ private static void _CopyDirectory(string sourceDirPath, string destDirPath, boo continue; } - string temppath = Path.Combine(destDirPath, file.Name); - file_transaction.Copy(file.FullName, temppath, false); + file_transaction.Copy(file.FullName, Path.Combine(destDirPath, file.Name), false); } // Create all first level subdirectories - DirectoryInfo[] dirs = sourceDir.GetDirectories(); - - foreach (DirectoryInfo subdir in dirs) + foreach (var subdir in sourceDir.GetDirectories()) { - string temppath = Path.Combine(destDirPath, subdir.Name); - file_transaction.CreateDirectory(temppath); - - // If copying subdirectories, copy their contents to new location. - if (copySubDirs) + var temppath = Path.Combine(destDirPath, subdir.Name); + // If already a sym link, replicate it in the new location + if (DirectoryLink.TryGetTarget(subdir.FullName, out string existingLinkTarget)) + { + DirectoryLink.Create(existingLinkTarget, temppath, file_transaction); + } + else { - _CopyDirectory(subdir.FullName, temppath, copySubDirs, file_transaction); + if (subFolderRelPathsToSymlink.Contains(subdir.Name, Platform.PathComparer)) + { + DirectoryLink.Create(subdir.FullName, temppath, file_transaction); + } + else + { + file_transaction.CreateDirectory(temppath); + + if (!subFolderRelPathsToLeaveEmpty.Contains(subdir.Name, Platform.PathComparer)) + { + // Copy subdir contents to new location + CopyDirectory(subdir.FullName, temppath, file_transaction, + SubPaths(subdir.Name, subFolderRelPathsToSymlink).ToArray(), + SubPaths(subdir.Name, subFolderRelPathsToLeaveEmpty).ToArray()); + } + } } } } + // Select only paths within subdir, prune prefixes + private static IEnumerable SubPaths(string parent, string[] paths) + => paths.Where(p => p.StartsWith($"{parent}/", Platform.PathComparison)) + .Select(p => p.Remove(0, parent.Length + 1)); + /// /// Launch a URL. For YEARS this was done by Process.Start in a /// cross-platform way, but Microsoft chose to break that, diff --git a/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs b/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs index f426a89be3..dbe1c2d058 100644 --- a/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs +++ b/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs @@ -30,6 +30,7 @@ private void InitializeComponent() { this.components = new System.ComponentModel.Container(); System.ComponentModel.ComponentResourceManager resources = new SingleAssemblyComponentResourceManager(typeof(CloneGameInstanceDialog)); + this.ToolTip = new System.Windows.Forms.ToolTip(); this.labelOldInstance = new System.Windows.Forms.Label(); this.comboBoxKnownInstance = new System.Windows.Forms.ComboBox(); this.labelOldPath = new System.Windows.Forms.Label(); @@ -42,12 +43,20 @@ private void InitializeComponent() this.buttonPathBrowser = new System.Windows.Forms.Button(); this.checkBoxSetAsDefault = new System.Windows.Forms.CheckBox(); this.checkBoxSwitchInstance = new System.Windows.Forms.CheckBox(); + this.checkBoxShareStock = new System.Windows.Forms.CheckBox(); this.buttonOK = new System.Windows.Forms.Button(); this.buttonCancel = new System.Windows.Forms.Button(); this.progressBar = new System.Windows.Forms.ProgressBar(); this.folderBrowserDialogNewPath = new System.Windows.Forms.FolderBrowserDialog(); this.SuspendLayout(); // + // ToolTip + // + this.ToolTip.AutoPopDelay = 10000; + this.ToolTip.InitialDelay = 250; + this.ToolTip.ReshowDelay = 250; + this.ToolTip.ShowAlways = true; + // // labelOldInstance // this.labelOldInstance.AutoSize = true; @@ -154,7 +163,7 @@ private void InitializeComponent() // this.checkBoxSetAsDefault.AutoSize = true; this.checkBoxSetAsDefault.FlatStyle = System.Windows.Forms.FlatStyle.Flat; - this.checkBoxSetAsDefault.Location = new System.Drawing.Point(12, 144); + this.checkBoxSetAsDefault.Location = new System.Drawing.Point(181, 144); this.checkBoxSetAsDefault.Name = "checkBoxSetAsDefault"; this.checkBoxSetAsDefault.Size = new System.Drawing.Size(157, 17); this.checkBoxSetAsDefault.TabIndex = 19; @@ -167,20 +176,33 @@ private void InitializeComponent() this.checkBoxSwitchInstance.Checked = true; this.checkBoxSwitchInstance.CheckState = System.Windows.Forms.CheckState.Checked; this.checkBoxSwitchInstance.FlatStyle = System.Windows.Forms.FlatStyle.Flat; - this.checkBoxSwitchInstance.Location = new System.Drawing.Point(181, 144); + this.checkBoxSwitchInstance.Location = new System.Drawing.Point(181, 174); this.checkBoxSwitchInstance.Name = "checkBoxSwitchInstance"; this.checkBoxSwitchInstance.Size = new System.Drawing.Size(136, 17); this.checkBoxSwitchInstance.TabIndex = 20; this.checkBoxSwitchInstance.UseVisualStyleBackColor = true; resources.ApplyResources(this.checkBoxSwitchInstance, "checkBoxSwitchInstance"); // + // checkBoxShareStock + // + this.checkBoxShareStock.AutoSize = true; + this.checkBoxShareStock.Checked = true; + this.checkBoxShareStock.CheckState = System.Windows.Forms.CheckState.Checked; + this.checkBoxShareStock.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.checkBoxShareStock.Location = new System.Drawing.Point(181, 204); + this.checkBoxShareStock.Name = "checkBoxShareStock"; + this.checkBoxShareStock.Size = new System.Drawing.Size(136, 17); + this.checkBoxShareStock.TabIndex = 21; + this.checkBoxShareStock.UseVisualStyleBackColor = true; + resources.ApplyResources(this.checkBoxShareStock, "checkBoxShareStock"); + // // buttonOK // this.buttonOK.FlatStyle = System.Windows.Forms.FlatStyle.Flat; - this.buttonOK.Location = new System.Drawing.Point(256, 170); + this.buttonOK.Location = new System.Drawing.Point(256, 230); this.buttonOK.Name = "buttonOK"; this.buttonOK.Size = new System.Drawing.Size(75, 23); - this.buttonOK.TabIndex = 21; + this.buttonOK.TabIndex = 22; this.buttonOK.UseVisualStyleBackColor = true; this.buttonOK.Click += new System.EventHandler(this.buttonOK_Click); resources.ApplyResources(this.buttonOK, "buttonOK"); @@ -188,10 +210,10 @@ private void InitializeComponent() // buttonCancel // this.buttonCancel.FlatStyle = System.Windows.Forms.FlatStyle.Flat; - this.buttonCancel.Location = new System.Drawing.Point(337, 170); + this.buttonCancel.Location = new System.Drawing.Point(337, 230); this.buttonCancel.Name = "buttonCancel"; this.buttonCancel.Size = new System.Drawing.Size(75, 23); - this.buttonCancel.TabIndex = 22; + this.buttonCancel.TabIndex = 23; this.buttonCancel.UseVisualStyleBackColor = true; this.buttonCancel.Click += new System.EventHandler(this.buttonCancel_Click); resources.ApplyResources(this.buttonCancel, "buttonCancel"); @@ -204,7 +226,7 @@ private void InitializeComponent() this.progressBar.Name = "progressBar"; this.progressBar.Size = new System.Drawing.Size(230, 23); this.progressBar.Style = System.Windows.Forms.ProgressBarStyle.Marquee; - this.progressBar.TabIndex = 23; + this.progressBar.TabIndex = 24; this.progressBar.Visible = false; // // CloneGameInstanceDialog @@ -213,7 +235,7 @@ private void InitializeComponent() this.AllowDrop = true; this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(424, 205); + this.ClientSize = new System.Drawing.Size(424, 265); this.Controls.Add(this.labelOldInstance); this.Controls.Add(this.comboBoxKnownInstance); this.Controls.Add(this.labelOldPath); @@ -222,6 +244,7 @@ private void InitializeComponent() this.Controls.Add(this.progressBar); this.Controls.Add(this.buttonPathBrowser); this.Controls.Add(this.checkBoxSwitchInstance); + this.Controls.Add(this.checkBoxShareStock); this.Controls.Add(this.textBoxNewPath); this.Controls.Add(this.labelNewPath); this.Controls.Add(this.checkBoxSetAsDefault); @@ -247,6 +270,7 @@ private void InitializeComponent() #endregion + private System.Windows.Forms.ToolTip ToolTip; private System.Windows.Forms.Label labelOldInstance; private System.Windows.Forms.ComboBox comboBoxKnownInstance; private System.Windows.Forms.Label labelOldPath; @@ -259,6 +283,7 @@ private void InitializeComponent() private System.Windows.Forms.Button buttonPathBrowser; private System.Windows.Forms.CheckBox checkBoxSetAsDefault; private System.Windows.Forms.CheckBox checkBoxSwitchInstance; + private System.Windows.Forms.CheckBox checkBoxShareStock; private System.Windows.Forms.Button buttonOK; private System.Windows.Forms.Button buttonCancel; private System.Windows.Forms.FolderBrowserDialog folderBrowserDialogNewPath; diff --git a/GUI/Dialogs/CloneGameInstanceDialog.cs b/GUI/Dialogs/CloneGameInstanceDialog.cs index fca4476845..96ff730d98 100644 --- a/GUI/Dialogs/CloneGameInstanceDialog.cs +++ b/GUI/Dialogs/CloneGameInstanceDialog.cs @@ -15,7 +15,7 @@ namespace CKAN.GUI { /// - /// The GUI implementation of clone and fake. + /// The GUI implementation of clone. /// It's a separate window, handling the whole process. /// #if NET5_0_OR_GREATER @@ -34,6 +34,8 @@ public CloneGameInstanceDialog(GameInstanceManager manager, IUser user, string s InitializeComponent(); + ToolTip.SetToolTip(checkBoxShareStock, Properties.Resources.CloneGameInstanceToolTipShareStock); + // Populate the instances combobox with names of known instances comboBoxKnownInstance.DataSource = new string[] { "" } .Concat(manager.Instances.Values @@ -138,7 +140,7 @@ await Task.Run(() => { if (instanceToClone.Valid) { - manager.CloneInstance(instanceToClone, newName, newPath); + manager.CloneInstance(instanceToClone, newName, newPath, checkBoxShareStock.Checked); } else { diff --git a/GUI/Dialogs/CloneGameInstanceDialog.resx b/GUI/Dialogs/CloneGameInstanceDialog.resx index bd30c662e0..a40d752b1e 100644 --- a/GUI/Dialogs/CloneGameInstanceDialog.resx +++ b/GUI/Dialogs/CloneGameInstanceDialog.resx @@ -125,7 +125,8 @@ Select... Set new instance as default Switch to new instance - Create + Share stock files + Clone Cancel Clone Game Instance diff --git a/GUI/Properties/Resources.resx b/GUI/Properties/Resources.resx index 5644a4ddf9..1a2abb16ee 100644 --- a/GUI/Properties/Resources.resx +++ b/GUI/Properties/Resources.resx @@ -133,6 +133,8 @@ Creating new instance... This name is already used. Successfully created instance. + Create junction points (Windows) or symbolic links (Unix) to stock directories instead of copying them. +If you choose this option, DO NOT move or delete the old instance!! <NONE> The game has been updated since you last reviewed your compatible game versions. Please make sure that settings are correct. {0} (previous game version: {1}) diff --git a/Tests/Core/GameInstance.cs b/Tests/Core/GameInstance.cs index 1a70b9e7e4..7bfea76e50 100644 --- a/Tests/Core/GameInstance.cs +++ b/Tests/Core/GameInstance.cs @@ -22,7 +22,7 @@ public void Setup() { ksp_dir = TestData.NewTempDir(); nullUser = new NullUser(); - CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), ksp_dir, true); + CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), ksp_dir, Array.Empty(), Array.Empty()); ksp = new GameInstance(new KerbalSpaceProgram(), ksp_dir, "test", nullUser); } @@ -111,7 +111,7 @@ public void Valid_MissingVersionData_False() }"; // Generate a valid game dir except for missing buildID.txt and readme.txt - CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, true); + CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, Array.Empty(), Array.Empty()); File.Delete(buildid); File.Delete(readme); @@ -143,7 +143,7 @@ public void Constructor_NullMainCompatVer_NoCrash() }"; // Generate a valid game dir except for missing buildID.txt and readme.txt - CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, true); + CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, Array.Empty(), Array.Empty()); File.Delete(buildid); File.Delete(readme); diff --git a/Tests/Core/Utilities.cs b/Tests/Core/Utilities.cs index cc7ba0d8b7..106abd0a9d 100644 --- a/Tests/Core/Utilities.cs +++ b/Tests/Core/Utilities.cs @@ -1,7 +1,10 @@ +using System; using System.IO; -using CKAN; + using NUnit.Framework; + using Tests.Data; +using CKAN; namespace Tests.Core { @@ -25,36 +28,39 @@ public void EmptyTempDir() [Test] public void CopyDirectory_Recursive_DestHasAllContents() { - CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, true); + CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, Array.Empty(), Array.Empty()); + var fi = new FileInfo(Path.Combine(tempDir, "GameData")); + Assert.IsFalse(fi.Attributes.HasFlag(FileAttributes.ReparsePoint), + "GameData should not be a symlink"); Assert.IsTrue(UtilStatic.CompareFiles( Path.Combine(goodKspDir, "GameData", "README.md"), - Path.Combine(tempDir, "GameData", "README.md"))); + Path.Combine(tempDir, "GameData", "README.md"))); Assert.IsTrue(UtilStatic.CompareFiles( Path.Combine(goodKspDir, "buildID.txt"), - Path.Combine(tempDir, "buildID.txt"))); + Path.Combine(tempDir, "buildID.txt"))); Assert.IsTrue(UtilStatic.CompareFiles( Path.Combine(goodKspDir, "readme.txt"), - Path.Combine(tempDir, "readme.txt"))); + Path.Combine(tempDir, "readme.txt"))); } [Test] - public void CopyDirectory_NotRecursive_DestHasOnlyFirstLevelFiles() + public void CopyDirectory_WithSymlinks_MakesSymlinks() { - CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, false); + // Arrange / Act + CKAN.Utilities.CopyDirectory(Path.Combine(TestData.DataDir(), "KSP"), tempDir, + new string[] { "KSP-0.25/GameData" }, Array.Empty()); - Assert.IsFalse(File.Exists(Path.Combine(tempDir, "GameData", "README.md"))); - // The following assertion is per se already included in the above assertion, - // but this also tests CompareFiles, so no harm in including this. - Assert.IsFalse(UtilStatic.CompareFiles( - Path.Combine(goodKspDir, "GameData", "README.md"), - Path.Combine(tempDir, "GameData", "README.md"))); - Assert.IsTrue(UtilStatic.CompareFiles( - Path.Combine(goodKspDir, "buildID.txt"), - Path.Combine(tempDir, "buildID.txt"))); - Assert.IsTrue(UtilStatic.CompareFiles( - Path.Combine(goodKspDir, "readme.txt"), - Path.Combine(tempDir, "readme.txt"))); + // Assert + var fi1 = new FileInfo(Path.Combine(tempDir, "KSP-0.25")); + Assert.IsFalse(fi1.Attributes.HasFlag(FileAttributes.ReparsePoint), + "KSP-0.25 should not be a symlink"); + var fi2 = new FileInfo(Path.Combine(tempDir, "KSP-0.25", "GameData")); + Assert.IsTrue(fi2.Attributes.HasFlag(FileAttributes.ReparsePoint), + "KSP-0.25/GameData should be a symlink"); + var fi3 = new FileInfo(Path.Combine(tempDir, "KSP-0.25", "GameData", "README.md")); + Assert.IsFalse(fi3.Attributes.HasFlag(FileAttributes.ReparsePoint), + "KSP-0.25/GameData/README.md should not be a symlink"); } [Test] @@ -63,7 +69,7 @@ public void CopyDirectory_SourceNotExisting_ThrowsDirectoryNotFoundKraken() var sourceDir = "/gibberish/DOESNTEXIST/hopefully"; // Act and Assert - Assert.Throws(() => CKAN.Utilities.CopyDirectory(sourceDir, tempDir, true)); + Assert.Throws(() => CKAN.Utilities.CopyDirectory(sourceDir, tempDir, Array.Empty(), Array.Empty())); } [Test] @@ -71,7 +77,7 @@ public void CopyDirectory_DestNotEmpty_ThrowsException() { File.WriteAllText(Path.Combine(tempDir, "thatsafile"), "not empty"); - Assert.Throws(() => CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, true)); + Assert.Throws(() => CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, Array.Empty(), Array.Empty())); } } } diff --git a/Tests/Data/DisposableKSP.cs b/Tests/Data/DisposableKSP.cs index 90c0f08e1d..02a366d9df 100644 --- a/Tests/Data/DisposableKSP.cs +++ b/Tests/Data/DisposableKSP.cs @@ -27,7 +27,7 @@ public class DisposableKSP : IDisposable public DisposableKSP() { _disposableDir = TestData.NewTempDir(); - Utilities.CopyDirectory(_goodKsp, _disposableDir, true); + Utilities.CopyDirectory(_goodKsp, _disposableDir, Array.Empty(), Array.Empty()); KSP = new GameInstance(new KerbalSpaceProgram(), _disposableDir, "disposable", new NullUser()); Logging.Initialize(); }