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

KSP-CKAN / CKAN / 15833572481

23 Jun 2025 07:42PM UTC coverage: 42.239% (+0.1%) from 42.099%
15833572481

push

github

HebaruSan
Merge #4398 Exception handling revamp, parallel multi-host inflation

3882 of 9479 branches covered (40.95%)

Branch coverage included in aggregate %.

48 of 137 new or added lines in 30 files covered. (35.04%)

12 existing lines in 6 files now uncovered.

8334 of 19442 relevant lines covered (42.87%)

0.88 hits per line

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

68.04
/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.FileManager;
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
            User        = user;
2✔
36
            this.cache  = cache;
2✔
37
            this.config = config;
2✔
38
            instance    = inst;
2✔
39
            this.cancelToken = cancelToken;
2✔
40
            log.DebugFormat("Creating ModuleInstaller for {0}", instance.GameDir());
2✔
41
        }
2✔
42

43
        public IUser User { get; set; }
44

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

49
        #region Installation
50

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

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

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

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

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

162
                User.RaiseProgress(Properties.Resources.ModuleInstallerUpdatingRegistry, 90);
2✔
163
                registry_manager.Save(!options.without_enforce_consistency);
2✔
164

165
                User.RaiseProgress(Properties.Resources.ModuleInstallerCommitting, 95);
2✔
166
                transaction.Complete();
2✔
167
            }
2✔
168

169
            EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
170
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
171
        }
2✔
172

173
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver    resolver,
174
                                                                     ICollection<CkanModule> cached,
175
                                                                     ICollection<CkanModule> toDownload,
176
                                                                     IDownloader?            downloader)
177

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

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

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

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

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

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

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

288
            User.RaiseMessage(Properties.Resources.ModuleInstallerInstallingMod,
2✔
289
                              $"{module.name} {module.version}");
290

291
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
292
            {
2✔
293
                // Install all the things!
294
                var files = InstallModule(module, filename, registry, candidateDuplicates,
2✔
295
                                          ref possibleConfigOnlyDirs, progress);
296

297
                // Register our module and its files.
298
                registry.RegisterModule(module, files, instance, autoInstalled);
2✔
299

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

307
            User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
2✔
308
                              $"{module.name} {module.version}");
309

310
            // Fire our callback that we've installed a module, if we have one.
311
            OneComplete?.Invoke(module);
2!
312
        }
2✔
313

314
        /// <summary>
315
        /// Check if the given module is a DLC:
316
        /// if it is, throws ModuleIsDLCKraken.
317
        /// </summary>
318
        private static void CheckKindInstallationKraken(CkanModule module)
319
        {
2✔
320
            if (module.IsDLC)
2!
321
            {
×
NEW
322
                throw new ModuleIsDLCKraken(module);
×
323
            }
324
        }
2✔
325

326
        /// <summary>
327
        /// Installs the module from the zipfile provided.
328
        /// Returns a list of files installed.
329
        /// Propagates a DllLocationMismatchKraken if the user has a bad manual install.
330
        /// Propagates a BadMetadataKraken if our install metadata is bad.
331
        /// Propagates a CancelledActionKraken if the user decides not to overwite unowned files.
332
        /// Propagates a FileExistsKraken if we were going to overwrite a file.
333
        /// </summary>
334
        private List<string> InstallModule(CkanModule                    module,
335
                                           string?                       zip_filename,
336
                                           Registry                      registry,
337
                                           Dictionary<string, string[]>? candidateDuplicates,
338
                                           ref HashSet<string>?          possibleConfigOnlyDirs,
339
                                           IProgress<long>?              moduleProgress)
