From 035993767962b7e8ff6fd7154b68982dc7fb5a93 Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Wed, 29 Apr 2020 17:01:42 -0500 Subject: [PATCH] Prompt user to overwrite manually installed files --- Core/ModuleInstaller.cs | 121 +++++++++++++++++++++++++++- Core/Registry/Registry.cs | 9 +++ Core/Types/Kraken.cs | 19 ++++- GUI/Dialogs/YesNoDialog.Designer.cs | 11 ++- GUI/Dialogs/YesNoDialog.cs | 14 +++- GUI/Main/MainInstall.cs | 11 ++- 6 files changed, 173 insertions(+), 12 deletions(-) diff --git a/Core/ModuleInstaller.cs b/Core/ModuleInstaller.cs index 26bfb78504..b0554798f7 100644 --- a/Core/ModuleInstaller.cs +++ b/Core/ModuleInstaller.cs @@ -290,13 +290,13 @@ private void Install(CkanModule module, bool autoInstalled, Registry registry, s ModuleVersion version = registry.InstalledVersion(module.identifier); // TODO: This really should be handled by higher-up code. - if (version != null) + if (version != null && !(version is UnmanagedModuleVersion)) { User.RaiseMessage(" {0} {1} already installed, skipped", module.identifier, version); return; } - // Find our in the cache if we don't already have it. + // Find ZIP in the cache if we don't already have it. filename = filename ?? Cache.GetCachedZip(module); // If we *still* don't have a file, then kraken bitterly. @@ -348,6 +348,7 @@ private static void CheckKindInstallationKraken(CkanModule module) /// Installs the module from the zipfile provided. /// Returns a list of files installed. /// Propagates a BadMetadataKraken if our install metadata is bad. + /// Propagates a CancelledActionKraken if the user decides not to overwite unowned files. /// Propagates a FileExistsKraken if we were going to overwrite a file. /// private IEnumerable InstallModule(CkanModule module, string zip_filename, Registry registry) @@ -360,6 +361,32 @@ private IEnumerable InstallModule(CkanModule module, string zip_filename try { + var dll = registry.DllPath(module.identifier); + if (dll != null && !files.Any(f => ksp.ToRelativeGameDir(f.destination) == dll)) + { + throw new DllLocationMismatchKraken(dll, $"DLL for module {module.identifier} found at {dll}, but it's not where CKAN would install it. Aborting to prevent multiple copies of the same mod being installed. To install this module, uninstall it manually and try again."); + } + + // Look for overwritable files if session is interactive + if (!User.Headless) + { + var conflicting = FindConflictingFiles(zipfile, files, registry).Memoize(); + if (conflicting.Any()) + { + var fileMsg = conflicting + .OrderBy(c => c.Value) + .Aggregate("", (a, b) => + $"{a}\r\n- {ksp.ToRelativeGameDir(b.Key.destination)} ({(b.Value ? "same" : "DIFFERENT")})"); + if (User.RaiseYesNoDialog($"Module {module.name} wants to overwrite the following manually installed files:\r\n{fileMsg}\r\n\r\nOverwrite?")) + { + DeleteConflictingFiles(conflicting.Select(f => f.Key)); + } + else + { + throw new CancelledActionKraken($"Not overwriting manually installed files, can't install {module.name}."); + } + } + } foreach (InstallableFile file in files) { log.DebugFormat("Copying {0}", file.source.Name); @@ -380,6 +407,94 @@ private IEnumerable InstallModule(CkanModule module, string zip_filename } } + /// + /// Find files in the given list that are already installed and unowned. + /// Note, this compares files on demand; Memoize for performance! + /// + /// Files that we want to install for a module + /// + /// List of pairs: Key = file, Value = true if identical, false if different + /// + private IEnumerable> FindConflictingFiles(ZipFile zip, IEnumerable files, Registry registry) + { + foreach (InstallableFile file in files) + { + if (File.Exists(file.destination) + && registry.FileOwner(ksp.ToRelativeGameDir(file.destination)) == null) + { + log.DebugFormat("Comparing {0}", file.destination); + using (Stream zipStream = zip.GetInputStream(file.source)) + using (FileStream curFile = new FileStream(file.destination, FileMode.Open, FileAccess.Read)) + { + yield return new KeyValuePair( + file, + file.source.Size == curFile.Length + && StreamsEqual(zipStream, curFile) + ); + } + } + } + } + + /// + /// Compare the contents of two streams + /// + /// First stream to compare + /// Second stream to compare + /// + /// true if both streams contain same bytes, false otherwise + /// + private bool StreamsEqual(Stream s1, Stream s2) + { + const int bufLen = 1024; + byte[] bytes1 = new byte[bufLen]; + byte[] bytes2 = new byte[bufLen]; + for (int bytesChecked = 0; bytesChecked < s1.Length; ) + { + int bytesFrom1 = s1.Read(bytes1, 0, bufLen); + int bytesFrom2 = s2.Read(bytes2, 0, bufLen); + if (bytesFrom1 != bytesFrom2) + { + // One ended early, not equal. + log.DebugFormat("Read {0} bytes from stream1 and {1} bytes from stream2", bytesFrom1, bytesFrom2); + return false; + } + for (int i = 0; i < bytesFrom1; ++i) + { + if (bytes1[i] != bytes2[i]) + { + log.DebugFormat("Byte {0} doesn't match", bytesChecked + i); + // Bytes don't match, not equal. + return false; + } + } + bytesChecked += bytesFrom1; + } + // Same bytes, they're equal. + return true; + } + + /// + /// Remove files that the user chose to overwrite, so + /// the installer can replace them. + /// Uses a transaction so they can be undeleted if the install + /// fails at a later stage. + /// + /// The files to overwrite + private void DeleteConflictingFiles(IEnumerable files) + { + TxFileManager file_transaction = new TxFileManager(); + using (var transaction = CkanTransaction.CreateTransactionScope()) + { + foreach (InstallableFile file in files) + { + log.DebugFormat("Trying to delete {0}", file.destination); + file_transaction.Delete(file.destination); + } + transaction.Complete(); + } + } + /// /// Checks the path against a list of reserved game directories /// @@ -698,7 +813,7 @@ internal static void CopyZipEntry(ZipFile zipfile, ZipEntry entry, string fullPa } // We don't allow for the overwriting of files. See #208. - if (File.Exists(fullPath)) + if (file_transaction.FileExists(fullPath)) { throw new FileExistsKraken(fullPath, string.Format("Trying to write {0} but it already exists.", fullPath)); } diff --git a/Core/Registry/Registry.cs b/Core/Registry/Registry.cs index bdd28f486a..2b7d1b4ef3 100644 --- a/Core/Registry/Registry.cs +++ b/Core/Registry/Registry.cs @@ -99,6 +99,15 @@ [JsonIgnore] public IEnumerable InstalledDlls get { return installed_dlls.Keys; } } + /// + /// Returns the file path of a DLL. + /// null if not found. + /// + public string DllPath(string identifier) + { + return installed_dlls.TryGetValue(identifier, out string path) ? path : null; + } + /// /// A map between module identifiers and versions for official DLC that are installed. /// diff --git a/Core/Types/Kraken.cs b/Core/Types/Kraken.cs index cabb5e442e..76823a5985 100644 --- a/Core/Types/Kraken.cs +++ b/Core/Types/Kraken.cs @@ -567,7 +567,7 @@ public InstanceNameTakenKraken(string name, string reason = null) this.instName = name; } } - + public class ModuleIsDLCKraken : Kraken { /// @@ -581,5 +581,20 @@ public ModuleIsDLCKraken(CkanModule module, string reason = null) this.module = module; } } - + + /// + /// A manually installed mod is installed somewhere other than + /// where CKAN would install it, so we can't safely overwrite it. + /// + public class DllLocationMismatchKraken : Kraken + { + public readonly string path; + + public DllLocationMismatchKraken(string path, string reason = null) + : base(reason) + { + this.path = path; + } + } + } diff --git a/GUI/Dialogs/YesNoDialog.Designer.cs b/GUI/Dialogs/YesNoDialog.Designer.cs index 21d3637a2a..936403e24b 100644 --- a/GUI/Dialogs/YesNoDialog.Designer.cs +++ b/GUI/Dialogs/YesNoDialog.Designer.cs @@ -31,7 +31,7 @@ private void InitializeComponent() this.components = new System.ComponentModel.Container(); System.ComponentModel.ComponentResourceManager resources = new SingleAssemblyComponentResourceManager(typeof(YesNoDialog)); this.panel1 = new System.Windows.Forms.Panel(); - this.DescriptionLabel = new System.Windows.Forms.Label(); + this.DescriptionLabel = new TransparentTextBox(); this.YesButton = new System.Windows.Forms.Button(); this.NoButton = new System.Windows.Forms.Button(); this.panel1.SuspendLayout(); @@ -55,7 +55,12 @@ private void InitializeComponent() this.DescriptionLabel.Name = "DescriptionLabel"; this.DescriptionLabel.Size = new System.Drawing.Size(393, 73); this.DescriptionLabel.TabIndex = 0; - this.DescriptionLabel.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + this.DescriptionLabel.TabStop = false; + this.DescriptionLabel.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + this.DescriptionLabel.ReadOnly = true; + this.DescriptionLabel.BackColor = System.Drawing.SystemColors.Control; + this.DescriptionLabel.ForeColor = System.Drawing.SystemColors.ControlText; + this.DescriptionLabel.BorderStyle = System.Windows.Forms.BorderStyle.None; resources.ApplyResources(this.DescriptionLabel, "DescriptionLabel"); // // YesButton @@ -103,7 +108,7 @@ private void InitializeComponent() #endregion private System.Windows.Forms.Panel panel1; - private System.Windows.Forms.Label DescriptionLabel; + private TransparentTextBox DescriptionLabel; private System.Windows.Forms.Button YesButton; private System.Windows.Forms.Button NoButton; } diff --git a/GUI/Dialogs/YesNoDialog.cs b/GUI/Dialogs/YesNoDialog.cs index 9f43cd5afe..7ebe8793d9 100644 --- a/GUI/Dialogs/YesNoDialog.cs +++ b/GUI/Dialogs/YesNoDialog.cs @@ -1,3 +1,4 @@ +using System; using System.Drawing; using System.Windows.Forms; using System.Threading.Tasks; @@ -19,10 +20,20 @@ public DialogResult ShowYesNoDialog(Form parentForm, string text, string yesText Util.Invoke(parentForm, () => { + var height = StringHeight(text, ClientSize.Width - 25) + 2 * 54; DescriptionLabel.Text = text; + DescriptionLabel.TextAlign = text.Contains("\n") + ? HorizontalAlignment.Left + : HorizontalAlignment.Center; + DescriptionLabel.ScrollBars = height < maxHeight + ? ScrollBars.None + : ScrollBars.Vertical; YesButton.Text = yesText ?? defaultYes; NoButton.Text = noText ?? defaultNo; - ClientSize = new Size(ClientSize.Width, StringHeight(text, ClientSize.Width - 25) + 2 * 54); + ClientSize = new Size( + ClientSize.Width, + Math.Min(maxHeight, height) + ); task.SetResult(ShowDialog(parentForm)); }); @@ -47,6 +58,7 @@ public void HideYesNoDialog() Util.Invoke(this, Close); } + private const int maxHeight = 600; private TaskCompletionSource task; private string defaultYes; private string defaultNo; diff --git a/GUI/Main/MainInstall.cs b/GUI/Main/MainInstall.cs index b7224ab1ae..4a6c43cf86 100644 --- a/GUI/Main/MainInstall.cs +++ b/GUI/Main/MainInstall.cs @@ -279,8 +279,10 @@ out Dictionary> supporters currentUser.RaiseMessage(ex.InconsistenciesPretty); return; } - catch (CancelledActionKraken) + catch (CancelledActionKraken kraken) { + currentUser.RaiseMessage(kraken.Message); + installCanceled = true; return; } catch (MissingCertificateKraken kraken) @@ -335,6 +337,11 @@ out Dictionary> supporters currentUser.RaiseError(msg); return; } + catch (DllLocationMismatchKraken kraken) + { + currentUser.RaiseMessage(kraken.Message); + return; + } } } @@ -410,8 +417,6 @@ private void PostInstallMods(object sender, RunWorkerCompletedEventArgs e) else if (installCanceled) { // User cancelled the installation - // Rebuilds the list of GUIMods - UpdateModsList(ChangeSet); if (result.Key) { FailWaitDialog( Properties.Resources.MainInstallCancelTooLate,