• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KSP-CKAN / CKAN / 21418537054

27 Jan 2026 11:36PM UTC coverage: 85.296% (-0.05%) from 85.342%
21418537054

Pull #4489

github

web-flow
Merge 4df90efb5 into 07b29fbe9
Pull Request #4489: Add x_netkan_check_parent_downloads to spec

1996 of 2161 branches covered (92.36%)

Branch coverage included in aggregate %.

11949 of 14188 relevant lines covered (84.22%)

1.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.38
/Core/IO/ModuleInstaller.cs
1
using System;
2
using System.IO;
3
using System.Linq;
4
using System.Collections.Generic;
5
using System.Threading;
6

7
using ICSharpCode.SharpZipLib.Core;
8
using ICSharpCode.SharpZipLib.Zip;
9
using log4net;
10
using ChinhDo.Transactions;
11

12
using CKAN.Extensions;
13
using CKAN.Versioning;
14
using CKAN.Configuration;
15
using CKAN.Games;
16

17
namespace CKAN.IO
18
{
19
    public struct InstallableFile
20
    {
21
        public ZipEntry source;
22
        public string   destination;
23
        public bool     makedir;
24
    }
25

26
    public class ModuleInstaller
27
    {
28
        // Constructor
29
        public ModuleInstaller(GameInstance      inst,
2✔
30
                               NetModuleCache    cache,
31
                               IConfiguration    config,
32
                               IUser             user,
33
                               CancellationToken cancelToken = default)
34
        {
2✔
35
            log.DebugFormat("Creating ModuleInstaller for {0}", inst.GameDir);
2✔
36
            instance         = inst;
2✔
37
            // Make a transaction file manager that uses a temp dir in the instance's CKAN dir
38
            this.cache       = cache;
2✔
39
            this.config      = config;
2✔
40
            User             = user;
2✔
41
            this.cancelToken = cancelToken;
2✔
42
        }
2✔
43

44
        public IUser User { get; set; }
45

46
        public event Action<CkanModule, long, long>?      InstallProgress;
47
        public event Action<InstalledModule, long, long>? RemoveProgress;
48
        public event Action<CkanModule>?                  OneComplete;
49

50
        #region Installation
51

52
        /// <summary>
53
        ///     Installs all modules given a list of identifiers as a transaction. Resolves dependencies.
54
        ///     This *will* save the registry at the end of operation.
55
        ///
56
        /// Propagates a BadMetadataKraken if our install metadata is bad.
57
        /// Propagates a FileExistsKraken if we were going to overwrite a file.
58
        /// Propagates a CancelledActionKraken if the user cancelled the install.
59
        /// </summary>
60
        public void InstallList(IReadOnlyCollection<CkanModule> modules,
61
                                RelationshipResolverOptions     options,
62
                                RegistryManager                 registry_manager,
63
                                ref HashSet<string>?            possibleConfigOnlyDirs,
64
                                InstalledFilesDeduplicator?     deduper       = null,
65
                                string?                         userAgent     = null,
66
                                IDownloader?                    downloader    = null,
67
                                ISet<CkanModule>?               autoInstalled = null,
68
                                bool                            ConfirmPrompt = true)
69
        {
2✔
70
            if (modules.Count == 0)
2✔
71
            {
×
72
                User.RaiseProgress(Properties.Resources.ModuleInstallerNothingToInstall, 100);
×
73
                return;
×
74
            }
75
            var resolver = new RelationshipResolver(modules, null, options,
2✔
76
                                                    registry_manager.registry,
77
                                                    instance.Game, instance.VersionCriteria());
78
            var modsToInstall = resolver.ModList().ToArray();
2✔
79
            // Alert about attempts to install DLC before downloading or installing anything
80
            var dlc = modsToInstall.Where(m => m.IsDLC).ToArray();
2✔
81
            if (dlc.Length > 0)
2✔
82
            {
2✔
83
                throw new ModuleIsDLCKraken(dlc.First());
2✔
84
            }
85

86
            // Make sure we have enough space to install this stuff
87
            var installBytes = modsToInstall.Sum(m => m.install_size);
2✔
88
            CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.GameDir),
2✔
89
                                         installBytes,
90
                                         Properties.Resources.NotEnoughSpaceToInstall);
91

92
            var cached    = new List<CkanModule>();
2✔
93
            var downloads = new List<CkanModule>();
2✔
94
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToInstall);
2✔
95
            User.RaiseMessage("");
2✔
96
            foreach (var module in modsToInstall)
8✔
97
            {
2✔
98
                User.RaiseMessage(" * {0}", cache.DescribeAvailability(config, module));
2✔
99
                if (!module.IsMetapackage && !cache.IsMaybeCachedZip(module))
2✔
100
                {
2✔
101
                    downloads.Add(module);
2✔
102
                }
2✔
103
                else
104
                {
2✔
105
                    cached.Add(module);
2✔
106
                }
2✔
107
            }
2✔
108
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
109
            {
×
110
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUserDeclined);
×
111
            }
112

113
            var downloadBytes = CkanModule.GroupByDownloads(downloads)
2✔
114
                                          .Sum(grp => grp.First().download_size);
2✔
115
            var rateCounter = new ByteRateCounter()
2✔
116
            {
117
                Size      = downloadBytes + installBytes,
118
                BytesLeft = downloadBytes + installBytes,
119
            };
120
            rateCounter.Start();
2✔
121
            long downloadedBytes = 0;
2✔
122
            long installedBytes  = 0;
2✔
123
            if (downloads.Count > 0)
2✔
124
            {
2✔
125
                downloader ??= new NetAsyncModulesDownloader(User, cache, userAgent, cancelToken);
2✔
126
                downloader.OverallDownloadProgress += brc =>
2✔
127
                {
2✔
128
                    downloadedBytes = downloadBytes - brc.BytesLeft;
2✔
129
                    rateCounter.BytesLeft = downloadBytes - downloadedBytes
2✔
130
                                          + installBytes  - installedBytes;
131
                    User.RaiseProgress(rateCounter);
2✔
132
                };
2✔
133
            }
2✔
134

135
            // We're about to install all our mods; so begin our transaction.
136
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
137
            {
2✔
138
                var gameDir = new DirectoryInfo(instance.GameDir);
2✔
139
                long modInstallCompletedBytes = 0;
2✔
140
                foreach (var mod in ModsInDependencyOrder(resolver, cached, downloads, downloader))
8✔
141
                {
2✔
142
                    // Re-check that there's enough free space in case game dir and cache are on same drive
143
                    CKANPathUtils.CheckFreeSpace(gameDir, mod.install_size,
2✔
144
                                                 Properties.Resources.NotEnoughSpaceToInstall);
145
                    Install(mod,
2✔
146
                            (autoInstalled?.Contains(mod) ?? false) || resolver.IsAutoInstalled(mod),
147
                            registry_manager.registry,
148
                            deduper?.ModuleCandidateDuplicates(mod.identifier, mod.version),
149
                            ref possibleConfigOnlyDirs,
150
                            new ProgressImmediate<long>(bytes =>
151
                            {
2✔
152
                                InstallProgress?.Invoke(mod,
2✔
153
                                                        Math.Max(0,     mod.install_size - bytes),
154
                                                        Math.Max(bytes, mod.install_size));
155
                                installedBytes = modInstallCompletedBytes
2✔
156
                                                 + Math.Min(bytes, mod.install_size);
157
                                rateCounter.BytesLeft = downloadBytes - downloadedBytes
2✔
158
                                                      + installBytes  - installedBytes;
159
                                User.RaiseProgress(rateCounter);
2✔
160
                            }));
2✔
161
                    modInstallCompletedBytes += mod.install_size;
2✔
162
                }
2✔
163
                rateCounter.Stop();
2✔
164

165
                User.RaiseProgress(Properties.Resources.ModuleInstallerUpdatingRegistry, 90);
2✔
166
                registry_manager.Save(!options.without_enforce_consistency);
2✔
167

168
                User.RaiseProgress(Properties.Resources.ModuleInstallerCommitting, 95);
2✔
169
                transaction.Complete();
2✔
170
            }
2✔
171

172
            EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
173
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
174
        }
2✔
175

176
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver            resolver,
177
                                                                     IReadOnlyCollection<CkanModule> cached,
178
                                                                     IReadOnlyCollection<CkanModule> toDownload,
179
                                                                     IDownloader?                    downloader)
180

181
            => ModsInDependencyOrder(resolver, cached,
2✔
182
                                     downloader != null && toDownload.Count > 0
183
                                         ? downloader.ModulesAsTheyFinish(cached, toDownload)
184
                                         : null);
185

186
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver            resolver,
187
                                                                     IReadOnlyCollection<CkanModule> cached,
188
                                                                     IEnumerable<CkanModule>?        downloading)
189
        {
2✔
190
            var waiting = new HashSet<CkanModule>();
2✔
191
            var done    = new HashSet<CkanModule>();
2✔
192
            if (downloading != null)
2✔
193
            {
2✔
194
                foreach (var newlyCached in downloading)
8✔
195
                {
2✔
196
                    waiting.Add(newlyCached);
2✔
197
                    foreach (var m in OnePass(resolver, waiting, done))
8✔
198
                    {
2✔
199
                        yield return m;
2✔
200
                    }
2✔
201
                }
2✔
202
            }
2✔
203
            else
204
            {
2✔
205
                waiting.UnionWith(cached);
2✔
206
                // Treat excluded mods as already done
207
                done.UnionWith(resolver.ModList().Except(waiting));
2✔
208
                foreach (var m in OnePass(resolver, waiting, done))
8✔
209
                {
2✔
210
                    yield return m;
2✔
211
                }
2✔
212
            }
2✔
213
            if (waiting.Count > 0)
2✔
214
            {
×
215
                // Sanity check in case something goes haywire with the threading logic
216
                throw new InconsistentKraken(
×
217
                    string.Format("Mods should have been installed but were not: {0}",
218
                                  string.Join(", ", waiting)));
219
            }
220
        }
2✔
221

222
        private static IEnumerable<CkanModule> OnePass(RelationshipResolver resolver,
223
                                                       HashSet<CkanModule>  waiting,
224
                                                       HashSet<CkanModule>  done)