340
        {
2✔
341
            var createdPaths = new List<string>();
2✔
342
            if (module.IsMetapackage || zip_filename == null)
2!
343
            {
×
344
                // It's OK to include metapackages in changesets,
345
                // but there's no work to do for them
346
                return createdPaths;
×
347
            }
348
            using (ZipFile zipfile = new ZipFile(zip_filename))
2✔
349
            {
2✔
350
                var filters = config.GetGlobalInstallFilters(instance.game)
2✔
351
                                    .Concat(instance.InstallFilters)
352
                                    .ToHashSet();
353
                var files = FindInstallableFiles(module, zipfile, instance)
2✔
354
                    .Where(instF => !filters.Any(filt =>
2✔
355
                                        instF.destination != null
2✔
356
                                        && instF.destination.Contains(filt))
357
                                    // Skip the file if it's a ckan file, these should never be copied to GameData
358
                                    && !IsInternalCkan(instF.source))
359
                    .ToList();
360

361
                try
362
                {
2✔
363
                    var dll = registry.DllPath(module.identifier);
2✔
364
                    if (dll is not null && !string.IsNullOrEmpty(dll))
2✔
365
                    {
2✔
366
                        // Find where we're installing identifier.optionalversion.dll
367
                        // (file name might not be an exact match with manually installed)
368
                        var dllFolders = files
2✔
369
                            .Select(f => instance.ToRelativeGameDir(f.destination))
2✔
370
                            .Where(relPath => instance.DllPathToIdentifier(relPath) == module.identifier)
2✔
371
                            .Select(Path.GetDirectoryName)
372
                            .ToHashSet();
373
                        // Make sure that the DLL is actually included in the install
374
                        // (NearFutureElectrical, NearFutureElectrical-Core)
375
                        if (dllFolders.Count > 0 && registry.FileOwner(dll) == null)
2✔
376
                        {
2✔
377
                            if (!dllFolders.Contains(Path.GetDirectoryName(dll)))
2!
378
                            {
2✔
379
                                // Manually installed DLL is somewhere else where we're not installing files,
380
                                // probable bad install, alert user and abort
381
                                throw new DllLocationMismatchKraken(dll, string.Format(
2✔
382
                                    Properties.Resources.ModuleInstallerBadDLLLocation, module.identifier, dll));
383
                            }
384
                            // Delete the manually installed DLL transaction-style because we believe we'll be replacing it
385
                            var toDelete = instance.ToAbsoluteGameDir(dll);
×
386
                            log.DebugFormat("Deleting manually installed DLL {0}", toDelete);
×
387
                            TxFileManager file_transaction = new TxFileManager();
×
388
                            file_transaction.Snapshot(toDelete);
×
389
                            file_transaction.Delete(toDelete);
×
390
                        }
×
391
                    }
2✔
392

393
                    // Look for overwritable files if session is interactive
394
                    if (!User.Headless)
2✔
395
                    {
2✔
396
                        var conflicting = FindConflictingFiles(zipfile, files, registry).Memoize();
2✔
397
                        if (conflicting.Any())
2!
398
                        {
×
399
                            var fileMsg = conflicting
×
400
                                .OrderBy(c => c.Value)
×
401
                                .Aggregate("", (a, b) =>
402
                                    $"{a}\r\n- {instance.ToRelativeGameDir(b.Key.destination)}  ({(b.Value ? Properties.Resources.ModuleInstallerFileSame : Properties.Resources.ModuleInstallerFileDifferent)})");
×
403
                            if (User.RaiseYesNoDialog(string.Format(
×
404
                                Properties.Resources.ModuleInstallerOverwrite, module.name, fileMsg)))
405
                            {
×
406
                                DeleteConflictingFiles(conflicting.Select(f => f.Key));
×
407
                            }
×
408
                            else
409
                            {
×
410
                                throw new CancelledActionKraken(string.Format(
×
411
                                    Properties.Resources.ModuleInstallerOverwriteCancelled, module.name));
412
                            }
413
                        }
×
414
                    }
2✔
415
                    long installedBytes = 0;
2✔
416
                    var fileProgress = new ProgressImmediate<long>(bytes => moduleProgress?.Report(installedBytes + bytes));
2!
417
                    foreach (InstallableFile file in files)
5✔
418
                    {
2✔
419
                        if (cancelToken.IsCancellationRequested)
2!
420
                        {
×
421
                            throw new CancelledActionKraken();
×
422
                        }
423
                        log.DebugFormat("Copying {0}", file.source.Name);
2✔
424
                        var path = InstallFile(zipfile, file.source, file.destination, file.makedir,
2✔
425
                                               (candidateDuplicates != null
426
                                                && candidateDuplicates.TryGetValue(instance.ToRelativeGameDir(file.destination),
427
                                                                                 out string[]? duplicates))
428
                                                   ? duplicates
429
                                                   : Array.Empty<string>(),
430
                                               fileProgress);
431
                        installedBytes += file.source.Size;
2✔
432
                        if (path != null)
2!
433
                        {
2✔
434
                            createdPaths.Add(path);
2✔
435
                            if (file.source.IsDirectory && possibleConfigOnlyDirs != null)
2✔
436
                            {
2✔
437
                                possibleConfigOnlyDirs.Remove(file.destination);
2✔
438
                            }
2✔
439
                        }
2✔
440
                    }
2✔
441
                    log.InfoFormat("Installed {0}", module);
2✔
442
                }
2✔
443
                catch (FileExistsKraken kraken)
×
444
                {
×
445
                    // Decorate the kraken with our module and re-throw
446
                    kraken.filename = instance.ToRelativeGameDir(kraken.filename);
×
447
                    kraken.installingModule = module;
×
448
                    kraken.owningModule = registry.FileOwner(kraken.filename);
×
449
                    throw;
×
450
                }
451
            }
2✔
452
            return createdPaths;
2✔
453
        }
2✔
454

455
        public static bool IsInternalCkan(ZipEntry ze)
456
            => ze.Name.EndsWith(".ckan", StringComparison.OrdinalIgnoreCase);
2✔
457

458
        #region File overwrites
459

460
        /// <summary>
461
        /// Find files in the given list that are already installed and unowned.
462
        /// Note, this compares files on demand; Memoize for performance!
463
        /// </summary>
464
        /// <param name="zip">Zip file that we are installing from</param>
465
        /// <param name="files">Files that we want to install for a module</param>
466
        /// <param name="registry">Registry to check for file ownership</param>
467
        /// <returns>
468
        /// List of pairs: Key = file, Value = true if identical, false if different
469
        /// </returns>
470
        private IEnumerable<KeyValuePair<InstallableFile, bool>> FindConflictingFiles(ZipFile zip, IEnumerable<InstallableFile> files, Registry registry)
