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

KSP-CKAN / CKAN / 28623334872

02 Jul 2026 09:40PM UTC coverage: 87.242% (-0.6%) from 87.84%
28623334872

Pull #4687

github

web-flow
Merge 21f0d1a8f into 0daeea8d7
Pull Request #4687: Revert starmap integration

2037 of 2177 branches covered (93.57%)

Branch coverage included in aggregate %.

155 of 210 new or added lines in 11 files covered. (73.81%)

45 existing lines in 8 files now uncovered.

8747 of 10184 relevant lines covered (85.89%)

0.9 hits per line

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

90.83
/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,
1✔
30
                               NetModuleCache    cache,
31
                               IConfiguration    config,
32
                               IUser             user,
33
                               CancellationToken cancelToken = default)
34
        {
35
            log.DebugFormat("Creating ModuleInstaller for {0}", inst.GameDir);
1✔
36
            instance         = inst;
1✔
37
            // Make a transaction file manager that uses a temp dir in the instance's CKAN dir
38
            this.cache       = cache;
1✔
39
            this.config      = config;
1✔
40
            User             = user;
1✔
41
            this.cancelToken = cancelToken;
1✔
42
        }
1✔
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)
1✔
71
            {
72
                User.RaiseProgress(Properties.Resources.ModuleInstallerNothingToInstall, 100);
×
73
                return;
×
74
            }
75
            var resolver = new RelationshipResolver(modules, null, options,
1✔
76
                                                    registry_manager.registry,
77
                                                    instance.Game, instance.VersionCriteria());
78
            var modsToInstall = resolver.ModList().ToArray();
1✔
79
            // Alert about attempts to install DLC before downloading or installing anything
80
            var dlc = modsToInstall.Where(m => m.IsDLC).ToArray();
1✔
81
            if (dlc.Length > 0)
1✔
82
            {
83
                throw new ModuleIsDLCKraken(dlc.First());
1✔
84
            }
85

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

101
            // Make sure we have enough space to install this stuff
102
            var installBytes  = modsToInstall.Sum(m => m.install_size);
1✔
103
            var downloadBytes = CkanModule.GroupByDownloads(downloads)
1✔
104
                                          .Sum(grp => grp.First().download_size);
1✔
105
            // Measure the drive the mods actually land on, which for KSA is the
106
            // external user mods folder rather than GameDir. For KSP1/KSP2 the mod
107
            // directory is under GameDir, so this resolves to the same drive.
108
            var modInstallDir = new DirectoryInfo(instance.Game.PrimaryModDirectory(instance));
1✔
109
            CKANPathUtils.CheckFreeSpace(modInstallDir,
1✔
110
                                         cache.OnSameDevice(modInstallDir)
111
                                             // Check for combined download+install space if same device
112
                                             ? downloadBytes + installBytes
113
                                             : installBytes,
114
                                         Properties.Resources.NotEnoughSpaceToInstall);
115

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

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

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

178
                User.RaiseProgress(Properties.Resources.ModuleInstallerUpdatingRegistry, 90);
1✔
179
                registry_manager.Save(!options.without_enforce_consistency);
1✔
180

181
                User.RaiseProgress(Properties.Resources.ModuleInstallerCommitting, 95);
1✔
182
                transaction.Complete();
1✔
183
            }
1✔
184

185
            EnforceCacheSizeLimit(registry_manager.registry, cache, config);
1✔
186
            // Keep any game specific mod list file (like KSA's manifest.toml) in
187
            // sync right away instead of waiting for the next CKAN launch, since
188
            // the game can also be started some other way (Steam, a shortcut, etc).
189
            instance.Game.ProcessLoadedModsBeforeGameStart(registry_manager.registry.InstalledModules);
1✔
190
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
1✔
191
        }
1✔
192

193
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver            resolver,
194
                                                                     IReadOnlyCollection<CkanModule> cached,
195
                                                                     ISet<CkanModule>                done,
196
                                                                     IReadOnlyCollection<CkanModule> toDownload,
197
                                                                     IDownloader?                    downloader)
198

199
            => ModsInDependencyOrder(resolver, cached, done,
1✔
200
                                     downloader != null && toDownload.Count > 0
201
                                         ? downloader.ModulesAsTheyFinish(cached, toDownload)
202
                                         : null);
203

204
        private static IEnumerable<CkanModule> ModsInDependencyOrder(RelationshipResolver            resolver,
205
                                                                     IReadOnlyCollection<CkanModule> cached,
206
                                                                     ISet<CkanModule>                done,
207
                                                                     IEnumerable<CkanModule>?        downloading)
208
        {
209
            var waiting = new HashSet<CkanModule>();
1✔
210
            if (downloading != null)
1✔
211
            {
212
                foreach (var newlyCached in downloading)
3✔
213
                {
214
                    waiting.Add(newlyCached);
1✔
215
                    foreach (var m in OnePass(resolver, waiting, done))
3✔
216
                    {
217
                        yield return m;
1✔
218
                    }
219
                }
220
            }
221
            else
222
            {
223
                // With no downloading sequence, we're only going to return cached mods
224
                waiting.UnionWith(cached);
1✔
225
                // Treat excluded mods as already done
226
                done.UnionWith(resolver.ModList().Except(waiting));
1✔
227
                foreach (var m in OnePass(resolver, waiting, done))
3✔
228
                {
229
                    yield return m;
1✔
230
                }
231
            }
232
            if (waiting.Count > 0)
1✔
233
            {
234
                // Sanity check in case something goes haywire with the threading logic
235
                throw new InconsistentKraken(
×
236
                    string.Format("Mods should have been installed but were not: {0}",
237
                                  string.Join(", ", waiting)));
238
            }
239
        }
1✔
240

241
        private static IEnumerable<CkanModule> OnePass(RelationshipResolver resolver,
242
                                                       ISet<CkanModule>     waiting,
243
                                                       ISet<CkanModule>     done)
244
        {
245
            while (true)
1✔
246
            {
247
                var newlyDone = waiting.Where(m => resolver.ReadyToInstall(m, done))
1✔
248
                                       .OrderBy(m => m.identifier)
1✔
249
                                       .ToArray();
250
                if (newlyDone.Length == 0)
1✔
251
                {
252
                    // No mods ready to install
253
                    break;
254
                }
255
                foreach (var m in newlyDone)
3✔
256
                {
257
                    waiting.Remove(m);
1✔
258
                    done.Add(m);
1✔
259
                    yield return m;
1✔
260
                }
261
            }
262
        }
1✔
263

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

287
            // TODO: This really should be handled by higher-up code.
288
            if (version is not null and not UnmanagedModuleVersion)
1✔
289
            {
290
                User.RaiseMessage(Properties.Resources.ModuleInstallerAlreadyInstalled,
1✔
291
                                  module.name, version);
292
                return;
1✔
293
            }
294

295
            string? filename = null;
1✔
296
            if (!module.IsMetapackage)