225
        {
2✔
226
            while (true)
2✔
227
            {
2✔
228
                var newlyDone = waiting.Where(m => resolver.ReadyToInstall(m, done))
2✔
229
                                       .OrderBy(m => m.identifier)
2✔
230
                                       .ToArray();
231
                if (newlyDone.Length == 0)
2✔
232
                {
2✔
233
                    // No mods ready to install
234
                    break;
2✔
235
                }
236
                foreach (var m in newlyDone)
8✔
237
                {
2✔
238
                    waiting.Remove(m);
2✔
239
                    done.Add(m);
2✔
240
                    yield return m;
2✔
241
                }
2✔
242
            }
2✔
243
        }
2✔
244

245
        /// <summary>
246
        ///     Install our mod from the filename supplied.
247
        ///     If no file is supplied, we will check the cache or throw FileNotFoundKraken.
248
        ///     Does *not* resolve dependencies; this does the heavy lifting.
249
        ///     Does *not* save the registry.
250
        ///     Do *not* call this directly, use InstallList() instead.
251
        ///
252
        /// Propagates a BadMetadataKraken if our install metadata is bad.
253
        /// Propagates a FileExistsKraken if we were going to overwrite a file.
254
        /// Throws a FileNotFoundKraken if we can't find the downloaded module.
255
        ///
256
        /// TODO: The name of this and InstallModule() need to be made more distinctive.
257
        /// </summary>
258
        private void Install(CkanModule                                         module,
259
                             bool                                               autoInstalled,
260
                             Registry                                           registry,
261
                             Dictionary<(string relPath, long size), string[]>? candidateDuplicates,
262
                             ref HashSet<string>?                               possibleConfigOnlyDirs,
263
                             IProgress<long>?                                   progress)
264
        {
2✔
265
            CheckKindInstallationKraken(module);
2✔
266
            var version = registry.InstalledVersion(module.identifier);
2✔
267

268
            // TODO: This really should be handled by higher-up code.
269
            if (version is not null and not UnmanagedModuleVersion)
2✔
270
            {
2✔
271
                User.RaiseMessage(Properties.Resources.ModuleInstallerAlreadyInstalled,
2✔
272
                                  module.name, version);
273
                return;
2✔
274
            }
275

276
            string? filename = null;
2✔
277
            if (!module.IsMetapackage)
2✔
278
            {
2✔
279
                // Find ZIP in the cache if we don't already have it.
280
                filename ??= cache.GetCachedFilename(module);
2✔
281

282
                // If we *still* don't have a file, then kraken bitterly.
283
                if (filename == null)
2✔
284
                {
×
285
                    throw new FileNotFoundKraken(null,
×
286
                                                 string.Format(Properties.Resources.ModuleInstallerZIPNotInCache,
287
                                                               module));
288
                }
289
            }
2✔
290

291
            User.RaiseMessage(Properties.Resources.ModuleInstallerInstallingMod,
2✔
292
                              $"{module.name} {module.version}");
293

294
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
295
            {
2✔
296
                // Install all the things!
297
                var files = InstallModule(module, filename, registry, candidateDuplicates,
2✔
298
                                          ref possibleConfigOnlyDirs, out int filteredCount, progress);
299

300
                // Register our module and its files.
301
                registry.RegisterModule(module, files, instance, autoInstalled);
2✔
302

303
                // Finish our transaction, but *don't* save the registry; we may be in an
304
                // intermediate, inconsistent state.
305
                // This is fine from a transaction standpoint, as we may not have an enclosing
306
                // transaction, and if we do, they can always roll us back.
307
                transaction.Complete();
2✔
308

309
                if (filteredCount > 0)
2✔
310
                {
2✔
311
                    User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledModFiltered,
2✔
312
                                      $"{module.name} {module.version}", filteredCount);
313
                }
2✔
314
                else
315
                {
2✔
316
                    User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
2✔
317
                                      $"{module.name} {module.version}");
318
                }
2✔
319
            }
2✔
320

321
            // Fire our callback that we've installed a module, if we have one.
322
            OneComplete?.Invoke(module);
2✔
323
        }
2✔
324

325
        /// <summary>
326
        /// Check if the given module is a DLC:
327
        /// if it is, throws ModuleIsDLCKraken.
328
        /// </summary>
329
        private static void CheckKindInstallationKraken(CkanModule module)
330
        {
2✔
331
            if (module.IsDLC)
2✔
332
            {
×
333
                throw new ModuleIsDLCKraken(module);
×
334
            }
335
        }
2✔
336

337
        /// <summary>
338
        /// Installs the module from the zipfile provided.
339
        /// Returns a list of files installed.
340
        /// Propagates a DllLocationMismatchKraken if the user has a bad manual install.
341
        /// Propagates a BadMetadataKraken if our install metadata is bad.
342
        /// Propagates a CancelledActionKraken if the user decides not to overwite unowned files.
343
        /// Propagates a FileExistsKraken if we were going to overwrite a file.
344
        /// </summary>
345
        private List<string> InstallModule(CkanModule                                         module,
346
                                           string?                                            zip_filename,
347
                                           Registry                                           registry,
348
                                           Dictionary<(string relPath, long size), string[]>? candidateDuplicates,
349
                                           ref HashSet<string>?                               possibleConfigOnlyDirs,
350
                                           out int                                            filteredCount,
351
                                           IProgress<long>?                                   moduleProgress)
352
        {
2✔
353
            var createdPaths = new List<string>();
2✔
354
            if (module.IsMetapackage || zip_filename == null)
2✔
355
            {
2✔
356
                // It's OK to include metapackages in changesets,
357
                // but there's no work to do for them
358
                filteredCount = 0;
2✔
359
                return createdPaths;
2✔
360
            }
361
            using (ZipFile zipfile = new ZipFile(zip_filename))
2✔
362
            {
2✔
363
                var filters = config.GetGlobalInstallFilters(instance.Game)
2✔
364
                                    .Concat(instance.InstallFilters)
365
                                    .ToHashSet();
366
                var groups = FindInstallableFiles(module, zipfile, instance.Game)
2✔
367
                    // Skip the file if it's a ckan file, these should never be copied to GameData
368
                    .Where(instF => !IsInternalCkan(instF.source))
2✔
369
                    // Check whether each file matches any installation filter
370
                    .ToGroupedDictionary(instF => filters.Any(filt => instF.destination != null
2✔
371
                                                                      && instF.destination.Contains(filt)));
372
                var files = groups.GetValueOrDefault(false) ?? Array.Empty<InstallableFile>();
2✔
373
                filteredCount = groups.GetValueOrDefault(true)?.Length ?? 0;
2✔
374
                try
375
                {
2✔
376
                    if (registry.DllPath(module.identifier)
2✔
377
                        is string { Length: > 0 } dll)
378
                    {
2✔
379
                        // Find where we're installing identifier.optionalversion.dll
380
                        // (file name might not be an exact match with manually installed)
381
                        var dllFolders = files
2✔
382
                            .Select(f => f.destination)
2✔
383
                            .Where(relPath => instance.DllPathToIdentifier(relPath) == module.identifier)
2✔
384
                            .Select(Path.GetDirectoryName)
385
                            .ToHashSet();
386
                        // Make sure that the DLL is actually included in the install
387
                        // (NearFutureElectrical, NearFutureElectrical-Core)
388
                        if (dllFolders.Count > 0 && registry.FileOwner(dll) == null)
2✔
389
                        {
2✔
390
                            if (!dllFolders.Contains(Path.GetDirectoryName(dll)))
2✔
391
                            {
2✔
392
                                // Manually installed DLL is somewhere else where we're not installing files,
393
                                // probable bad install, alert user and abort
394
                                throw new DllLocationMismatchKraken(dll, string.Format(
2✔
395
                                    Properties.Resources.ModuleInstallerBadDLLLocation, module.identifier, dll));
396
                            }
397
                            // Delete the manually installed DLL transaction-style because we believe we'll be replacing it
398
                            var toDelete = instance.ToAbsoluteGameDir(dll);
2✔
399
                            log.DebugFormat("Deleting manually installed DLL {0}", toDelete);
2✔
400
                            var txFileMgr = new TxFileManager(instance.CkanDir);
2✔
401
                            txFileMgr.Snapshot(toDelete);
2✔
402
                            txFileMgr.Delete(toDelete);
2✔
403
                        }
2✔
404
                    }
2✔
405

406
                    // Look for overwritable files if session is interactive
407
                    if (!User.Headless)
2✔
408
                    {
2✔
409
                        if (FindConflictingFiles(zipfile, files, registry).ToArray()
2✔
410
                            is (InstallableFile file, bool same)[] { Length: > 0 } conflicting)
411
                        {
2✔
412
                            var fileMsg = string.Join(Environment.NewLine,
2✔
413
                                                      conflicting.OrderBy(tuple => tuple.same)
2✔
414
                                                                 .Select(tuple => $"- {tuple.file.destination}  ({(tuple.same ? Properties.Resources.ModuleInstallerFileSame : Properties.Resources.ModuleInstallerFileDifferent)})"));
2✔
415
                            if (User.RaiseYesNoDialog(string.Format(Properties.Resources.ModuleInstallerOverwrite,
2✔
416
                                                                    module.name, fileMsg)))
417
                            {
2✔
418
                                DeleteConflictingFiles(conflicting.Select(tuple => tuple.file));
2✔
419
                            }
2✔
420
                            else
421
                            {
2✔
422
                                throw new CancelledActionKraken(string.Format(Properties.Resources.ModuleInstallerOverwriteCancelled,
2✔
423
                                                                              module.name));
424
                            }
425
                        }
2✔
426
                    }
2✔
427
                    long installedBytes = 0;
2✔
428
                    var fileProgress = new ProgressImmediate<long>(bytes => moduleProgress?.Report(installedBytes + bytes));
2✔
429
                    foreach (InstallableFile file in files)
8✔
430
                    {
2✔
431
                        if (cancelToken.IsCancellationRequested)
2✔
432
                        {
×
433
                            throw new CancelledActionKraken();
×
434
                        }
435
                        log.DebugFormat("Copying {0}", file.source.Name);
2✔
436
                        var path = InstallFile(zipfile, file.source, instance.ToAbsoluteGameDir(file.destination), file.makedir,
2✔
437
                                               candidateDuplicates?.GetValueOrDefault((relPath: file.destination,
438
                                                                                       size:    file.source.Size))
439
                                                                  ?? Array.Empty<string>(),
440
                                               fileProgress);
441
                        installedBytes += file.source.Size;
2✔
442
                        if (path != null)
2✔
443
                        {
2✔
444
                            createdPaths.Add(path);
2✔
445
                            if (file.source.IsDirectory && possibleConfigOnlyDirs != null)
2✔
446
                            {
2✔
447
                                possibleConfigOnlyDirs.Remove(file.destination);
2✔
448
                            }
2✔
449
                        }
2✔
450
                    }
2✔
451
                    log.InfoFormat("Installed {0}", module);
2✔
452
                }
2✔
453
                catch (FileExistsKraken kraken)
2✔
454
                {
2✔
455
                    // Decorate the kraken with our module and re-throw
456
                    kraken.filename = instance.ToRelativeGameDir(kraken.filename);
2✔
457
                    kraken.installingModule = module;
2✔
458
                    kraken.owningModule = registry.FileOwner(kraken.filename);
2✔
459
                    throw;
2✔
460
                }
461
                return createdPaths;
2✔
462
            }
463
        }