471
        {
2✔
472
            foreach (InstallableFile file in files)
5✔
473
            {
2✔
474
                if (!file.source.IsDirectory
2!
475
                    && File.Exists(file.destination)
476
                    && registry.FileOwner(instance.ToRelativeGameDir(file.destination)) == null)
477
                {
×
478
                    log.DebugFormat("Comparing {0}", file.destination);
×
479
                    using (Stream zipStream = zip.GetInputStream(file.source))
×
480
                    using (FileStream curFile = new FileStream(file.destination, FileMode.Open, FileAccess.Read))
×
481
                    {
×
482
                        yield return new KeyValuePair<InstallableFile, bool>(
×
483
                            file,
484
                            file.source.Size == curFile.Length
485
                                && StreamsEqual(zipStream, curFile)
486
                        );
487
                    }
×
488
                }
×
489
            }
2✔
490
        }
2✔
491

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

534
        /// <summary>
535
        /// Remove files that the user chose to overwrite, so
536
        /// the installer can replace them.
537
        /// Uses a transaction so they can be undeleted if the install
538
        /// fails at a later stage.
539
        /// </summary>
540
        /// <param name="files">The files to overwrite</param>
541
        private static void DeleteConflictingFiles(IEnumerable<InstallableFile> files)
542
        {
×
543
            TxFileManager file_transaction = new TxFileManager();
×
544
            foreach (InstallableFile file in files)
×
545
            {
×
546
                log.DebugFormat("Trying to delete {0}", file.destination);
×
547
                file_transaction.Delete(file.destination);
×
548
            }
×
549
        }
×
550

551
        #endregion
552

553
        #region Find files
554

555
        /// <summary>
556
        /// Given a module and an open zipfile, return all the files that would be installed
557
        /// for this module.
558
        ///
559
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
560
        ///
561
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
562
        /// </summary>
563
        public static List<InstallableFile> FindInstallableFiles(CkanModule module, ZipFile zipfile, GameInstance ksp)
564
        {
2✔
565
            try
566
            {
2✔
567
                // Use the provided stanzas, or use the default install stanza if they're absent.
568
                return module.install is { Length: > 0 }
2✔
569
                    ? module.install
570
                            .SelectMany(stanza => stanza.FindInstallableFiles(zipfile, ksp))
2✔
571
                            .ToList()
572
                    : ModuleInstallDescriptor.DefaultInstallStanza(ksp.game,
573
                                                                   module.identifier)
574
                                             .FindInstallableFiles(zipfile, ksp);
575
            }
576
            catch (BadMetadataKraken kraken)
2✔
577
            {
2✔
578
                // Decorate our kraken with the current module, as the lower-level
579
                // methods won't know it.
580
                kraken.module ??= module;
2!
581
                throw;
2✔
582
            }
583
        }
2✔
584

585
        /// <summary>
586
        /// Given a module and a path to a zipfile, returns all the files that would be installed
587
        /// from that zip for this module.
588
        ///
589
        /// This *will* throw an exception if the file does not exist.
590
        ///
591
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
592
        ///
593
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
594
        /// </summary>
595
        // TODO: Document which exception!
596
        public static List<InstallableFile> FindInstallableFiles(CkanModule module, string zip_filename, GameInstance ksp)
597
        {
2✔
598
            // `using` makes sure our zipfile gets closed when we exit this block.
599
            using (ZipFile zipfile = new ZipFile(zip_filename))
2✔
600
            {
2✔
601
                log.DebugFormat("Searching {0} using {1} as module", zip_filename, module);
2✔
602
                return FindInstallableFiles(module, zipfile, ksp);
2✔
603
            }
604
        }
2✔
605

606
        /// <summary>
607
        /// Returns contents of an installed module
608
        /// </summary>
609
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
610
                GameInstance                instance,
611
                IReadOnlyCollection<string> installed,
612
                HashSet<string>             filters)
613
            => GetModuleContents(instance, installed,
×
614
                                 installed.SelectMany(f => f.TraverseNodes(Path.GetDirectoryName)
×
615
                                                            .Skip(1)
616
                                                            .Where(s => s.Length > 0)
×
617
                                                            .Select(CKANPathUtils.NormalizePath))
618
                                          .ToHashSet(),
619
                                 filters);
620

621
        private static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
622
                GameInstance                instance,
623
                IReadOnlyCollection<string> installed,
624
                HashSet<string>             parents,
625
                HashSet<string>             filters)
626
            => installed.Where(f => !filters.Any(filt => f.Contains(filt)))
×
627
                        .GroupBy(parents.Contains)
628
                        .SelectMany(grp =>
629
                            grp.Select(p => (path:   p,
×
630
                                             dir:    grp.Key,
631
                                             exists: grp.Key ? Directory.Exists(instance.ToAbsoluteGameDir(p))
632
                                                             : File.Exists(instance.ToAbsoluteGameDir(p)))));
633

634
        /// <summary>
635
        /// Returns the module contents if and only if we have it
636
        /// available in our cache, empty sequence otherwise.
637
        ///
638
        /// Intended for previews.
639
        /// </summary>
640
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
641
                NetModuleCache  Cache,
642
                GameInstance    instance,
643
                CkanModule      module,
644
                HashSet<string> filters)
645
            => (Cache.GetCachedFilename(module) is string filename
×
646
                    ? GetModuleContents(instance,
647
                                        Utilities.DefaultIfThrows(
648
                                            () => FindInstallableFiles(module, filename, instance)),
×
649
                                        filters)
650
                    : null)
