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

KSP-CKAN / CKAN / 25198663652

01 May 2026 01:58AM UTC coverage: 87.472% (+1.6%) from 85.851%
25198663652

push

github

HebaruSan
Merge #4594 Windows dark mode in .NET 10 build

1982 of 2112 branches covered (93.84%)

Branch coverage included in aggregate %.

35 of 36 new or added lines in 7 files covered. (97.22%)

33 existing lines in 24 files now uncovered.

8491 of 9861 relevant lines covered (86.11%)

2.69 hits per line

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

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

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

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

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

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

44
        public IUser User { get; set; }
45

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

50
        #region Installation
51

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

302
                    // Register our module and its files.
303
                    registry.RegisterModule(module, files, instance, autoInstalled);
3✔
304

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

311
                    if (filteredCount > 0)
3✔
312
                    {
313
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledModFiltered,
3✔
314
                                          $"{module.name} {module.version}", filteredCount);
315
                    }
316
                    else
317
                    {
318
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
3✔
319
                                          $"{module.name} {module.version}");
320
                    }
321
                }
3✔
322
            }
3✔
323
            catch (ZipException zexc)
3✔
324
            {
325
                cache.Purge(module);
3✔
326
                throw new InvalidModuleFileKraken(module, filename ?? "",
3✔
327
                                                  string.Format(Properties.Resources.ModuleInstallerCorruptInCache,
328
                                                                module, zexc.Message));
329
            }
330

331
            // Fire our callback that we've installed a module, if we have one.
332
            OneComplete?.Invoke(module);
3✔
UNCOV
333
        }
×
334

335
        /// <summary>
336
        /// Check if the given module is a DLC:
337
        /// if it is, throws ModuleIsDLCKraken.
338
        /// </summary>
339
        private static void CheckKindInstallationKraken(CkanModule module)
340
        {
341
            if (module.IsDLC)
3✔
342
            {
343
                throw new ModuleIsDLCKraken(module);
×
344
            }
345
        }
3✔
346

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

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

475
        public static bool IsInternalCkan(ZipEntry ze)
476
            => ze.Name.EndsWith(".ckan", StringComparison.OrdinalIgnoreCase);
3✔
477

478
        #region File overwrites
479

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

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

551
        /// <summary>
552
        /// Remove files that the user chose to overwrite, so
553
        /// the installer can replace them.
554
        /// Uses a transaction so they can be undeleted if the install
555
        /// fails at a later stage.
556
        /// </summary>
557
        /// <param name="files">The files to overwrite</param>
558
        private void DeleteConflictingFiles(IEnumerable<InstallableFile> files)
559
        {
560
            var txFileMgr = new TxFileManager(instance.CkanDir);
3✔
561
            foreach (var absPath in files.Select(f => instance.ToAbsoluteGameDir(f.destination)))
9✔
562
            {
563
                log.DebugFormat("Trying to delete {0}", absPath);
3✔
564
                txFileMgr.Delete(absPath);
3✔
565
            }
566
        }
3✔
567

568
        #endregion
569

570
        #region Find files
571

572
        /// <summary>
573
        /// Given a module and an open zipfile, return all the files that would be installed
574
        /// for this module.
575
        ///
576
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
577
        ///
578
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
579
        /// </summary>
580
        public static IEnumerable<InstallableFile> FindInstallableFiles(CkanModule module,
581
                                                                        ZipFile    zipfile,
582
                                                                        IGame      game)
583
            // Use the provided stanzas, or use the default install stanza if they're absent.
584
            => module.GetInstallStanzas(game)
3✔
585
                     .SelectMany(stanza => stanza.FindInstallableFiles(module, zipfile, game))
3✔
586
                     .ToArray();
587

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

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

623
        private static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
624
                GameInstance        instance,
625
                IEnumerable<string> installed,
626
                HashSet<string>     parents,
627
                HashSet<string>     filters)
628
            => installed.Where(f => !filters.Any(filt => f.Contains(filt)))
×
629
                        .Select(f => (relPath: f,
3✔
630
                                      absPath: instance.ToAbsoluteGameDir(f)))
631
                        .Select(f => (f.relPath, f.absPath,
3✔
632
                                      dir: parents.Contains(f.relPath)
633
                                           // Empty dirs are parents of nothing
634
                                           || Directory.Exists(f.absPath)))
635
                        .GroupBy(f => f.dir)
3✔
636
                        .SelectMany(grp =>
637
                            grp.Select(p => (path:   p.relPath, p.dir,
3✔
638
                                             exists: grp.Key ? Directory.Exists(p.absPath)
639
                                                             : File.Exists(p.absPath))));