2✔
464

465
        public static bool IsInternalCkan(ZipEntry ze)
466
            => ze.Name.EndsWith(".ckan", StringComparison.OrdinalIgnoreCase);
2✔
467

468
        #region File overwrites
469

470
        /// <summary>
471
        /// Find files in the given list that are already installed and unowned.
472
        /// Note, this compares files on demand; Memoize for performance!
473
        /// </summary>
474
        /// <param name="zip">Zip file that we are installing from</param>
475
        /// <param name="files">Files that we want to install for a module</param>
476
        /// <param name="registry">Registry to check for file ownership</param>
477
        /// <returns>
478
        /// List of pairs: Key = file, Value = true if identical, false if different
479
        /// </returns>
480
        private IEnumerable<(InstallableFile file, bool same)> FindConflictingFiles(ZipFile                      zip,
481
                                                                                    IEnumerable<InstallableFile> files,
482
                                                                                    Registry                     registry)
483
            => files.Where(file => !file.source.IsDirectory
2✔
484
                                   && registry.FileOwner(file.destination) == null)
485
                    .Select(file => (file, absPath: instance.ToAbsoluteGameDir(file.destination)))
2✔
486
                    .Where(tuple => File.Exists(tuple.absPath))
2✔
487
                    .Select(tuple =>
488
                            {
2✔
489
                                log.DebugFormat("Comparing {0}", tuple.absPath);
2✔
490
                                using (Stream     zipStream = zip.GetInputStream(tuple.file.source))
2✔
491
                                using (FileStream curFile   = File.OpenRead(tuple.absPath))
2✔
492
                                {
2✔
493
                                    return (tuple.file,
2✔
494
                                            same: tuple.file.source.Size == curFile.Length
495
                                                  && StreamsEqual(zipStream, curFile));
496
                                }
497
                            });
2✔
498

499
        /// <summary>
500
        /// Compare the contents of two streams
501
        /// </summary>
502
        /// <param name="s1">First stream to compare</param>
503
        /// <param name="s2">Second stream to compare</param>
504
        /// <returns>
505
        /// true if both streams contain same bytes, false otherwise
506
        /// </returns>
507
        private static bool StreamsEqual(Stream s1, Stream s2)
508
        {
2✔
509
            const int bufLen = 1024;
510
            byte[] bytes1 = new byte[bufLen];
2✔
511
            byte[] bytes2 = new byte[bufLen];
2✔
512
            int bytesChecked = 0;
2✔
513
            while (true)
2✔
514
            {
2✔
515
                int bytesFrom1 = s1.Read(bytes1, 0, bufLen);
2✔
516
                int bytesFrom2 = s2.Read(bytes2, 0, bufLen);
2✔
517
                if (bytesFrom1 == 0 && bytesFrom2 == 0)
2✔
518
                {
2✔
519
                    // Boths streams finished, all bytes are equal
520
                    return true;
2✔
521
                }
522
                if (bytesFrom1 != bytesFrom2)
2✔
523
                {
×
524
                    // One ended early, not equal.
525
                    log.DebugFormat("Read {0} bytes from stream1 and {1} bytes from stream2", bytesFrom1, bytesFrom2);
×
526
                    return false;
×
527
                }
528
                for (int i = 0; i < bytesFrom1; ++i)
6✔
529
                {
2✔
530
                    if (bytes1[i] != bytes2[i])
2✔
531
                    {
×
532
                        log.DebugFormat("Byte {0} doesn't match", bytesChecked + i);
×
533
                        // Bytes don't match, not equal.
534
                        return false;
×
535
                    }
536
                }
2✔
537
                bytesChecked += bytesFrom1;
2✔
538
            }
2✔
539
        }
2✔
540

541
        /// <summary>
542
        /// Remove files that the user chose to overwrite, so
543
        /// the installer can replace them.
544
        /// Uses a transaction so they can be undeleted if the install
545
        /// fails at a later stage.
546
        /// </summary>
547
        /// <param name="files">The files to overwrite</param>
548
        private void DeleteConflictingFiles(IEnumerable<InstallableFile> files)
549
        {
2✔
550
            var txFileMgr = new TxFileManager(instance.CkanDir);
2✔
551
            foreach (var absPath in files.Select(f => instance.ToAbsoluteGameDir(f.destination)))
8✔
552
            {
2✔
553
                log.DebugFormat("Trying to delete {0}", absPath);
2✔
554
                txFileMgr.Delete(absPath);
2✔
555
            }
2✔
556
        }
2✔
557

558
        #endregion
559

560
        #region Find files
561

562
        /// <summary>
563
        /// Given a module and an open zipfile, return all the files that would be installed
564
        /// for this module.
565
        ///
566
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
567
        ///
568
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
569
        /// </summary>
570
        public static IEnumerable<InstallableFile> FindInstallableFiles(CkanModule module,
571
                                                                        ZipFile    zipfile,
572
                                                                        IGame      game)
573
            // Use the provided stanzas, or use the default install stanza if they're absent.
574
            => module.GetInstallStanzas(game)
2✔
575
                     .SelectMany(stanza => stanza.FindInstallableFiles(module, zipfile, game))
2✔
576
                     .ToArray();
577

578
        /// <summary>
579
        /// Given a module and a path to a zipfile, returns all the files that would be installed
580
        /// from that zip for this module.
581
        ///
582
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
583
        ///
584
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
585
        /// </summary>
586
        public static IEnumerable<InstallableFile> FindInstallableFiles(CkanModule module,
587
                                                                        string     zip_filename,
588
                                                                        IGame      game)
589
        {
2✔
590
            // `using` makes sure our zipfile gets closed when we exit this block.
591
            using (ZipFile zipfile = new ZipFile(zip_filename))
2✔
592
            {
2✔
593
                log.DebugFormat("Searching {0} using {1} as module", zip_filename, module);
2✔
594
                return FindInstallableFiles(module, zipfile, game);
2✔
595
            }
596
        }
2✔
597

598
        /// <summary>
599
        /// Returns contents of an installed module
600
        /// </summary>
601
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
602
                GameInstance                instance,
603
                IReadOnlyCollection<string> installed,
604
                HashSet<string>             filters)
605
            => GetModuleContents(instance, installed,
2✔
606
                                 installed.SelectMany(f => f.TraverseNodes(Path.GetDirectoryName)
2✔
607
                                                            .Skip(1)
608
                                                            .Where(s => s.Length > 0)
2✔
609
                                                            .Select(CKANPathUtils.NormalizePath))
610
                                          .ToHashSet(),
611
                                 filters);
612

613
        private static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
614
                GameInstance        instance,
615
                IEnumerable<string> installed,
616
                HashSet<string>     parents,
617
                HashSet<string>     filters)
618
            => installed.Where(f => !filters.Any(filt => f.Contains(filt)))
×
619
                        .GroupBy(parents.Contains)
620
                        .SelectMany(grp =>
621
                            grp.Select(p => (path:   p,
2✔
622
                                             dir:    grp.Key,
623
                                             exists: grp.Key ? Directory.Exists(instance.ToAbsoluteGameDir(p))
624
                                                             : File.Exists(instance.ToAbsoluteGameDir(p)))));
625

626
        /// <summary>
627
        /// Returns the module contents if and only if we have it
628
        /// available in our cache, empty sequence otherwise.
629
        ///
630
        /// Intended for previews.
631
        /// </summary>
632
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
633
                NetModuleCache  Cache,
634
                GameInstance    instance,
635
                CkanModule      module,
636
                HashSet<string> filters)
637
            => (Cache.GetCachedFilename(module) is string filename
2✔
638
                    ? GetModuleContents(Utilities.DefaultIfThrows(
639
                                            () => FindInstallableFiles(module, filename, instance.Game)),
2✔
640
                                        filters)
641
                    : null)
642
               ?? Enumerable.Empty<(string path, bool dir, bool exists)>();
643

644
        private static IEnumerable<(string path, bool dir, bool exists)>? GetModuleContents(
645
                IEnumerable<InstallableFile>? installable,
646
                HashSet<string>               filters)
647
            => installable?.Where(instF => !filters.Any(filt => instF.destination != null
×
648
                                                                && instF.destination.Contains(filt)))
649
                           .Select(f => (path:   f.destination,
2✔
650
                                         dir:    f.source.IsDirectory,
651
                                         exists: true));
652

653
        #endregion
654

655
        private string? InstallFile(ZipFile          zipfile,
656
                                    ZipEntry         entry,
657
                                    string           fullPath,
658
                                    bool             makeDirs,
659
                                    string[]         candidateDuplicates,
660
                                    IProgress<long>? progress)
661
            => InstallFile(zipfile, entry, fullPath, makeDirs,
2✔
662
                           new TxFileManager(instance.CkanDir),
663
                           candidateDuplicates, progress);