651
               ?? Enumerable.Empty<(string path, bool dir, bool exists)>();
652

653
        private static IEnumerable<(string path, bool dir, bool exists)>? GetModuleContents(
654
                GameInstance                  instance,
655
                IEnumerable<InstallableFile>? installable,
656
                HashSet<string>               filters)
657
            => installable?.Where(instF => !filters.Any(filt => instF.destination != null
×
658
                                                                && instF.destination.Contains(filt)))
659
                           .Select(f => (path:   instance.ToRelativeGameDir(f.destination),
×
660
                                         dir:    f.source.IsDirectory,
661
                                         exists: true));
662

663
        #endregion
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
                                            string[]         candidateDuplicates,
678
                                            IProgress<long>? progress)
679
        {
2✔
680
            var file_transaction = new TxFileManager();
2✔
681

682
            if (entry.IsDirectory)
2✔
683
            {
2✔
684
                // Skip if we're not making directories for this install.
685
                if (!makeDirs)
2!
686
                {
×
687
                    log.DebugFormat("Skipping '{0}', we don't make directories for this path", fullPath);
×
688
                    return null;
×
689
                }
690

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

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

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

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

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

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

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

766
        private static readonly TimeSpan UnzipProgressInterval = TimeSpan.FromMilliseconds(200);
2✔
767

768
        #endregion
769

770
        #region Uninstallation
771

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

786
            foreach (string mod in mods.Where(mod => registry_manager.registry.InstalledModule(mod) == null))
2!
787
            {
2✔
788
                throw new ModNotInstalledKraken(mod);
2✔
789
            }
790

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

799
            // Find all the things which need uninstalling.
800
            var revdep = mods
2✔
801
                .Union(registry_manager.registry.FindReverseDependencies(
802
                    mods.Except(installing?.Select(m => m.identifier) ?? Array.Empty<string>())
×
803
                        .ToList(),
804
                    installing))
805
                .ToArray();
806

807
            var goners = revdep.Union(
2✔
808
                    registry_manager.registry.FindRemovableAutoInstalled(
809
                        registry_manager.registry.InstalledModules
810
                            .Where(im => !revdep.Contains(im.identifier))
2✔
811
                            .ToArray(),
812
                        installing ?? new List<CkanModule>(),
813
                        instance.game, instance.StabilityToleranceConfig,
814
                        instance.VersionCriteria())
815
                    .Select(im => im.identifier))
2✔
816
                .OrderBy(ident => ident)
2✔
817
                .ToArray();
818

819
            // If there is nothing to uninstall, skip out.
820
            if (goners.Length == 0)
2✔
821
            {
2✔
822
                return;
2✔
823
            }
824

825
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToRemove);
2✔
826
            User.RaiseMessage("");
2✔
827

828
            foreach (var module in goners.Select(registry_manager.registry.InstalledModule)
5✔
829
                                         .OfType<InstalledModule>())
830
            {
2✔
831
                User.RaiseMessage(" * {0} {1}", module.Module.name, module.Module.version);
2✔
832
            }
2✔
833

834
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2!
835
            {
×
836
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerRemoveAborted);
×
837
            }
838

839
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
840
            {
2✔
841
                var registry = registry_manager.registry;
2✔
842
                long removeBytes = goners.Select(registry.InstalledModule)
2✔
843
                                         .OfType<InstalledModule>()
844
                                         .Sum(m => m.Module.install_size);
2✔
845
                var rateCounter = new ByteRateCounter()
2✔
846
                {
847
                    Size      = removeBytes,
848
                    BytesLeft = removeBytes,
849
                };
850
                rateCounter.Start();
2✔
851

852
                long modRemoveCompletedBytes = 0;
2✔
853
                foreach (string ident in goners)
5✔
854
                {
2✔
855
                    if (registry.InstalledModule(ident) is InstalledModule instMod)
2!
856
                    {
2✔
857
                        Uninstall(ident, ref possibleConfigOnlyDirs, registry,
2✔
858
                                  new ProgressImmediate<long>(bytes =>
859
                                  {
2✔
860
                                      RemoveProgress?.Invoke(instMod,
2!
861
                                                             Math.Max(0,     instMod.Module.install_size - bytes),
862
                                                             Math.Max(bytes, instMod.Module.install_size));
863
                                      rateCounter.BytesLeft = removeBytes - (modRemoveCompletedBytes
2✔
864
                                                                             + Math.Min(bytes, instMod.Module.install_size));
865
                                      User.RaiseProgress(rateCounter);
2✔
866
                                  }));
2✔
867
                        modRemoveCompletedBytes += instMod?.Module.install_size ?? 0;
2✔
868
                    }
2✔
869
                }
2✔
870

871
                // Enforce consistency if we're not installing anything,
872
                // otherwise consistency will be enforced after the installs
873
                registry_manager.Save(installing == null);
2✔
874

875
                transaction.Complete();
2✔
876
            }
2✔
877

878
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
879
        }
2✔
880

881
        /// <summary>
882
        /// Uninstall the module provided. For internal use only.
883
        /// Use UninstallList for user queries, it also does dependency handling.
884
        /// This does *NOT* save the registry.
885
        /// </summary>
886
        /// <param name="identifier">Identifier of module to uninstall</param>