1✔
297
            {
298
                // Find ZIP in the cache if we don't already have it.
299
                filename ??= cache.GetCachedFilename(module);
1✔
300

301
                // If we *still* don't have a file, then kraken bitterly.
302
                if (filename == null)
1✔
303
                {
304
                    throw new FileNotFoundKraken(null,
×
305
                                                 string.Format(Properties.Resources.ModuleInstallerZIPNotInCache,
306
                                                               module));
307
                }
308
            }
309

310
            User.RaiseMessage(Properties.Resources.ModuleInstallerInstallingMod,
1✔
311
                              $"{module.name} {module.version}");
312

313
            try
314
            {
315
                using (var transaction = CkanTransaction.CreateTransactionScope())
1✔
316
                {
317
                    // Install all the things!
318
                    var files = InstallModule(module, filename, registry, candidateDuplicates,
1✔
319
                                              ref possibleConfigOnlyDirs, out int filteredCount, progress);
320

321
                    // Register our module and its files.
322
                    registry.RegisterModule(module, files, instance, autoInstalled);
1✔
323

324
                    // Finish our transaction, but *don't* save the registry; we may be in an
325
                    // intermediate, inconsistent state.
326
                    // This is fine from a transaction standpoint, as we may not have an enclosing
327
                    // transaction, and if we do, they can always roll us back.
328
                    transaction.Complete();
1✔
329

330
                    if (filteredCount > 0)
1✔
331
                    {
332
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledModFiltered,
1✔
333
                                          $"{module.name} {module.version}", filteredCount);
334
                    }
335
                    else
336
                    {
337
                        User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
1✔
338
                                          $"{module.name} {module.version}");
339
                    }
340
                }
1✔
341
            }
1✔
342
            catch (ZipException zexc)
1✔
343
            {
344
                cache.Purge(module);
1✔
345
                throw new InvalidModuleFileKraken(module, filename ?? "",
1✔
346
                                                  string.Format(Properties.Resources.ModuleInstallerCorruptInCache,
347
                                                                module, zexc.Message));
348
            }
349

350
            // Fire our callback that we've installed a module, if we have one.
351
            OneComplete?.Invoke(module);
1✔
352
        }
×
353

354
        /// <summary>
355
        /// Check if the given module is a DLC:
356
        /// if it is, throws ModuleIsDLCKraken.
357
        /// </summary>
358
        private static void CheckKindInstallationKraken(CkanModule module)
359
        {
360
            if (module.IsDLC)
1✔
361
            {
362
                throw new ModuleIsDLCKraken(module);
×
363
            }
364
        }
1✔
365

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

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

494
        public static bool IsInternalCkan(ZipEntry ze)
495
            => ze.Name.EndsWith(".ckan", StringComparison.OrdinalIgnoreCase);
1✔
496

497
        #region File overwrites
498

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

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

570
        /// <summary>
571
        /// Remove files that the user chose to overwrite, so
572
        /// the installer can replace them.
573
        /// Uses a transaction so they can be undeleted if the install
574
        /// fails at a later stage.
575
        /// </summary>
576
        /// <param name="files">The files to overwrite</param>
577
        private void DeleteConflictingFiles(IEnumerable<InstallableFile> files)
578
        {
579
            var txFileMgr = new TxFileManager(instance.CkanDir);
1✔
580
            foreach (var absPath in files.Select(f => instance.ToAbsoluteGameDir(f.destination)))
3✔
581
            {
582
                log.DebugFormat("Trying to delete {0}", absPath);
1✔
583
                txFileMgr.Delete(absPath);
1✔
584
            }
585
        }
1✔
586

587
        #endregion
588

589
        #region Find files
590

591
        /// <summary>
592
        /// Given a module and an open zipfile, return all the files that would be installed
593
        /// for this module.
594
        ///
595
        /// If a KSP instance is provided, it will be used to generate output paths, otherwise these will be null.
596
        ///
597
        /// Throws a BadMetadataKraken if the stanza resulted in no files being returned.
598
        /// </summary>
599
        public static IEnumerable<InstallableFile> FindInstallableFiles(CkanModule module,
600
                                                                        ZipFile    zipfile,
601
                                                                        IGame      game)
602
            // Use the provided stanzas, or use the default install stanza if they're absent.
603
            => module.GetInstallStanzas(game)
1✔
604
                     .SelectMany(stanza => stanza.FindInstallableFiles(module, zipfile, game))
1✔
605
                     .ToArray();
606

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

627
        /// <summary>
628
        /// Returns contents of an installed module
629
        /// </summary>
630
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
631
                GameInstance                instance,
632
                IReadOnlyCollection<string> installed,
633
                HashSet<string>             filters)
634
            => GetModuleContents(instance, installed,
1✔
635
                                 installed.SelectMany(f => f.TraverseNodes(Path.GetDirectoryName)
1✔
636
                                                            .Skip(1)
637
                                                            .Where(s => s.Length > 0)
1✔
638
                                                            .Select(CKANPathUtils.NormalizePath))
639
                                          .ToHashSet(),
640
                                 filters);
641

642
        private static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
643
                GameInstance        instance,
644
                IEnumerable<string> installed,
645
                HashSet<string>     parents,
646
                HashSet<string>     filters)
647
            => installed.Where(f => !filters.Any(filt => f.Contains(filt)))
×
648
                        .Select(f => (relPath: f,
1✔
649
                                      absPath: instance.ToAbsoluteGameDir(f)))
650
                        .Select(f => (f.relPath, f.absPath,
1✔
651
                                      dir: parents.Contains(f.relPath)
652
                                           // Empty dirs are parents of nothing
653
                                           || Directory.Exists(f.absPath)))
654
                        .GroupBy(f => f.dir)
1✔
655
                        .SelectMany(grp =>
656
                            grp.Select(p => (path:   p.relPath, p.dir,
1✔
657
                                             exists: grp.Key ? Directory.Exists(p.absPath)
658
                                                             : File.Exists(p.absPath))));
659

660
        /// <summary>
661
        /// Returns the module contents if and only if we have it
662
        /// available in our cache, empty sequence otherwise.
663
        ///
664
        /// Intended for previews.
665
        /// </summary>
666
        public static IEnumerable<(string path, bool dir, bool exists)> GetModuleContents(
667
                NetModuleCache  Cache,
668
                GameInstance    instance,
669
                CkanModule      module,
670
                HashSet<string> filters)
671
            => (Cache.GetCachedFilename(module) is string filename
1✔
672
                    ? GetModuleContents(Utilities.DefaultIfThrows(
673
                                            () => FindInstallableFiles(module, filename, instance.Game)),
1✔
674
                                        filters)
675
                    : null)
676
               ?? Enumerable.Empty<(string path, bool dir, bool exists)>();
677

678
        private static IEnumerable<(string path, bool dir, bool exists)>? GetModuleContents(
679
                IEnumerable<InstallableFile>? installable,
680
                HashSet<string>               filters)
681
            => installable?.Where(instF => !filters.Any(filt => instF.destination != null
×
682
                                                                && instF.destination.Contains(filt)))