664

665
        /// <summary>
666
        /// Copy the entry from the opened zipfile to the path specified.
667
        /// </summary>
668
        /// <returns>
669
        /// Path of file or directory that was created.
670
        /// May differ from the input fullPath!
671
        /// Throws a FileExistsKraken if we were going to overwrite the file.
672
        /// </returns>
673
        internal static string? InstallFile(ZipFile          zipfile,
674
                                            ZipEntry         entry,
675
                                            string           fullPath,
676
                                            bool             makeDirs,
677
                                            IFileManager     txFileMgr,
678
                                            string[]         candidateDuplicates,
679
                                            IProgress<long>? progress)
680
        {
2✔
681
            if (entry.IsDirectory)
2✔
682
            {
2✔
683
                // Skip if we're not making directories for this install.
684
                if (!makeDirs)
2✔
685
                {
×
686
                    log.DebugFormat("Skipping '{0}', we don't make directories for this path", fullPath);
×
687
                    return null;
×
688
                }
689

690
                // Windows silently trims trailing spaces, get the path it will actually use
691
                fullPath = Path.GetDirectoryName(Path.Combine(fullPath, "DUMMY")) is string p
2✔
692
                    ? CKANPathUtils.NormalizePath(p)
693
                    : fullPath;
694

695
                log.DebugFormat("Making directory '{0}'", fullPath);
2✔
696
                txFileMgr.CreateDirectory(fullPath);
2✔
697
            }
2✔
698
            else
699
            {
2✔
700
                log.DebugFormat("Writing file '{0}'", fullPath);
2✔
701

702
                // ZIP format does not require directory entries
703
                if (makeDirs && Path.GetDirectoryName(fullPath) is string d)
2✔
704
                {
2✔
705
                    log.DebugFormat("Making parent directory '{0}'", d);
2✔
706
                    txFileMgr.CreateDirectory(d);
2✔
707
                }
2✔
708

709
                // We don't allow for the overwriting of files. See #208.
710
                if (txFileMgr.FileExists(fullPath))
2✔
711
                {
2✔
712
                    throw new FileExistsKraken(fullPath);
2✔
713
                }
714

715
                // Snapshot whatever was there before. If there's nothing, this will just
716
                // remove our file on rollback. We still need this even though we won't
717
                // overwite files, as it ensures deletion on rollback.
718
                txFileMgr.Snapshot(fullPath);
2✔
719

720
                // Try making hard links if already installed in another instance (faster, less space)
721
                foreach (var installedSource in candidateDuplicates)
8✔
722
                {
2✔
723
                    try
724
                    {
2✔
725
                        HardLink.Create(installedSource, fullPath);
2✔
726
                        return fullPath;
2✔
727
                    }
728
                    catch
×
729
                    {
×
730
                        // If hard link creation fails, try more hard links, or copy if none work
731
                    }
×
732
                }
×
733

734
                try
735
                {
2✔
736
                    // It's a file! Prepare the streams
737
                    using (var zipStream = zipfile.GetInputStream(entry))
2✔
738
                    using (var writer = File.Create(fullPath))
2✔
739
                    {
2✔
740
                        // Windows silently changes paths ending with spaces, get the name it actually used
741
                        fullPath = CKANPathUtils.NormalizePath(writer.Name);
2✔
742
                        // 4k is the block size on practically every disk and OS.
743
                        byte[] buffer = new byte[4096];
2✔
744
                        progress?.Report(0);
2✔
745
                        StreamUtils.Copy(zipStream, writer, buffer,
2✔
746
                                         // This doesn't fire at all if the interval never elapses
747
                                         (sender, e) => progress?.Report(e.Processed),
2✔
748
                                         UnzipProgressInterval,
749
                                         entry, "InstallFile");
750
                    }
2✔
751
                }
2✔
752
                catch (DirectoryNotFoundException ex)
×
753
                {
×
754
                    throw new DirectoryNotFoundKraken("", ex.Message, ex);
×
755
                }
756
            }
2✔
757
            // Usually, this is the path we're given.
758
            // Sometimes it has trailing spaces trimmed by the OS.
759
            return fullPath;
2✔
760
        }
2✔
761

762
        private static readonly TimeSpan UnzipProgressInterval = TimeSpan.FromMilliseconds(200);
2✔
763

764
        #endregion
765

766
        #region Uninstallation
767

768
        /// <summary>
769
        /// Uninstalls all the mods provided, including things which depend upon them.
770
        /// This *DOES* save the registry.
771
        /// Preferred over Uninstall.
772
        /// </summary>
773
        public void UninstallList(IEnumerable<string>              mods,
774
                                  ref HashSet<string>?             possibleConfigOnlyDirs,
775
                                  RegistryManager                  registry_manager,
776
                                  bool                             ConfirmPrompt = true,
777
                                  IReadOnlyCollection<CkanModule>? installing    = null)
778
        {
2✔
779
            mods = mods.Memoize();
2✔
780
            installing ??= Array.Empty<CkanModule>();
2✔
781
            // Pre-check, have they even asked for things which are installed?
782

783
            foreach (string mod in mods.Where(mod => registry_manager.registry.InstalledModule(mod) == null))
2✔
784
            {
2✔
785
                throw new ModNotInstalledKraken(mod);
2✔
786
            }
787

788
            var instDlc = mods.Select(registry_manager.registry.InstalledModule)
2✔
789
                              .OfType<InstalledModule>()
790
                              .FirstOrDefault(m => m.Module.IsDLC);
2✔
791
            if (instDlc != null)
2✔
792
            {
×
793
                throw new ModuleIsDLCKraken(instDlc.Module);
×
794
            }
795

796
            // Find all the things which need uninstalling.
797
            var revdep = mods
2✔
798
                .Union(registry_manager.registry.FindReverseDependencies(
799
                    mods.Except(installing.Select(m => m.identifier)).ToArray(),
×
800
                    installing))
801
                .ToArray();
802

803
            var goners = revdep.Union(
2✔
804
                                registry_manager.registry.FindRemovableAutoInstalled(installing,
805
                                                                                     revdep.ToHashSet(),
806
                                                                                     instance)
807
                                                         .Select(im => im.identifier))
2✔
808
                               .Order()
809
                               .ToArray();
810

811
            // If there is nothing to uninstall, skip out.
812
            if (goners.Length == 0)
2✔
813
            {
2✔
814
                return;
2✔
815
            }
816

817
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToRemove);
2✔
818
            User.RaiseMessage("");
2✔
819

820
            foreach (var module in goners.Select(registry_manager.registry.InstalledModule)
8✔
821
                                         .OfType<InstalledModule>())
822
            {
2✔
823
                User.RaiseMessage(" * {0} {1}", module.Module.name, module.Module.version);
2✔
824
            }
2✔
825

826
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
827
            {
×
828
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerRemoveAborted);
×
829
            }
830

831
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
832
            {
2✔
833
                var registry = registry_manager.registry;
2✔
834
                long removeBytes = goners.Select(registry.InstalledModule)
2✔
835
                                         .OfType<InstalledModule>()
836
                                         .Sum(m => m.Module.install_size);
2✔
837
                var rateCounter = new ByteRateCounter()
2✔
838
                {
839
                    Size      = removeBytes,
840
                    BytesLeft = removeBytes,
841
                };
842
                rateCounter.Start();
2✔
843

844
                long modRemoveCompletedBytes = 0;
2✔
845
                foreach (string ident in goners)
8✔
846
                {
2✔
847
                    if (registry.InstalledModule(ident) is InstalledModule instMod)
2✔
848
                    {
2✔
849
                        Uninstall(ident, ref possibleConfigOnlyDirs, registry,
2✔
850
                                  new ProgressImmediate<long>(bytes =>
851
                                  {
2✔
852
                                      RemoveProgress?.Invoke(instMod,
2✔
853
                                                             Math.Max(0,     instMod.Module.install_size - bytes),
854
                                                             Math.Max(bytes, instMod.Module.install_size));
855
                                      rateCounter.BytesLeft = removeBytes - (modRemoveCompletedBytes
2✔
856
                                                                             + Math.Min(bytes, instMod.Module.install_size));
857
                                      User.RaiseProgress(rateCounter);
2✔
858
                                  }));
2✔
859
                        modRemoveCompletedBytes += instMod?.Module.install_size ?? 0;
2✔
860
                    }
2✔
861
                }
2✔
862

863
                // Enforce consistency if we're not installing anything,
864
                // otherwise consistency will be enforced after the installs
865
                registry_manager.Save(installing == null);
2✔
866

867
                transaction.Complete();
2✔
868
            }
2✔
869

870
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
871
        }
2✔
872

873
        /// <summary>
874
        /// Uninstall the module provided. For internal use only.
875
        /// Use UninstallList for user queries, it also does dependency handling.
876
        /// This does *NOT* save the registry.
877
        /// </summary>
878
        /// <param name="identifier">Identifier of module to uninstall</param>
879
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
880
        /// <param name="registry">Registry to use</param>
881
        /// <param name="progress">Progress to report</param>
882
        private void Uninstall(string               identifier,
883
                               ref HashSet<string>? possibleConfigOnlyDirs,
884
                               Registry             registry,
885
                               IProgress<long>      progress)