887
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
888
        /// <param name="registry">Registry to use</param>
889
        /// <param name="progress">Progress to report</param>
890
        private void Uninstall(string               identifier,
891
                               ref HashSet<string>? possibleConfigOnlyDirs,
892
                               Registry             registry,
893
                               IProgress<long>      progress)
894
        {
2✔
895
            var file_transaction = new TxFileManager();
2✔
896

897
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
898
            {
2✔
899
                var instMod = registry.InstalledModule(identifier);
2✔
900

901
                if (instMod == null)
2!
902
                {
×
903
                    log.ErrorFormat("Trying to uninstall {0} but it's not installed", identifier);
×
904
                    throw new ModNotInstalledKraken(identifier);
×
905
                }
906
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovingMod,
2✔
907
                                  $"{instMod.Module.name} {instMod.Module.version}");
908

909
                // Walk our registry to find all files for this mod.
910
                var modFiles = instMod.Files.ToArray();
2✔
911

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

915
                // Files that Windows refused to delete due to locking (probably)
916
                var undeletableFiles = new List<string>();
2✔
917

918
                long bytesDeleted = 0;
2✔
919
                foreach (string relPath in modFiles)
5✔
920
                {
2✔
921
                    if (cancelToken.IsCancellationRequested)
2!
922
                    {
×
923
                        throw new CancelledActionKraken();
×
924
                    }
925

926
                    string absPath = instance.ToAbsoluteGameDir(relPath);
2✔
927

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

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

979
                if (undeletableFiles.Count > 0)
2!
980
                {
×
981
                    throw new FailedToDeleteFilesKraken(identifier, undeletableFiles);
×
982
                }
983

984
                // Remove from registry.
985
                registry.DeregisterModule(instance, identifier);
2✔
986

987
                // Our collection of directories may leave empty parent directories.
988
                directoriesToDelete = AddParentDirectories(directoriesToDelete);
2✔
989

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

1004
                    // See what's left in this folder and what we can do about it
1005
                    GroupFilesByRemovable(instance.ToRelativeGameDir(directory),
2✔
1006
                                          registry, modFiles, instance.game,
1007
                                          (Directory.Exists(directory)
1008
                                              ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories)
1009
                                              : Enumerable.Empty<string>())
1010
                                           .Select(instance.ToRelativeGameDir)
1011
                                           .ToArray(),
1012
                                          out string[] removable,
1013
                                          out string[] notRemovable);
1014

1015
                    // Delete the auto-removable files and dirs
1016
                    foreach (var absPath in removable.Select(instance.ToAbsoluteGameDir))
4!
1017
                    {
×
1018
                        if (File.Exists(absPath))
×
1019
                        {
×
1020
                            log.DebugFormat("Attempting transaction deletion of file {0}", absPath);
×
1021
                            file_transaction.Delete(absPath);
×
1022
                        }
×
1023
                        else if (Directory.Exists(absPath))
×
1024
                        {
×
1025
                            log.DebugFormat("Attempting deletion of directory {0}", absPath);
×
1026
                            try
1027
                            {
×
1028
                                Directory.Delete(absPath);
×
1029
                            }
×
1030
                            catch
×
1031
                            {
×
1032
                                // There might be files owned by other mods, oh well
1033
                                log.DebugFormat("Failed to delete {0}", absPath);
×
1034
                            }
×
1035
                        }
×
1036
                    }
×
1037

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

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

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

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

1132
                    // If this is a parentless directory (Windows)
1133
                    // or if the Root equals the current directory (Mono)
1134
                    if (dirInfo.Parent == null || dirInfo.Root == dirInfo)
2✔
1135
                    {
2✔
1136
                        return results;
2✔
1137
                    }
1138

1139
                    if (!dir.StartsWith(gameDir, Platform.PathComparison))
2!
1140
                    {
×
1141
                        dir = CKANPathUtils.ToAbsolute(dir, gameDir);
×
1142
                    }
×
1143

1144
                    // Remove the system paths, leaving the path under the instance directory
1145
                    var relativeHead = CKANPathUtils.ToRelative(dir, gameDir);
2✔
1146
                    // Don't try to remove GameRoot
1147
                    if (!string.IsNullOrEmpty(relativeHead))
2✔
1148
                    {
2✔
1149
                        var pathArray = relativeHead.Split('/');
2✔
1150
                        var builtPath = "";
2✔
1151
                        foreach (var path in pathArray)
5✔
1152
                        {
2✔
1153
                            builtPath += path + '/';
2✔
1154
                            results.Add(CKANPathUtils.ToAbsolute(builtPath, gameDir));
2✔
1155
                        }
2✔
1156
                    }
2✔
1157

1158
                    return results;
2✔
1159
                })
2✔
1160
                .Where(dir => !instance.game.IsReservedDirectory(instance, dir))
2✔
1161
                .ToHashSet();
1162
        }
2✔
1163

1164
        #endregion
1165

1166
        #region AddRemove
1167

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

1200
                long removeBytes     = remove.Sum(m => m.Module.install_size);
2✔
1201
                long removedBytes    = 0;
2✔
1202
                long downloadBytes   = toDownload.Sum(m => m.download_size);
1✔
1203
                long downloadedBytes = 0;
2✔
1204
                long installBytes    = add.Sum(m => m.install_size);
