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

KSP-CKAN / CKAN / 27596207600

16 Jun 2026 05:26AM UTC coverage: 87.801% (+0.08%) from 87.725%
27596207600

Pull #4669

github

web-flow
Merge 7f64f8057 into c1cebfebd
Pull Request #4669: Better partial upgrade checking

2017 of 2143 branches covered (94.12%)

Branch coverage included in aggregate %.

74 of 80 new or added lines in 8 files covered. (92.5%)

4 existing lines in 2 files now uncovered.

8628 of 9981 relevant lines covered (86.44%)

1.8 hits per line

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

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

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

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

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

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

44
        public IUser User { get; set; }
45

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

50
        #region Installation
51

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

86
            // Check which mods need to be downloaded
87
            var cached    = new List<CkanModule>();
2✔
88
            var downloads = new List<CkanModule>();
2✔
89
            foreach (var module in modsToInstall)
6✔
90
            {
91
                if (!module.IsMetapackage && !cache.IsMaybeCachedZip(module))
2✔
92
                {
93
                    downloads.Add(module);
2✔
94
                }
95
                else
96
                {
97
                    cached.Add(module);
2✔
98
                }
99
            }
100

101
            // Make sure we have enough space to install this stuff
102
            var installBytes  = modsToInstall.Sum(m => m.install_size);
2✔
103
            var downloadBytes = CkanModule.GroupByDownloads(downloads)
2✔
104
                                          .Sum(grp => grp.First().download_size);
2✔
105
            CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.GameDir),
2✔
106
                                         cache.OnSameDevice(new DirectoryInfo(instance.GameDir))
107
                                             // Check for combined download+install space if same device
108
                                             ? downloadBytes + installBytes
109
                                             : installBytes,
110
                                         Properties.Resources.NotEnoughSpaceToInstall);
111

112
            // Prompt user for confirmation, if needed
113
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToInstall);
2✔
114
            User.RaiseMessage("");
2✔
115
            foreach (var module in modsToInstall)
6✔
116
            {
117
                User.RaiseMessage(" * {0}", cache.DescribeAvailability(config, module));
2✔
118
            }
119
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
120
            {
121
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUserDeclined);
×
122
            }
123

124
            var rateCounter = new ByteRateCounter()
2✔
125
            {
126
                Size      = downloadBytes + installBytes,
127
                BytesLeft = downloadBytes + installBytes,
128
            };
129
            rateCounter.Start();
2✔
130
            long downloadedBytes = 0;
2✔
131
            long installedBytes  = 0;
2✔
132
            if (downloads.Count > 0)
2✔
133
            {
134
                downloader ??= new NetAsyncModulesDownloader(User, cache, userAgent, cancelToken);
2✔
135
                downloader.OverallDownloadProgress += brc =>
2✔
136
                {
137
                    downloadedBytes = downloadBytes - brc.BytesLeft;
2✔
138
                    rateCounter.BytesLeft = downloadBytes - downloadedBytes
2✔
139
                                          + installBytes  - installedBytes;
140
                    User.RaiseProgress(rateCounter);
2✔
141
                };
2✔
142
            }
143

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

174
                User.RaiseProgress(Properties.Resources.ModuleInstallerUpdatingRegistry, 90);
2✔
175
                registry_manager.Save(!options.without_enforce_consistency);
2✔
176

177
                User.RaiseProgress(Properties.Resources.ModuleInstallerCommitting, 95);
2✔
178
                transaction.Complete();
2✔
179
            }
2✔
180

181
            EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
182
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
183
        }
2✔
184

185
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver            resolver,
186
                                                                     IReadOnlyCollection<CkanModule> cached,
187
                                                                     IReadOnlyCollection<CkanModule> toDownload,
188
                                                                     IDownloader?                    downloader)
189

190
            => ModsInDependencyOrder(resolver, cached,
2✔
191
                                     downloader != null && toDownload.Count > 0
192
                                         ? downloader.ModulesAsTheyFinish(cached, toDownload)
193
                                         : null);
194

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

231
        private static IEnumerable<CkanModule> OnePass(RelationshipResolver resolver,
232
                                                       HashSet<CkanModule>  waiting,
233
                                                       HashSet<CkanModule>  done)
234
        {
235
            while (true)
2✔
236
            {
237
                var newlyDone = waiting.Where(m => resolver.ReadyToInstall(m, done))
2✔
238
                                       .OrderBy(m => m.identifier)
2✔
239
                                       .ToArray();
240
                if (newlyDone.Length == 0)
2✔
241
                {
242
                    // No mods ready to install
243
                    break;
244
                }
245
                foreach (var m in newlyDone)
6✔
246
                {
247
                    waiting.Remove(m);
2✔
248
                    done.Add(m);
2✔
249
                    yield return m;
2✔
250
                }
251
            }
252
        }
2✔
253

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

277
            // TODO: This really should be handled by higher-up code.
278
            if (version is not null and not UnmanagedModuleVersion)
2✔
279
            {
280
                User.RaiseMessage(Properties.Resources.ModuleInstallerAlreadyInstalled,
2✔
281
                                  module.name, version);
282
                return;
2✔
283
            }
284

285
            string? filename = null;
2✔
286
            if (!module.IsMetapackage)
2✔
287
            {
288
                // Find ZIP in the cache if we don't already have it.
289
                filename ??= cache.GetCachedFilename(module);
2✔
290

291
                // If we *still* don't have a file, then kraken bitterly.
292
                if (filename == null)
2✔
293
                {
294
                    throw new FileNotFoundKraken(null,
×
295
                                                 string.Format(Properties.Resources.ModuleInstallerZIPNotInCache,
296
                                                               module));
297
                }
298
            }
299

300
            User.RaiseMessage(Properties.Resources.ModuleInstallerInstallingMod,
2✔
301
                              $"{module.name} {module.version}");
302

303
            try