886
        {
2✔
887
            var txFileMgr = new TxFileManager(instance.CkanDir);
2✔
888

889
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
890
            {
2✔
891
                var instMod = registry.InstalledModule(identifier);
2✔
892

893
                if (instMod == null)
2✔
894
                {
×
895
                    log.ErrorFormat("Trying to uninstall {0} but it's not installed", identifier);
×
896
                    throw new ModNotInstalledKraken(identifier);
×
897
                }
898
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovingMod,
2✔
899
                                  $"{instMod.Module.name} {instMod.Module.version}");
900

901
                // Walk our registry to find all files for this mod.
902
                var modFiles = instMod.Files.ToArray();
2✔
903

904
                // We need case insensitive path matching on Windows
905
                var directoriesToDelete = new HashSet<string>(Platform.PathComparer);
2✔
906

907
                // Files that Windows refused to delete due to locking (probably)
908
                var undeletableFiles = new List<string>();
2✔
909

910
                long bytesDeleted = 0;
2✔
911
                foreach (string relPath in modFiles)
8✔
912
                {
2✔
913
                    if (cancelToken.IsCancellationRequested)
2✔
914
                    {
×
915
                        throw new CancelledActionKraken();
×
916
                    }
917

918
                    string absPath = instance.ToAbsoluteGameDir(relPath);
2✔
919

920
                    try
921
                    {
2✔
922
                        if (File.GetAttributes(absPath)
2✔
923
                                .HasFlag(FileAttributes.Directory))
924
                        {
2✔
925
                            directoriesToDelete.Add(absPath);
2✔
926
                        }
2✔
927
                        else
928
                        {
2✔
929
                            // Add this file's directory to the list for deletion if it isn't already there.
930
                            // Helps clean up directories when modules are uninstalled out of dependency order
931
                            // Since we check for directory contents when deleting, this should purge empty
932
                            // dirs, making less ModuleManager headaches for people.
933
                            if (Path.GetDirectoryName(absPath) is string p)
2✔
934
                            {
2✔
935
                                directoriesToDelete.Add(p);
2✔
936
                            }
2✔
937

938
                            bytesDeleted += new FileInfo(absPath).Length;
2✔
939
                            progress.Report(bytesDeleted);
2✔
940
                            log.DebugFormat("Removing {0}", relPath);
2✔
941
                            txFileMgr.Delete(absPath);
2✔
942
                        }
2✔
943
                    }
2✔
944
                    catch (FileNotFoundException exc)
2✔
945
                    {
2✔
946
                        log.Debug("Ignoring missing file while deleting", exc);
2✔
947
                    }
2✔
948
                    catch (DirectoryNotFoundException exc)
2✔
949
                    {
2✔
950
                        log.Debug("Ignoring missing directory while deleting", exc);
2✔
951
                    }
2✔
952
                    catch (IOException)
×
953
                    {
×
954
                        // "The specified file is in use."
955
                        undeletableFiles.Add(relPath);
×
956
                    }
×
957
                    catch (UnauthorizedAccessException)
×
958
                    {
×
959
                        // "The caller does not have the required permission."
960
                        // "The file is an executable file that is in use."
961
                        undeletableFiles.Add(relPath);
×
962
                    }
×
963
                    catch (Exception exc)
×
964
                    {
×
965
                        // We don't consider this problem serious enough to abort and revert,
966
                        // so treat it as a "--verbose" level log message.
967
                        log.InfoFormat("Failure in locating file {0}: {1}", absPath, exc.Message);
×
968
                    }
×
969
                }
2✔
970

971
                if (undeletableFiles.Count > 0)
2✔
972
                {
×
973
                    throw new FailedToDeleteFilesKraken(identifier, undeletableFiles);
×
974
                }
975

976
                // Remove from registry.
977
                registry.DeregisterModule(instance, identifier);
2✔
978

979
                // Our collection of directories may leave empty parent directories.
980
                directoriesToDelete = AddParentDirectories(directoriesToDelete);
2✔
981

982
                // Sort our directories from longest to shortest, to make sure we remove child directories
983
                // before parents. GH #78.
984
                foreach (string directory in directoriesToDelete.OrderByDescending(dir => dir.Length))
2✔
985
                {
2✔
986
                    log.DebugFormat("Checking {0}...", directory);
2✔
987
                    // It is bad if any of this directories gets removed
988
                    // So we protect them
989
                    // A few string comparisons will be cheaper than hitting the disk, so do this first
990
                    if (instance.Game.IsReservedDirectory(instance, directory))
2✔
991
                    {
×
992
                        log.DebugFormat("Directory {0} is reserved, skipping", directory);
×
993
                        continue;
×
994
                    }
995

996
                    // See what's left in this folder and what we can do about it
997
                    GroupFilesByRemovable(instance.ToRelativeGameDir(directory),
2✔
998
                                          registry, modFiles, instance.Game,
999
                                          (Directory.Exists(directory)
1000
                                              ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories)
1001
                                              : Enumerable.Empty<string>())
1002
                                           .Select(instance.ToRelativeGameDir)
1003
                                           .ToArray(),
1004
                                          out string[] removable,
1005
                                          out string[] notRemovable);
1006

1007
                    // Delete the auto-removable files and dirs
1008
                    foreach (var absPath in removable.Select(instance.ToAbsoluteGameDir))
8✔
1009
                    {
2✔
1010
                        if (File.Exists(absPath))
2✔
1011
                        {
2✔
1012
                            log.DebugFormat("Attempting transaction deletion of file {0}", absPath);
2✔
1013
                            txFileMgr.Delete(absPath);
2✔
1014
                        }
2✔
1015
                        else if (Directory.Exists(absPath))
2✔
1016
                        {
2✔
1017
                            log.DebugFormat("Attempting deletion of directory {0}", absPath);
2✔
1018
                            try
1019
                            {
2✔
1020
                                Directory.Delete(absPath);
2✔
1021
                            }
2✔
1022
                            catch
×
1023
                            {
×
1024
                                // There might be files owned by other mods, oh well
1025
                                log.DebugFormat("Failed to delete {0}", absPath);
×
1026
                            }
×
1027
                        }
2✔
1028
                    }
2✔
1029

1030
                    if (notRemovable.Length < 1)
2✔
1031
                    {
2✔
1032
                        // We *don't* use our txFileMgr to delete files here, because
1033
                        // it fails if the system's temp directory is on a different device
1034
                        // to KSP. However we *can* safely delete it now we know it's empty,
1035
                        // because the TxFileMgr *will* put it back if there's a file inside that
1036
                        // needs it.
1037
                        //
1038
                        // This works around GH #251.
1039
                        // The filesystem boundary bug is described in https://transactionalfilemgr.codeplex.com/workitem/20
1040

1041
                        log.DebugFormat("Removing {0}", directory);
2✔
1042
                        Directory.Delete(directory);
2✔
1043
                    }
2✔
1044
                    else if (notRemovable.Except(possibleConfigOnlyDirs?.Select(instance.ToRelativeGameDir)
2✔
1045
                                                 ?? Enumerable.Empty<string>())
1046
                                         // Can't remove if owned by some other mod
1047
                                         .Any(relPath => registry.FileOwner(relPath) != null
2✔
1048
                                                         || modFiles.Contains(relPath)))
1049
                    {
×
1050
                        log.InfoFormat("Not removing directory {0}, it's not empty", directory);
×
1051
                    }
×
1052
                    else
1053
                    {
2✔
1054
                        log.DebugFormat("Directory {0} contains only non-registered files, ask user about it later: {1}",
2✔
1055
                                        directory,
1056
                                        string.Join(", ", notRemovable));
1057
                        possibleConfigOnlyDirs ??= new HashSet<string>(Platform.PathComparer);
2✔
1058
                        possibleConfigOnlyDirs.Add(directory);
2✔
1059
                    }
2✔
1060
                }
2✔
1061
                log.InfoFormat("Removed {0}", identifier);
2✔
1062
                transaction.Complete();
2✔
1063
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovedMod,
2✔
1064
                                  $"{instMod.Module.name} {instMod.Module.version}");
1065
            }
2✔
1066
        }
2✔
1067

1068
        internal static void GroupFilesByRemovable(string                      relRoot,
1069
                                                   Registry                    registry,
1070
                                                   IReadOnlyCollection<string> alreadyRemoving,
1071
                                                   IGame                       game,
1072
                                                   IReadOnlyCollection<string> relPaths,
1073
                                                   out string[]                removable,
1074
                                                   out string[]                notRemovable)
1075
        {
2✔
1076
            if (relPaths.Count < 1)
2✔
1077
            {
2✔
1078
                removable    = Array.Empty<string>();
2✔
1079
                notRemovable = Array.Empty<string>();
2✔
1080
                return;
2✔
1081
            }
1082
            log.DebugFormat("Getting contents of {0}", relRoot);
2✔
1083
            var contents = relPaths
2✔
1084
                // Split into auto-removable and not-removable
1085
                // Removable must not be owned by other mods
1086
                .GroupBy(f => registry.FileOwner(f) == null
2✔
1087
                              // Also skip owned by this module since it's already deregistered
1088
                              && !alreadyRemoving.Contains(f)
1089
                              // Must have a removable dir name somewhere in path AFTER main dir
1090
                              && f[relRoot.Length..]
1091
                                  .Split('/')
1092
                                  .Where(piece => !string.IsNullOrEmpty(piece))
2✔
1093
                                  .Any(piece => game.AutoRemovableDirs.Contains(piece)))
2✔
1094
                .ToDictionary(grp => grp.Key,
2✔
1095
                              grp => grp.OrderByDescending(f => f.Length)
2✔
1096
                                        .ToArray());
1097
            removable    = contents.GetValueOrDefault(true)  ?? Array.Empty<string>();
2✔
1098
            notRemovable = contents.GetValueOrDefault(false) ?? Array.Empty<string>();
2✔
1099
            log.DebugFormat("Got removable: {0}",    string.Join(", ", removable));
2✔
1100
            log.DebugFormat("Got notRemovable: {0}", string.Join(", ", notRemovable));
2✔
1101
        }
2✔
1102

1103
        /// <summary>
1104
        /// Takes a collection of directories and adds all parent directories within the GameData structure.
1105
        /// </summary>
1106
        /// <param name="directories">The collection of directory path strings to examine</param>
1107
        public HashSet<string> AddParentDirectories(HashSet<string> directories)