640

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

659
        private static IEnumerable<(string path, bool dir, bool exists)>? GetModuleContents(
660
                IEnumerable<InstallableFile>? installable,
661
                HashSet<string>               filters)
662
            => installable?.Where(instF => !filters.Any(filt => instF.destination != null
×
663
                                                                && instF.destination.Contains(filt)))
664
                           .Select(f => (path:   f.destination,
3✔
665
                                         dir:    f.source.IsDirectory,
666
                                         exists: true));
667

668
        #endregion
669

670
        private string? InstallFile(ZipFile          zipfile,
671
                                    ZipEntry         entry,
672
                                    string           fullPath,
673
                                    bool             makeDirs,
674
                                    string[]         candidateDuplicates,
675
                                    IProgress<long>? progress)
676
            => InstallFile(zipfile, entry, fullPath, makeDirs,
3✔
677
                           new TxFileManager(instance.CkanDir),
678
                           candidateDuplicates, progress);
679

680
        /// <summary>
681
        /// Copy the entry from the opened zipfile to the path specified.
682
        /// </summary>
683
        /// <returns>
684
        /// Path of file or directory that was created.
685
        /// May differ from the input fullPath!
686
        /// Throws a FileExistsKraken if we were going to overwrite the file.
687
        /// </returns>
688
        internal static string? InstallFile(ZipFile          zipfile,
689
                                            ZipEntry         entry,
690
                                            string           fullPath,
691
                                            bool             makeDirs,
692
                                            IFileManager     txFileMgr,
693
                                            string[]         candidateDuplicates,
694
                                            IProgress<long>? progress)