304
            {
305
                using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
306
                {
307
                    // Install all the things!
308
                    var files = InstallModule(module, filename, registry, candidateDuplicates,
2✔
309
                                              ref possibleConfigOnlyDirs, out int filteredCount, progress);
310

311
                    // Register our module and its files.
312
                    registry.RegisterModule(module, files, instance, autoInstalled);
2✔
313

314
                    // Finish our transaction, but *don't* save the registry; we may be in an
315
                    // intermediate, inconsistent state.
316
                    // This is fine from a transaction standpoint, as we may not have an enclosing
317
                    // transaction, and if we do, they can always roll us back.
318
                    transaction.Complete();
2✔
319

320
                    if (filteredCount > 0)
2✔
321
                    {
322
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledModFiltered,
2✔
323
                                          $"{module.name} {module.version}", filteredCount);
324
                    }
325
                    else
326
                    {
327
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
2✔
328
                                          $"{module.name} {module.version}");
329
                    }
330
                }
2✔
331
            }
2✔
332
            catch (ZipException zexc)
2✔
333
            {
334
                cache.Purge(module);
2✔
335
                throw new InvalidModuleFileKraken(module, filename ?? "",
2✔
336
                                                  string.Format(Properties.Resources.ModuleInstallerCorruptInCache,
337
                                                                module, zexc.Message));
338
            }
339

340
            // Fire our callback that we've installed a module, if we have one.
341
            OneComplete?.Invoke(module);
2✔
342
        }
×
343

344
        /// <summary>
345
        /// Check if the given module is a DLC:
346
        /// if it is, throws ModuleIsDLCKraken.
347
        /// </summary>
348
        private static void CheckKindInstallationKraken(CkanModule module)
349
        {
350
            if (module.IsDLC)
2✔
351
            {
352
                throw new ModuleIsDLCKraken(module);
×
353
            }
354
        }
2✔
355

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

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

484
        public static bool IsInternalCkan(ZipEntry ze)
485
            => ze.Name.EndsWith(".ckan", StringComparison.OrdinalIgnoreCase);
2✔
486

487
        #region File overwrites
488

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

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

560
        /// <summary>
561
        /// Remove files that the user chose to overwrite, so
562
        /// the installer can replace them.
563
        /// Uses a transaction so they can be undeleted if the install
564
        /// fails at a later stage.
565
        /// </summary>
566
        /// <param name="files">The files to overwrite</param>
567
        private void DeleteConflictingFiles(IEnumerable<InstallableFile> files)
568
        {
569
            var txFileMgr = new TxFileManager(instance.CkanDir);
2✔
570
            foreach (var absPath in files.Select(f => instance.ToAbsoluteGameDir(f.destination)))
6✔
571
            {
572
                log.DebugFormat("Trying to delete {0}", absPath);
2✔
573
                txFileMgr.Delete(absPath);
2✔
574
            }
575
        }
2✔
576

577
        #endregion
578

579
        #region Find files
580

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

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

617
        /// <summary>
618
        /// Returns contents of an installed module
619
        /// </summary>
620
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
621
                GameInstance                instance,
622
                IReadOnlyCollection<string> installed,
623
                HashSet<string>             filters)
624
            => GetModuleContents(instance, installed,
2✔
625
                                 installed.SelectMany(f => f.TraverseNodes(Path.GetDirectoryName)
2✔
626
                                                            .Skip(1)
627
                                                            .Where(s => s.Length > 0)
2✔
628
                                                            .Select(CKANPathUtils.NormalizePath))
629
                                          .ToHashSet(),
630
                                 filters);
631

632
        private static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
633
                GameInstance        instance,
634
                IEnumerable<string> installed,
635
                HashSet<string>     parents,
636
                HashSet<string>     filters)
637
            => installed.Where(f => !filters.Any(filt => f.Contains(filt)))
×
638
                        .Select(f => (relPath: f,
2✔
639
                                      absPath: instance.ToAbsoluteGameDir(f)))
640
                        .Select(f => (f.relPath, f.absPath,
2✔
641
                                      dir: parents.Contains(f.relPath)
642
                                           // Empty dirs are parents of nothing
643
                                           || Directory.Exists(f.absPath)))
644
                        .GroupBy(f => f.dir)
2✔
645
                        .SelectMany(grp =>
646
                            grp.Select(p => (path:   p.relPath, p.dir,
2✔
647
                                             exists: grp.Key ? Directory.Exists(p.absPath)
648
                                                             : File.Exists(p.absPath))));
649

650
        /// <summary>
651
        /// Returns the module contents if and only if we have it
652
        /// available in our cache, empty sequence otherwise.
653
        ///
654
        /// Intended for previews.
655
        /// </summary>
656
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
657
                NetModuleCache  Cache,
658
                GameInstance    instance,
659
                CkanModule      module,
660
                HashSet<string> filters)
661
            => (Cache.GetCachedFilename(module) is string filename
2✔
662
                    ? GetModuleContents(Utilities.DefaultIfThrows(
663
                                            () => FindInstallableFiles(module, filename, instance.Game)),
2✔
664
                                        filters)
665
                    : null)
666
               ?? Enumerable.Empty<(string path, bool dir, bool exists)>();
667

668
        private static IEnumerable<(string path, bool dir, bool exists)>? GetModuleContents(
669
                IEnumerable<InstallableFile>? installable,
670
                HashSet<string>               filters)
671
            => installable?.Where(instF => !filters.Any(filt => instF.destination != null
×
672
                                                                && instF.destination.Contains(filt)))
673
                           .Select(f => (path:   f.destination,
2✔
674
                                         dir:    f.source.IsDirectory,
675
                                         exists: true));
676

677
        #endregion
678

679
        private string? InstallFile(ZipFile          zipfile,
680
                                    ZipEntry         entry,
681
                                    string           fullPath,
682
                                    bool             makeDirs,
683
                                    string[]         candidateDuplicates,
684
                                    IProgress<long>? progress)
685
            => InstallFile(zipfile, entry, fullPath, makeDirs,
2✔
686
                           new TxFileManager(instance.CkanDir),
687
                           candidateDuplicates, progress);
688

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

714
                // Windows silently trims trailing spaces, get the path it will actually use
715
                fullPath = Path.GetDirectoryName(Path.Combine(fullPath, "DUMMY")) is string p
2✔
716
                    ? CKANPathUtils.NormalizePath(p)
717
                    : fullPath;
718

719
                log.DebugFormat("Making directory '{0}'", fullPath);
2✔
720
                txFileMgr.CreateDirectory(fullPath);
2✔
721
            }
722
            else