683
                           .Select(f => (path:   f.destination,
1✔
684
                                         dir:    f.source.IsDirectory,
685
                                         exists: true));
686

687
        #endregion
688

689
        private string? InstallFile(ZipFile          zipfile,
690
                                    ZipEntry         entry,
691
                                    string           fullPath,
692
                                    bool             makeDirs,
693
                                    string[]         candidateDuplicates,
694
                                    IProgress<long>? progress)
695
            => InstallFile(zipfile, entry, fullPath, makeDirs,
1✔
696
                           new TxFileManager(instance.CkanDir),
697
                           candidateDuplicates, progress);
698

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

724
                // Windows silently trims trailing spaces, get the path it will actually use
725
                fullPath = Path.GetDirectoryName(Path.Combine(fullPath, "DUMMY")) is string p
1✔
726
                    ? CKANPathUtils.NormalizePath(p)
727
                    : fullPath;
728

729
                log.DebugFormat("Making directory '{0}'", fullPath);
1✔
730
                txFileMgr.CreateDirectory(fullPath);
1✔
731
            }
732
            else
733
            {
734
                log.DebugFormat("Writing file '{0}'", fullPath);
1✔
735

736
                // ZIP format does not require directory entries
737
                if (makeDirs && Path.GetDirectoryName(fullPath) is string d)
1✔
738
                {
739
                    log.DebugFormat("Making parent directory '{0}'", d);
1✔
740
                    txFileMgr.CreateDirectory(d);
1✔
741
                }
742

743
                // We don't allow for the overwriting of files. See #208.
744
                if (txFileMgr.FileExists(fullPath))
1✔
745
                {
746
                    throw new FileExistsKraken(fullPath);
1✔
747
                }
748

749
                // Snapshot whatever was there before. If there's nothing, this will just
750
                // remove our file on rollback. We still need this even though we won't
751
                // overwite files, as it ensures deletion on rollback.
752
                txFileMgr.Snapshot(fullPath);
1✔
753

754
                // Try making hard links if already installed in another instance (faster, less space)
755
                foreach (var installedSource in candidateDuplicates)
3✔
756
                {
757
                    try
758
                    {
759
                        HardLink.Create(installedSource, fullPath);
1✔
760
                        return fullPath;
1✔
761
                    }
762
                    catch
×
763
                    {
764
                        // If hard link creation fails, try more hard links, or copy if none work
765
                    }
×
766
                }
767

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

796
        private static readonly TimeSpan UnzipProgressInterval = TimeSpan.FromMilliseconds(200);
1✔
797

798
        #endregion
799

800
        #region Uninstallation
801

802
        /// <summary>
803
        /// Uninstalls all the mods provided, including things which depend upon them.
804
        /// This *DOES* save the registry.
805
        /// Preferred over Uninstall.
806
        /// </summary>
807
        public void UninstallList(IEnumerable<string>              mods,
808
                                  ref HashSet<string>?             possibleConfigOnlyDirs,
809
                                  RegistryManager                  registry_manager,
810
                                  bool                             ConfirmPrompt = true,
811
                                  IReadOnlyCollection<CkanModule>? installing    = null)
812
        {
813
            mods = mods.Memoize();
1✔
814
            installing ??= Array.Empty<CkanModule>();
1✔
815
            // Pre-check, have they even asked for things which are installed?
816

817
            foreach (string mod in mods.Where(mod => registry_manager.registry.InstalledModule(mod) == null))
1✔
818
            {
819
                throw new ModNotInstalledKraken(mod);
1✔
820
            }
821

822
            var instDlc = mods.Select(registry_manager.registry.InstalledModule)
1✔
823
                              .OfType<InstalledModule>()
824
                              .FirstOrDefault(m => m.Module.IsDLC);
1✔
825
            if (instDlc != null)
1✔
826
            {
827
                throw new ModuleIsDLCKraken(instDlc.Module);
×
828
            }
829

830
            // Find all the things which need uninstalling.
831
            var revdep = mods
1✔
832
                .Union(registry_manager.registry.FindReverseDependencies(
833
                    mods.Except(installing.Select(m => m.identifier)).ToArray(),
×
834
                    installing))
835
                .ToArray();
836

837
            var goners = revdep.Union(
1✔
838
                                registry_manager.registry.FindRemovableAutoInstalled(installing,
839
                                                                                     revdep.ToHashSet(),
840
                                                                                     instance)
841
                                                         .Select(im => im.identifier))
1✔
842
                               .Order()
843
                               .ToArray();
844

845
            // If there is nothing to uninstall, skip out.
846
            if (goners.Length == 0)
1✔
847
            {
848
                return;
1✔
849
            }
850

851
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToRemove);
1✔
852
            User.RaiseMessage("");
1✔
853

854
            foreach (var module in goners.Select(registry_manager.registry.InstalledModule)
3✔
855
                                         .OfType<InstalledModule>())
856
            {
857
                User.RaiseMessage(" * {0} {1}", module.Module.name, module.Module.version);
1✔
858
            }
859

860
            if (ConfirmPrompt && !User.RaiseYesNoDialog(Properties.Resources.ModuleInstallerContinuePrompt))
1✔
861
            {
862
                throw new CancelledActionKraken(Properties.Resources.ModuleInstallerRemoveAborted);
×
863
            }
864

865
            using (var transaction = CkanTransaction.CreateTransactionScope())
1✔
866
            {
867
                var registry = registry_manager.registry;
1✔
868
                long removeBytes = goners.Select(registry.InstalledModule)
1✔
869
                                         .OfType<InstalledModule>()
870
                                         .Sum(m => m.Module.install_size);
1✔
871
                var rateCounter = new ByteRateCounter()
1✔
872
                {
873
                    Size      = removeBytes,
874
                    BytesLeft = removeBytes,
875
                };
876
                rateCounter.Start();
1✔
877

878
                long modRemoveCompletedBytes = 0;
1✔
879
                foreach (string ident in goners)
3✔
880
                {
881
                    if (registry.InstalledModule(ident) is InstalledModule instMod)
1✔
882
                    {
883
                        Uninstall(ident, ref possibleConfigOnlyDirs, registry,
1✔
884
                                  new ProgressImmediate<long>(bytes =>
885
                                  {
886
                                      RemoveProgress?.Invoke(instMod,
1✔
887
                                                             Math.Max(0,     instMod.Module.install_size - bytes),
888
                                                             Math.Max(bytes, instMod.Module.install_size));
889
                                      rateCounter.BytesLeft = removeBytes - (modRemoveCompletedBytes
1✔
890
                                                                             + Math.Min(bytes, instMod.Module.install_size));
891
                                      User.RaiseProgress(rateCounter);
1✔
892
                                  }));
1✔
893
                        modRemoveCompletedBytes += instMod?.Module.install_size ?? 0;
1✔
894
                    }
895
                }
896

897
                // Enforce consistency if we're not installing anything,
898
                // otherwise consistency will be enforced after the installs
899
                registry_manager.Save(installing == null);
1✔
900

901
                transaction.Complete();
1✔
902
            }
1✔
903

904
            // Same reasoning as InstallList: update the mod list file right away
905
            // rather than waiting for the next CKAN launch, in case the game gets
906
            // started some other way.
907
            instance.Game.ProcessLoadedModsBeforeGameStart(registry_manager.registry.InstalledModules);
1✔
908
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
1✔
909
        }
