From 39d85b18d92727907a45104f36485867adaee0ec Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Sun, 7 Aug 2022 16:08:49 -0500 Subject: [PATCH] Check free space before downloading --- Cmdline/Action/Cache.cs | 12 +++--- Cmdline/Properties/Resources.resx | 2 +- Core/CKANPathUtils.cs | 19 +++++++++- Core/Extensions/IOExtensions.cs | 54 +++++++++++++++++++++++++++ Core/ModuleInstaller.cs | 13 +++++++ Core/Net/IDownloader.cs | 2 +- Core/Net/NetAsyncModulesDownloader.cs | 10 +++++ Core/Net/NetFileCache.cs | 15 ++++++-- Core/Net/NetModuleCache.cs | 9 ++++- Core/Properties/Resources.Designer.cs | 12 ++++++ Core/Properties/Resources.resx | 7 ++++ Core/Types/CkanModule.cs | 22 +++++------ Core/Types/Kraken.cs | 19 ++++++++++ GUI/Dialogs/NewRepoDialog.cs | 2 +- GUI/Dialogs/SettingsDialog.cs | 7 ++-- GUI/Main/MainInstall.cs | 4 ++ GUI/Properties/Resources.resx | 2 +- 17 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 Core/Extensions/IOExtensions.cs diff --git a/Cmdline/Action/Cache.cs b/Cmdline/Action/Cache.cs index a61f3e6bad..4217f15d46 100644 --- a/Cmdline/Action/Cache.cs +++ b/Cmdline/Action/Cache.cs @@ -1,9 +1,10 @@ using CommandLine; using CommandLine.Text; using log4net; -using CKAN.Configuration; using Autofac; +using CKAN.Configuration; + namespace CKAN.CmdLine { public class CacheSubOptions : VerbCommandOptions @@ -229,10 +230,11 @@ private int SetCacheSizeLimit(SetLimitOptions options) private void printCacheInfo() { - int fileCount; - long bytes; - manager.Cache.GetSizeInfo(out fileCount, out bytes); - user.RaiseMessage(Properties.Resources.CacheInfo, fileCount, CkanModule.FmtSize(bytes)); + manager.Cache.GetSizeInfo(out int fileCount, out long bytes, out long bytesFree); + user.RaiseMessage(Properties.Resources.CacheInfo, + fileCount, + CkanModule.FmtSize(bytes), + CkanModule.FmtSize(bytesFree)); } private IUser user; diff --git a/Cmdline/Properties/Resources.resx b/Cmdline/Properties/Resources.resx index 10ab523c94..eb1a34e08f 100644 --- a/Cmdline/Properties/Resources.resx +++ b/Cmdline/Properties/Resources.resx @@ -161,7 +161,7 @@ Update recommended! Download cache reset to {0} Can't reset cache path: {0} Unlimited - {0} files, {1} + {0} files, {1}, {2} free "{0}" and "{1}" are the same versions. "{0}" is lower than "{1}". "{0}" is higher than "{1}". diff --git a/Core/CKANPathUtils.cs b/Core/CKANPathUtils.cs index 030b675cec..193b4aa65e 100644 --- a/Core/CKANPathUtils.cs +++ b/Core/CKANPathUtils.cs @@ -3,9 +3,11 @@ using System.Text.RegularExpressions; using log4net; +using CKAN.Extensions; + namespace CKAN { - public class CKANPathUtils + public static class CKANPathUtils { private static readonly ILog log = LogManager.GetLogger(typeof(CKANPathUtils)); @@ -194,5 +196,20 @@ public static string ToAbsolute(string path, string root) // the un-prettiest slashes. return NormalizePath(Path.Combine(root, path)); } + + public static void CheckFreeSpace(DirectoryInfo where, long bytesToStore, string errorDescription) + { + var bytesFree = where.GetDrive()?.AvailableFreeSpace; + if (bytesFree.HasValue && bytesToStore > bytesFree.Value) { + throw new NotEnoughSpaceKraken(errorDescription, where, + bytesFree.Value, bytesToStore); + } + log.DebugFormat("Storing {0} to {1} ({2} free)...", + CkanModule.FmtSize(bytesToStore), + where.FullName, + bytesFree.HasValue ? CkanModule.FmtSize(bytesFree.Value) + : "unknown bytes"); + } + } } diff --git a/Core/Extensions/IOExtensions.cs b/Core/Extensions/IOExtensions.cs new file mode 100644 index 0000000000..fa1e19907b --- /dev/null +++ b/Core/Extensions/IOExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; + +namespace CKAN.Extensions +{ + public static class IOExtensions + { + + private static bool StringArrayStartsWith(string[] child, string[] parent) + { + if (parent.Length > child.Length) + // Only child is allowed to have extra pieces + return false; + var opt = Platform.IsWindows ? StringComparison.InvariantCultureIgnoreCase + : StringComparison.InvariantCulture; + for (int i = 0; i < parent.Length; ++i) { + if (!parent[i].Equals(child[i], opt)) { + return false; + } + } + return true; + } + + /// + /// Check whether a given path is an ancestor of another + /// + /// The path to treat as potential ancestor + /// The path to treat as potential descendant + /// true if child is a descendant of parent, false otherwise + public static bool IsAncestorOf(this DirectoryInfo parent, DirectoryInfo child) + => StringArrayStartsWith( + child.FullName.Split(new char[] {Path.DirectorySeparatorChar}, + StringSplitOptions.RemoveEmptyEntries), + parent.FullName.Split(new char[] {Path.DirectorySeparatorChar}, + StringSplitOptions.RemoveEmptyEntries)); + + /// + /// Extension method to fill in the gap of getting from a + /// directory to its drive in .NET. + /// Returns the drive with the longest RootDirectory.FullName + /// that's a prefix of the dir's FullName. + /// + /// Any DirectoryInfo object + /// The DriveInfo associated with this directory + public static DriveInfo GetDrive(this DirectoryInfo dir) + => DriveInfo.GetDrives() + .Where(dr => dr.RootDirectory.IsAncestorOf(dir)) + .OrderByDescending(dr => dr.RootDirectory.FullName.Length) + .FirstOrDefault(); + + } +} diff --git a/Core/ModuleInstaller.cs b/Core/ModuleInstaller.cs index d07042cf5a..a4c040ec7f 100644 --- a/Core/ModuleInstaller.cs +++ b/Core/ModuleInstaller.cs @@ -139,6 +139,12 @@ public void InstallList(ICollection modules, RelationshipResolverOpt var modsToInstall = resolver.ModList().ToList(); List downloads = new List(); + // Make sure we have enough space to install this stuff + CKANPathUtils.CheckFreeSpace(new DirectoryInfo(ksp.GameDir()), + modsToInstall.Select(m => m.install_size) + .Sum(), + Properties.Resources.NotEnoughSpaceToInstall); + // TODO: All this user-stuff should be happening in another method! // We should just be installing mods as a transaction. @@ -178,6 +184,13 @@ public void InstallList(ICollection modules, RelationshipResolverOpt downloader.DownloadModules(downloads); } + // Make sure we STILL have enough space to install this stuff + // now that the downloads have been stored to the cache + CKANPathUtils.CheckFreeSpace(new DirectoryInfo(ksp.GameDir()), + modsToInstall.Select(m => m.install_size) + .Sum(), + Properties.Resources.NotEnoughSpaceToInstall); + // We're about to install all our mods; so begin our transaction. using (TransactionScope transaction = CkanTransaction.CreateTransactionScope()) { diff --git a/Core/Net/IDownloader.cs b/Core/Net/IDownloader.cs index 0b17d3d495..0919502274 100644 --- a/Core/Net/IDownloader.cs +++ b/Core/Net/IDownloader.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Collections.Generic; namespace CKAN { diff --git a/Core/Net/NetAsyncModulesDownloader.cs b/Core/Net/NetAsyncModulesDownloader.cs index 6c9845cfd3..aba7608284 100644 --- a/Core/Net/NetAsyncModulesDownloader.cs +++ b/Core/Net/NetAsyncModulesDownloader.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; + +using ChinhDo.Transactions.FileManager; using log4net; namespace CKAN @@ -62,6 +64,14 @@ public void DownloadModules(IEnumerable modules) .Where(group => !currentlyActive.Contains(group.Key)) .ToDictionary(group => group.Key, group => group.First()); + // Make sure we have enough space to download this stuff + var downloadSize = unique_downloads.Values.Select(m => m.download_size).Sum(); + CKANPathUtils.CheckFreeSpace(new DirectoryInfo(new TxFileManager().GetTempDirectory()), + downloadSize, + Properties.Resources.NotEnoughSpaceToDownload); + // Make sure we have enough space to cache this stuff + cache.CheckFreeSpace(downloadSize); + this.modules.AddRange(unique_downloads.Values); try diff --git a/Core/Net/NetFileCache.cs b/Core/Net/NetFileCache.cs index fd45a0d35e..5be5508c92 100644 --- a/Core/Net/NetFileCache.cs +++ b/Core/Net/NetFileCache.cs @@ -277,11 +277,13 @@ public string GetCachedZip(Uri url) /// /// Output parameter set to number of files in cache /// Output parameter set to number of bytes in cache - public void GetSizeInfo(out int numFiles, out long numBytes) + /// Output parameter set to number of bytes free + public void GetSizeInfo(out int numFiles, out long numBytes, out long bytesFree) { numFiles = 0; numBytes = 0; GetSizeInfo(cachePath, ref numFiles, ref numBytes); + bytesFree = new DirectoryInfo(cachePath).GetDrive()?.AvailableFreeSpace ?? 0; foreach (var legacyDir in legacyDirs()) { GetSizeInfo(legacyDir, ref numFiles, ref numBytes); @@ -298,6 +300,13 @@ private void GetSizeInfo(string path, ref int numFiles, ref long numBytes) } } + public void CheckFreeSpace(long bytesToStore) + { + CKANPathUtils.CheckFreeSpace(new DirectoryInfo(cachePath), + bytesToStore, + Properties.Resources.NotEnoughSpaceToCache); + } + private HashSet legacyDirs() { return manager?.Instances.Values @@ -310,9 +319,7 @@ private HashSet legacyDirs() public void EnforceSizeLimit(long bytes, Registry registry) { - int numFiles; - long curBytes; - GetSizeInfo(out numFiles, out curBytes); + GetSizeInfo(out int numFiles, out long curBytes, out long _); if (curBytes > bytes) { // This object will let us determine whether a module is compatible with any of our instances diff --git a/Core/Net/NetModuleCache.cs b/Core/Net/NetModuleCache.cs index 8eb66d2bfc..0d81701a79 100644 --- a/Core/Net/NetModuleCache.cs +++ b/Core/Net/NetModuleCache.cs @@ -70,14 +70,19 @@ public string GetCachedZip(CkanModule m) { return cache.GetCachedZip(m.download); } - public void GetSizeInfo(out int numFiles, out long numBytes) + public void GetSizeInfo(out int numFiles, out long numBytes, out long bytesFree) { - cache.GetSizeInfo(out numFiles, out numBytes); + cache.GetSizeInfo(out numFiles, out numBytes, out bytesFree); } public void EnforceSizeLimit(long bytes, Registry registry) { cache.EnforceSizeLimit(bytes, registry); } + public void CheckFreeSpace(long bytesToStore) + { + cache.CheckFreeSpace(bytesToStore); + } + /// /// Calculate the SHA1 hash of a file diff --git a/Core/Properties/Resources.Designer.cs b/Core/Properties/Resources.Designer.cs index b732d05301..425749264f 100644 --- a/Core/Properties/Resources.Designer.cs +++ b/Core/Properties/Resources.Designer.cs @@ -513,6 +513,18 @@ internal static string KrakenAlreadyRunning { internal static string KrakenReinstallModule { get { return (string)(ResourceManager.GetObject("KrakenReinstallModule", resourceCulture)); } } + internal static string NotEnoughSpaceToDownload { + get { return (string)(ResourceManager.GetObject("NotEnoughSpaceToDownload", resourceCulture)); } + } + internal static string NotEnoughSpaceToCache { + get { return (string)(ResourceManager.GetObject("NotEnoughSpaceToCache", resourceCulture)); } + } + internal static string NotEnoughSpaceToInstall { + get { return (string)(ResourceManager.GetObject("NotEnoughSpaceToInstall", resourceCulture)); } + } + internal static string KrakenNotEnoughSpace { + get { return (string)(ResourceManager.GetObject("KrakenNotEnoughSpace", resourceCulture)); } + } internal static string RelationshipResolverConflictsWith { get { return (string)(ResourceManager.GetObject("RelationshipResolverConflictsWith", resourceCulture)); } diff --git a/Core/Properties/Resources.resx b/Core/Properties/Resources.resx index b0d1c6413b..8d9dffbeb0 100644 --- a/Core/Properties/Resources.resx +++ b/Core/Properties/Resources.resx @@ -280,6 +280,13 @@ Consider adding an authentication token to increase the throttling limit. Metadata changed, reinstallation recommended: {0} + Not enough space in temp folder to download modules! + Not enough space in cache folder to store modules! + Not enough space in game folder to install modules! + {0} +Need to store {3} to {1}, but only {2} is available! +Free up space on that device or change your settings to use another location. + {0} conflicts with {1} {0} required, but an incompatible version is in the resolver {0} required, but an incompatible version is installed diff --git a/Core/Types/CkanModule.cs b/Core/Types/CkanModule.cs index fc62df9b35..8b5aef8157 100644 --- a/Core/Types/CkanModule.cs +++ b/Core/Types/CkanModule.cs @@ -727,26 +727,22 @@ public Uri InternetArchiveDownload } } + private const double K = 1024; + /// /// Format a byte count into readable file size /// /// Number of bytes in a file /// - /// ### bytes or ### KB or ### MB or ### GB + /// ### bytes or ### KiB or ### MiB or ### GiB or ### TiB /// public static string FmtSize(long bytes) - { - const double K = 1024; - if (bytes < K) { - return $"{bytes} B"; - } else if (bytes < K * K) { - return $"{bytes / K :N1} KiB"; - } else if (bytes < K * K * K) { - return $"{bytes / K / K :N1} MiB"; - } else { - return $"{bytes / K / K / K :N1} GiB"; - } - } + => bytes < K ? $"{bytes} B" + : bytes < K*K ? $"{bytes /K :N1} KiB" + : bytes < K*K*K ? $"{bytes /K/K :N1} MiB" + : bytes < K*K*K*K ? $"{bytes /K/K/K :N1} GiB" + : $"{bytes /K/K/K/K :N1} TiB"; + } public class InvalidModuleAttributesException : Exception diff --git a/Core/Types/Kraken.cs b/Core/Types/Kraken.cs index 4c730fa56f..2bc680566b 100644 --- a/Core/Types/Kraken.cs +++ b/Core/Types/Kraken.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Net; using System.Text; @@ -38,6 +39,24 @@ public DirectoryNotFoundKraken(string directory, string reason = null, Exception } } + public class NotEnoughSpaceKraken : Kraken + { + public DirectoryInfo destination; + public long bytesFree; + public long bytesToStore; + + public NotEnoughSpaceKraken(string description, DirectoryInfo destination, long bytesFree, long bytesToStore) + : base(string.Format(Properties.Resources.KrakenNotEnoughSpace, + description, destination, + CkanModule.FmtSize(bytesFree), + CkanModule.FmtSize(bytesToStore))) + { + this.destination = destination; + this.bytesFree = bytesFree; + this.bytesToStore = bytesToStore; + } + } + /// /// A bad install location was provided. /// Valid locations are GameData, GameRoot, Ships, etc. diff --git a/GUI/Dialogs/NewRepoDialog.cs b/GUI/Dialogs/NewRepoDialog.cs index a51bf9012c..2c3dbf9dda 100644 --- a/GUI/Dialogs/NewRepoDialog.cs +++ b/GUI/Dialogs/NewRepoDialog.cs @@ -1,5 +1,5 @@ using System; -using System.Linq; +using System.Linq; using System.Windows.Forms; namespace CKAN.GUI diff --git a/GUI/Dialogs/SettingsDialog.cs b/GUI/Dialogs/SettingsDialog.cs index 882a4e901d..7f4358f294 100644 --- a/GUI/Dialogs/SettingsDialog.cs +++ b/GUI/Dialogs/SettingsDialog.cs @@ -17,7 +17,8 @@ public partial class SettingsDialog : Form private IUser m_user; private long m_cacheSize; - private int m_cacheFileCount; + private int m_cacheFileCount; + private long m_cacheFreeSpace; private IConfiguration config; private List _sortedRepos = new List(); @@ -128,7 +129,7 @@ private void UpdateCacheInfo(string newPath) Task.Factory.StartNew(() => { // This might take a little while if the cache is big - Main.Instance.Manager.Cache.GetSizeInfo(out m_cacheFileCount, out m_cacheSize); + Main.Instance.Manager.Cache.GetSizeInfo(out m_cacheFileCount, out m_cacheSize, out long m_cacheFreeSpace); Util.Invoke(this, () => { if (config.CacheSizeLimit.HasValue) @@ -137,7 +138,7 @@ private void UpdateCacheInfo(string newPath) CacheLimit.Text = (config.CacheSizeLimit.Value / 1024 / 1024).ToString(); } CachePath.Text = config.DownloadCacheDir; - CacheSummary.Text = string.Format(Properties.Resources.SettingsDialogSummmary, m_cacheFileCount, CkanModule.FmtSize(m_cacheSize)); + CacheSummary.Text = string.Format(Properties.Resources.SettingsDialogSummmary, m_cacheFileCount, CkanModule.FmtSize(m_cacheSize), CkanModule.FmtSize(m_cacheFreeSpace)); CacheSummary.ForeColor = SystemColors.ControlText; OpenCacheButton.Enabled = true; ClearCacheButton.Enabled = (m_cacheSize > 0); diff --git a/GUI/Main/MainInstall.cs b/GUI/Main/MainInstall.cs index ac67ecdc66..fbf651fcdc 100644 --- a/GUI/Main/MainInstall.cs +++ b/GUI/Main/MainInstall.cs @@ -320,6 +320,10 @@ private void PostInstallMods(object sender, RunWorkerCompletedEventArgs e) currentUser.RaiseMessage(Properties.Resources.MainInstallBadMetadata, exc.module, exc.Message); break; + case NotEnoughSpaceKraken exc: + currentUser.RaiseMessage(exc.Message); + break; + case FileExistsKraken exc: if (exc.owningModule != null) { diff --git a/GUI/Properties/Resources.resx b/GUI/Properties/Resources.resx index 3074340a50..73b982affe 100644 --- a/GUI/Properties/Resources.resx +++ b/GUI/Properties/Resources.resx @@ -340,7 +340,7 @@ If you suspect a bug in the client: https://github.com/KSP-CKAN/CKAN/issues/new/ {0} (LOCKED) Failed to fetch master list. CKAN Plugins (*.dll)|*.dll - {0} files, {1} + {0} files, {1}, {2} free Invalid path: {0} Choose a folder for storing CKAN's mod downloads: Do you really want to delete {0} cached files, freeing {1}?