723
            {
724
                log.DebugFormat("Writing file '{0}'", fullPath);
2✔
725

726
                // ZIP format does not require directory entries
727
                if (makeDirs && Path.GetDirectoryName(fullPath) is string d)
2✔
728
                {
729
                    log.DebugFormat("Making parent directory '{0}'", d);
2✔
730
                    txFileMgr.CreateDirectory(d);
2✔
731
                }
732

733
                // We don't allow for the overwriting of files. See #208.
734
                if (txFileMgr.FileExists(fullPath))
2✔
735
                {
736
                    throw new FileExistsKraken(fullPath);
2✔
737
                }
738

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

744
                // Try making hard links if already installed in another instance (faster, less space)
745
                foreach (var installedSource in candidateDuplicates)
6✔
746
                {
747
                    try
748
                    {
749
                        HardLink.Create(installedSource, fullPath);
2✔
750
                        return fullPath;
2✔
751
                    }
752
                    catch
×
753
                    {
754
                        // If hard link creation fails, try more hard links, or copy if none work
755
                    }
×
756
                }
757

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

786
        private static readonly TimeSpan UnzipProgressInterval = TimeSpan.FromMilliseconds(200);
2✔
787

788
        #endregion
789

790
        #region Uninstallation
791

792
        /// <summary>
793
        /// Uninstalls all the mods provided, including things which depend upon them.
794
        /// This *DOES* save the registry.
795
        /// Preferred over Uninstall.
796
        /// </summary>
797
        public void UninstallList(IEnumerable<string>              mods,
798
                                  ref HashSet<string>?             possibleConfigOnlyDirs,
799
                                  RegistryManager                  registry_manager,
800
                                  bool                             ConfirmPrompt = true,
801
                                  IReadOnlyCollection<CkanModule>? installing    = null)
802
        {
803
            mods = mods.Memoize();
2✔
804
            installing ??= Array.Empty<CkanModule>();
2✔
805
            // Pre-check, have they even asked for things which are installed?
806

807
            foreach (string mod in mods.Where(mod => registry_manager.registry.InstalledModule(mod) == null))
2✔
808
            {
809
                throw new ModNotInstalledKraken(mod);
2✔
810
            }
811

812
            var instDlc = mods.Select(registry_manager.registry.InstalledModule)
2✔
813
                              .OfType<InstalledModule>()
814
                              .FirstOrDefault(m => m.Module.IsDLC);
2✔
815
            if (instDlc != null)
2✔
816
            {
817
                throw new ModuleIsDLCKraken(instDlc.Module);
×
818
            }
819

820
            // Find all the things which need uninstalling.
821
            var revdep = mods
2✔
822
                .Union(registry_manager.registry.FindReverseDependencies(
823
                    mods.Except(installing.Select(m => m.identifier)).ToArray(),
×
824
                    installing))
825
                .ToArray();
826

827
            var goners = revdep.Union(
2✔
828
                                registry_manager.registry.FindRemovableAutoInstalled(installing,
829
                                                                                     revdep.ToHashSet(),
830
                                                                                     instance)
831
                                                         .Select(im => im.identifier))
2✔
832
                               .Order()
833
                               .ToArray();
834

835
            // If there is nothing to uninstall, skip out.
836
            if (goners.Length == 0)
2✔
837
            {
838
                return;
2✔
839
            }
840

841
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToRemove);
2✔
842
            User.RaiseMessage("");
2✔
843

844
            foreach (var module in goners.Select(registry_manager.registry.InstalledModule)
6✔
845
                                         .OfType<InstalledModule>())
846
            {
847
                User.RaiseMessage(" * {0} {1}", module.Module.name, module.Module.version);
2✔
848
            }
849

850
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
851
            {
852
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerRemoveAborted);
×
853
            }
854

855
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
856
            {
857
                var registry = registry_manager.registry;
2✔
858
                long removeBytes = goners.Select(registry.InstalledModule)
2✔
859
                                         .OfType<InstalledModule>()
860
                                         .Sum(m => m.Module.install_size);
2✔
861
                var rateCounter = new ByteRateCounter()
2✔
862
                {
863
                    Size      = removeBytes,
864
                    BytesLeft = removeBytes,
865
                };
866
                rateCounter.Start();
2✔
867

868
                long modRemoveCompletedBytes = 0;
2✔
869
                foreach (string ident in goners)
6✔
870
                {
871
                    if (registry.InstalledModule(ident) is InstalledModule instMod)
2✔
872
                    {
873
                        Uninstall(ident, ref possibleConfigOnlyDirs, registry,
2✔
874
                                  new ProgressImmediate<long>(bytes =>
875
                                  {
876
                                      RemoveProgress?.Invoke(instMod,
2✔
877
                                                             Math.Max(0,     instMod.Module.install_size - bytes),
878
                                                             Math.Max(bytes, instMod.Module.install_size));
879
                                      rateCounter.BytesLeft = removeBytes - (modRemoveCompletedBytes
2✔
880
                                                                             + Math.Min(bytes, instMod.Module.install_size));
881
                                      User.RaiseProgress(rateCounter);
2✔
882
                                  }));
2✔
883
                        modRemoveCompletedBytes += instMod?.Module.install_size ?? 0;
2✔
884
                    }
885
                }
886

887
                // Enforce consistency if we're not installing anything,
888
                // otherwise consistency will be enforced after the installs
889
                registry_manager.Save(installing == null);
2✔
890

891
                transaction.Complete();
2✔
892
            }
2✔
893

894
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
895
        }
2✔
896

897
        /// <summary>
898
        /// Uninstall the module provided. For internal use only.
899
        /// Use UninstallList for user queries, it also does dependency handling.
900
        /// This does *NOT* save the registry.
901
        /// </summary>
902
        /// <param name="identifier">Identifier of module to uninstall</param>
903
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
904
        /// <param name="registry">Registry to use</param>
905
        /// <param name="progress">Progress to report</param>
906
        private void Uninstall(string               identifier,
907
                               ref HashSet<string>? possibleConfigOnlyDirs,
908
                               Registry             registry,
909
                               IProgress<long>      progress)