2✔
1205
                long installedBytes  = 0;
2✔
1206
                var rateCounter = new ByteRateCounter()
2✔
1207
                {
1208
                    Size      = removeBytes + downloadBytes + installBytes,
1209
                    BytesLeft = removeBytes + downloadBytes + installBytes,
1210
                };
1211
                rateCounter.Start();
2✔
1212

1213
                downloader.OverallDownloadProgress += brc =>
2✔
1214
                {
×
1215
                    downloadedBytes = downloadBytes - brc.BytesLeft;
×
1216
                    rateCounter.BytesLeft = removeBytes   - removedBytes
×
1217
                                          + downloadBytes - downloadedBytes
1218
                                          + installBytes  - installedBytes;
1219
                    User.RaiseProgress(rateCounter);
×
1220
                };
×
1221
                var toInstall = ModsInDependencyOrder(resolver, cached, toDownload, downloader);
2✔
1222

1223
                long modRemoveCompletedBytes = 0;
2✔
1224
                foreach (var instMod in remove)
5✔
1225
                {
2✔
1226
                    Uninstall(instMod.Module.identifier,
2✔
1227
                              ref possibleConfigOnlyDirs,
1228
                              registry_manager.registry,
1229
                              new ProgressImmediate<long>(bytes =>
1230
                              {
2✔
1231
                                  RemoveProgress?.Invoke(instMod,
2!
1232
                                                         Math.Max(0,     instMod.Module.install_size - bytes),
1233
                                                         Math.Max(bytes, instMod.Module.install_size));
1234
                                  removedBytes = modRemoveCompletedBytes
2✔
1235
                                                 + Math.Min(bytes, instMod.Module.install_size);
1236
                                  rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1237
                                                        + downloadBytes - downloadedBytes
1238
                                                        + installBytes  - installedBytes;
1239
                                  User.RaiseProgress(rateCounter);
2✔
1240
                              }));
2✔
1241
                     modRemoveCompletedBytes += instMod.Module.install_size;
2✔
1242
                }
2✔
1243

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

1274
                registry_manager.Save(enforceConsistency);
2✔
1275
                tx.Complete();
2✔
1276
                EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
1277
            }
2✔
1278
        }
2✔
1279

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

1295
            var removingIdents = registry.InstalledModules.Select(im => im.identifier)
2✔
1296
                                         .Intersect(modules.Select(m => m.identifier))
2✔
1297
                                         .ToHashSet();
1298
            var autoRemoving = registry
2✔
1299
                .FindRemovableAutoInstalled(
1300
                    // Conjure the future state of the installed modules list after upgrading
1301
                    registry.InstalledModules
1302
                            .Where(im => !removingIdents.Contains(im.identifier))
2✔
1303
                            .ToArray(),
1304
                    modules,
1305
                    instance.game, instance.StabilityToleranceConfig, instance.VersionCriteria())
1306
                .ToHashSet();
1307

1308
            var resolver = new RelationshipResolver(
2✔
1309
                modules,
1310
                modules.Select(m => registry.InstalledModule(m.identifier)?.Module)
2!
1311
                       .OfType<CkanModule>()
1312
                       .Concat(autoRemoving.Select(im => im.Module)),
2✔
1313
                RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig),
1314
                registry,
1315
                instance.game, instance.VersionCriteria());
1316
            var fullChangeset = resolver.ModList()
2✔
1317
                                        .ToDictionary(m => m.identifier,
2✔
1318
                                                      m => m);
2✔
1319

1320
            // Skip removing ones we still need
1321
            var keepIdents = fullChangeset.Keys.Intersect(autoRemoving.Select(im => im.Module.identifier))
2✔
1322
                                               .ToHashSet();
1323
            autoRemoving.RemoveWhere(im => keepIdents.Contains(im.Module.identifier));
2✔
1324
            foreach (var ident in keepIdents)
5✔
1325
            {
2✔
1326
                fullChangeset.Remove(ident);
2✔
1327
            }
2✔
1328

1329
            // Only install stuff that's already there if explicitly requested in param
1330
            var toInstall = fullChangeset.Values
2✔
1331
                                         .Except(registry.InstalledModules
1332
                                                         .Select(im => im.Module)
2✔
1333
                                                         .Except(modules))
1334
                                         .ToArray();
1335
            var autoInstalled = toInstall.ToDictionary(m => m, resolver.IsAutoInstalled);
2✔
1336

1337
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToUpgrade);
2✔
1338
            User.RaiseMessage("");
2✔
1339

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

1346
            // Let's discover what we need to do with each module!
1347
            foreach (CkanModule module in toInstall)
5✔
1348
            {
2✔
1349
                var installed_mod = registry.InstalledModule(module.identifier);
2✔
1350

1351
                if (installed_mod == null)
2✔
1352
                {
2✔
1353
                    if (!cache.IsMaybeCachedZip(module)
2!
1354
                        && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1355
                    {
×
1356
                        if (inProgressFile.Exists)
×
1357
                        {
×
1358
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingResuming,
×
1359
                                              module.name, module.version,
1360
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1361
                                              CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1362
                        }
×
1363
                        else
1364
                        {
×
1365
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingUncached,
×
1366
                                              module.name, module.version,
1367
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1368
                                              CkanModule.FmtSize(module.download_size));
1369
                        }
×
1370
                    }
×
1371
                    else
1372
                    {
2✔
1373
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingCached,
2✔
1374
                                          module.name, module.version);
1375
                    }
2✔
1376
                }