1108
        {
2✔
1109
            var gameDir = CKANPathUtils.NormalizePath(instance.GameDir);
2✔
1110
            return directories
2✔
1111
                .Where(dir => !string.IsNullOrWhiteSpace(dir))
2✔
1112
                // Normalize all paths before deduplicate
1113
                .Select(CKANPathUtils.NormalizePath)
1114
                // Remove any duplicate paths
1115
                .Distinct()
1116
                .SelectMany(dir =>
1117
                {
2✔
1118
                    var results = new HashSet<string>(Platform.PathComparer);
2✔
1119
                    // Adding in the DirectorySeparatorChar fixes attempts on Windows
1120
                    // to parse "X:" which resolves to Environment.CurrentDirectory
1121
                    var dirInfo = new DirectoryInfo(
2✔
1122
                        dir.EndsWith("/") ? dir : dir + Path.DirectorySeparatorChar);
1123

1124
                    // If this is a parentless directory (Windows)
1125
                    // or if the Root equals the current directory (Mono)
1126
                    if (dirInfo.Parent == null || dirInfo.Root == dirInfo)
2✔
1127
                    {
2✔
1128
                        return results;
2✔
1129
                    }
1130

1131
                    if (!dir.StartsWith(gameDir, Platform.PathComparison))
2✔
1132
                    {
×
1133
                        dir = CKANPathUtils.ToAbsolute(dir, gameDir);
×
1134
                    }
×
1135

1136
                    // Remove the system paths, leaving the path under the instance directory
1137
                    var relativeHead = CKANPathUtils.ToRelative(dir, gameDir);
2✔
1138
                    // Don't try to remove GameRoot
1139
                    if (!string.IsNullOrEmpty(relativeHead))
2✔
1140
                    {
2✔
1141
                        var pathArray = relativeHead.Split('/');
2✔
1142
                        var builtPath = "";
2✔
1143
                        foreach (var path in pathArray)
8✔
1144
                        {
2✔
1145
                            builtPath += path + '/';
2✔
1146
                            results.Add(CKANPathUtils.ToAbsolute(builtPath, gameDir));
2✔
1147
                        }
2✔
1148
                    }
2✔
1149

1150
                    return results;
2✔
1151
                })
2✔
1152
                .Where(dir => !instance.Game.IsReservedDirectory(instance, dir))
2✔
1153
                .ToHashSet();
1154
        }
2✔
1155

1156
        #endregion
1157

1158
        #region AddRemove
1159

1160
        /// <summary>
1161
        /// Adds and removes the listed modules as a single transaction.
1162
        /// No relationships will be processed.
1163
        /// This *will* save the registry.
1164
        /// </summary>
1165
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
1166
        /// <param name="registry_manager">Registry to use</param>
1167
        /// <param name="resolver">Relationship resolver to use</param>
1168
        /// <param name="add">Modules to add</param>
1169
        /// <param name="autoInstalled">true or false for each item in `add`</param>
1170
        /// <param name="remove">Modules to remove</param>
1171
        /// <param name="downloader">Downloader to use</param>
1172
        /// <param name="deduper">Deduplicator to use</param>
1173
        /// <param name="enforceConsistency">Whether to enforce consistency</param>
1174
        private void AddRemove(ref HashSet<string>?                 possibleConfigOnlyDirs,
1175
                               RegistryManager                      registry_manager,
1176
                               RelationshipResolver                 resolver,
1177
                               IReadOnlyCollection<CkanModule>      add,
1178
                               ISet<CkanModule>                     autoInstalled,
1179
                               IReadOnlyCollection<InstalledModule> remove,
1180
                               IDownloader                          downloader,
1181
                               bool                                 enforceConsistency,
1182
                               InstalledFilesDeduplicator?          deduper = null)
1183
        {
2✔
1184
            using (var tx = CkanTransaction.CreateTransactionScope())
2✔
1185
            {
2✔
1186
                var groups = add.GroupBy(m => m.IsMetapackage || cache.IsCached(m));
2✔
1187
                var cached = groups.FirstOrDefault(grp => grp.Key)?.ToArray()
2✔
1188
                                                                  ?? Array.Empty<CkanModule>();
1189
                var toDownload = groups.FirstOrDefault(grp => !grp.Key)?.ToArray()
2✔
1190
                                                                       ?? Array.Empty<CkanModule>();
1191

1192
                long removeBytes     = remove.Sum(m => m.Module.install_size);
2✔
1193
                long removedBytes    = 0;
2✔
1194
                long downloadBytes   = toDownload.Sum(m => m.download_size);
2✔
1195
                long downloadedBytes = 0;
2✔
1196
                long installBytes    = add.Sum(m => m.install_size);
2✔
1197
                long installedBytes  = 0;
2✔
1198
                var rateCounter = new ByteRateCounter()
2✔
1199
                {
1200
                    Size      = removeBytes + downloadBytes + installBytes,
1201
                    BytesLeft = removeBytes + downloadBytes + installBytes,
1202
                };
1203
                rateCounter.Start();
2✔
1204

1205
                downloader.OverallDownloadProgress += brc =>
2✔
1206
                {
2✔
1207
                    downloadedBytes = downloadBytes - brc.BytesLeft;
2✔
1208
                    rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1209
                                          + downloadBytes - downloadedBytes
1210
                                          + installBytes  - installedBytes;
1211
                    User.RaiseProgress(rateCounter);
2✔
1212
                };
2✔
1213
                var toInstall = ModsInDependencyOrder(resolver, cached, toDownload, downloader);
2✔
1214

1215
                long modRemoveCompletedBytes = 0;
2✔
1216
                foreach (var instMod in remove)
8✔
1217
                {
2✔
1218
                    Uninstall(instMod.Module.identifier,
2✔
1219
                              ref possibleConfigOnlyDirs,
1220
                              registry_manager.registry,
1221
                              new ProgressImmediate<long>(bytes =>
1222
                              {
2✔
1223
                                  RemoveProgress?.Invoke(instMod,
2✔
1224
                                                         Math.Max(0,     instMod.Module.install_size - bytes),
1225
                                                         Math.Max(bytes, instMod.Module.install_size));
1226
                                  removedBytes = modRemoveCompletedBytes
2✔
1227
                                                 + Math.Min(bytes, instMod.Module.install_size);
1228
                                  rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1229
                                                        + downloadBytes - downloadedBytes
1230
                                                        + installBytes  - installedBytes;
1231
                                  User.RaiseProgress(rateCounter);
2✔
1232
                              }));
2✔
1233
                     modRemoveCompletedBytes += instMod.Module.install_size;
2✔
1234
                }
2✔
1235

1236
                var gameDir = new DirectoryInfo(instance.GameDir);
2✔
1237
                long modInstallCompletedBytes = 0;
2✔
1238
                foreach (var mod in toInstall)
8✔
1239
                {
2✔
1240
                    CKANPathUtils.CheckFreeSpace(gameDir, mod.install_size,
2✔
1241
                                                 Properties.Resources.NotEnoughSpaceToInstall);
1242
                    Install(mod,
2✔
1243
                            // For upgrading, new modules are dependencies and should be marked auto-installed,
1244
                            // for replacing, new modules are the replacements and should not be marked auto-installed
1245
                            remove?.FirstOrDefault(im => im.Module.identifier == mod.identifier)
2✔
1246
                                  ?.AutoInstalled
1247
                                  ?? autoInstalled.Contains(mod),
1248
                            registry_manager.registry,
1249
                            deduper?.ModuleCandidateDuplicates(mod.identifier, mod.version),
1250
                            ref possibleConfigOnlyDirs,
1251
                            new ProgressImmediate<long>(bytes =>
1252
                            {
2✔
1253
                                InstallProgress?.Invoke(mod,
2✔
1254
                                                        Math.Max(0,     mod.install_size - bytes),
1255
                                                        Math.Max(bytes, mod.install_size));
1256
                                installedBytes = modInstallCompletedBytes
2✔
1257
                                                 + Math.Min(bytes, mod.install_size);
1258
                                rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1259
                                                      + downloadBytes - downloadedBytes
1260
                                                      + installBytes  - installedBytes;
1261
                                User.RaiseProgress(rateCounter);
2✔
1262
                            }));
2✔
1263
                    modInstallCompletedBytes += mod.install_size;
2✔
1264
                }
2✔
1265

1266
                registry_manager.Save(enforceConsistency);
2✔
1267
                tx.Complete();
2✔
1268
                EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
1269
            }
2✔
1270
        }
2✔
1271

1272
        /// <summary>
1273
        /// Upgrades or installs the mods listed to the specified versions for the user's KSP.
1274
        /// Will *re-install* or *downgrade* (with a warning) as well as upgrade.
1275
        /// Throws ModuleNotFoundKraken if a module is not installed.
1276
        /// </summary>
1277
        public void Upgrade(in IReadOnlyCollection<CkanModule> modules,
1278
                            IDownloader                        downloader,
1279
                            ref HashSet<string>?               possibleConfigOnlyDirs,
1280
                            RegistryManager                    registry_manager,
1281
                            InstalledFilesDeduplicator?        deduper            = null,
1282
                            ISet<CkanModule>?                  autoInstalled      = null,
1283
                            bool                               enforceConsistency = true,
1284
                            bool                               ConfirmPrompt      = true)
1285
        {
2✔
1286
            var registry = registry_manager.registry;
2✔
1287

1288
            var removingIdents = registry.InstalledModules.Select(im => im.identifier)
2✔
1289
                                         .Intersect(modules.Select(m => m.identifier))
2✔
1290
                                         .ToHashSet();
1291
            var autoRemoving = registry
2✔
1292
                .FindRemovableAutoInstalled(modules, removingIdents, instance)
1293
                .ToHashSet();
1294

1295
            var resolver = new RelationshipResolver(
2✔
1296
                modules,
1297
                modules.Select(m => registry.InstalledModule(m.identifier)?.Module)
2✔
1298
                       .OfType<CkanModule>()
1299
                       .Concat(autoRemoving.Select(im => im.Module)),
2✔
1300
                RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig),
1301
                registry,
1302
                instance.Game, instance.VersionCriteria());
1303
            var fullChangeset = resolver.ModList()
2✔
1304
                                        .ToDictionary(m => m.identifier,
2✔
1305
                                                      m => m);
2✔
1306

1307
            // Skip removing ones we still need
1308
            var keepIdents = fullChangeset.Keys.Intersect(autoRemoving.Select(im => im.Module.identifier))
2✔
1309
                                               .ToHashSet();
1310
            autoRemoving.RemoveWhere(im => keepIdents.Contains(im.Module.identifier));
2✔
1311
            foreach (var ident in keepIdents)