910
        {
911
            var txFileMgr = new TxFileManager(instance.CkanDir);
2✔
912

913
            using (var transaction = CkanTransaction.CreateTransactionScope())
2✔
914
            {
915
                var instMod = registry.InstalledModule(identifier);
2✔
916

917
                if (instMod == null)
2✔
918
                {
919
                    log.ErrorFormat("Trying to uninstall {0} but it's not installed", identifier);
×
920
                    throw new ModNotInstalledKraken(identifier);
×
921
                }
922
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovingMod,
2✔
923
                                  $"{instMod.Module.name} {instMod.Module.version}");
924

925
                // Walk our registry to find all files for this mod.
926
                var modFiles = instMod.Files.ToArray();
2✔
927

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

931
                // Files that Windows refused to delete due to locking (probably)
932
                var undeletableFiles = new List<string>();
2✔
933

934
                long bytesDeleted = 0;
2✔
935
                foreach (string relPath in modFiles)
6✔
936
                {
937
                    if (cancelToken.IsCancellationRequested)
2✔
938
                    {
939
                        throw new CancelledActionKraken();
×
940
                    }
941

942
                    string absPath = instance.ToAbsoluteGameDir(relPath);
2✔
943

944
                    try
945
                    {
946
                        if (File.GetAttributes(absPath)
2✔
947
                                .HasFlag(FileAttributes.Directory))
948
                        {
949
                            directoriesToDelete.Add(absPath);
2✔
950
                        }
951
                        else
952
                        {
953
                            // Add this file's directory to the list for deletion if it isn't already there.
954
                            // Helps clean up directories when modules are uninstalled out of dependency order
955
                            // Since we check for directory contents when deleting, this should purge empty
956
                            // dirs, making less ModuleManager headaches for people.
957
                            if (Path.GetDirectoryName(absPath) is string p)
2✔
958
                            {
959
                                directoriesToDelete.Add(p);
2✔
960
                            }
961

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

995
                if (undeletableFiles.Count > 0)
2✔
996
                {
997
                    throw new FailedToDeleteFilesKraken(identifier, undeletableFiles);
×
998
                }
999

1000
                // Remove from registry.
1001
                registry.DeregisterModule(instance, identifier);
2✔
1002

1003
                // Our collection of directories may leave empty parent directories.
1004
                directoriesToDelete = AddParentDirectories(directoriesToDelete);
2✔
1005

1006
                // Sort our directories from longest to shortest, to make sure we remove child directories
1007
                // before parents. GH #78.
1008
                foreach (string directory in directoriesToDelete.OrderByDescending(dir => dir.Length))
2✔
1009
                {
1010
                    log.DebugFormat("Checking {0}...", directory);
2✔
1011
                    // It is bad if any of this directories gets removed
1012
                    // So we protect them
1013
                    // A few string comparisons will be cheaper than hitting the disk, so do this first
1014
                    if (instance.Game.IsReservedDirectory(instance, directory))
2✔
1015
                    {
1016
                        log.DebugFormat("Directory {0} is reserved, skipping", directory);
×
1017
                        continue;
×
1018
                    }
1019

1020
                    // See what's left in this folder and what we can do about it
1021
                    GroupFilesByRemovable(instance.ToRelativeGameDir(directory),
2✔
1022
                                          registry, modFiles, instance.Game,
1023
                                          (Directory.Exists(directory)
1024
                                              ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories)
1025
                                              : Enumerable.Empty<string>())
1026
                                           .Select(instance.ToRelativeGameDir)
1027
                                           .ToArray(),
1028
                                          out string[] removable,
1029
                                          out string[] notRemovable);
1030

1031
                    // Delete the auto-removable files and dirs
1032
                    foreach (var absPath in removable.Select(instance.ToAbsoluteGameDir))
6✔
1033
                    {
1034
                        if (File.Exists(absPath))
2✔
1035
                        {
1036
                            log.DebugFormat("Attempting transaction deletion of file {0}", absPath);
2✔
1037
                            txFileMgr.Delete(absPath);
2✔
1038
                        }
1039
                        else if (Directory.Exists(absPath))
2✔
1040
                        {
1041
                            log.DebugFormat("Attempting deletion of directory {0}", absPath);
2✔
1042
                            try
1043
                            {
1044
                                Directory.Delete(absPath);
2✔
1045
                            }
2✔
1046
                            catch
×
1047
                            {
1048
                                // There might be files owned by other mods, oh well
1049
                                log.DebugFormat("Failed to delete {0}", absPath);
×
1050
                            }
×
1051
                        }
1052
                    }
1053

1054
                    if (notRemovable.Length < 1)
2✔
1055
                    {
1056
                        // We *don't* use our txFileMgr to delete files here, because
1057
                        // it fails if the system's temp directory is on a different device
1058
                        // to KSP. However we *can* safely delete it now we know it's empty,
1059
                        // because the TxFileMgr *will* put it back if there's a file inside that
1060
                        // needs it.
1061
                        //
1062
                        // This works around GH #251.
1063
                        // The filesystem boundary bug is described in https://transactionalfilemgr.codeplex.com/workitem/20
1064

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

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

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

1148
                    // If this is a parentless directory (Windows)
1149
                    // or if the Root equals the current directory (Mono)
1150
                    if (dirInfo.Parent == null || dirInfo.Root == dirInfo)
2✔
1151
                    {
1152
                        return results;
2✔
1153
                    }
1154

1155
                    if (!dir.StartsWith(gameDir, Platform.PathComparison))
2✔
1156
                    {
1157
                        dir = CKANPathUtils.ToAbsolute(dir, gameDir);
×
1158
                    }
1159

1160
                    // Remove the system paths, leaving the path under the instance directory
1161
                    var relativeHead = CKANPathUtils.ToRelative(dir, gameDir);
2✔
1162
                    // Don't try to remove GameRoot
1163
                    if (!string.IsNullOrEmpty(relativeHead))
2✔
1164
                    {
1165
                        var pathArray = relativeHead.Split('/');
2✔
1166
                        var builtPath = "";
2✔
1167
                        foreach (var path in pathArray)
6✔
1168
                        {
1169
                            builtPath += path + '/';
2✔
1170
                            results.Add(CKANPathUtils.ToAbsolute(builtPath, gameDir));
2✔
1171
                        }
1172
                    }
1173

1174
                    return results;
2✔
1175
                })
1176
                .Where(dir => !instance.Game.IsReservedDirectory(instance, dir))
2✔
1177
                .ToHashSet();
1178
        }
1179

1180
        #endregion
1181

1182
        #region AddRemove
1183

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

1216
                long removeBytes     = remove.Sum(m => m.Module.install_size);
2✔
1217
                long removedBytes    = 0;
2✔
1218
                long downloadBytes   = toDownload.Sum(m => m.download_size);
2✔
1219
                long downloadedBytes = 0;
2✔
1220
                long installBytes    = add.Sum(m => m.install_size);
2✔
1221
                long installedBytes  = 0;
2✔
1222
                var rateCounter = new ByteRateCounter()
2✔
1223
                {
1224
                    Size      = removeBytes + downloadBytes + installBytes,
1225
                    BytesLeft = removeBytes + downloadBytes + installBytes,
1226
                };
1227
                rateCounter.Start();
2✔
1228

1229
                downloader.OverallDownloadProgress += brc =>
2✔
1230
                {
1231
                    downloadedBytes = downloadBytes - brc.BytesLeft;
2✔
1232
                    rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1233
                                          + downloadBytes - downloadedBytes
1234
                                          + installBytes  - installedBytes;
1235
                    User.RaiseProgress(rateCounter);
2✔
1236
                };
2✔
1237
                var toInstall = ModsInDependencyOrder(resolver, cached, toDownload, downloader);
2✔
1238

1239
                long modRemoveCompletedBytes = 0;
2✔
1240
                foreach (var instMod in remove)
6✔
1241
                {
1242
                    Uninstall(instMod.Module.identifier,
2✔
1243
                              ref possibleConfigOnlyDirs,
1244
                              registry_manager.registry,
1245
                              new ProgressImmediate<long>(bytes =>
1246
                              {
1247
                                  RemoveProgress?.Invoke(instMod,
2✔
1248
                                                         Math.Max(0,     instMod.Module.install_size - bytes),
1249
                                                         Math.Max(bytes, instMod.Module.install_size));
1250
                                  removedBytes = modRemoveCompletedBytes
2✔
1251
                                                 + Math.Min(bytes, instMod.Module.install_size);
1252
                                  rateCounter.BytesLeft = removeBytes   - removedBytes
2✔
1253
                                                        + downloadBytes - downloadedBytes
1254
                                                        + installBytes  - installedBytes;
1255
                                  User.RaiseProgress(rateCounter);
2✔
1256
                              }));
2✔
1257
                     modRemoveCompletedBytes += instMod.Module.install_size;
2✔
1258
                }
1259

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

1290
                registry_manager.Save(enforceConsistency);
2✔
1291
                tx.Complete();
2✔
1292
                EnforceCacheSizeLimit(registry_manager.registry, cache, config);
2✔
1293
            }
2✔
1294
        }