2✔
1377
                else
1378
                {
2✔
1379
                    // Module already installed. We'll need to remove it first.
1380
                    toRemove.Add(installed_mod);
2✔
1381

1382
                    CkanModule installed = installed_mod.Module;
2✔
1383
                    if (installed.version.IsEqualTo(module.version))
2!
1384
                    {
×
1385
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeReinstalling,
×
1386
                                          module.name, module.version);
1387
                    }
×
1388
                    else if (installed.version.IsGreaterThan(module.version))
2✔
1389
                    {
2✔
1390
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeDowngrading,
2✔
1391
                                          module.name, installed.version, module.version);
1392
                    }
2✔
1393
                    else
1394
                    {
2✔
1395
                        if (!cache.IsMaybeCachedZip(module)
2!
1396
                            && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1397
                        {
×
1398
                            if (inProgressFile.Exists)
×
1399
                            {
×
1400
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingResuming,
×
1401
                                                  module.name, installed.version, module.version,
1402
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1403
                                                  CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1404
                            }
×
1405
                            else
1406
                            {
×
1407
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingUncached,
×
1408
                                                  module.name, installed.version, module.version,
1409
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1410
                                                  CkanModule.FmtSize(module.download_size));
1411
                            }
×
1412
                        }
×
1413
                        else
1414
                        {
2✔
1415
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingCached,
2✔
1416
                                              module.name, installed.version, module.version);
1417
                        }
2✔
1418
                    }
2✔
1419
                }
2✔
1420
            }
2✔
1421

1422
            if (autoRemoving.Count > 0)
2✔
1423
            {
2✔
1424
                foreach (var im in autoRemoving)
5✔
1425
                {
2✔
1426
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeAutoRemoving,
2✔
1427
                                      im.Module.name, im.Module.version);
1428
                }
2✔
1429
                toRemove.AddRange(autoRemoving);
2✔
1430
            }
2✔
1431

1432
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2!
1433
            {
×
1434
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUpgradeUserDeclined);
×
1435
            }
1436

1437
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1438
                      registry_manager,
1439
                      resolver,
1440
                      toInstall,
1441
                      autoInstalled,
1442
                      toRemove,
1443
                      downloader,
1444
                      enforceConsistency,
1445
                      deduper);
1446
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1447
        }
2✔
1448

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

1473
            // Our replacement involves removing the currently installed mods, then
1474
            // adding everything that needs installing (which may involve new mods to
1475
            // satisfy dependencies).
1476

1477
            // Let's discover what we need to do with each module!
1478
            foreach (ModuleReplacement repl in replacements)
5✔
1479
            {
2✔
1480
                string ident = repl.ToReplace.identifier;
2✔
1481
                var installedMod = registry_manager.registry.InstalledModule(ident);
2✔
1482

1483
                if (installedMod == null)
2!
1484
                {
×
1485
                    log.WarnFormat("Wait, {0} is not actually installed?", ident);
×
1486
                    //Maybe ModuleNotInstalled ?
1487
                    if (registry_manager.registry.IsAutodetected(ident))
×
1488
                    {
×
1489
                        throw new ModuleNotFoundKraken(ident,
×
1490
                            repl.ToReplace.version.ToString(),
1491
                            string.Format(Properties.Resources.ModuleInstallerReplaceAutodetected, ident));
1492
                    }
1493

1494
                    throw new ModuleNotFoundKraken(ident,
×
1495
                        repl.ToReplace.version.ToString(),
1496
                        string.Format(Properties.Resources.ModuleInstallerReplaceNotInstalled, ident, repl.ReplaceWith.identifier));
1497
                }
1498
                else
1499
                {
2✔
1500
                    // Obviously, we need to remove the mod we are replacing
1501
                    modsToRemove.Add(installedMod);
2✔
1502

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

1507
                    // If replacement is not installed, we've already added it to modsToInstall above
1508
                    if (installed_replacement != null)
2!
1509
                    {
×
1510
                        //Module already installed. We'll need to treat it as an upgrade.
1511
                        log.DebugFormat("It turns out {0} is already installed, we'll upgrade it.", installed_replacement.identifier);
×
1512
                        modsToRemove.Add(installed_replacement);
×
1513

1514
                        CkanModule installed = installed_replacement.Module;
×
1515
                        if (installed.version.IsEqualTo(repl.ReplaceWith.version))
×
1516
                        {
×
1517
                            log.InfoFormat("{0} is already at the latest version, reinstalling to replace {1}", repl.ReplaceWith.identifier, repl.ToReplace.identifier);
×
1518
                        }
×
1519
                        else if (installed.version.IsGreaterThan(repl.ReplaceWith.version))
×
1520
                        {
×
1521
                            log.WarnFormat("Downgrading {0} from {1} to {2} to replace {3}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1522
                        }
×
1523
                        else
1524
                        {
×
1525
                            log.InfoFormat("Upgrading {0} to {1} to replace {2}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1526
                        }
×
1527
                    }
×
1528
                    else
1529
                    {
2✔
1530
                        log.InfoFormat("Replacing {0} with {1} {2}", repl.ToReplace.identifier, repl.ReplaceWith.identifier, repl.ReplaceWith.version);
2✔
1531
                    }
2✔
1532
                }
2✔
1533
            }
2✔
1534
            var resolver = new RelationshipResolver(modsToInstall, null, options, registry_manager.registry,
2✔
1535
                                                    instance.game, instance.VersionCriteria());
1536
            var resolvedModsToInstall = resolver.ModList().ToList();
2✔
1537

1538
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1539
                      registry_manager,
1540
                      resolver,
1541
                      resolvedModsToInstall,
1542
                      resolvedModsToInstall.ToDictionary(m => m, m => false),
2✔
1543
                      modsToRemove,
1544
                      downloader,
1545
                      enforceConsistency,
1546
                      deduper);
1547
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1548
        }