8✔
1312
            {
2✔
1313
                fullChangeset.Remove(ident);
2✔
1314
            }
2✔
1315

1316
            // Only install stuff that's already there if explicitly requested in param
1317
            var toInstall = fullChangeset.Values
2✔
1318
                                         .Except(registry.InstalledModules
1319
                                                         .Select(im => im.Module)
2✔
1320
                                                         .Except(modules))
1321
                                         .ToArray();
1322
            autoInstalled ??= new HashSet<CkanModule>();
2✔
1323
            autoInstalled.UnionWith(toInstall.Where(resolver.IsAutoInstalled));
2✔
1324

1325
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToUpgrade);
2✔
1326
            User.RaiseMessage("");
2✔
1327

1328
            // Our upgrade involves removing everything that's currently installed, then
1329
            // adding everything that needs installing (which may involve new mods to
1330
            // satisfy dependencies). We always know the list passed in is what we need to
1331
            // install, but we need to calculate what needs to be removed.
1332
            var toRemove = new List<InstalledModule>();
2✔
1333

1334
            // Let's discover what we need to do with each module!
1335
            foreach (CkanModule module in toInstall)
8✔
1336
            {
2✔
1337
                var installed_mod = registry.InstalledModule(module.identifier);
2✔
1338

1339
                if (installed_mod == null)
2✔
1340
                {
2✔
1341
                    if (!cache.IsMaybeCachedZip(module)
2✔
1342
                        && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1343
                    {
2✔
1344
                        if (inProgressFile.Exists)
2✔
1345
                        {
2✔
1346
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingResuming,
2✔
1347
                                              module.name, module.version,
1348
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1349
                                              CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1350
                        }
2✔
1351
                        else
1352
                        {
2✔
1353
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingUncached,
2✔
1354
                                              module.name, module.version,
1355
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1356
                                              CkanModule.FmtSize(module.download_size));
1357
                        }
2✔
1358
                    }
2✔
1359
                    else
1360
                    {
2✔
1361
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingCached,
2✔
1362
                                          module.name, module.version);
1363
                    }
2✔
1364
                }
2✔
1365
                else
1366
                {
2✔
1367
                    // Module already installed. We'll need to remove it first.
1368
                    toRemove.Add(installed_mod);
2✔
1369

1370
                    CkanModule installed = installed_mod.Module;
2✔
1371
                    if (installed.version.Equals(module.version))
2✔
1372
                    {
×
1373
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeReinstalling,
×
1374
                                          module.name, module.version);
1375
                    }
×
1376
                    else if (installed.version.IsGreaterThan(module.version))
2✔
1377
                    {
2✔
1378
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeDowngrading,
2✔
1379
                                          module.name, installed.version, module.version);
1380
                    }
2✔
1381
                    else
1382
                    {
2✔
1383
                        if (!cache.IsMaybeCachedZip(module)
2✔
1384
                            && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1385
                        {
2✔
1386
                            if (inProgressFile.Exists)
2✔
1387
                            {
2✔
1388
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingResuming,
2✔
1389
                                                  module.name, installed.version, module.version,
1390
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1391
                                                  CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1392
                            }
2✔
1393
                            else
1394
                            {
2✔
1395
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingUncached,
2✔
1396
                                                  module.name, installed.version, module.version,
1397
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1398
                                                  CkanModule.FmtSize(module.download_size));
1399
                            }
2✔
1400
                        }
2✔
1401
                        else
1402
                        {
2✔
1403
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingCached,
2✔
1404
                                              module.name, installed.version, module.version);
1405
                        }
2✔
1406
                    }
2✔
1407
                }
2✔
1408
            }
2✔
1409

1410
            if (autoRemoving.Count > 0)
2✔
1411
            {
2✔
1412
                foreach (var im in autoRemoving)
8✔
1413
                {
2✔
1414
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeAutoRemoving,
2✔
1415
                                      im.Module.name, im.Module.version);
1416
                }
2✔
1417
                toRemove.AddRange(autoRemoving);
2✔
1418
            }
2✔
1419

1420
            CheckAddRemoveFreeSpace(toInstall, toRemove);
2✔
1421

1422
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
1423
            {
×
1424
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUpgradeUserDeclined);
×
1425
            }
1426

1427
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1428
                      registry_manager,
1429
                      resolver,
1430
                      toInstall,
1431
                      autoInstalled,
1432
                      toRemove,
1433
                      downloader,
1434
                      enforceConsistency,
1435
                      deduper);
1436
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1437
        }
2✔
1438

1439
        /// <summary>
1440
        /// Enacts listed Module Replacements to the specified versions for the user's KSP.
1441
        /// Will *re-install* or *downgrade* (with a warning) as well as upgrade.
1442
        /// </summary>
1443
        /// <exception cref="DependenciesNotSatisfiedKraken">Thrown if a dependency for a replacing module couldn't be satisfied.</exception>
1444
        /// <exception cref="ModuleNotFoundKraken">Thrown if a module that should be replaced is not installed.</exception>
1445
        public void Replace(IEnumerable<ModuleReplacement> replacements,
1446
                            RelationshipResolverOptions    options,
1447
                            IDownloader                    downloader,
1448
                            ref HashSet<string>?           possibleConfigOnlyDirs,
1449
                            RegistryManager                registry_manager,
1450
                            InstalledFilesDeduplicator?    deduper = null,
1451
                            bool                           enforceConsistency = true)