2✔
1295

1296
        /// <summary>
1297
        /// Upgrades or installs the mods listed to the specified versions for the user's KSP.
1298
        /// Will *re-install* or *downgrade* (with a warning) as well as upgrade.
1299
        /// Throws ModuleNotFoundKraken if a module is not installed.
1300
        /// </summary>
1301
        public void Upgrade(in IReadOnlyCollection<CkanModule> modules,
1302
                            IDownloader                        downloader,
1303
                            ref HashSet<string>?               possibleConfigOnlyDirs,
1304
                            RegistryManager                    registry_manager,
1305
                            InstalledFilesDeduplicator?        deduper            = null,
1306
                            ISet<CkanModule>?                  autoInstalled      = null,
1307
                            ISet<CkanModule>?                  skipFiles          = null,
1308
                            bool                               enforceConsistency = true,
1309
                            bool                               ConfirmPrompt      = true)
1310
        {
1311
            var registry = registry_manager.registry;
2✔
1312

1313
            var removingIdents = registry.InstalledModules.Select(im => im.identifier)
2✔
1314
                                         .Intersect(modules.Select(m => m.identifier))
2✔
1315
                                         .ToHashSet();
1316
            var autoRemoving = registry
2✔
1317
                .FindRemovableAutoInstalled(modules, removingIdents, instance)
1318
                .ToHashSet();
1319

1320
            var resolver = new RelationshipResolver(
2✔
1321
                modules,
1322
                modules.Select(m => registry.InstalledModule(m.identifier)?.Module)
2✔
1323
                       .OfType<CkanModule>()
1324
                       .Concat(autoRemoving.Select(im => im.Module)),
2✔
1325
                RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig),
1326
                registry,
1327
                instance.Game, instance.VersionCriteria());
1328
            var fullChangeset = resolver.ModList()
2✔
1329
                                        .ToDictionary(m => m.identifier,
2✔
1330
                                                      m => m);
2✔
1331

1332
            // Skip removing ones we still need
1333
            var keepIdents = fullChangeset.Keys.Intersect(autoRemoving.Select(im => im.Module.identifier))
2✔
1334
                                               .ToHashSet();
1335
            autoRemoving.RemoveWhere(im => keepIdents.Contains(im.Module.identifier));
2✔
1336
            foreach (var ident in keepIdents)
6✔
1337
            {
1338
                fullChangeset.Remove(ident);
2✔
1339
            }
1340

1341
            var toInstall = fullChangeset.Values
2✔
1342
                                         // Only install stuff that's already there if explicitly requested in param
1343
                                         .Except(registry.InstalledModules
1344
                                                         .Select(im => im.Module)
2✔
1345
                                                         .Except(modules))
1346
                                         // Don't touch files for mods in skipFiles (still need to handle their dependencies though)
1347
                                         .Except(skipFiles ?? new HashSet<CkanModule>())
1348
                                         .ToArray();
1349
            autoInstalled ??= new HashSet<CkanModule>();
2✔
1350
            autoInstalled.UnionWith(toInstall.Where(resolver.IsAutoInstalled));
2✔
1351

1352
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToUpgrade);
2✔
1353
            User.RaiseMessage("");
2✔
1354

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

1361
            // Let's discover what we need to do with each module!
1362
            foreach (CkanModule module in toInstall)
