diff --git a/Core/Extensions/IOExtensions.cs b/Core/Extensions/IOExtensions.cs index 1f89ab42df..774a904b8f 100644 --- a/Core/Extensions/IOExtensions.cs +++ b/Core/Extensions/IOExtensions.cs @@ -54,5 +54,28 @@ public static DriveInfo GetDrive(this DirectoryInfo dir) .OrderByDescending(dr => dr.RootDirectory.FullName.Length) .FirstOrDefault(); + /// + /// A version of Stream.CopyTo with progress updates. + /// + /// Stream from which to copy + /// Stream to which to copy + /// Callback to notify as we traverse the input, called with count of bytes received + public static void CopyTo(this Stream src, Stream dest, IProgress progress) + { + // CopyTo says its default buffer is 81920, but we want more than 1 update for a 100 KiB file + const int bufSize = 8192; + var buffer = new byte[bufSize]; + long total = 0; + while (true) + { + var bytesRead = src.Read(buffer, 0, bufSize); + if (bytesRead == 0) + { + break; + } + dest.Write(buffer, 0, bytesRead); + progress.Report(total += bytesRead); + } + } } } diff --git a/Core/ModuleInstaller.cs b/Core/ModuleInstaller.cs index 97d97748cb..d6e78843af 100644 --- a/Core/ModuleInstaller.cs +++ b/Core/ModuleInstaller.cs @@ -155,20 +155,11 @@ public void InstallList(ICollection modules, RelationshipResolverOpt foreach (CkanModule module in modsToInstall) { + User.RaiseMessage(" * {0}", Cache.DescribeAvailability(module)); if (!Cache.IsMaybeCachedZip(module)) { - User.RaiseMessage(" * {0} {1} ({2}, {3})", - module.name, - module.version, - module.download.Host, - CkanModule.FmtSize(module.download_size) - ); downloads.Add(module); } - else - { - User.RaiseMessage(Properties.Resources.ModuleInstallerModuleCached, module.name, module.version); - } } if (ConfirmPrompt && !User.RaiseYesNoDialog("Continue?")) @@ -1053,12 +1044,21 @@ public void Upgrade(IEnumerable modules, IDownloader netAsyncDownloa { if (!Cache.IsMaybeCachedZip(module)) { - User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingUncached, - module.name, - module.version, - module.download.Host, - CkanModule.FmtSize(module.download_size) - ); + var inProgressFile = new FileInfo(Cache.GetInProgressFileName(module)); + if (inProgressFile.Exists) + { + User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingResuming, + module.name, module.version, + module.download.Host, + CkanModule.FmtSize(module.download_size - inProgressFile.Length)); + } + else + { + User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingUncached, + module.name, module.version, + module.download.Host, + CkanModule.FmtSize(module.download_size)); + } } else { @@ -1086,13 +1086,19 @@ public void Upgrade(IEnumerable modules, IDownloader netAsyncDownloa { if (!Cache.IsMaybeCachedZip(module)) { - User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingUncached, - module.name, - installed.version, - module.version, - module.download.Host, - CkanModule.FmtSize(module.download_size) - ); + var inProgressFile = new FileInfo(Cache.GetInProgressFileName(module)); + if (inProgressFile.Exists) + { + User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingResuming, + module.name, installed.version, module.version, + module.download.Host, CkanModule.FmtSize(module.download_size - inProgressFile.Length)); + } + else + { + User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingUncached, + module.name, installed.version, module.version, + module.download.Host, CkanModule.FmtSize(module.download_size)); + } } else { diff --git a/Core/Net/Net.cs b/Core/Net/Net.cs index c178297b69..037eaf18a6 100644 --- a/Core/Net/Net.cs +++ b/Core/Net/Net.cs @@ -6,9 +6,11 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; + using Autofac; using ChinhDo.Transactions.FileManager; using log4net; + using CKAN.Configuration; namespace CKAN @@ -25,7 +27,7 @@ public class Net private const int MaxRetries = 3; private const int RetryDelayMilliseconds = 100; - private static readonly ILog log = LogManager.GetLogger(typeof(Net)); + private static readonly ILog log = LogManager.GetLogger(typeof(Net)); public static readonly Dictionary ThrottledHosts = new Dictionary() { @@ -355,109 +357,5 @@ public static Uri GetRawUri(Uri remoteUri) return remoteUri; } } - - /// - /// A WebClient with some CKAN-sepcific adjustments: - /// - A user agent string (required by GitHub API policy) - /// - Sets the Accept header to a given MIME type (needed to get raw files from GitHub API) - /// - Times out after a specified amount of time in milliseconds, 100 000 milliseconds (=100 seconds) by default (https://stackoverflow.com/a/3052637) - /// - Handles permanent redirects to the same host without clearing the Authorization header (needed to get files from renamed GitHub repositories via API) - /// - private sealed class RedirectingTimeoutWebClient : WebClient - { - /// - /// Initialize our special web client - /// - /// Timeout for the request in milliseconds, defaulting to 100 000 (=100 seconds) - /// A mime type sent with the "Accept" header - public RedirectingTimeoutWebClient(int timeout = 100000, string mimeType = "") - { - this.timeout = timeout; - this.mimeType = mimeType; - } - - protected override WebRequest GetWebRequest(Uri address) - { - // Set user agent and MIME type for every request. including redirects - Headers.Add("User-Agent", UserAgentString); - if (!string.IsNullOrEmpty(mimeType)) - { - log.InfoFormat("Setting MIME type {0}", mimeType); - Headers.Add("Accept", mimeType); - } - if (permanentRedirects.TryGetValue(address, out Uri redirUri)) - { - // Obey a previously received permanent redirect - address = redirUri; - } - var request = base.GetWebRequest(address); - if (request is HttpWebRequest hwr) - { - // GitHub API tokens cannot be passed via auto-redirect - hwr.AllowAutoRedirect = false; - hwr.Timeout = timeout; - } - return request; - } - - protected override WebResponse GetWebResponse(WebRequest request) - { - if (request == null) - return null; - var response = base.GetWebResponse(request); - if (response == null) - return null; - - if (response is HttpWebResponse hwr) - { - int statusCode = (int)hwr.StatusCode; - var location = hwr.Headers["Location"]; - if (statusCode >= 300 && statusCode <= 399 && location != null) - { - log.InfoFormat("Redirecting to {0}", location); - hwr.Close(); - var redirUri = new Uri(request.RequestUri, location); - if (Headers.AllKeys.Contains("Authorization") - && request.RequestUri.Host != redirUri.Host) - { - log.InfoFormat("Host mismatch, purging token for redirect"); - Headers.Remove("Authorization"); - } - // Moved or PermanentRedirect - if (statusCode == 301 || statusCode == 308) - { - permanentRedirects.Add(request.RequestUri, redirUri); - } - return GetWebResponse(GetWebRequest(redirUri)); - } - } - return response; - } - - private int timeout; - private string mimeType; - private static readonly Dictionary permanentRedirects = new Dictionary(); - } - - // HACK: The ancient WebClient doesn't support setting the request type to HEAD and WebRequest doesn't support - // setting the User-Agent header. - // Maybe one day we'll be able to use HttpClient (https://msdn.microsoft.com/en-us/library/system.net.http.httpclient%28v=vs.118%29.aspx) - private sealed class RedirectWebClient : WebClient - { - public RedirectWebClient() - { - Headers.Add("User-Agent", UserAgentString); - } - - protected override WebRequest GetWebRequest(Uri address) - { - var webRequest = (HttpWebRequest)base.GetWebRequest(address); - webRequest.AllowAutoRedirect = false; - - webRequest.Method = "HEAD"; - - return webRequest; - } - } } } diff --git a/Core/Net/NetAsyncDownloader.cs b/Core/Net/NetAsyncDownloader.cs index 4ba4e33771..00c63ad92d 100644 --- a/Core/Net/NetAsyncDownloader.cs +++ b/Core/Net/NetAsyncDownloader.cs @@ -32,11 +32,14 @@ private class NetAsyncDownloaderDownloadPart public Exception error; public int lastProgressUpdateSize; - public event DownloadProgressChangedEventHandler Progress; + /// + /// Percentage, bytes received, total bytes to receive + /// + public event Action Progress; public event Action Done; private string mimeType; - private WebClient agent; + private ResumingWebClient agent; public NetAsyncDownloaderDownloadPart(Net.DownloadTarget target) { @@ -51,7 +54,7 @@ public NetAsyncDownloaderDownloadPart(Net.DownloadTarget target) public void Download(Uri url, string path) { ResetAgent(); - agent.DownloadFileAsync(url, path); + agent.DownloadFileAsyncWithResume(url, path); } public void Abort() @@ -61,7 +64,7 @@ public void Abort() private void ResetAgent() { - agent = new WebClient(); + agent = new ResumingWebClient(); agent.Headers.Add("User-Agent", Net.UserAgentString); @@ -86,7 +89,11 @@ private void ResetAgent() // Forward progress and completion events to our listeners agent.DownloadProgressChanged += (sender, args) => { - Progress?.Invoke(sender, args); + Progress?.Invoke(args.ProgressPercentage, args.BytesReceived, args.TotalBytesToReceive); + }; + agent.DownloadProgress += (percent, bytesReceived, totalBytesToReceive) => + { + Progress?.Invoke(percent, bytesReceived, totalBytesToReceive); }; agent.DownloadFileCompleted += (sender, args) => { @@ -163,11 +170,8 @@ private void DownloadModule(Net.DownloadTarget target) dl.target.url.ToString().Replace(" ", "%20")); // Schedule for us to get back progress reports. - dl.Progress += (sender, args) => - FileProgressReport(index, - args.ProgressPercentage, - args.BytesReceived, - args.TotalBytesToReceive); + dl.Progress += (ProgressPercentage, BytesReceived, TotalBytesToReceive) => + FileProgressReport(index, ProgressPercentage, BytesReceived, TotalBytesToReceive); // And schedule a notification if we're done (or if something goes wrong) dl.Done += (sender, args, etag) => diff --git a/Core/Net/NetAsyncModulesDownloader.cs b/Core/Net/NetAsyncModulesDownloader.cs index 87587d66df..0fe1fae1b9 100644 --- a/Core/Net/NetAsyncModulesDownloader.cs +++ b/Core/Net/NetAsyncModulesDownloader.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; -using ChinhDo.Transactions.FileManager; using log4net; namespace CKAN @@ -62,33 +61,28 @@ 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); + // Make sure we have enough space to download and cache + cache.CheckFreeSpace(unique_downloads.Values + .Select(m => m.download_size) + .Sum()); this.modules.AddRange(unique_downloads.Values); try { // Start the downloads! - downloader.DownloadAndWait( - unique_downloads.Select(item => new Net.DownloadTarget( + downloader.DownloadAndWait(unique_downloads + .Select(item => new Net.DownloadTarget( item.Key, item.Value.InternetArchiveDownload, - // Use a temp file name - null, + cache.GetInProgressFileName(item.Value), item.Value.download_size, // Send the MIME type to use for the Accept header // The GitHub API requires this to include application/octet-stream string.IsNullOrEmpty(item.Value.download_content_type) ? defaultMimeType - : $"{item.Value.download_content_type};q=1.0,{defaultMimeType};q=0.9" - )).ToList() - ); + : $"{item.Value.download_content_type};q=1.0,{defaultMimeType};q=0.9")) + .ToList()); this.modules.Clear(); AllComplete?.Invoke(); } @@ -104,8 +98,11 @@ public void DownloadModules(IEnumerable modules) private void ModuleDownloadComplete(Uri url, string filename, Exception error, string etag) { + log.DebugFormat("Received download completion: {0}, {1}, {2}", + url, filename, error?.Message); if (error != null) { + // If there was an error in DOWNLOADING, keep the file so we can retry it later log.Info(error.ToString()); } else @@ -120,6 +117,8 @@ private void ModuleDownloadComplete(Uri url, string filename, Exception error, s catch (InvalidModuleFileKraken kraken) { User.RaiseError(kraken.ToString()); + // If there was an error in STORING, delete the file so we can try it from scratch later + File.Delete(filename); } catch (FileNotFoundException e) { diff --git a/Core/Net/NetFileCache.cs b/Core/Net/NetFileCache.cs index 5be5508c92..acffdc5d6d 100644 --- a/Core/Net/NetFileCache.cs +++ b/Core/Net/NetFileCache.cs @@ -67,6 +67,9 @@ public NetFileCache(string path) Properties.Resources.NetFileCacheCannotFind, cachePath)); } + // Files go here while they're downloading + Directory.CreateDirectory(InProgressPath); + // Establish a watch on our cache. This means we can cache the directory contents, // and discard that cache if we spot changes. watcher = new FileSystemWatcher(cachePath, "*.zip") @@ -103,6 +106,19 @@ public void Dispose() watcher.Dispose(); } + private string InProgressPath => Path.Combine(cachePath, "downloading"); + + private string GetInProgressFileName(string hash, string description) + => Directory.EnumerateFiles(InProgressPath) + .Where(path => new FileInfo(path).Name.StartsWith(hash)) + .FirstOrDefault() + // If not found, return the name to create + ?? Path.Combine(InProgressPath, $"{hash}-{description}"); + + public string GetInProgressFileName(Uri url, string description) + => GetInProgressFileName(NetFileCache.CreateURLHash(url), + description); + /// /// Called from our FileSystemWatcher. Use OnCacheChanged() /// without arguments to signal manually. @@ -293,7 +309,7 @@ public void GetSizeInfo(out int numFiles, out long numBytes, out long bytesFree) private void GetSizeInfo(string path, ref int numFiles, ref long numBytes) { DirectoryInfo cacheDir = new DirectoryInfo(path); - foreach (var file in cacheDir.EnumerateFiles()) + foreach (var file in cacheDir.EnumerateFiles("*", SearchOption.AllDirectories)) { ++numFiles; numBytes += file.Length; @@ -341,12 +357,10 @@ public void EnforceSizeLimit(long bytes, Registry registry) kvp.Value.RemoveAll(mod => !mod.IsCompatibleKSP(aggregateCriteria)); } - // Now get all the files in all the caches... - List files = allFiles(); - // ... and sort them by compatibilty and timestamp... - files.Sort((a, b) => compareFiles( - hashMap, aggregateCriteria, a, b - )); + // Now get all the files in all the caches, including in progress... + List files = allFiles(true); + // ... and sort them by compatibility and timestamp... + files.Sort((a, b) => compareFiles(hashMap, aggregateCriteria, a, b)); // ... and delete them till we're under the limit foreach (FileInfo fi in files) @@ -407,10 +421,12 @@ private int compareFiles(Dictionary> hashMap, GameVersi } } - private List allFiles() + private List allFiles(bool includeInProgress = false) { DirectoryInfo mainDir = new DirectoryInfo(cachePath); - var files = mainDir.EnumerateFiles(); + var files = mainDir.EnumerateFiles("*", + includeInProgress ? SearchOption.AllDirectories + : SearchOption.TopDirectoryOnly); foreach (string legacyDir in legacyDirs()) { DirectoryInfo legDir = new DirectoryInfo(legacyDir); @@ -583,15 +599,10 @@ private void PurgeHashes(TxFileManager tx_file, string file) /// public void RemoveAll() { - foreach (string file in Directory.EnumerateFiles(cachePath)) - { - try - { - File.Delete(file); - } - catch { } - } - foreach (string dir in legacyDirs()) + var dirs = Enumerable.Repeat(cachePath, 1) + .Concat(Enumerable.Repeat(InProgressPath, 1)) + .Concat(legacyDirs()); + foreach (string dir in dirs) { foreach (string file in Directory.EnumerateFiles(dir)) { diff --git a/Core/Net/NetModuleCache.cs b/Core/Net/NetModuleCache.cs index 0d81701a79..7ed202b11d 100644 --- a/Core/Net/NetModuleCache.cs +++ b/Core/Net/NetModuleCache.cs @@ -83,6 +83,23 @@ public void CheckFreeSpace(long bytesToStore) cache.CheckFreeSpace(bytesToStore); } + public string GetInProgressFileName(CkanModule m) + => cache.GetInProgressFileName(m.download, m.StandardName()); + + private static string DescribeUncachedAvailability(CkanModule m, FileInfo fi) + => fi.Exists + ? string.Format(Properties.Resources.NetModuleCacheModuleResuming, + m.name, m.version, m.download.Host ?? "", + CkanModule.FmtSize(m.download_size - fi.Length)) + : string.Format(Properties.Resources.NetModuleCacheModuleHostSize, + m.name, m.version, m.download.Host ?? "", CkanModule.FmtSize(m.download_size)); + + public string DescribeAvailability(CkanModule m) + => m.IsMetapackage + ? string.Format(Properties.Resources.NetModuleCacheMetapackage, m.name, m.version) + : IsMaybeCachedZip(m) + ? string.Format(Properties.Resources.NetModuleCacheModuleCached, m.name, m.version) + : DescribeUncachedAvailability(m, new FileInfo(GetInProgressFileName(m))); /// /// Calculate the SHA1 hash of a file diff --git a/Core/Net/RedirectWebClient.cs b/Core/Net/RedirectWebClient.cs new file mode 100644 index 0000000000..0849b48764 --- /dev/null +++ b/Core/Net/RedirectWebClient.cs @@ -0,0 +1,26 @@ +using System; +using System.Net; + +namespace CKAN +{ + // HACK: The ancient WebClient doesn't support setting the request type to HEAD and WebRequest doesn't support + // setting the User-Agent header. + // Maybe one day we'll be able to use HttpClient (https://msdn.microsoft.com/en-us/library/system.net.http.httpclient%28v=vs.118%29.aspx) + internal sealed class RedirectWebClient : WebClient + { + public RedirectWebClient() + { + Headers.Add("User-Agent", Net.UserAgentString); + } + + protected override WebRequest GetWebRequest(Uri address) + { + var webRequest = (HttpWebRequest)base.GetWebRequest(address); + webRequest.AllowAutoRedirect = false; + + webRequest.Method = "HEAD"; + + return webRequest; + } + } +} diff --git a/Core/Net/RedirectingTimeoutWebClient.cs b/Core/Net/RedirectingTimeoutWebClient.cs new file mode 100644 index 0000000000..bb87bb7072 --- /dev/null +++ b/Core/Net/RedirectingTimeoutWebClient.cs @@ -0,0 +1,93 @@ +using System; +using System.Net; +using System.Collections.Generic; +using System.Linq; + +using log4net; + +namespace CKAN +{ + /// + /// A WebClient with some CKAN-sepcific adjustments: + /// - A user agent string (required by GitHub API policy) + /// - Sets the Accept header to a given MIME type (needed to get raw files from GitHub API) + /// - Times out after a specified amount of time in milliseconds, 100 000 milliseconds (=100 seconds) by default (https://stackoverflow.com/a/3052637) + /// - Handles permanent redirects to the same host without clearing the Authorization header (needed to get files from renamed GitHub repositories via API) + /// + internal sealed class RedirectingTimeoutWebClient : WebClient + { + /// + /// Initialize our special web client + /// + /// Timeout for the request in milliseconds, defaulting to 100 000 (=100 seconds) + /// A mime type sent with the "Accept" header + public RedirectingTimeoutWebClient(int timeout = 100000, string mimeType = "") + { + this.timeout = timeout; + this.mimeType = mimeType; + } + + protected override WebRequest GetWebRequest(Uri address) + { + // Set user agent and MIME type for every request. including redirects + Headers.Add("User-Agent", Net.UserAgentString); + if (!string.IsNullOrEmpty(mimeType)) + { + log.InfoFormat("Setting MIME type {0}", mimeType); + Headers.Add("Accept", mimeType); + } + if (permanentRedirects.TryGetValue(address, out Uri redirUri)) + { + // Obey a previously received permanent redirect + address = redirUri; + } + var request = base.GetWebRequest(address); + if (request is HttpWebRequest hwr) + { + // GitHub API tokens cannot be passed via auto-redirect + hwr.AllowAutoRedirect = false; + hwr.Timeout = timeout; + } + return request; + } + + protected override WebResponse GetWebResponse(WebRequest request) + { + if (request == null) + return null; + var response = base.GetWebResponse(request); + if (response == null) + return null; + + if (response is HttpWebResponse hwr) + { + int statusCode = (int)hwr.StatusCode; + var location = hwr.Headers["Location"]; + if (statusCode >= 300 && statusCode <= 399 && location != null) + { + log.InfoFormat("Redirecting to {0}", location); + hwr.Close(); + var redirUri = new Uri(request.RequestUri, location); + if (Headers.AllKeys.Contains("Authorization") + && request.RequestUri.Host != redirUri.Host) + { + log.InfoFormat("Host mismatch, purging token for redirect"); + Headers.Remove("Authorization"); + } + // Moved or PermanentRedirect + if (statusCode == 301 || statusCode == 308) + { + permanentRedirects.Add(request.RequestUri, redirUri); + } + return GetWebResponse(GetWebRequest(redirUri)); + } + } + return response; + } + + private int timeout; + private string mimeType; + private static readonly Dictionary permanentRedirects = new Dictionary(); + private static readonly ILog log = LogManager.GetLogger(typeof(RedirectingTimeoutWebClient)); + } +} diff --git a/Core/Net/ResumingWebClient.cs b/Core/Net/ResumingWebClient.cs new file mode 100644 index 0000000000..d24648b9b4 --- /dev/null +++ b/Core/Net/ResumingWebClient.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Net; +using System.ComponentModel; +using System.Threading.Tasks; + +using log4net; + +using CKAN.Extensions; + +namespace CKAN +{ + internal class ResumingWebClient : WebClient + { + /// + /// A version of DownloadFileAsync that appends to its destination + /// file if it already exists and skips downloading the bytes + /// we already have. + /// + /// What to download + /// Where to save it + public void DownloadFileAsyncWithResume(Uri url, string path) + { + Task.Factory.StartNew(() => + { + var fi = new FileInfo(path); + if (fi.Exists) + { + log.DebugFormat("File exists at {0}, {1} bytes", path, fi.Length); + bytesToSkip = fi.Length; + } + else + { + // Reset in case we try multiple with same webclient + bytesToSkip = 0; + } + // Ideally the bytes to skip would be passed in the userToken param, + // but GetWebRequest can't access it!! + OpenReadAsync(url, path); + }); + } + + /// + /// Same as DownloadProgressChanged, but usable by us. + /// Called with percent, bytes received, total bytes to receive. + /// + /// DownloadProgressChangedEventArg has an internal constructor + /// and readonly properties, and everyplace that does make one + /// is private instead of protected, so we have to reinvent this wheel. + /// (Meanwhile AsyncCompletedEventArgs has none of these problems.) + /// + public event Action DownloadProgress; + + protected override WebRequest GetWebRequest(Uri address) + { + var request = base.GetWebRequest(address); + if (request is HttpWebRequest webRequest && bytesToSkip > 0) + { + log.DebugFormat("Skipping {0} bytes of {1}", bytesToSkip, address); + webRequest.AddRange(bytesToSkip); + } + return request; + } + + protected override WebResponse GetWebResponse(WebRequest request, IAsyncResult result) + { + try + { + var response = base.GetWebResponse(request, result); + contentLength = response.ContentLength; + return response; + } + catch (WebException wexc) + when (wexc.Status == WebExceptionStatus.ProtocolError + && wexc.Response is HttpWebResponse response + && response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable) + { + log.Debug("GetWebResponse failed with range error, closing stream and suppressing exception"); + // Don't save the error page into a file + response.Close(); + return response; + } + catch (Exception exc) + { + log.Debug("Failed to get web response", exc); + OnDownloadFileCompleted(new AsyncCompletedEventArgs(exc, false, null)); + throw; + } + } + + protected override void OnOpenReadCompleted(OpenReadCompletedEventArgs e) + { + base.OnOpenReadCompleted(e); + if (!e.Cancelled && e.Error == null) + { + var destination = e.UserState as string; + using (var netStream = e.Result) + { + if (!netStream.CanRead) + { + log.Debug("OnOpenReadCompleted got closed stream, skipping download"); + } + else + { + log.DebugFormat("OnOpenReadCompleted got open stream, appending to {0}", destination); + using (var fileStream = new FileStream(destination, FileMode.Append, FileAccess.Write)) + { + netStream.CopyTo(fileStream, new Progress(bytesDownloaded => + { + DownloadProgress?.Invoke(100 * (int)(bytesDownloaded / contentLength), + bytesDownloaded, contentLength); + })); + } + } + } + } + OnDownloadFileCompleted(new AsyncCompletedEventArgs(e.Error, e.Cancelled, e.UserState)); + } + + private long bytesToSkip = 0; + private long contentLength = 0; + private static readonly ILog log = LogManager.GetLogger(typeof(ResumingWebClient)); + } +} diff --git a/Core/Properties/Resources.Designer.cs b/Core/Properties/Resources.Designer.cs index 425749264f..770b674491 100644 --- a/Core/Properties/Resources.Designer.cs +++ b/Core/Properties/Resources.Designer.cs @@ -340,6 +340,19 @@ internal static string GameInstancePathNotFound { get { return (string)(ResourceManager.GetObject("GameInstancePathNotFound", resourceCulture)); } } + internal static string NetModuleCacheMetapackage { + get { return (string)(ResourceManager.GetObject("NetModuleCacheModuleHostSize", resourceCulture)); } + } + internal static string NetModuleCacheModuleHostSize { + get { return (string)(ResourceManager.GetObject("NetModuleCacheModuleHostSize", resourceCulture)); } + } + internal static string NetModuleCacheModuleCached { + get { return (string)(ResourceManager.GetObject("NetModuleCacheModuleCached", resourceCulture)); } + } + internal static string NetModuleCacheModuleResuming { + get { return (string)(ResourceManager.GetObject("NetModuleCacheModuleResuming", resourceCulture)); } + } + internal static string ModuleInstallerDownloading { get { return (string)(ResourceManager.GetObject("ModuleInstallerDownloading", resourceCulture)); } } @@ -349,9 +362,6 @@ internal static string ModuleInstallerNothingToInstall { internal static string ModuleInstallerAboutToInstall { get { return (string)(ResourceManager.GetObject("ModuleInstallerAboutToInstall", resourceCulture)); } } - internal static string ModuleInstallerModuleCached { - get { return (string)(ResourceManager.GetObject("ModuleInstallerModuleCached", resourceCulture)); } - } internal static string ModuleInstallerUserDeclined { get { return (string)(ResourceManager.GetObject("ModuleInstallerUserDeclined", resourceCulture)); } } @@ -418,6 +428,9 @@ internal static string ModuleInstallerAboutToUpgrade { internal static string ModuleInstallerUpgradeInstallingUncached { get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeInstallingUncached", resourceCulture)); } } + internal static string ModuleInstallerUpgradeInstallingResuming { + get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeInstallingResuming", resourceCulture)); } + } internal static string ModuleInstallerUpgradeInstallingCached { get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeInstallingCached", resourceCulture)); } } @@ -433,6 +446,9 @@ internal static string ModuleInstallerUpgradeUpgradingUncached { internal static string ModuleInstallerUpgradeUpgradingCached { get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeUpgradingCached", resourceCulture)); } } + internal static string ModuleInstallerUpgradeUpgradingResuming { + get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeUpgradingResuming", resourceCulture)); } + } internal static string ModuleInstallerUpgradeUserDeclined { get { return (string)(ResourceManager.GetObject("ModuleInstallerUpgradeUserDeclined", resourceCulture)); } } diff --git a/Core/Properties/Resources.de-DE.resx b/Core/Properties/Resources.de-DE.resx index a00076a67a..86ec8eeed3 100644 --- a/Core/Properties/Resources.de-DE.resx +++ b/Core/Properties/Resources.de-DE.resx @@ -118,4 +118,5 @@ System.Resources.ResXResourceWriter, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Neue Mod-Installation ausgewählt vom Nutzer + {0} {1} (Metapacket) diff --git a/Core/Properties/Resources.fr-FR.resx b/Core/Properties/Resources.fr-FR.resx index 3caccd36b1..262f6344d2 100644 --- a/Core/Properties/Resources.fr-FR.resx +++ b/Core/Properties/Resources.fr-FR.resx @@ -213,7 +213,6 @@ Souhaitez-vous réinstaller maintenant ? Téléchargement de "{0}" Rien à installer Vont être installés : - * {0} {1} (en cache) L'utilisateur a refusé la liste d'installation Installation du mod "{0}" Mise à jour du registre @@ -294,4 +293,7 @@ Si vous êtes certain que ce n'est pas le cas, alors supprimez : Recommandé par {0} {0} a une dépendance non-satisfaite : {1} n'est pas installé {0} est en conflit avec {1} + {0} {1} (méta-paquet) + {0} {1} (en cache) + {0} {1} ({2}, {3}) diff --git a/Core/Properties/Resources.ja-JP.resx b/Core/Properties/Resources.ja-JP.resx new file mode 100644 index 0000000000..b72e4546db --- /dev/null +++ b/Core/Properties/Resources.ja-JP.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {0} {1} (メタパッケージ) + {0} {1} (キャッシュ済) + {0} {1} ({2}, {3}) + diff --git a/Core/Properties/Resources.ko-KR.resx b/Core/Properties/Resources.ko-KR.resx new file mode 100644 index 0000000000..cc23f52e77 --- /dev/null +++ b/Core/Properties/Resources.ko-KR.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + {0} {1} (메타패키지) + {0} {1} (캐시됨) + {0} {1} ({2}, {3}) + diff --git a/Core/Properties/Resources.pt-BR.resx b/Core/Properties/Resources.pt-BR.resx index 8498c3ad09..f51f491f62 100644 --- a/Core/Properties/Resources.pt-BR.resx +++ b/Core/Properties/Resources.pt-BR.resx @@ -118,4 +118,7 @@ System.Resources.ResXResourceWriter, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Nova instalação de mod selecionada pelo usuário + {0} {1} (metapackage) + {0} {1} (Em cache) + {0} {1} ({2}, {3}) diff --git a/Core/Properties/Resources.resx b/Core/Properties/Resources.resx index 8d9dffbeb0..78004e460f 100644 --- a/Core/Properties/Resources.resx +++ b/Core/Properties/Resources.resx @@ -214,7 +214,6 @@ Do you wish to reinstall now? Downloading "{0}" Nothing to install About to install: - * {0} {1} (cached) User declined install list Installing mod "{0}" Updating registry @@ -302,4 +301,10 @@ Free up space on that device or change your settings to use another location. Recommended by {0} {0} has an unsatisfied dependency: {1} is not installed {0} conflicts with {1} + {0} {1} (metapackage) + {0} {1} (cached) + {0} {1} ({2}, {3}) + {0} {1} ({2}, {3} remaining) + * Install: {0} {1} ({2}, {3} remaining) + * Upgrade: {0} {1} to {2} ({3}, {4} remaining) diff --git a/Core/Properties/Resources.ru-RU.resx b/Core/Properties/Resources.ru-RU.resx index 90557b3910..8aa835422a 100644 --- a/Core/Properties/Resources.ru-RU.resx +++ b/Core/Properties/Resources.ru-RU.resx @@ -213,7 +213,6 @@ Загружается «{0}» Нечего устанавливать Будут установлены: - * {0} {1} (кэшировано) Пользователь отклонил установку Установка модификации «{0}» Обновление реестра @@ -294,4 +293,7 @@ https://github.com/KSP-CKAN/CKAN/wiki/SSL-certificate-errors Рекомендуется {0} {0} имеет неудовлетворённую зависимость: {1} не установлен {0} конфликтует с {1} + {0} {1} (метапакет) + {0} {1} (кэшировано) + {0} {1} ({2}, {3}) diff --git a/Core/Properties/Resources.zh-CN.resx b/Core/Properties/Resources.zh-CN.resx index d0a23003e7..8f2c676bfd 100644 --- a/Core/Properties/Resources.zh-CN.resx +++ b/Core/Properties/Resources.zh-CN.resx @@ -213,7 +213,6 @@ 正在下载 "{0}" 没有需要安装的 中止安装: - * {0} {1} (已缓存) 用户取消安装列表 安装模组 "{0}" 正在更新注册表 @@ -293,4 +292,7 @@ https://github.com/KSP-CKAN/CKAN/wiki/SSL-certificate-errors 由 {0} 推荐 {0} 有一个未满足的依赖项: {1} 未安装 {0} 与 {1} 冲突 + {0} {1} (metapackage) + {0} {1} (已缓存) + {0} {1} ({2}, {3}) diff --git a/GUI/Controls/ChooseProvidedMods.cs b/GUI/Controls/ChooseProvidedMods.cs index a2610daaf3..1bb72773a5 100644 --- a/GUI/Controls/ChooseProvidedMods.cs +++ b/GUI/Controls/ChooseProvidedMods.cs @@ -18,18 +18,16 @@ public void LoadProviders(string message, List modules, NetModuleCac Util.Invoke(this, () => { ChooseProvidedModsLabel.Text = message; - + ChooseProvidedModsListView.Items.Clear(); ChooseProvidedModsListView.Items.AddRange(modules .Select(module => new ListViewItem(new string[] { - cache.IsMaybeCachedZip(module) - ? string.Format(Properties.Resources.MainChangesetCached, module.name, module.version) - : string.Format(Properties.Resources.MainChangesetHostSize, module.name, module.version, module.download.Host ?? "", CkanModule.FmtSize(module.download_size)), + cache.DescribeAvailability(module), module.@abstract }) { - Tag = module, + Tag = module, Checked = false }) .ToArray()); diff --git a/GUI/Controls/ChooseRecommendedMods.cs b/GUI/Controls/ChooseRecommendedMods.cs index 4bc4b3fdee..c7f33379e1 100644 --- a/GUI/Controls/ChooseRecommendedMods.cs +++ b/GUI/Controls/ChooseRecommendedMods.cs @@ -173,10 +173,7 @@ private ListViewItem getRecSugItem(NetModuleCache cache, CkanModule module, stri { return new ListViewItem(new string[] { - module.IsDLC ? module.name - : cache.IsMaybeCachedZip(module) - ? string.Format(Properties.Resources.MainChangesetCached, module.name, module.version) - : string.Format(Properties.Resources.MainChangesetHostSize, module.name, module.version, module.download?.Host ?? "", CkanModule.FmtSize(module.download_size)), + module.IsDLC ? module.name : cache.DescribeAvailability(module), descrip, module.@abstract }) diff --git a/GUI/Controls/Wait.cs b/GUI/Controls/Wait.cs index 3160a5611d..0afed08dfe 100644 --- a/GUI/Controls/Wait.cs +++ b/GUI/Controls/Wait.cs @@ -117,12 +117,7 @@ public void SetProgress(string label, long remaining, long total) /// Number of bytes in complete download public void SetModuleProgress(CkanModule module, long remaining, long total) { - SetProgress(string.Format(Properties.Resources.MainChangesetHostSize, - module.name, - module.version, - module.download.Host ?? "", - CkanModule.FmtSize(module.download_size)), - remaining, total); + SetProgress(module.ToString(), remaining, total); } private Action bgLogic; diff --git a/GUI/Model/ModChange.cs b/GUI/Model/ModChange.cs index 2cb1a30cbb..afd97437bb 100644 --- a/GUI/Model/ModChange.cs +++ b/GUI/Model/ModChange.cs @@ -75,21 +75,11 @@ public override string ToString() return $"{ChangeType.ToI18nString()} {Mod} ({Reason})"; } - protected string modNameAndStatus(CkanModule m) - { - return m.IsMetapackage - ? string.Format(Properties.Resources.MainChangesetMetapackage, m.name, m.version) - : Main.Instance.Manager.Cache.IsMaybeCachedZip(m) - ? string.Format(Properties.Resources.MainChangesetCached, m.name, m.version) - : string.Format(Properties.Resources.MainChangesetHostSize, - m.name, m.version, m.download.Host ?? "", CkanModule.FmtSize(m.download_size)); - } - public virtual string NameAndStatus { get { - return modNameAndStatus(Mod); + return Main.Instance.Manager.Cache.DescribeAvailability(Mod); } } @@ -114,7 +104,7 @@ public override string NameAndStatus { get { - return modNameAndStatus(targetMod); + return Main.Instance.Manager.Cache.DescribeAvailability(targetMod); } } diff --git a/GUI/Properties/Resources.Designer.cs b/GUI/Properties/Resources.Designer.cs index 77602c6c47..816ee24d89 100644 --- a/GUI/Properties/Resources.Designer.cs +++ b/GUI/Properties/Resources.Designer.cs @@ -488,15 +488,6 @@ internal static string AllModVersionsInstallNo { get { return (string)(ResourceManager.GetObject("AllModVersionsInstallNo", resourceCulture)); } } - internal static string MainChangesetMetapackage { - get { return (string)(ResourceManager.GetObject("MainChangesetMetapackage", resourceCulture)); } - } - internal static string MainChangesetCached { - get { return (string)(ResourceManager.GetObject("MainChangesetCached", resourceCulture)); } - } - internal static string MainChangesetHostSize { - get { return (string)(ResourceManager.GetObject("MainChangesetHostSize", resourceCulture)); } - } internal static string MainChangesetUpdateSelected { get { return (string)(ResourceManager.GetObject("MainChangesetUpdateSelected", resourceCulture)); } } diff --git a/GUI/Properties/Resources.de-DE.resx b/GUI/Properties/Resources.de-DE.resx index 70a0241742..d33416298e 100644 --- a/GUI/Properties/Resources.de-DE.resx +++ b/GUI/Properties/Resources.de-DE.resx @@ -192,7 +192,6 @@ Das bedeuted, dass CKAN alle installierten Mods vergessen hat, sie befinden sich Möchten Sie es wirklich installieren? Installieren Abbrechen - {0} {1} (Metapacket) Mod-Update ausgewählt vom Nutzer {0}. Mod-Import Statuslog diff --git a/GUI/Properties/Resources.fr-FR.resx b/GUI/Properties/Resources.fr-FR.resx index 4b4ef51a7e..482dc72acf 100644 --- a/GUI/Properties/Resources.fr-FR.resx +++ b/GUI/Properties/Resources.fr-FR.resx @@ -193,9 +193,6 @@ Cela veut dire que CKAN a oublié tous les mods que vous avez installé, mais ce Voulez-vous vraiment l'installer? Installer Annuler - {0} {1} (méta-paquet) - {0} {1} (en cache) - {0} {1} ({2}, {3}) Mise à jour demandée vers la version {0}. Importer des Mods Mods (*.zip)|*.zip diff --git a/GUI/Properties/Resources.ja-JP.resx b/GUI/Properties/Resources.ja-JP.resx index f50c48fb46..eef926d8f0 100644 --- a/GUI/Properties/Resources.ja-JP.resx +++ b/GUI/Properties/Resources.ja-JP.resx @@ -194,9 +194,6 @@ Try to move {2} out of {3} and restart CKAN. 本当にインストールしますか? インストール 取消 - {0} {1} (メタパッケージ) - {0} {1} (キャッシュ済) - {0} {1} ({2}, {3}) 選択されたものをバージョン{0}にアップデートする。 Modインポート Mod (*.zip)|*.zip diff --git a/GUI/Properties/Resources.ko-KR.resx b/GUI/Properties/Resources.ko-KR.resx index b3adcba285..67b6322c86 100644 --- a/GUI/Properties/Resources.ko-KR.resx +++ b/GUI/Properties/Resources.ko-KR.resx @@ -214,9 +214,6 @@ 정말 이 모드를 설치할까요? 설치 취소하기 - {0} {1} (메타패키지) - {0} {1} (캐시됨) - {0} {1} ({2}, {3}) 유저가 선택한 것을 버전 {0}으로 업데이트 하기. 유저가 선택한 모드를 설치하기. 모드들 가져오기 diff --git a/GUI/Properties/Resources.pt-BR.resx b/GUI/Properties/Resources.pt-BR.resx index fe979e9ed2..3db47b8d1d 100644 --- a/GUI/Properties/Resources.pt-BR.resx +++ b/GUI/Properties/Resources.pt-BR.resx @@ -189,9 +189,6 @@ Tentando mover {2} de {3} e reiniciar o CKAN. Você quer realmente instalá-la? Instalar Cancelar - {0} {1} (metapackage) - {0} {1} (Em cache) - {0} {1} ({2}, {3}) Atualização selecionada pelo usuário para a versão {0}. Importar mods Mods (*.zip)|*.zip diff --git a/GUI/Properties/Resources.resx b/GUI/Properties/Resources.resx index 4de5d02452..b401ab0cc7 100644 --- a/GUI/Properties/Resources.resx +++ b/GUI/Properties/Resources.resx @@ -217,9 +217,6 @@ This means that CKAN forgot about all your installed mods, but they are still in Do you really want to install it? Install Cancel - {0} {1} (metapackage) - {0} {1} (cached) - {0} {1} ({2}, {3}) Update selected by user to version {0}. Import Mods Mods (*.zip)|*.zip diff --git a/GUI/Properties/Resources.ru-RU.resx b/GUI/Properties/Resources.ru-RU.resx index 370db3cc34..4e0fcd640a 100644 --- a/GUI/Properties/Resources.ru-RU.resx +++ b/GUI/Properties/Resources.ru-RU.resx @@ -192,9 +192,6 @@ Вы действительно хотите установить его? Установить Отмена - {0} {1} (метапакет) - {0} {1} (загружено) - {0} {1} ({2}, {3}) Обновить выбранное пользователем до версии {0}. Импортировать модификации Модификации (*.zip)|*.zip diff --git a/GUI/Properties/Resources.zh-CN.resx b/GUI/Properties/Resources.zh-CN.resx index 1116539100..a9b66e36ac 100644 --- a/GUI/Properties/Resources.zh-CN.resx +++ b/GUI/Properties/Resources.zh-CN.resx @@ -188,9 +188,6 @@ 您真的想要安装它吗? 安装 取消 - {0} {1} (metapackage) - {0} {1} (cached) - {0} {1} ({2}, {3}) 用户选择更新到 {0} 版本. 导入Mod Mods (*.zip)|*.zip