1✔
910

911
        /// <summary>
912
        /// Uninstall the module provided. For internal use only.
913
        /// Use UninstallList for user queries, it also does dependency handling.
914
        /// This does *NOT* save the registry.
915
        /// </summary>
916
        /// <param name="identifier">Identifier of module to uninstall</param>
917
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
918
        /// <param name="registry">Registry to use</param>
919
        /// <param name="progress">Progress to report</param>
920
        private void Uninstall(string               identifier,
921
                               ref HashSet<string>? possibleConfigOnlyDirs,
922
                               Registry             registry,
923
                               IProgress<long>      progress)
924
        {
925
            var txFileMgr = new TxFileManager(instance.CkanDir);
1✔
926

927
            using (var transaction = CkanTransaction.CreateTransactionScope())
1✔
928
            {
929
                var instMod = registry.InstalledModule(identifier);
1✔
930

931
                if (instMod == null)
1✔
932
                {
933
                    log.ErrorFormat("Trying to uninstall {0} but it's not installed", identifier);
×
934
                    throw new ModNotInstalledKraken(identifier);
×
935
                }
936
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovingMod,
1✔
937
                                  $"{instMod.Module.name} {instMod.Module.version}");
938

939
                // Walk our registry to find all files for this mod.
940
                var modFiles = instMod.Files.ToArray();
1✔
941

942
                // We need case insensitive path matching on Windows
943
                var directoriesToDelete = new HashSet<string>(Platform.PathComparer);
1✔
944

945
                // Files that Windows refused to delete due to locking (probably)
946
                var undeletableFiles = new List<string>();
1✔
947

948
                long bytesDeleted = 0;
1✔
949
                foreach (string relPath in modFiles)
3✔
950
                {
951
                    if (cancelToken.IsCancellationRequested)
1✔
952
                    {
953
                        throw new CancelledActionKraken();
×
954
                    }
955

956
                    string absPath = instance.ToAbsoluteGameDir(relPath);
1✔
957

958
                    try
959
                    {
960
                        if (File.GetAttributes(absPath)
1✔
961
                                .HasFlag(FileAttributes.Directory))
962
                        {
963
                            directoriesToDelete.Add(absPath);
1✔
964
                        }
965
                        else
966
                        {
967
                            // Add this file's directory to the list for deletion if it isn't already there.
968
                            // Helps clean up directories when modules are uninstalled out of dependency order
969
                            // Since we check for directory contents when deleting, this should purge empty
970
                            // dirs, making less ModuleManager headaches for people.
971
                            if (Path.GetDirectoryName(absPath) is string p)
1✔
972
                            {
973
                                directoriesToDelete.Add(p);
1✔
974
                            }
975

976
                            bytesDeleted += new FileInfo(absPath).Length;
1✔
977
                            progress.Report(bytesDeleted);
1✔
978
                            log.DebugFormat("Removing {0}", relPath);
1✔
979
                            txFileMgr.Delete(absPath);
1✔
980
                        }
981
                    }
1✔
982
                    catch (FileNotFoundException exc)
1✔
983
                    {
984
                        log.Debug("Ignoring missing file while deleting", exc);
1✔
985
                    }
1✔
986
                    catch (DirectoryNotFoundException exc)
1✔
987
                    {
988
                        log.Debug("Ignoring missing directory while deleting", exc);
1✔
989
                    }
1✔
990
                    catch (IOException)
×
991
                    {
992
                        // "The specified file is in use."
993
                        undeletableFiles.Add(relPath);
×
994
                    }
×
995
                    catch (UnauthorizedAccessException)
×
996
                    {
997
                        // "The caller does not have the required permission."
998
                        // "The file is an executable file that is in use."
999
                        undeletableFiles.Add(relPath);
×
1000
                    }
×
1001
                    catch (Exception exc)
×
1002
                    {
1003
                        // We don't consider this problem serious enough to abort and revert,
1004
                        // so treat it as a "--verbose" level log message.
1005
                        log.InfoFormat("Failure in locating file {0}: {1}", absPath, exc.Message);
×
1006
                    }
×
1007
                }
1008

1009
                if (undeletableFiles.Count > 0)
1✔
1010
                {
1011
                    throw new FailedToDeleteFilesKraken(identifier, undeletableFiles);
×
1012
                }
1013

1014
                // Remove from registry.
1015
                registry.DeregisterModule(instance, identifier);
1✔
1016

1017
                // Our collection of directories may leave empty parent directories.
1018
                directoriesToDelete = AddParentDirectories(directoriesToDelete);
1✔
1019

1020
                // Sort our directories from longest to shortest, to make sure we remove child directories
1021
                // before parents. GH #78.
1022
                foreach (string directory in directoriesToDelete.OrderByDescending(dir => dir.Length))
1✔
1023
                {
1024
                    var relPath = instance.ToRelativeGameDir(directory);
1✔
1025
                    log.DebugFormat("Checking {0}...", directory);
1✔
1026
                    // It is bad if any of this directories gets removed
1027
                    // So we protect them
1028
                    // A few string comparisons will be cheaper than hitting the disk, so do this first
1029
                    if (instance.Game.IsReservedDirectory(instance, directory)
1✔
1030
                        || instance.Game.StockFolders.Contains(relPath))
1031
                    {
1032
                        log.DebugFormat("Directory {0} is reserved, skipping", directory);
1✔
1033
                        continue;
1✔
1034
                    }
1035

1036
                    // See what's left in this folder and what we can do about it
1037
                    GroupFilesByRemovable(relPath, registry, modFiles, instance.Game,
1✔
1038
                                          (Directory.Exists(directory)
1039
                                              ? Directory.EnumerateFileSystemEntries(directory, "*", SearchOption.AllDirectories)
1040
                                              : Enumerable.Empty<string>())
1041
                                           .Select(instance.ToRelativeGameDir)
1042
                                           .ToArray(),
1043
                                          out string[] removable,
1044
                                          out string[] notRemovable);
1045

1046
                    // Delete the auto-removable files and dirs
1047
                    foreach (var absPath in removable.Select(instance.ToAbsoluteGameDir))
3✔
1048
                    {
1049
                        if (File.Exists(absPath))
1✔
1050
                        {
1051
                            log.DebugFormat("Attempting transaction deletion of file {0}", absPath);
1✔
1052
                            txFileMgr.Delete(absPath);
1✔
1053
                        }
1054
                        else if (Directory.Exists(absPath))
1✔
1055
                        {
1056
                            log.DebugFormat("Attempting deletion of directory {0}", absPath);
1✔
1057
                            try
1058
                            {
1059
                                Directory.Delete(absPath);
1✔
1060
                            }
1✔
1061
                            catch
×
1062
                            {
1063
                                // There might be files owned by other mods, oh well
1064
                                log.DebugFormat("Failed to delete {0}", absPath);
×
1065
                            }
×
1066
                        }
1067
                    }
1068

1069
                    if (notRemovable.Length < 1)
1✔
1070
                    {
1071
                        // We *don't* use our txFileMgr to delete files here, because
1072
                        // it fails if the system's temp directory is on a different device
1073
                        // to KSP. However we *can* safely delete it now we know it's empty,
1074
                        // because the TxFileMgr *will* put it back if there's a file inside that
1075
                        // needs it.
1076
                        //
1077
                        // This works around GH #251.
1078
                        // The filesystem boundary bug is described in https://transactionalfilemgr.codeplex.com/workitem/20
1079

1080
                        log.DebugFormat("Removing {0}", directory);
1✔
1081
                        Directory.Delete(directory);
1✔
1082
                    }
1083
                    else if (notRemovable.Except(possibleConfigOnlyDirs?.Select(instance.ToRelativeGameDir)
1✔
1084
                                                 ?? Enumerable.Empty<string>())
1085
                                         // Can't remove if owned by some other mod
1086
                                         .Any(relPath => registry.FileOwner(relPath) != null
1✔
1087
                                                         || modFiles.Contains(relPath)))
1088
                    {
1089
                        log.InfoFormat("Not removing directory {0}, it's not empty", directory);
×
1090
                    }
1091
                    else
1092
                    {
1093
                        log.DebugFormat("Directory {0} contains only non-registered files, ask user about it later: {1}",
1✔
1094
                                        directory,
1095
                                        string.Join(", ", notRemovable));
1096
                        possibleConfigOnlyDirs ??= new HashSet<string>(Platform.PathComparer);
1✔
1097
                        possibleConfigOnlyDirs.Add(directory);
1✔
1098
                    }
1099
                }
1100
                log.InfoFormat("Removed {0}", identifier);
1✔
1101
                transaction.Complete();
1✔
1102
                User.RaiseMessage(Properties.Resources.ModuleInstallerRemovedMod,
1✔
1103
                                  $"{instMod.Module.name} {instMod.Module.version}");
1104
            }