695
        {
696
            if (entry.IsDirectory)
3✔
697
            {
698
                // Skip if we're not making directories for this install.
699
                if (!makeDirs)
3✔
700
                {
701
                    log.DebugFormat("Skipping '{0}', we don't make directories for this path", fullPath);
×
702
                    return null;
×
703
                }
704

705
                // Windows silently trims trailing spaces, get the path it will actually use
706
                fullPath = Path.GetDirectoryName(Path.Combine(fullPath, "DUMMY")) is string p
3✔
707
                    ? CKANPathUtils.NormalizePath(p)
708
                    : fullPath;
709

710
                log.DebugFormat("Making directory '{0}'", fullPath);
3✔
711
                txFileMgr.CreateDirectory(fullPath);
3✔
712
            }
713
            else
714
            {
715
                log.DebugFormat("Writing file '{0}'", fullPath);
3✔
716

717
                // ZIP format does not require directory entries
718
                if (makeDirs && Path.GetDirectoryName(fullPath) is string d)
3✔
719
                {
720
                    log.DebugFormat("Making parent directory '{0}'", d);
3✔
721
                    txFileMgr.CreateDirectory(d);
3✔
722
                }
723

724
                // We don't allow for the overwriting of files. See #208.
725
                if (txFileMgr.FileExists(fullPath))
3✔
726
                {
727
                    throw new FileExistsKraken(fullPath);
3✔
728
                }
729

730
                // Snapshot whatever was there before. If there's nothing, this will just
731
                // remove our file on rollback. We still need this even though we won't
732
                // overwite files, as it ensures deletion on rollback.
733
                txFileMgr.Snapshot(fullPath);
3✔
734

735
                // Try making hard links if already installed in another instance (faster, less space)
736
                foreach (var installedSource in candidateDuplicates)
9✔
737
                {
738
                    try
739
                    {
740
                        HardLink.Create(installedSource, fullPath);
3✔
741
                        return fullPath;
3✔
742
                    }
743
                    catch
×
744
                    {
745
                        // If hard link creation fails, try more hard links, or copy if none work
746
                    }
×
747
                }
748

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

777
        private static readonly TimeSpan UnzipProgressInterval = TimeSpan.FromMilliseconds(200);
3✔
778

779
        #endregion
780

781
        #region Uninstallation
782

783
        /// <summary>
784
        /// Uninstalls all the mods provided, including things which depend upon them.
785
        /// This *DOES* save the registry.
786
        /// Preferred over Uninstall.
787
        /// </summary>
788
        public void UninstallList(IEnumerable<string>              mods,
789
                                  ref HashSet<string>?             possibleConfigOnlyDirs,
790
                                  RegistryManager                  registry_manager,
791
                                  bool                             ConfirmPrompt = true,
792
                                  IReadOnlyCollection<CkanModule>? installing    = null)
793
        {
794
            mods = mods.Memoize();
3✔
795
            installing ??= Array.Empty<CkanModule>();
3✔
796
            // Pre-check, have they even asked for things which are installed?
797

798
            foreach (string mod in mods.Where(mod => registry_manager.registry.InstalledModule(mod) == null))
3✔
799
            {
800
                throw new ModNotInstalledKraken(mod);
3✔
801
            }
802

803
            var instDlc = mods.Select(registry_manager.registry.InstalledModule)
3✔
804
                              .OfType<InstalledModule>()
805
                              .FirstOrDefault(m => m.Module.IsDLC);
3✔
806
            if (instDlc != null)
3✔
807
            {
808
                throw new ModuleIsDLCKraken(instDlc.Module);
×
809
            }
810

811
            // Find all the things which need uninstalling.
812
            var revdep = mods
3✔
813
                .Union(registry_manager.registry.FindReverseDependencies(
814
                    mods.Except(installing.Select(m => m.identifier)).ToArray(),
×
815
                    installing))
816
                .ToArray();
817

818
            var goners = revdep.Union(
3✔
819
                                registry_manager.registry.FindRemovableAutoInstalled(installing,
820
                                                                                     revdep.ToHashSet(),
821
                                                                                     instance)
822
                                                         .Select(im => im.identifier))
3✔
823
                               .Order()
824
                               .ToArray();
825

826
            // If there is nothing to uninstall, skip out.
827
            if (goners.Length == 0)
3✔
828
            {
829
                return;
3✔
830
            }
831

832
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToRemove);
3✔
833
            User.RaiseMessage("");
3✔
834

835
            foreach (var module in goners.Select(registry_manager.registry.InstalledModule)
9✔
836
                                         .OfType<InstalledModule>())
837
            {
838
                User.RaiseMessage(" * {0} {1}", module.Module.name, module.Module.version);
3✔
839
            }
840

841
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
3✔
842
            {
843
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerRemoveAborted);
×
844
            }
845

846
            using (var transaction = CkanTransaction.CreateTransactionScope())
3✔
847
            {
848
                var registry = registry_manager.registry;
3✔
849
                long removeBytes = goners.Select(registry.InstalledModule)
3✔
850
                                         .OfType<InstalledModule>()
851
                                         .Sum(m => m.Module.install_size);
3✔
852
                var rateCounter = new ByteRateCounter()
3✔
853
                {
854
                    Size      = removeBytes,
855
                    BytesLeft = removeBytes,
856
                };
857
                rateCounter.Start();
3✔
858

859
                long modRemoveCompletedBytes = 0;
3✔
860
                foreach (string ident in goners)
9✔
861
                {
862
                    if (registry.InstalledModule(ident) is InstalledModule instMod)
3✔
863
                    {
864
                        Uninstall(ident, ref possibleConfigOnlyDirs, registry,
3✔
865
                                  new ProgressImmediate<long>(bytes =>
866
                                  {
867
                                      RemoveProgress?.Invoke(instMod,
3✔
868
                                                             Math.Max(0,     instMod.Module.install_size - bytes),
869
                                                             Math.Max(bytes, instMod.Module.install_size));
870
                                      rateCounter.BytesLeft = removeBytes - (modRemoveCompletedBytes
3✔
871
                                                                             + Math.Min(bytes, instMod.Module.install_size));
872
                                      User.RaiseProgress(rateCounter);
3✔
873
                                  }));
3✔
874
                        modRemoveCompletedBytes += instMod?.Module.install_size ?? 0;
3✔
875
                    }
876
                }
877

878
                // Enforce consistency if we're not installing anything,
879
                // otherwise consistency will be enforced after the installs
880
                registry_manager.Save(installing == null);
3✔
881

882
                transaction.Complete();
3✔
883
            }
3✔
884

885
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
3✔
886
        }
3✔
887

888
        /// <summary>
889
        /// Uninstall the module provided. For internal use only.
890
        /// Use UninstallList for user queries, it also does dependency handling.
891
        /// This does *NOT* save the registry.
892
        /// </summary>
893
        /// <param name="identifier">Identifier of module to uninstall</param>
894
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
895
        /// <param name="registry">Registry to use</param>
896
        /// <param name="progress">Progress to report</param>
897
        private void Uninstall(string               identifier,
898
                               ref HashSet<string>? possibleConfigOnlyDirs,
899
                               Registry             registry,
900
                               IProgress<long>      progress)