6✔
1363
            {
1364
                var installed_mod = registry.InstalledModule(module.identifier);
2✔
1365

1366
                if (installed_mod == null)
2✔
1367
                {
1368
                    if (!cache.IsMaybeCachedZip(module)
2✔
1369
                        && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1370
                    {
1371
                        if (inProgressFile.Exists)
2✔
1372
                        {
1373
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingResuming,
2✔
1374
                                              module.name, module.version,
1375
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1376
                                              CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1377
                        }
1378
                        else
1379
                        {
1380
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingUncached,
2✔
1381
                                              module.name, module.version,
1382
                                              string.Join(", ", PrioritizedHosts(config, module.download)),
1383
                                              CkanModule.FmtSize(module.download_size));
1384
                        }
1385
                    }
1386
                    else
1387
                    {
1388
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstallingCached,
2✔
1389
                                          module.name, module.version);
1390
                    }
1391
                }
1392
                else
1393
                {
1394
                    // Module already installed. We'll need to remove it first.
1395
                    toRemove.Add(installed_mod);
2✔
1396

1397
                    CkanModule installed = installed_mod.Module;
2✔
1398
                    if (installed.version.Equals(module.version))
2✔
1399
                    {
1400
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeReinstalling,
×
1401
                                          module.name, module.version);
1402
                    }
1403
                    else if (installed.version.IsGreaterThan(module.version))
2✔
1404
                    {
1405
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeDowngrading,
2✔
1406
                                          module.name, installed.version, module.version);
1407
                    }
1408
                    else
1409
                    {
1410
                        if (!cache.IsMaybeCachedZip(module)
2✔
1411
                            && cache.GetInProgressFileName(module) is FileInfo inProgressFile)
1412
                        {
1413
                            if (inProgressFile.Exists)
2✔
1414
                            {
1415
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingResuming,
2✔
1416
                                                  module.name, installed.version, module.version,
1417
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1418
                                                  CkanModule.FmtSize(module.download_size - inProgressFile.Length));
1419
                            }
1420
                            else
1421
                            {
1422
                                User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingUncached,
2✔
1423
                                                  module.name, installed.version, module.version,
1424
                                                  string.Join(", ", PrioritizedHosts(config, module.download)),
1425
                                                  CkanModule.FmtSize(module.download_size));
1426
                            }
1427
                        }
1428
                        else
1429
                        {
1430
                            User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgradingCached,
2✔
1431
                                              module.name, installed.version, module.version);
1432
                        }
1433
                    }
1434
                }
1435
            }
1436

1437
            if (autoRemoving.Count > 0)
2✔
1438
            {
1439
                foreach (var im in autoRemoving)
6✔
1440
                {
1441
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeAutoRemoving,
2✔
1442
                                      im.Module.name, im.Module.version);
1443
                }
1444
                toRemove.AddRange(autoRemoving);
2✔
1445
            }
1446

1447
            CheckAddRemoveFreeSpace(toInstall, toRemove);
2✔
1448

1449
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
2✔
1450
            {
1451
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerUpgradeUserDeclined);
×
1452
            }
1453

1454
            if (skipFiles != null)
2✔
1455
            {
1456
                foreach (var module in skipFiles.Intersect(modules))
6✔
1457
                {
1458
                    registry.ReregisterModule(instance, module);
2✔
1459
                }
1460
            }
1461
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1462
                      registry_manager,
1463
                      resolver,
1464
                      toInstall,
1465
                      autoInstalled,
1466
                      toRemove,
1467
                      downloader,
1468
                      enforceConsistency,
1469
                      deduper);
1470
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1471
        }
2✔
1472

1473
        /// <summary>
1474
        /// Enacts listed Module Replacements to the specified versions for the user's KSP.
1475
        /// Will *re-install* or *downgrade* (with a warning) as well as upgrade.
1476
        /// </summary>
1477
        /// <exception cref="DependenciesNotSatisfiedKraken">Thrown if a dependency for a replacing module couldn't be satisfied.</exception>
1478
        /// <exception cref="ModuleNotFoundKraken">Thrown if a module that should be replaced is not installed.</exception>
1479
        public void Replace(IEnumerable<ModuleReplacement> replacements,
1480
                            RelationshipResolverOptions    options,
1481
                            IDownloader                    downloader,
1482
                            ref HashSet<string>?           possibleConfigOnlyDirs,
1483
                            RegistryManager                registry_manager,
1484
                            InstalledFilesDeduplicator?    deduper = null,
1485
                            bool                           enforceConsistency = true)