1✔
1105
        }
1✔
1106

1107
        internal static void GroupFilesByRemovable(string                      relRoot,
1108
                                                   Registry                    registry,
1109
                                                   IReadOnlyCollection<string> alreadyRemoving,
1110
                                                   IGame                       game,
1111
                                                   IReadOnlyCollection<string> relPaths,
1112
                                                   out string[]                removable,
1113
                                                   out string[]                notRemovable)
1114
        {
1115
            if (relPaths.Count < 1)
1✔
1116
            {
1117
                removable    = Array.Empty<string>();
1✔
1118
                notRemovable = Array.Empty<string>();
1✔
1119
                return;
1✔
1120
            }
1121
            log.DebugFormat("Getting contents of {0}", relRoot);
1✔
1122
            var contents = relPaths
1✔
1123
                // Split into auto-removable and not-removable
1124
                // Removable must not be owned by other mods
1125
                .GroupBy(f => registry.FileOwner(f) == null
1✔
1126
                              // Also skip owned by this module since it's already deregistered
1127
                              && !alreadyRemoving.Contains(f)
1128
                              // Must have a removable dir name somewhere in path AFTER main dir
1129
                              && f[relRoot.Length..]
1130
                                  .Split('/')
1131
                                  .Where(piece => !string.IsNullOrEmpty(piece))
1✔
1132
                                  .Any(piece => game.AutoRemovableDirs.Contains(piece)))
1✔
1133
                .ToDictionary(grp => grp.Key,
1✔
1134
                              grp => grp.OrderByDescending(f => f.Length)
1✔
1135
                                        .ToArray());
1136
            removable    = contents.GetValueOrDefault(true)  ?? Array.Empty<string>();
1✔
1137
            notRemovable = contents.GetValueOrDefault(false) ?? Array.Empty<string>();
1✔
1138
            log.DebugFormat("Got removable: {0}",    string.Join(", ", removable));
1✔
1139
            log.DebugFormat("Got notRemovable: {0}", string.Join(", ", notRemovable));
1✔
1140
        }
1✔
1141

1142
        /// <summary>
1143
        /// Takes a collection of directories and adds all parent directories within the GameData structure.
1144
        /// </summary>
1145
        /// <param name="directories">The collection of directory path strings to examine</param>
1146
        public HashSet<string> AddParentDirectories(HashSet<string> directories)
1147
            => directories.Where(dir => dir is { Length: > 0 })
1✔
1148
                          // Normalize all paths before deduplicate
1149
                          .Select(CKANPathUtils.NormalizePath)
1150
                          // Remove any duplicate paths
1151
                          .Distinct()
1152
                          .SelectMany(dir =>
1153
                          {
1154
                              var results = new HashSet<string>(Platform.PathComparer);
1✔
1155
                              // Adding in the DirectorySeparatorChar fixes attempts on Windows
1156
                              // to parse "X:" which resolves to Environment.CurrentDirectory
1157
                              var dirInfo = new DirectoryInfo(
1✔
1158
                                  dir.EndsWith("/") ? dir : dir + Path.DirectorySeparatorChar);
1159

1160
                              // If this is a parentless directory (Windows)
1161
                              // or if the Root equals the current directory (Mono)
1162
                              if (dirInfo.Parent == null || dirInfo.Root == dirInfo)
1✔
1163
                              {
1164
                                  return results;
1✔
1165
                              }
1166

1167
                              // dir may be absolute (under GameDir, or under an
1168
                              // external mod root for games like KSA) or relative.
1169
                              // Route conversions through the instance so external
1170
                              // mod paths map onto the PrimaryModDirectoryRelative
1171
                              // prefix instead of throwing on the GameDir root.
1172
                              if (!Path.IsPathRooted(dir))
1✔
1173
                              {
NEW
1174
                                  dir = instance.ToAbsoluteGameDir(dir);
×
1175
                              }
1176

1177
                              for (var builtPath = instance.ToRelativeGameDir(dir);
1✔
1178
                                   // Don't try to remove GameRoot
1179
                                   builtPath is { Length: > 0 };
1✔
1180
                                   builtPath = Path.GetDirectoryName(builtPath))
1✔
1181
                              {
1182
                                  if (instance.Game.StockFolders.Contains(builtPath))
1✔
1183
                                  {
1184
                                      // Can't delete this, no point in checking parent either
1185
                                      break;
1186
                                  }
1187
                                  results.Add(instance.ToAbsoluteGameDir(builtPath));
1✔
1188
                              }
1189
                              return results;
1✔
1190
                          })
1191
                          .Where(dir => !instance.Game.IsReservedDirectory(instance, dir))
1✔
1192
                          .ToHashSet();
1193

1194
        #endregion
1195

1196
        #region AddRemove
1197

1198
        /// <summary>
1199
        /// Adds and removes the listed modules as a single transaction.