901
        {
902
            var txFileMgr = new TxFileManager(instance.CkanDir);
3✔
903

904
            using (var transaction = CkanTransaction.CreateTransactionScope())
3✔
905
            {
906
                var instMod = registry.InstalledModule(identifier);
3✔
907

908
                if (instMod == null)
3✔
909
                {
910
                    log.ErrorFormat("Trying to uninstall {0} but it's not installed", identifier);
×
911
                    throw new ModNotInstalledKraken(identifier);
×
912
                }
913
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovingMod,
3✔
914
                                  $"{instMod.Module.name} {instMod.Module.version}");
915

916
                // Walk our registry to find all files for this mod.
917
                var modFiles = instMod.Files.ToArray();
3✔
918

919
                // We need case insensitive path matching on Windows
920
                var directoriesToDelete = new HashSet<string>(Platform.PathComparer);
3✔
921

922
                // Files that Windows refused to delete due to locking (probably)
923
                var undeletableFiles = new List<string>();
3✔
924

925
                long bytesDeleted = 0;
3✔
926
                foreach (string relPath in modFiles)
9✔
927
                {
928
                    if (cancelToken.IsCancellationRequested)
3✔
929
                    {
930
                        throw new CancelledActionKraken();
×
931
                    }
932

933
                    string absPath = instance.ToAbsoluteGameDir(relPath);
3✔
934

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

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

986
                if (undeletableFiles.Count > 0)
3✔
987
                {
988
                    throw new FailedToDeleteFilesKraken(identifier, undeletableFiles);
×
989
                }
990

991
                // Remove from registry.
992
                registry.DeregisterModule(instance, identifier);
3✔
993

994
                // Our collection of directories may leave empty parent directories.
995
                directoriesToDelete = AddParentDirectories(directoriesToDelete);
3✔
996

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

1011
                    // See what's left in this folder and what we can do about it
1012
                    GroupFilesByRemovable(instance.ToRelativeGameDir(directory),
3✔
1013
                                          registry, modFiles, instance.Game,
1014
                                          (Directory.Exists(directory)
1015
                                              ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories)
1016
                                              : Enumerable.Empty<string>())
1017
                                           .Select(instance.ToRelativeGameDir)
1018
                                           .ToArray(),
1019
                                          out string[] removable,
1020
                                          out string[] notRemovable);
1021

1022
                    // Delete the auto-removable files and dirs
1023
                    foreach (var absPath in removable.Select(instance.ToAbsoluteGameDir))
9✔
1024
                    {
1025
                        if (File.Exists(absPath))
3✔
1026
                        {
1027
                            log.DebugFormat("Attempting transaction deletion of file {0}", absPath);
3✔
1028
                            txFileMgr.Delete(absPath);
3✔
1029
                        }
1030
                        else if (Directory.Exists(absPath))
3✔
1031
                        {
1032
                            log.DebugFormat("Attempting deletion of directory {0}", absPath);
3✔
1033
                            try
1034
                            {
1035
                                Directory.Delete(absPath);
3✔
1036
                            }
3✔
1037
                            catch
×
1038
                            {
1039
                                // There might be files owned by other mods, oh well
1040
                                log.DebugFormat("Failed to delete {0}", absPath);
×
1041
                            }
×
1042
                        }
1043
                    }
1044

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

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

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

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

1139
                    // If this is a parentless directory (Windows)
1140
                    // or if the Root equals the current directory (Mono)
1141
                    if (dirInfo.Parent == null || dirInfo.Root == dirInfo)
3✔
1142
                    {
1143
                        return results;
3✔
1144
                    }
1145

1146
                    if (!dir.StartsWith(gameDir, Platform.PathComparison))
3✔
1147
                    {
1148
                        dir = CKANPathUtils.ToAbsolute(dir, gameDir);
×
1149
                    }
1150

1151
                    // Remove the system paths, leaving the path under the instance directory
1152
                    var relativeHead = CKANPathUtils.ToRelative(dir, gameDir);
3✔
1153
                    // Don't try to remove GameRoot
1154
                    if (!string.IsNullOrEmpty(relativeHead))
3✔
1155
                    {
1156
                        var pathArray = relativeHead.Split('/');
3✔
1157
                        var builtPath = "";
3✔
1158
                        foreach (var path in pathArray)
9✔
1159
                        {
1160
                            builtPath += path + '/';
3✔
1161
                            results.Add(CKANPathUtils.ToAbsolute(builtPath, gameDir));
3✔
1162
                        }
1163
                    }
1164

1165
                    return results;
3✔
1166
                })