1486
        {
1487
            replacements = replacements.Memoize();
2✔
1488
            log.Debug("Using Replace method");
2✔
1489
            var modsToInstall = new List<CkanModule>();
2✔
1490
            var modsToRemove  = new List<InstalledModule>();
2✔
1491
            foreach (ModuleReplacement repl in replacements)
6✔
1492
            {
1493
                modsToInstall.Add(repl.ReplaceWith);
2✔
1494
                log.DebugFormat("We want to install {0} as a replacement for {1}", repl.ReplaceWith.identifier, repl.ToReplace.identifier);
2✔
1495
            }
1496

1497
            // Our replacement involves removing the currently installed mods, then
1498
            // adding everything that needs installing (which may involve new mods to
1499
            // satisfy dependencies).
1500

1501
            // Let's discover what we need to do with each module!
1502
            foreach (ModuleReplacement repl in replacements)
6✔
1503
            {
1504
                string ident = repl.ToReplace.identifier;
2✔
1505
                var installedMod = registry_manager.registry.InstalledModule(ident);
2✔
1506

1507
                if (installedMod == null)
2✔
1508
                {
1509
                    log.WarnFormat("Wait, {0} is not actually installed?", ident);
×
1510
                    //Maybe ModuleNotInstalled ?
1511
                    if (registry_manager.registry.IsAutodetected(ident))
×
1512
                    {
1513
                        throw new ModuleNotFoundKraken(ident,
×
1514
                            repl.ToReplace.version.ToString(),
1515
                            string.Format(Properties.Resources.ModuleInstallerReplaceAutodetected, ident));
1516
                    }
1517

1518
                    throw new ModuleNotFoundKraken(ident,
×
1519
                        repl.ToReplace.version.ToString(),
1520
                        string.Format(Properties.Resources.ModuleInstallerReplaceNotInstalled, ident, repl.ReplaceWith.identifier));
1521
                }
1522
                else
1523
                {
1524
                    // Obviously, we need to remove the mod we are replacing
1525
                    modsToRemove.Add(installedMod);
2✔
1526

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

1531
                    // If replacement is not installed, we've already added it to modsToInstall above
1532
                    if (installed_replacement != null)
2✔
1533
                    {
1534
                        //Module already installed. We'll need to treat it as an upgrade.
1535
                        log.DebugFormat("It turns out {0} is already installed, we'll upgrade it.", installed_replacement.identifier);
2✔
1536
                        modsToRemove.Add(installed_replacement);
2✔
1537

1538
                        CkanModule installed = installed_replacement.Module;
2✔
1539
                        if (installed.version.Equals(repl.ReplaceWith.version))
2✔
1540
                        {
1541
                            log.InfoFormat("{0} is already at the latest version, reinstalling to replace {1}", repl.ReplaceWith.identifier, repl.ToReplace.identifier);
2✔
1542
                        }
1543
                        else if (installed.version.IsGreaterThan(repl.ReplaceWith.version))
×
1544
                        {
1545
                            log.WarnFormat("Downgrading {0} from {1} to {2} to replace {3}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1546
                        }
1547
                        else
1548
                        {
1549
                            log.InfoFormat("Upgrading {0} to {1} to replace {2}", repl.ReplaceWith.identifier, repl.ReplaceWith.version, repl.ToReplace.identifier);
×
1550
                        }
1551
                    }
1552
                    else
1553
                    {
1554
                        log.InfoFormat("Replacing {0} with {1} {2}", repl.ToReplace.identifier, repl.ReplaceWith.identifier, repl.ReplaceWith.version);
2✔
1555
                    }
1556
                }
1557
            }
1558
            var resolver = new RelationshipResolver(modsToInstall, null, options, registry_manager.registry,
2✔
1559
                                                    instance.Game, instance.VersionCriteria());
1560
            var resolvedModsToInstall = resolver.ModList().ToArray();
2✔
1561

1562
            CheckAddRemoveFreeSpace(resolvedModsToInstall, modsToRemove);
2✔
1563
            AddRemove(ref possibleConfigOnlyDirs,
2✔
1564
                      registry_manager,
1565
                      resolver,
1566
                      resolvedModsToInstall,
1567
                      new HashSet<CkanModule>(),
1568
                      modsToRemove,
1569
                      downloader,
1570
                      enforceConsistency,
1571
                      deduper);
1572
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
2✔
1573
        }
2✔
1574

1575
        #endregion
1576

1577
        public static IEnumerable<string> PrioritizedHosts(IConfiguration    config,
1578
                                                           IEnumerable<Uri>? urls)
1579
            => urls?.OrderBy(u => u, new PreferredHostUriComparer(config.PreferredHosts))
2✔
1580
                    .Select(dl => dl.Host)
2✔
1581
                    .Distinct()
1582
                   ?? Enumerable.Empty<string>();
1583

1584
        #region Recommendations
1585

1586
        /// <summary>
1587
        /// Looks for optional related modules that could be installed alongside the given modules
1588
        /// </summary>
1589
        /// <param name="instance">Game instance to use</param>
1590
        /// <param name="sourceModules">Modules to check for relationships, should contain the complete changeset including dependencies</param>
1591
        /// <param name="toInstall">Modules already being installed, to be omitted from search</param>
1592
        /// <param name="toRemove">Modules being removed, to be excluded from relationship resolution</param>
1593
        /// <param name="exclude">Modules the user has already seen and decided not to install</param>
1594
        /// <param name="registry">Registry to use</param>
1595
        /// <param name="recommendations">Modules that are recommended to install</param>
1596
        /// <param name="suggestions">Modules that are suggested to install</param>
1597
        /// <param name="supporters">Modules that support other modules we're installing</param>
1598
        /// <returns>
1599
        /// true if anything found, false otherwise
1600
        /// </returns>
1601
        public static bool FindRecommendations(GameInstance                                          instance,
1602
                                               IReadOnlyCollection<CkanModule>                       sourceModules,
1603
                                               IReadOnlyCollection<CkanModule>                       toInstall,
1604
                                               IReadOnlyCollection<CkanModule>                       toRemove,
1605
                                               IReadOnlyCollection<CkanModule>                       exclude,
1606
                                               Registry                                              registry,
1607
                                               out Dictionary<CkanModule, Tuple<bool, List<string>>> recommendations,
1608
                                               out Dictionary<CkanModule, List<string>>              suggestions,
1609
                                               out Dictionary<CkanModule, HashSet<string>>           supporters)
1610
        {
1611
            log.DebugFormat("Finding recommendations for: {0}", string.Join(", ", sourceModules));
2✔
1612
            var crit     = instance.VersionCriteria();
2✔
1613

1614
            var rmvIdents = toRemove.Select(m => m.identifier).ToHashSet();
2✔
1615
            var allRemoving = toRemove
2✔
1616
                .Concat(registry.FindRemovableAutoInstalled(sourceModules, rmvIdents, instance)
1617
                                .Select(im => im.Module))
2✔
1618
                .ToArray();
1619

1620
            var resolver = new RelationshipResolver(sourceModules.Where(m => !m.IsDLC),
2✔
1621
                                                    allRemoving,
1622
                                                    RelationshipResolverOptions.KitchenSinkOpts(instance.StabilityToleranceConfig),
1623
                                                    registry, instance.Game, crit);
1624
            var recommenders = resolver.Dependencies().ToHashSet();
2✔
1625
            log.DebugFormat("Recommenders: {0}", string.Join(", ", recommenders));
2✔
1626

1627
            var checkedRecs = resolver.Recommendations(recommenders)
2✔
1628
                                      .Except(exclude)
1629
                                      .Where(m => resolver.ReasonsFor(m)
2✔
1630
                                                          .Any(r => r is SelectionReason.Recommended { ProvidesIndex: 0 }))
2✔
1631
                                      .ToHashSet();
1632
            var conflicting = new RelationshipResolver(toInstall.Concat(checkedRecs), allRemoving,
2✔
1633
                                                       RelationshipResolverOptions.ConflictsOpts(instance.StabilityToleranceConfig),
1634
                                                       registry, instance.Game, crit)
1635
                                  .ConflictList.Keys;
1636
            // Don't check recommendations that conflict with installed or installing mods
1637
            checkedRecs.ExceptWith(conflicting);
2✔
1638

1639
            recommendations = resolver.Recommendations(recommenders)
2✔
1640
                                      .Except(exclude)
1641
                                      .ToDictionary(m => m,
2✔
1642
                                                    m => new Tuple<bool, List<string>>(
2✔
1643
                                                             checkedRecs.Contains(m),
1644
                                                             resolver.ReasonsFor(m)
1645
                                                                     .OfType<SelectionReason.Recommended>()
1646
                                                                     .Where(r => recommenders.Contains(r.Parent))
2✔
1647
                                                                     .Select(r => r.Parent)
2✔
1648
                                                                     .OfType<CkanModule>()
1649
                                                                     .Select(m => m.identifier)
2✔
1650
                                                                     .ToList()));
1651
            suggestions = resolver.Suggestions(recommenders,
2✔
1652
                                               recommendations.Keys.ToList())
1653
                                  .Except(exclude)
1654
                                  .ToDictionary(m => m,
2✔
1655
                                                m => resolver.ReasonsFor(m)
2✔
1656
                                                             .OfType<SelectionReason.Suggested>()
1657
                                                             .Where(r => recommenders.Contains(r.Parent))
2✔
1658
                                                             .Select(r => r.Parent)
2✔
1659
                                                             .OfType<CkanModule>()
1660
                                                             .Select(m => m.identifier)
2✔
1661
                                                             .ToList());
1662

1663
            var opts = RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig);
2✔
1664
            supporters = resolver.Supporters(recommenders,
2✔
1665
                                             recommenders.Concat(recommendations.Keys)
1666
                                                         .Concat(suggestions.Keys))
1667
                                 .Where(kvp => !exclude.Contains(kvp.Key)
2✔
1668
                                               && CanInstall(toInstall.Append(kvp.Key).ToList(), toRemove,
1669
                                                             opts, registry, instance.Game, crit))
1670
                                 .ToDictionary();
1671

1672
            return recommendations.Count > 0
2✔
1673
                || suggestions.Count > 0
1674
                || supporters.Count > 0;
1675
        }