1200
        /// No relationships will be processed.
1201
        /// This *will* save the registry.
1202
        /// </summary>
1203
        /// <param name="possibleConfigOnlyDirs">Directories that the user might want to remove after uninstall</param>
1204
        /// <param name="registry_manager">Registry to use</param>
1205
        /// <param name="resolver">Relationship resolver to use</param>
1206
        /// <param name="add">Modules to add</param>
1207
        /// <param name="autoInstalled">true or false for each item in `add`</param>
1208
        /// <param name="remove">Modules to remove</param>
1209
        /// <param name="skipFiles">Modules that have been reregistered without file changes</param>
1210
        /// <param name="downloader">Downloader to use</param>
1211
        /// <param name="deduper">Deduplicator to use</param>
1212
        /// <param name="enforceConsistency">Whether to enforce consistency</param>
1213
        private void AddRemove(ref HashSet<string>?                 possibleConfigOnlyDirs,
1214
                               RegistryManager                      registry_manager,
1215
                               RelationshipResolver                 resolver,
1216
                               IReadOnlyCollection<CkanModule>      add,
1217
                               ISet<CkanModule>                     autoInstalled,
1218
                               IReadOnlyCollection<InstalledModule> remove,
1219
                               ISet<CkanModule>                     skipFiles,
1220
                               IDownloader                          downloader,
1221
                               bool                                 enforceConsistency,
1222
                               InstalledFilesDeduplicator?          deduper = null)
1223
        {
1224
            using (var tx = CkanTransaction.CreateTransactionScope())
1✔
1225
            {
1226
                var groups = add.GroupBy(m => m.IsMetapackage || cache.IsCached(m));
1✔
1227
                var cached = groups.FirstOrDefault(grp => grp.Key)?.ToArray()
1✔
1228
                                                                  ?? Array.Empty<CkanModule>();
1229
                var toDownload = groups.FirstOrDefault(grp => !grp.Key)?.ToArray()
1✔
1230
                                                                       ?? Array.Empty<CkanModule>();
1231

1232
                long removeBytes     = remove.Sum(m => m.Module.install_size);
1✔
1233
                long removedBytes    = 0;
1✔
1234
                long downloadBytes   = toDownload.Sum(m => m.download_size);
1✔
1235
                long downloadedBytes = 0;
1✔
1236
                long installBytes    = add.Sum(m => m.install_size);
1✔
1237
                long installedBytes  = 0;
1✔
1238
                var rateCounter = new ByteRateCounter()
1✔
1239
                {
1240
                    Size      = removeBytes + downloadBytes + installBytes,
1241
                    BytesLeft = removeBytes + downloadBytes + installBytes,
1242
                };
1243
                rateCounter.Start();
1✔
1244

1245
                downloader.OverallDownloadProgress += brc =>
1✔
1246
                {
1247
                    downloadedBytes = downloadBytes - brc.BytesLeft;
1✔
1248
                    rateCounter.BytesLeft = removeBytes   - removedBytes
1✔
1249
                                          + downloadBytes - downloadedBytes
1250
                                          + installBytes  - installedBytes;
1251
                    User.RaiseProgress(rateCounter);
1✔
1252
                };
1✔
1253
                var toInstall = ModsInDependencyOrder(resolver, cached, skipFiles, toDownload, downloader);
1✔
1254

1255
                long modRemoveCompletedBytes = 0;
1✔
1256
                foreach (var instMod in remove)
3✔
1257
                {
1258
                    Uninstall(instMod.Module.identifier,
1✔
1259
                              ref possibleConfigOnlyDirs,
1260
                              registry_manager.registry,
1261
                              new ProgressImmediate<long>(bytes =>
1262
                              {
1263
                                  RemoveProgress?.Invoke(instMod,
1✔
1264
                                                         Math.Max(0,     instMod.Module.install_size - bytes),
1265
                                                         Math.Max(bytes, instMod.Module.install_size));
1266
                                  removedBytes = modRemoveCompletedBytes
1✔
1267
                                                 + Math.Min(bytes, instMod.Module.install_size);
1268
                                  rateCounter.BytesLeft = removeBytes   - removedBytes
1✔
1269
                                                        + downloadBytes - downloadedBytes
1270
                                                        + installBytes  - installedBytes;
1271
                                  User.RaiseProgress(rateCounter);
1✔
1272
                              }));
1✔
1273
                     modRemoveCompletedBytes += instMod.Module.install_size;
1✔
1274
                }
1275

1276
                var gameDir = new DirectoryInfo(instance.GameDir);
1✔
1277
                long modInstallCompletedBytes = 0;
1✔
1278
                foreach (var mod in toInstall)
3✔
1279
                {
1280
                    CKANPathUtils.CheckFreeSpace(gameDir, mod.install_size,
1✔
1281
                                                 Properties.Resources.NotEnoughSpaceToInstall);
1282
                    Install(mod,
1✔
1283
                            // For upgrading, new modules are dependencies and should be marked auto-installed,
1284
                            // for replacing, new modules are the replacements and should not be marked auto-installed
1285
                            remove?.FirstOrDefault(im => im.Module.identifier == mod.identifier)
1✔
1286
                                  ?.AutoInstalled
1287
                                  ?? autoInstalled.Contains(mod),
1288
                            registry_manager.registry,
1289
                            deduper?.ModuleCandidateDuplicates(mod.identifier, mod.version),
1290
                            ref possibleConfigOnlyDirs,
1291
                            new ProgressImmediate<long>(bytes =>
1292
                            {
1293
                                InstallProgress?.Invoke(mod,
1✔
1294
                                                        Math.Max(0,     mod.install_size - bytes),
1295
                                                        Math.Max(bytes, mod.install_size));
1296
                                installedBytes = modInstallCompletedBytes
1✔
1297
                                                 + Math.Min(bytes, mod.install_size);
1298
                                rateCounter.BytesLeft = removeBytes   - removedBytes
1✔
1299
                                                      + downloadBytes - downloadedBytes
1300
                                                      + installBytes  - installedBytes;
1301
                                User.RaiseProgress(rateCounter);
1✔
1302
                            }));
1✔
1303
                    modInstallCompletedBytes += mod.install_size;
1✔
1304
                }
1305

1306
                registry_manager.Save(enforceConsistency);
1✔
1307
                tx.Complete();
1✔
1308
                EnforceCacheSizeLimit(registry_manager.registry, cache, config);
1✔
1309
                // Upgrade and Replace both come through here rather than InstallList/
1310
                // UninstallList, so the mod list file sync needs its own call here too.
1311
                instance.Game.ProcessLoadedModsBeforeGameStart(registry_manager.registry.InstalledModules);
1✔
1312
            }
1✔
1313
        }
1✔
1314

1315
        /// <summary>
1316
        /// Upgrades or installs the mods listed to the specified versions for the user's KSP.
1317
        /// Will *re-install* or *downgrade* (with a warning) as well as upgrade.
1318
        /// Throws ModuleNotFoundKraken if a module is not installed.