2✔
1549

1550
        #endregion
1551

1552
        public static IEnumerable<string> PrioritizedHosts(IConfiguration    config,
1553
                                                           IEnumerable<Uri>? urls)
1554
            => urls?.OrderBy(u => u, new PreferredHostUriComparer(config.PreferredHosts))
2!
1555
                    .Select(dl => dl.Host)
2✔
1556
                    .Distinct()
1557
                   ?? Enumerable.Empty<string>();
1558

1559
        #region Recommendations
1560

1561
        /// <summary>
1562
        /// Looks for optional related modules that could be installed alongside the given modules
1563
        /// </summary>
1564
        /// <param name="instance">Game instance to use</param>
1565
        /// <param name="sourceModules">Modules to check for relationships</param>
1566
        /// <param name="toInstall">Modules already being installed, to be omitted from search</param>
1567
        /// <param name="exclude">Modules the user has already seen and decided not to install</param>
1568
        /// <param name="registry">Registry to use</param>
1569
        /// <param name="recommendations">Modules that are recommended to install</param>
1570
        /// <param name="suggestions">Modules that are suggested to install</param>
1571
        /// <param name="supporters">Modules that support other modules we're installing</param>
1572
        /// <returns>
1573
        /// true if anything found, false otherwise
1574
        /// </returns>
1575
        public static bool FindRecommendations(GameInstance                                          instance,
1576
                                               ICollection<CkanModule>                               sourceModules,
1577
                                               ICollection<CkanModule>                               toInstall,
1578
                                               ICollection<CkanModule>                               exclude,
1579
                                               Registry                                              registry,
1580
                                               out Dictionary<CkanModule, Tuple<bool, List<string>>> recommendations,
1581
                                               out Dictionary<CkanModule, List<string>>              suggestions,
1582
                                               out Dictionary<CkanModule, HashSet<string>>           supporters)
1583
        {
2✔
1584
            log.DebugFormat("Finding recommendations for: {0}", string.Join(", ", sourceModules));
2✔
1585
            var crit     = instance.VersionCriteria();
2✔
1586
            var resolver = new RelationshipResolver(sourceModules.Where(m => !m.IsDLC),
2✔
1587
                                                    null,
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), null,
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,
×
1621
                                                m => resolver.ReasonsFor(m)
×
1622
                                                             .OfType<SelectionReason.Suggested>()
1623
                                                             .Where(r => recommenders.Contains(r.Parent))
×
1624
                                                             .Select(r => r.Parent)
×
1625
                                                             .OfType<CkanModule>()
1626
                                                             .Select(m => m.identifier)
×
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)
×
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(List<CkanModule>            toInstall,
1656
                                      RelationshipResolverOptions opts,
1657
                                      IRegistryQuerier            registry,
1658
                                      IGame                       game,
1659
                                      GameVersionCriteria         crit)
1660
        {
×
1661
            string request = string.Join(", ", toInstall.Select(m => m.identifier));
×
1662
            try
1663
            {
×
1664
                var installed = toInstall.Select(m => registry.InstalledModule(m.identifier)?.Module)
×
1665
                                         .OfType<CkanModule>();
1666
                var resolver = new RelationshipResolver(toInstall, installed, opts, registry, game, crit);
×
1667

1668
                var resolverModList = resolver.ModList(false).ToList();
×
1669
                if (resolverModList.Count >= toInstall.Count(m => !m.IsMetapackage))
×
1670
                {
×
1671
                    // We can install with no further dependencies
1672
                    string recipe = string.Join(", ", resolverModList.Select(m => m.identifier));
×
1673
                    log.Debug($"Installable: {request}: {recipe}");
×
1674
                    return true;
×
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)
×
1683
            {
×
1684
                // One of the dependencies is virtual
1685
                foreach (var mod in k.modules)
×
1686
                {
×
1687
                    // Try each option recursively to see if any are successful
1688
                    if (CanInstall(toInstall.Append(mod).ToList(), opts, registry, game, crit))
×
1689
                    {
×
1690
                        // Child call will emit debug output, so we don't need to here
1691
                        return true;
×
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
        }
×
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 readonly GameInstance      instance;
1721
        private readonly NetModuleCache    cache;
1722
        private readonly IConfiguration    config;
1723
        private readonly CancellationToken cancelToken;
1724

1725
        private static readonly ILog log = LogManager.GetLogger(typeof(ModuleInstaller));
2✔
1726
    }
1727
}
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