1167
                .Where(dir => !instance.Game.IsReservedDirectory(instance, dir))
3✔
1168
                .ToHashSet();
1169
        }
1170

1171
        #endregion
1172

1173
        #region AddRemove
1174

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

1207
                long removeBytes     = remove.Sum(m => m.Module.install_size);
3✔
1208
                long removedBytes    = 0;
3✔
1209
                long downloadBytes   = toDownload.Sum(m => m.download_size);
3✔
1210
                long downloadedBytes = 0;
3✔
1211
                long installBytes    = add.Sum(m => m.install_size);
3✔
1212
                long installedBytes  = 0;
3✔
1213
                var rateCounter = new ByteRateCounter()
3✔
1214
                {
1215
                    Size      = removeBytes + downloadBytes + installBytes,
1216
                    BytesLeft = removeBytes + downloadBytes + installBytes,
1217
                };
1218
                rateCounter.Start();
3✔
1219

1220
                downloader.OverallDownloadProgress += brc =>
3✔
1221
                {
1222
                    downloadedBytes = downloadBytes - brc.BytesLeft;
3✔
1223
                    rateCounter.BytesLeft = removeBytes   - removedBytes
3✔
1224
                                          + downloadBytes - downloadedBytes
1225
                                          + installBytes  - installedBytes;
1226
                    User.RaiseProgress(rateCounter);
3✔
1227
                };
3✔
1228
                var toInstall = ModsInDependencyOrder(resolver, cached, toDownload, downloader);
3✔
1229

1230
                long modRemoveCompletedBytes = 0;
3✔
1231
                foreach (var instMod in remove)
9✔
1232
                {
1233
                    Uninstall(instMod.Module.identifier,
3✔
1234
                              ref possibleConfigOnlyDirs,
1235
                              registry_manager.registry,
1236
                              new ProgressImmediate<long>(bytes =>
1237
                              {
1238
                                  RemoveProgress?.Invoke(instMod,
3✔
1239
                                                         Math.Max(0,     instMod.Module.install_size - bytes),
1240
                                                         Math.Max(bytes, instMod.Module.install_size));
1241
                                  removedBytes = modRemoveCompletedBytes
3✔
1242
                                                 + Math.Min(bytes, instMod.Module.install_size);
1243
                                  rateCounter.BytesLeft = removeBytes   - removedBytes
3✔
1244
                                                        + downloadBytes - downloadedBytes
1245
                                                        + installBytes  - installedBytes;
1246
                                  User.RaiseProgress(rateCounter);
3✔
1247
                              }));
3✔
1248
                     modRemoveCompletedBytes += instMod.Module.install_size;
3✔
1249
                }
1250

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

1281
                registry_manager.Save(enforceConsistency);
3✔
1282
                tx.Complete();
3✔
1283
                EnforceCacheSizeLimit(registry_manager.registry, cache, config);
3✔
1284
            }
3✔
1285
        }
3✔
1286

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

1303
            var removingIdents = registry.InstalledModules.Select(im => im.identifier)
3✔
1304
                                         .Intersect(modules.Select(m => m.identifier))
3✔
1305
                                         .ToHashSet();
1306
            var autoRemoving = registry
3✔
1307
                .FindRemovableAutoInstalled(modules, removingIdents, instance)
1308
                .ToHashSet();
1309

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

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

1331
            // Only install stuff that's already there if explicitly requested in param
1332
            var toInstall = fullChangeset.Values
3✔
1333
                                         .Except(registry.InstalledModules
1334
                                                         .Select(im => im.Module)
3✔
1335
                                                         .Except(modules))
1336
                                         .ToArray();
1337
            autoInstalled ??= new HashSet<CkanModule>();
3✔
1338
            autoInstalled.UnionWith(toInstall.Where(resolver.IsAutoInstalled));
3✔
1339

1340
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToUpgrade);
3✔
1341
            User.RaiseMessage("");
3✔
1342

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

1349
            // Let's discover what we need to do with each module!
1350
            foreach (CkanModule module in toInstall)
9✔
1351
            {
1352
                var installed_mod = registry.InstalledModule(module.identifier);
3✔
1353

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

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

1425
            if (autoRemoving.Count > 0)
3✔
1426
            {
1427
                foreach (var im in autoRemoving)
9✔
1428
                {
1429
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeAutoRemoving,
3✔
1430
                                      im.Module.name, im.Module.version);
1431
                }
1432
                toRemove.AddRange(autoRemoving);
3✔
1433
            }
1434

1435
            CheckAddRemoveFreeSpace(toInstall, toRemove);