1319
        /// </summary>
1320
        public void Upgrade(in IReadOnlyCollection<CkanModule> modules,
1321
                            IDownloader                        downloader,
1322
                            ref HashSet<string>?               possibleConfigOnlyDirs,
1323
                            RegistryManager                    registry_manager,
1324
                            InstalledFilesDeduplicator?        deduper            = null,
1325
                            ISet<CkanModule>?                  autoInstalled      = null,
1326
                            ISet<CkanModule>?                  skipFiles          = null,
1327
                            bool                               enforceConsistency = true,
1328
                            bool                               ConfirmPrompt      = true)
1329
        {
1330
            var registry = registry_manager.registry;
1✔
1331

1332
            var removingIdents = registry.InstalledModules.Select(im => im.identifier)
1✔
1333
                                         .Intersect(modules.Select(m => m.identifier))
1✔
1334
                                         .ToHashSet();
1335
            var autoRemoving = registry
1✔
1336
                .FindRemovableAutoInstalled(modules, removingIdents, instance)
1337
                .ToHashSet();
1338

1339
            var resolver = new RelationshipResolver(
1✔
1340
                modules,
1341
                modules.Select(m => registry.InstalledModule(m.identifier)?.Module)
1✔
1342
                       .OfType<CkanModule>()
1343
                       .Concat(autoRemoving.Select(im => im.Module)),
1✔
1344
                RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig),
1345
                registry,
1346
                instance.Game, instance.VersionCriteria());
1347
            var fullChangeset = resolver.ModList()
1✔
1348
                                        .ToDictionary(m => m.identifier,
1✔
1349
                                                      m => m);
1✔
1350

1351
            // Skip removing ones we still need
1352
            var keepIdents = fullChangeset.Keys.Intersect(autoRemoving.Select(im => im.Module.identifier))
1✔
1353
                                               .ToHashSet();
1354
            autoRemoving.RemoveWhere(im => keepIdents.Contains(im.Module.identifier));
1✔
1355
            foreach (var ident in keepIdents)
3✔
1356
            {
1357
                fullChangeset.Remove(ident);
1✔
1358
            }
1359

1360
            var toInstall = fullChangeset.Values
1✔
1361
                                         // Only install stuff that's already there if explicitly requested in param
1362
                                         .Except(registry.InstalledModules
1363
                                                         .Select(im => im.Module)
1✔
1364
                                                         .Except(modules))
1365
                                         // Don't touch files for mods in skipFiles (still need to handle their dependencies though)
1366
                                         .Except(skipFiles ?? new HashSet<CkanModule>())
1367
                                         .ToArray();
1368
            autoInstalled ??= new HashSet<CkanModule>();
1✔
1369
            autoInstalled.UnionWith(toInstall.Where(resolver.IsAutoInstalled));
1✔
1370

1371
            User.RaiseMessage(Properties.Resources.ModuleInstallerAboutToUpgrade);
1✔
1372
            User.RaiseMessage("");
1✔
1373

1374
            // Our upgrade involves removing everything that's currently installed, then
1375
            // adding everything that needs installing (which may involve new mods to
1376
            // satisfy dependencies). We always know the list passed in is what we need to
1377
            // install, but we need to calculate what needs to be removed.
1378
            var toRemove = new List<InstalledModule>();
1✔
1379

1380
            if (skipFiles != null)
1✔
1381
            {
1382
                foreach (var module in skipFiles)
3✔
1383
                {
1384
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeReinstalling,
1✔
1385
                                      cache.DescribeAvailability(config, module));
1386
                }
1387
            }
1388
            // Let's discover what we need to do with each module!
1389
            foreach (CkanModule module in toInstall)
3✔
1390
            {
1391
                var installed_mod = registry.InstalledModule(module.identifier);
1✔
1392

1393
                if (installed_mod == null)
1✔
1394
                {
1395
                    User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeInstalling,
1✔
1396
                                      cache.DescribeAvailability(config, module));
1397
                }
1398
                else
1399
                {
1400
                    // Module already installed. We'll need to remove it first.
1401
                    toRemove.Add(installed_mod);
1✔
1402

1403
                    CkanModule installed = installed_mod.Module;
1✔
1404
                    if (installed.version.Equals(module.version))
1✔
1405
                    {
1406
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeReinstalling,
×
1407
                                          cache.DescribeAvailability(config, module));
1408
                    }
1409
                    else if (installed.version.IsGreaterThan(module.version))
1✔
1410
                    {
1411
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeDowngrading,
1✔
1412
                                          installed.name, installed.version,
1413
                                          cache.DescribeAvailability(config, module));
1414
                    }
1415
                    else
1416
                    {
1417
                        User.RaiseMessage(Properties.Resources.ModuleInstallerUpgradeUpgrading,
1✔
1418
                                          installed.name, installed.version,
1419
                                          cache.DescribeAvailability(config, module));
1420
                    }
1421
                }
1422
            }
1423

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

1434
            CheckAddRemoveFreeSpace(toInstall, toRemove);
1✔
1435

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

1441
            if (skipFiles != null)
1✔
1442
            {
1443
                foreach (var module in skipFiles.Intersect(modules))
3✔
1444
                {
1445
                    registry.ReregisterModule(instance, module);
1✔
1446
                    User.RaiseMessage(Properties.Resources.ModuleInstallerInstalledMod,
1✔
1447
                                      $"{module.name} {module.version}");
1448
                }
1449
            }
1450
            AddRemove(ref possibleConfigOnlyDirs,
1✔
1451
                      registry_manager,
1452
                      resolver,
1453
                      toInstall,
1454
                      autoInstalled,
1455
                      toRemove,
1456
                      skipFiles ?? new HashSet<CkanModule>(),
1457
                      downloader,
1458
                      enforceConsistency,
1459
                      deduper);
1460
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
1✔
1461
        }
1✔
1462

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

1487
            // Our replacement involves removing the currently installed mods, then
1488
            // adding everything that needs installing (which may involve new mods to
1489
            // satisfy dependencies).
1490

1491
            // Let's discover what we need to do with each module!
1492
            foreach (ModuleReplacement repl in replacements)