1676

1677
        /// <summary>
1678
        /// Determine whether there is any way to install the given set of mods.
1679
        /// Handles virtual dependencies, including recursively.
1680
        /// </summary>
1681
        /// <param name="opts">Installer options</param>
1682
        /// <param name="toInstall">Mods we want to install</param>
1683
        /// <param name="toRemove">Mods we want to uninstall</param>
1684
        /// <param name="registry">Registry of instance into which we want to install</param>
1685
        /// <param name="game">Game instance</param>
1686
        /// <param name="crit">Game version criteria</param>
1687
        /// <returns>
1688
        /// True if it's possible to install these mods, false otherwise
1689
        /// </returns>
1690
        public static bool CanInstall(IReadOnlyCollection<CkanModule> toInstall,
1691
                                      IReadOnlyCollection<CkanModule> toRemove,
1692
                                      RelationshipResolverOptions     opts,
1693
                                      IRegistryQuerier                registry,
1694
                                      IGame                           game,
1695
                                      GameVersionCriteria             crit)
1696
        {
1697
            string request = string.Join(", ", toInstall.Select(m => m.identifier));
2✔
1698
            try
1699
            {
1700
                var resolver = new RelationshipResolver(toInstall, toRemove,
2✔
1701
                                                        opts, registry, game, crit);
1702
                var resolverModList = resolver.ModList(false).ToArray();
2✔
1703
                if (resolverModList.Length >= toInstall.Count(m => !m.IsMetapackage))
2✔
1704
                {
1705
                    // We can install with no further dependencies
1706
                    log.DebugFormat("Installable: {0}: {1}",
2✔
1707
                                    request, string.Join(", ", resolverModList.Select(m => m.identifier)));
2✔
1708
                    return true;
2✔
1709
                }
1710
                else
1711
                {
NEW
1712
                    log.DebugFormat("Can't install {0}: {1}",
×
1713
                                    request, string.Join("; ", resolver.ConflictDescriptions));
UNCOV
1714
                    return false;
×
1715
                }
1716
            }
1717
            catch (TooManyModsProvideKraken k)
1718
            {
1719
                // One of the dependencies is virtual
1720
                return k.modules.Any(mod => CanInstall(toInstall.Append(mod).ToArray(), toRemove,
2✔
1721
                                                       opts, registry, game, crit));
1722
            }
1723
            catch (InconsistentKraken k)
×
1724
            {
1725
                log.Debug($"Can't install {request}: {k.ShortDescription}");
×
1726
            }
×
1727
            catch (Exception ex)
×
1728
            {
1729
                log.Debug($"Can't install {request}: {ex.Message}");
×
1730
            }
×
1731
            return false;
×
1732
        }
2✔
1733

1734
        #endregion
1735

1736
        private static void EnforceCacheSizeLimit(Registry       registry,
1737
                                                  NetModuleCache Cache,
1738
                                                  IConfiguration config)
1739
        {
1740
            // Purge old downloads if we're over the limit
1741
            if (config.CacheSizeLimit.HasValue)
2✔
1742
            {
1743
                Cache.EnforceSizeLimit(config.CacheSizeLimit.Value, registry);
×
1744
            }
1745
        }
2✔
1746

1747
        private void CheckAddRemoveFreeSpace(IEnumerable<CkanModule>      toInstall,
1748
                                             IEnumerable<InstalledModule> toRemove)
1749
        {
1750
            if (toInstall.Sum(m => m.install_size) - toRemove.Sum(im => im.ActualInstallSize(instance))
2✔
1751
                is > 0 and var spaceDelta)
1752
            {
1753
                CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.GameDir),
×
1754
                                             spaceDelta,
1755
                                             Properties.Resources.NotEnoughSpaceToInstall);
1756
            }
1757
        }
2✔
1758

1759
        private readonly GameInstance      instance;
1760
        private readonly NetModuleCache    cache;
1761
        private readonly IConfiguration    config;
1762
        private readonly CancellationToken cancelToken;
1763

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