3✔
1436

1437
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
3✔
1438
            {
1439
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUpgradeUserDeclined);
×
1440
            }
1441

1442
            AddRemove(ref possibleConfigOnlyDirs,
3✔
1443
                      registry_manager,
1444
                      resolver,
1445
                      toInstall,
1446
                      autoInstalled,
1447
                      toRemove,
1448
                      downloader,
1449
                      enforceConsistency,
1450
                      deduper);
1451
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
3✔
1452
        }
3✔
1453

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

1478
            // Our replacement involves removing the currently installed mods, then
1479
            // adding everything that needs installing (which may involve new mods to
1480
            // satisfy dependencies).
1481

1482
            // Let's discover what we need to do with each module!
1483
            foreach (ModuleReplacement repl in replacements)
9✔
1484
            {
1485
                string ident = repl.ToReplace.identifier;
3✔
1486
                var installedMod = registry_manager.registry.InstalledModule(ident);
3✔
1487

1488
                if (installedMod == null)
3✔
1489
                {
1490
                    log.WarnFormat("Wait, {0} is not actually installed?", ident);
×
1491
                    //Maybe ModuleNotInstalled ?
1492
                    if (registry_manager.registry.IsAutodetected(ident))
×
1493
                    {
1494
                        throw new ModuleNotFoundKraken(ident,
×
1495
                            repl.ToReplace.version.ToString(),
1496
                            string.Format(Properties.Resources.ModuleInstallerReplaceAutodetected, ident));
1497
                    }
1498

1499
                    throw new ModuleNotFoundKraken(ident,
×
1500
                        repl.ToReplace.version.ToString(),
1501
                        string.Format(Properties.Resources.ModuleInstallerReplaceNotInstalled, ident, repl.ReplaceWith.identifier));
1502
                }
1503
                else
1504
                {
1505
                    // Obviously, we need to remove the mod we are replacing
1506
                    modsToRemove.Add(installedMod);
3✔
1507

1508
                    log.DebugFormat("Ok, we are removing {0}", repl.ToReplace.identifier);
3✔
1509
                    //Check whether our Replacement target is already installed
1510
                    var installed_replacement = registry_manager.registry.InstalledModule(repl.ReplaceWith.identifier);
3✔
1511

1512
                    // If replacement is not installed, we've already added it to modsToInstall above
1513
                    if (installed_replacement != null)
3✔
1514
                    {
1515
                        //Module already installed. We'll need to treat it as an upgrade.
1516
                        log.DebugFormat("It turns out {0} is already installed, we'll upgrade it.", installed_replacement.identifier);
3✔
1517
                        modsToRemove.Add(installed_replacement);
3✔
1518

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

1543
            CheckAddRemoveFreeSpace(resolvedModsToInstall, modsToRemove);
3✔
1544
            AddRemove(ref possibleConfigOnlyDirs,
3✔
1545
                      registry_manager,
1546
                      resolver,
1547
                      resolvedModsToInstall,
1548
                      new HashSet<CkanModule>(),
1549
                      modsToRemove,
1550
                      downloader,
1551
                      enforceConsistency,
1552
                      deduper);
1553
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
3✔
1554
        }
3✔
1555

1556
        #endregion
1557

1558
        public static IEnumerable<string> PrioritizedHosts(IConfiguration    config,
1559
                                                           IEnumerable<Uri>? urls)
1560
            => urls?.OrderBy(u => u, new PreferredHostUriComparer(config.PreferredHosts))
3✔
1561
                    .Select(dl => dl.Host)
3✔
1562
                    .Distinct()
1563
                   ?? Enumerable.Empty<string>();
1564

1565
        #region Recommendations
1566

1567
        /// <summary>
1568
        /// Looks for optional related modules that could be installed alongside the given modules
1569
        /// </summary>
1570
        /// <param name="instance">Game instance to use</param>
1571
        /// <param name="sourceModules">Modules to check for relationships, should contain the complete changeset including dependencies</param>
1572
        /// <param name="toInstall">Modules already being installed, to be omitted from search</param>
1573
        /// <param name="toRemove">Modules being removed, to be excluded from relationship resolution</param>
1574
        /// <param name="exclude">Modules the user has already seen and decided not to install</param>
1575
        /// <param name="registry">Registry to use</param>
1576
        /// <param name="recommendations">Modules that are recommended to install</param>
1577
        /// <param name="suggestions">Modules that are suggested to install</param>
1578
        /// <param name="supporters">Modules that support other modules we're installing</param>
1579
        /// <returns>
1580
        /// true if anything found, false otherwise
1581
        /// </returns>
1582
        public static bool FindRecommendations(GameInstance                                          instance,
1583
                                               IReadOnlyCollection<CkanModule>                       sourceModules,
1584
                                               IReadOnlyCollection<CkanModule>                       toInstall,
1585
                                               IReadOnlyCollection<CkanModule>                       toRemove,
1586
                                               IReadOnlyCollection<CkanModule>                       exclude,
1587
                                               Registry                                              registry,
1588
                                               out Dictionary<CkanModule, Tuple<bool, List<string>>> recommendations,
1589
                                               out Dictionary<CkanModule, List<string>>              suggestions,
1590
                                               out Dictionary<CkanModule, HashSet<string>>           supporters)
1591
        {
1592
            log.DebugFormat("Finding recommendations for: {0}", string.Join(", ", sourceModules));
3✔
1593
            var crit     = instance.VersionCriteria();
3✔
1594

1595
            var rmvIdents = toRemove.Select(m => m.identifier).ToHashSet();
3✔
1596
            var allRemoving = toRemove
3✔
1597
                .Concat(registry.FindRemovableAutoInstalled(sourceModules, rmvIdents, instance)
1598
                                .Select(im => im.Module))
3✔
1599
                .ToArray();
1600

1601
            var resolver = new RelationshipResolver(sourceModules.Where(m => !m.IsDLC),
3✔
1602
                                                    allRemoving,
1603
                                                    RelationshipResolverOptions.KitchenSinkOpts(instance.StabilityToleranceConfig),
1604
                                                    registry, instance.Game, crit);
1605
            var recommenders = resolver.Dependencies().ToHashSet();
3✔
1606
            log.DebugFormat("Recommenders: {0}", string.Join(", ", recommenders));
3✔
1607

1608
            var checkedRecs = resolver.Recommendations(recommenders)
3✔
1609
                                      .Except(exclude)
1610
                                      .Where(m => resolver.ReasonsFor(m)
3✔
1611
                                                          .Any(r => r is SelectionReason.Recommended { ProvidesIndex: 0 }))
3✔
1612
                                      .ToHashSet();
1613
            var conflicting = new RelationshipResolver(toInstall.Concat(checkedRecs), allRemoving,
3✔
1614
                                                       RelationshipResolverOptions.ConflictsOpts(instance.StabilityToleranceConfig),
1615
                                                       registry, instance.Game, crit)
1616
                                  .ConflictList.Keys;
1617
            // Don't check recommendations that conflict with installed or installing mods
1618
            checkedRecs.ExceptWith(conflicting);
3✔
1619

1620
            recommendations = resolver.Recommendations(recommenders)
3✔
1621
                                      .Except(exclude)
1622
                                      .ToDictionary(m => m,
3✔
1623
                                                    m => new Tuple<bool, List<string>>(
3✔
1624
                                                             checkedRecs.Contains(m),
1625
                                                             resolver.ReasonsFor(m)
1626
                                                                     .OfType<SelectionReason.Recommended>()
1627
                                                                     .Where(r => recommenders.Contains(r.Parent))
3✔
1628
                                                                     .Select(r => r.Parent)
3✔
1629
                                                                     .OfType<CkanModule>()
1630
                                                                     .Select(m => m.identifier)
3✔
1631
                                                                     .ToList()));
1632
            suggestions = resolver.Suggestions(recommenders,
3✔
1633
                                               recommendations.Keys.ToList())
1634
                                  .Except(exclude)
1635
                                  .ToDictionary(m => m,
3✔
1636
                                                m => resolver.ReasonsFor(m)
3✔
1637
                                                             .OfType<SelectionReason.Suggested>()
1638
                                                             .Where(r => recommenders.Contains(r.Parent))
3✔
1639
                                                             .Select(r => r.Parent)
3✔
1640
                                                             .OfType<CkanModule>()
1641
                                                             .Select(m => m.identifier)
3✔
1642
                                                             .ToList());
1643

1644
            var opts = RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig);
3✔
1645
            supporters = resolver.Supporters(recommenders,
3✔
1646
                                             recommenders.Concat(recommendations.Keys)
1647
                                                         .Concat(suggestions.Keys))
1648
                                 .Where(kvp => !exclude.Contains(kvp.Key)
3✔
1649
                                               && CanInstall(toInstall.Append(kvp.Key).ToList(),
1650
                                                          opts, registry, instance.Game, crit))
1651
                                 .ToDictionary();
1652

1653
            return recommendations.Count > 0
3✔
1654
                || suggestions.Count > 0
1655
                || supporters.Count > 0;
1656
        }
1657

1658
        /// <summary>
1659
        /// Determine whether there is any way to install the given set of mods.
1660
        /// Handles virtual dependencies, including recursively.
1661
        /// </summary>
1662
        /// <param name="opts">Installer options</param>
1663
        /// <param name="toInstall">Mods we want to install</param>
1664
        /// <param name="registry">Registry of instance into which we want to install</param>
1665
        /// <param name="game">Game instance</param>
1666
        /// <param name="crit">Game version criteria</param>
1667
        /// <returns>
1668
        /// True if it's possible to install these mods, false otherwise
1669
        /// </returns>
1670
        public static bool CanInstall(IReadOnlyCollection<CkanModule> toInstall,
1671
                                      RelationshipResolverOptions     opts,
1672
                                      IRegistryQuerier                registry,
1673
                                      IGame                           game,
1674
                                      GameVersionCriteria             crit)
1675
        {
1676
            string request = string.Join(", ", toInstall.Select(m => m.identifier));
3✔
1677
            try
1678
            {
1679
                var installed = toInstall.Select(m => registry.InstalledModule(m.identifier)?.Module)
3✔
1680
                                         .OfType<CkanModule>();
1681
                var resolver = new RelationshipResolver(toInstall, installed, opts, registry, game, crit);
3✔
1682

1683
                var resolverModList = resolver.ModList(false).ToArray();
3✔
1684
                if (resolverModList.Length >= toInstall.Count(m => !m.IsMetapackage))
3✔
1685
                {
1686
                    // We can install with no further dependencies
1687
                    string recipe = string.Join(", ", resolverModList.Select(m => m.identifier));
3✔
1688
                    log.Debug($"Installable: {request}: {recipe}");
3✔
1689
                    return true;
3✔
1690
                }
1691
                else
1692
                {
1693
                    log.DebugFormat("Can't install {0}: {1}", request, string.Join("; ", resolver.ConflictDescriptions));
×
1694
                    return false;
×
1695
                }
1696
            }
1697
            catch (TooManyModsProvideKraken k)
3✔
1698
            {
1699
                // One of the dependencies is virtual
1700
                foreach (var mod in k.modules)
9✔
1701
                {
1702
                    // Try each option recursively to see if any are successful
1703
                    if (CanInstall(toInstall.Append(mod).ToArray(), opts, registry, game, crit))
3✔
1704
                    {
1705
                        // Child call will emit debug output, so we don't need to here
1706
                        return true;
3✔
1707
                    }
1708
                }
1709
                log.Debug($"Can't install {request}: Can't install provider of {k.requested}");
×
1710
            }
×
1711
            catch (InconsistentKraken k)
×
1712
            {
1713
                log.Debug($"Can't install {request}: {k.ShortDescription}");
×
1714
            }
×
1715
            catch (Exception ex)
×
1716
            {
1717
                log.Debug($"Can't install {request}: {ex.Message}");
×
1718
            }
×
1719
            return false;
×
1720
        }
3✔
1721

1722
        #endregion
1723

1724
        private static void EnforceCacheSizeLimit(Registry       registry,
1725
                                                  NetModuleCache Cache,
1726
                                                  IConfiguration config)
1727
        {
1728
            // Purge old downloads if we're over the limit
1729
            if (config.CacheSizeLimit.HasValue)
3✔
1730
            {
1731
                Cache.EnforceSizeLimit(config.CacheSizeLimit.Value, registry);
×
1732
            }
1733
        }
3✔
1734

1735
        private void CheckAddRemoveFreeSpace(IEnumerable<CkanModule>      toInstall,
1736
                                             IEnumerable<InstalledModule> toRemove)
1737
        {
1738
            if (toInstall.Sum(m => m.install_size) - toRemove.Sum(im => im.ActualInstallSize(instance))
3✔
1739
                is > 0 and var spaceDelta)
1740
            {
1741
                CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.GameDir),
×
1742
                                             spaceDelta,
1743
                                             Properties.Resources.NotEnoughSpaceToInstall);
1744
            }
1745
        }
3✔
1746

1747
        private readonly GameInstance      instance;
1748
        private readonly NetModuleCache    cache;
1749
        private readonly IConfiguration    config;
1750
        private readonly CancellationToken cancelToken;
1751

1752
        private static readonly ILog log = LogManager.GetLogger(typeof(ModuleInstaller));
3✔
1753
    }
1754
}
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