3✔
1493
            {
1494
                string ident = repl.ToReplace.identifier;
1✔
1495
                var installedMod = registry_manager.registry.InstalledModule(ident);
1✔
1496

1497
                if (installedMod == null)
1✔
1498
                {
1499
                    log.WarnFormat("Wait, {0} is not actually installed?", ident);
×
1500
                    //Maybe ModuleNotInstalled ?
1501
                    if (registry_manager.registry.IsAutodetected(ident))
×
1502
                    {
1503
                        throw new ModuleNotFoundKraken(ident,
×
1504
                            repl.ToReplace.version.ToString(),
1505
                            string.Format(Properties.Resources.ModuleInstallerReplaceAutodetected, ident));
1506
                    }
1507

1508
                    throw new ModuleNotFoundKraken(ident,
×
1509
                        repl.ToReplace.version.ToString(),
1510
                        string.Format(Properties.Resources.ModuleInstallerReplaceNotInstalled, ident, repl.ReplaceWith.identifier));
1511
                }
1512
                else
1513
                {
1514
                    // Obviously, we need to remove the mod we are replacing
1515
                    modsToRemove.Add(installedMod);
1✔
1516

1517
                    log.DebugFormat("Ok, we are removing {0}", repl.ToReplace.identifier);
1✔
1518
                    //Check whether our Replacement target is already installed
1519
                    var installed_replacement = registry_manager.registry.InstalledModule(repl.ReplaceWith.identifier);
1✔
1520

1521
                    // If replacement is not installed, we've already added it to modsToInstall above
1522
                    if (installed_replacement != null)
1✔
1523
                    {
1524
                        //Module already installed. We'll need to treat it as an upgrade.
1525
                        log.DebugFormat("It turns out {0} is already installed, we'll upgrade it.", installed_replacement.identifier);
1✔
1526
                        modsToRemove.Add(installed_replacement);
1✔
1527

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

1552
            CheckAddRemoveFreeSpace(resolvedModsToInstall, modsToRemove);
1✔
1553
            AddRemove(ref possibleConfigOnlyDirs,
1✔
1554
                      registry_manager,
1555
                      resolver,
1556
                      resolvedModsToInstall,
1557
                      new HashSet<CkanModule>(),
1558
                      modsToRemove,
1559
                      new HashSet<CkanModule>(),
1560
                      downloader,
1561
                      enforceConsistency,
1562
                      deduper);
1563
            User.RaiseProgress(Properties.Resources.ModuleInstallerDone, 100);
1✔
1564
        }
1✔
1565

1566
        #endregion
1567

1568
        public static IEnumerable<string> PrioritizedHosts(IConfiguration    config,
1569
                                                           IEnumerable<Uri>? urls)
1570
            => urls?.OrderBy(u => u, new PreferredHostUriComparer(config.PreferredHosts))
1✔
1571
                    .Select(dl => dl.Host)
1✔
1572
                    .Distinct()
1573
                   ?? Enumerable.Empty<string>();
1574

1575
        #region Recommendations
1576

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

1605
            var rmvIdents = toRemove.Select(m => m.identifier).ToHashSet();
1✔
1606
            var allRemoving = toRemove
1✔
1607
                .Concat(registry.FindRemovableAutoInstalled(sourceModules, rmvIdents, instance)
1608
                                .Select(im => im.Module))
1✔
1609
                .ToArray();
1610

1611
            var resolver = new RelationshipResolver(sourceModules.Where(m => !m.IsDLC),
1✔
1612
                                                    allRemoving,
1613
                                                    RelationshipResolverOptions.KitchenSinkOpts(instance.StabilityToleranceConfig),
1614
                                                    registry, instance.Game, crit);
1615
            var recommenders = resolver.Dependencies().ToHashSet();
1✔
1616
            log.DebugFormat("Recommenders: {0}", string.Join(", ", recommenders));
1✔
1617

1618
            var checkedRecs = resolver.Recommendations(recommenders)
1✔
1619
                                      .Except(exclude)
1620
                                      .Where(m => resolver.ReasonsFor(m)
1✔
1621
                                                          .Any(r => r is SelectionReason.Recommended { ProvidesIndex: 0 }))
1✔
1622
                                      .ToHashSet();
1623
            var conflicting = new RelationshipResolver(toInstall.Concat(checkedRecs), allRemoving,
1✔
1624
                                                       RelationshipResolverOptions.ConflictsOpts(instance.StabilityToleranceConfig),
1625
                                                       registry, instance.Game, crit)
1626
                                  .ConflictList.Keys;
1627
            // Don't check recommendations that conflict with installed or installing mods
1628
            checkedRecs.ExceptWith(conflicting);
1✔
1629

1630
            recommendations = resolver.Recommendations(recommenders)
1✔
1631
                                      .Except(exclude)
1632
                                      .ToDictionary(m => m,
1✔
1633
                                                    m => new Tuple<bool, List<string>>(
1✔
1634
                                                             checkedRecs.Contains(m),
1635
                                                             resolver.ReasonsFor(m)
1636
                                                                     .OfType<SelectionReason.Recommended>()
1637
                                                                     .Where(r => recommenders.Contains(r.Parent))
1✔
1638
                                                                     .Select(r => r.Parent)
1✔
1639
                                                                     .OfType<CkanModule>()
1640
                                                                     .Select(m => m.identifier)
1✔
1641
                                                                     .ToList()));
1642
            suggestions = resolver.Suggestions(recommenders,
1✔
1643
                                               recommendations.Keys.ToList())
1644
                                  .Except(exclude)
1645
                                  .ToDictionary(m => m,
1✔
1646
                                                m => resolver.ReasonsFor(m)
1✔
1647
                                                             .OfType<SelectionReason.Suggested>()
1648
                                                             .Where(r => recommenders.Contains(r.Parent))
1✔
1649
                                                             .Select(r => r.Parent)
1✔
1650
                                                             .OfType<CkanModule>()
1651
                                                             .Select(m => m.identifier)
1✔
1652
                                                             .ToList());
1653

1654
            var opts = RelationshipResolverOptions.DependsOnlyOpts(instance.StabilityToleranceConfig);
1✔
1655
            supporters = resolver.Supporters(recommenders,
1✔
1656
                                             recommenders.Concat(recommendations.Keys)
1657
                                                         .Concat(suggestions.Keys))
1658
                                 .Where(kvp => !exclude.Contains(kvp.Key)
1✔
1659
                                               && CanInstall(toInstall.Append(kvp.Key).ToList(), toRemove,
1660
                                                             opts, registry, instance.Game, crit))
1661
                                 .ToDictionary();
1662

1663
            return recommendations.Count > 0
1✔
1664
                || suggestions.Count > 0
1665
                || supporters.Count > 0;
1666
        }
1667

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

1725
        #endregion
1726

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

1738
        private void CheckAddRemoveFreeSpace(IEnumerable<CkanModule>      toInstall,
1739
                                             IEnumerable<InstalledModule> toRemove)
1740
        {
1741
            if (toInstall.Sum(m => m.install_size) - toRemove.Sum(im => im.ActualInstallSize(instance))
1✔
1742
                is > 0 and var spaceDelta)
1743
            {
1744
                // Mods install to the mod directory's drive (external of GameDir for KSA).
NEW
1745
                CKANPathUtils.CheckFreeSpace(new DirectoryInfo(instance.Game.PrimaryModDirectory(instance)),
×
1746
                                             spaceDelta,
1747
                                             Properties.Resources.NotEnoughSpaceToInstall);
1748
            }
1749
        }
1✔
1750

1751
        private readonly GameInstance      instance;
1752
        private readonly NetModuleCache    cache;
1753
        private readonly IConfiguration    config;
1754
        private readonly CancellationToken cancelToken;
1755

1756
        private static readonly ILog log = LogManager.GetLogger(typeof(ModuleInstaller));
1✔
1757
    }
1758
}
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