1452
        {
2✔
1453
            replacements = replacements.Memoize();
2✔
1454
            log.Debug("Using Replace method");
2✔
1455
            var modsToInstall = new List<CkanModule>();
2✔
1456
            var modsToRemove  = new List<InstalledModule>();
2✔
1457
            foreach (ModuleReplacement repl in replacements)
8✔
1458
            {
2✔
1459
                modsToInstall.Add(repl.ReplaceWith);
2✔
1460
                log.DebugFormat("We want to install {0} as a replacement for {1}", repl.ReplaceWith.identifier, repl.ToReplace.identifier);
2✔
1461
            }
2✔
1462

1463
            // Our replacement involves removing the currently installed mods, then
1464
            // adding everything that needs installing (which may involve new mods to
1465
            // satisfy dependencies).
1466

1467
            // Let's discover what we need to do with each module!
1468
            foreach (ModuleReplacement repl in replacements)
8✔
1469
            {
2✔
1470
                string ident = repl.ToReplace.identifier;
2✔
1471
                var installedMod = registry_manager.registry.InstalledModule(ident);
2✔
1472

1473
                if (installedMod == null)
2✔
1474
                {
×
1475
                    log.WarnFormat("Wait, {0} is not actually installed?", ident);
×
1476
                    //Maybe ModuleNotInstalled ?
1477
                    if (registry_manager.registry.IsAutodetected(ident))
×
1478
                    {
×
1479
                        throw new ModuleNotFoundKraken(ident,
×
1480
                            repl.ToReplace.version.ToString(),
1481
                            string.Format(Properties.Resources.ModuleInstallerReplaceAutodetected, ident));
1482
                    }
1483

1484
                    throw new ModuleNotFoundKraken(ident,
×
1485
                        repl.ToReplace.version.ToString(),
1486
                        string.Format(Properties.Resources.ModuleInstallerReplaceNotInstalled, ident, repl.ReplaceWith.identifier));
1487
                }
1488
                else
1489
                {
2✔
1490
                    // Obviously, we need to remove the mod we are replacing
1491
                    modsToRemove.Add(installedMod);
2✔
1492

1493
                    log.DebugFormat("Ok, we are removing {0}", repl.ToReplace.identifier);
2✔
1494
                    //Check whether our Replacement target is already installed
1495
                    var installed_replacement = registry_manager.registry.InstalledModule(repl.ReplaceWith.identifier);
2✔
1496

1497
                    // If replacement is not installed, we've already added it to modsToInstall above
1498
                    if (installed_replacement != null)
2✔
1499
                    {
2✔
1500
                        //Module already installed. We'll need to treat it as an upgrade.
1501
                        log.DebugFormat("It turns out {0} is already installed, we'll upgrade it.", installed_replacement.identifier);
2✔
1502
                        modsToRemove.Add(installed_replacement);
2✔
1503

1504
                        CkanModule installed = installed_replacement.Module;
2✔
1505
                        if (installed.version.Equals(repl.ReplaceWith.version))
2✔
1506
                        {
2✔
1507
                            log.InfoFormat("{0} is already at the latest version, reinstalling to replace {1}", repl.ReplaceWith.identifier, repl.ToReplace.identifier);
2✔
1508
                        }
2✔
1509
                        else if (installed.version.IsGreaterThan(repl.ReplaceWith.version))
×
1510
                        {
×
1511
                            log.WarnFormat("Downgrading {0} from {1} to {2} to replace {3}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1512
                        }
×
1513
                        else
1514
                        {
×
1515
                            log.InfoFormat("Upgrading {0} to {1} to replace {2}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1516
                        }
×
1517
                    }
2✔
1518
                    else
1519
                    {
2✔
1520
                        log.InfoFormat("Replacing {0} with {1} {2}", repl.ToReplace.identifier, repl.ReplaceWith.identifier, repl.ReplaceWith.version);
2✔
1521
                    }
2✔
1522
                }
2✔
1523
            }
2✔
1524
            var resolver = new RelationshipResolver(modsToInstall, null, options, registry_manager.registry,
2✔
1525
                                                    instance.Game, instance.VersionCriteria());
1526
            var resolvedModsToInstall = resolver.ModList().ToArray();
2✔
1527

1528
            CheckAddRemoveFreeSpace(resolvedModsToInstall, modsToRemove);
2✔
1529
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1530
                      registry_manager,
1531
                      resolver,
1532
                      resolvedModsToInstall,
1533
                      new HashSet<CkanModule>(),
1534
                      modsToRemove,
1535
                      downloader,
1536
                      enforceConsistency,
1537
                      deduper);
1538
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1539
        }
2✔
1540

1541
        #endregion
1542

1543
        public static IEnumerable<string> PrioritizedHosts(IConfiguration    config,
1544
                                                           IEnumerable<Uri>? urls)
1545
            => urls?.OrderBy(u => u, new PreferredHostUriComparer(config.PreferredHosts))
2✔
1546
                    .Select(dl => dl.Host)
2✔
1547
                    .Distinct()
1548
                   ?? Enumerable.Empty<string>();
1549

1550
        #region Recommendations
1551

1552
        /// <summary>
1553
        /// Looks for optional related modules that could be installed alongside the given modules
1554
        /// </summary>
1555
        /// <param name="instance">Game instance to use</param>
1556
        /// <param name="sourceModules">Modules to check for relationships, should contain the complete changeset including dependencies</param>
1557
        /// <param name="toInstall">Modules already being installed, to be omitted from search</param>
1558
        /// <param name="toRemove">Modules being removed, to be excluded from relationship resolution</param>
1559
        /// <param name="exclude">Modules the user has already seen and decided not to install</param>
1560
        /// <param name="registry">Registry to use</param>
1561
        /// <param name="recommendations">Modules that are recommended to install</param>
1562
        /// <param name="suggestions">Modules that are suggested to install</param>
1563
        /// <param name="supporters">Modules that support other modules we're installing</param>
1564
        /// <returns>
1565
        /// true if anything found, false otherwise
1566
        /// </returns>
1567
        public static bool FindRecommendations(GameInstance                                          instance,
1568
                                               IReadOnlyCollection<CkanModule>                       sourceModules,
1569
                                               IReadOnlyCollection<CkanModule>                       toInstall,
1570
                                               IReadOnlyCollection<CkanModule>                       toRemove,
1571
                                               IReadOnlyCollection<CkanModule>                       exclude,
1572
                                               Registry                                              registry,
1573
                                               out Dictionary<CkanModule, Tuple<bool, List<string>>> recommendations,
1574
                                               out Dictionary<CkanModule, List<string>>              suggestions,
1575
                                               out Dictionary<CkanModule, HashSet<string>>           supporters)
1576
        {
2✔
1577
            log.DebugFormat("Finding recommendations for: {0}", string.Join(", ", sourceModules));
2✔
1578
            var crit     = instance.VersionCriteria();
2✔
1579

1580
            var rmvIdents = toRemove.Select(m => m.identifier).ToHashSet();
2✔
1581
            var allRemoving = toRemove
2✔
1582
                .Concat(registry.FindRemovableAutoInstalled(sourceModules, rmvIdents, instance)
1583
                                .Select(im => im.Module))
2✔
1584
                .ToArray();
1585

1586
            var resolver = new RelationshipResolver(sourceModules.Where(m => !m.IsDLC),
2✔
1587
                                                    allRemoving,
1588
                                                    RelationshipResolverOptions.KitchenSinkOpts(instance.StabilityToleranceConfig),
1589
                                                    registry, instance.Game, crit);
1590
            var recommenders = resolver.Dependencies().ToHashSet();
2✔
1591
            log.DebugFormat("Recommenders: {0}", string.Join(", ", recommenders));
2✔
1592

1593
            var checkedRecs = resolver.Recommendations(recommenders)
2✔
1594
                                      .Except(exclude)
1595
                                      .Where(m => resolver.ReasonsFor(m)
2✔
1596
                                                          .Any(r => r is SelectionReason.Recommended { ProvidesIndex: 0 }))
2✔
1597
                                      .ToHashSet();
1598
            var conflicting = new RelationshipResolver(toInstall.Concat(checkedRecs), allRemoving,
2✔
1599
                                                       RelationshipResolverOptions.ConflictsOpts(instance.StabilityToleranceConfig),
1600
                                                       registry, instance.Game, crit)
1601
                                  .ConflictList.Keys;
1602
            // Don't check recommendations that conflict with installed or installing mods
1603
            checkedRecs.ExceptWith(conflicting);
2✔
1604

1605
            recommendations = resolver.Recommendations(recommenders)
2✔
1606
                                      .Except(exclude)
1607
                                      .ToDictionary(m => m,
2✔
1608
                                                    m => new Tuple<bool, List<string>>(
2✔
1609
                                                             checkedRecs.Contains(m),
1610
                                                             resolver.ReasonsFor(m)
1611
                                                                     .OfType<SelectionReason.Recommended>()
1612
                                                                     .Where(r => recommenders.Contains(r.Parent))
2✔
1613
                                                                     .Select(r => r.Parent)
2✔
1614
                                                                     .OfType<CkanModule>()
1615
                                                                     .Select(m => m.identifier)
2✔
1616
                                                                     .ToList()));
1617
            suggestions = resolver.Suggestions(recommenders,
2✔
1618
                                               recommendations.Keys.ToList())
1619
                                  .Except(exclude)
1620
                                  .ToDictionary(m => m,
2✔
1621
                                                m => resolver.ReasonsFor(m)
2✔
1622
                                                             .OfType<SelectionReason.Suggested>()
1623
                                                             .Where(r => recommenders.Contains(r.Parent))
2✔
1624
                                                             .Select(r => r.Parent)
2✔
1625
                                                             .OfType<CkanModule>()
1626
                                                             .Select(m => m.identifier)
2✔
1627
                                                             .ToList());
1628

1629
            var opts = RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig);
2✔
1630
            supporters = resolver.Supporters(recommenders,
2✔
1631
                                             recommenders.Concat(recommendations.Keys)
1632
                                                         .Concat(suggestions.Keys))
1633
                                 .Where(kvp => !exclude.Contains(kvp.Key)
2✔
1634
                                               && CanInstall(toInstall.Append(kvp.Key).ToList(),
1635
                                                          opts, registry, instance.Game, crit))
1636
                                 .ToDictionary();
1637

1638
            return recommendations.Count > 0
2✔
1639
                || suggestions.Count > 0
1640
                || supporters.Count > 0;
1641
        }
2✔
1642

1643
        /// <summary>
1644
        /// Determine whether there is any way to install the given set of mods.
1645
        /// Handles virtual dependencies, including recursively.
1646
        /// </summary>
1647
        /// <param name="opts">Installer options</param>
1648
        /// <param name="toInstall">Mods we want to install</param>
1649
        /// <param name="registry">Registry of instance into which we want to install</param>
1650
        /// <param name="game">Game instance</param>
1651
        /// <param name="crit">Game version criteria</param>
1652
        /// <returns>
1653
        /// True if it's possible to install these mods, false otherwise
1654
        /// </returns>
1655
        public static bool CanInstall(IReadOnlyCollection<CkanModule> toInstall,
1656
                                      RelationshipResolverOptions     opts,
1657
                                      IRegistryQuerier                registry,
1658
                                      IGame                           game,
1659
                                      GameVersionCriteria             crit)
1660
        {
2✔
1661
            string request = string.Join(", ", toInstall.Select(m => m.identifier));
2✔
1662
            try
1663
            {
2✔
1664
                var installed = toInstall.Select(m => registry.InstalledModule(m.identifier)?.Module)
2✔
1665
                                         .OfType<CkanModule>();
1666
                var resolver = new RelationshipResolver(toInstall, installed, opts, registry, game, crit);
2✔
1667

1668
                var resolverModList = resolver.ModList(false).ToArray();
2✔
1669
                if (resolverModList.Length >= toInstall.Count(m => !m.IsMetapackage))
2✔
1670
                {
2✔
1671
                    // We can install with no further dependencies
1672
                    string recipe = string.Join(", ", resolverModList.Select(m => m.identifier));
2✔
1673
                    log.Debug($"Installable: {request}: {recipe}");
2✔
1674
                    return true;
2✔
1675
                }
1676
                else
1677
                {
×
1678
                    log.DebugFormat("Can't install {0}: {1}", request, string.Join("; ", resolver.ConflictDescriptions));
×
1679
                    return false;
×
1680
                }
1681
            }
1682
            catch (TooManyModsProvideKraken k)
2✔
1683
            {
2✔
1684
                // One of the dependencies is virtual
1685
                foreach (var mod in k.modules)
8✔
1686
                {
2✔
1687
                    // Try each option recursively to see if any are successful
1688
                    if (CanInstall(toInstall.Append(mod).ToArray(), opts, registry, game, crit))
2✔
1689
                    {
2✔
1690
                        // Child call will emit debug output, so we don't need to here
1691
                        return true;
2✔
1692
                    }
1693
                }
×
1694
                log.Debug($"Can't install {request}: Can't install provider of {k.requested}");
×
1695
            }
×
1696
            catch (InconsistentKraken k)
×
1697
            {
×
1698
                log.Debug($"Can't install {request}: {k.ShortDescription}");
×
1699
            }
×
1700
            catch (Exception ex)
×
1701
            {
×
1702
                log.Debug($"Can't install {request}: {ex.Message}");
×
1703
            }
×
1704
            return false;
×
1705
        }
2✔
1706

1707
        #endregion
1708

1709
        private static void EnforceCacheSizeLimit(Registry       registry,
1710
                                                  NetModuleCache Cache,
1711
                                                  IConfiguration config)
1712
        {
2✔
1713
            // Purge old downloads if we're over the limit
1714
            if (config.CacheSizeLimit.HasValue)
2✔
1715
            {
×
1716
                Cache.EnforceSizeLimit(config.CacheSizeLimit.Value, registry);
×
1717
            }
×
1718
        }
2✔
1719

1720
        private void CheckAddRemoveFreeSpace(IEnumerable<CkanModule>      toInstall,
1721
                                             IEnumerable<InstalledModule> toRemove)
1722
        {
2✔
1723
            if (toInstall.Sum(m => m.install_size) - toRemove.Sum(im => im.ActualInstallSize(instance))
2✔
1724
                is > 0 and var spaceDelta)
1725
            {
×
1726
                CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.GameDir),
×
1727
                                             spaceDelta,
1728
                                             Properties.Resources.NotEnoughSpaceToInstall);
1729
            }
×
1730
        }
2✔
1731

1732
        private readonly GameInstance      instance;
1733
        private readonly NetModuleCache    cache;
1734
        private readonly IConfiguration    config;
1735
        private readonly CancellationToken cancelToken;
1736

1737
        private static readonly ILog log = LogManager.GetLogger(typeof(ModuleInstaller));
2✔
1738
    }
1739
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc