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

KSP-CKAN / CKAN / 25198663652

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

push

github

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

1982 of 2112 branches covered (93.84%)

Branch coverage included in aggregate %.

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

33 existing lines in 24 files now uncovered.

8491 of 9861 relevant lines covered (86.11%)

2.69 hits per line

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

72.33
/Core/GameInstanceManager.cs
1
using System;
2
using System.Collections.Generic;
3
using System.IO;
4
using System.Linq;
5
using System.Transactions;
6
using System.Diagnostics.CodeAnalysis;
7

8
using Autofac;
9
using ChinhDo.Transactions;
10
using log4net;
11

12
using CKAN.IO;
13
using CKAN.Versioning;
14
using CKAN.Configuration;
15
using CKAN.Games;
16
using CKAN.Games.KerbalSpaceProgram;
17
using CKAN.Games.KerbalSpaceProgram.GameVersionProviders;
18
using CKAN.DLC;
19

20
namespace CKAN
21
{
22
    /// <summary>
23
    /// Manage multiple game installs.
24
    /// </summary>
25
    public class GameInstanceManager : IDisposable
26
    {
27
        /// <summary>
28
        /// An IUser object for user interaction.
29
        /// It is initialized during the startup with a ConsoleUser,
30
        /// do not use in functions that could be called by the GUI.
31
        /// </summary>
32
        public IUser          User            { get; set; }
33
        public IConfiguration Configuration   { get; set; }
34
        public GameInstance?  CurrentInstance { get; private set; }
35
        public event Action<GameInstance?, GameInstance?>? InstanceChanged;
36

37
        public NetModuleCache? Cache { get; private set; }
38
        public event Action<NetModuleCache>? CacheChanged;
39

40
        public  SteamLibrary  SteamLibrary => steamLib ??= new SteamLibrary();
3✔
41
        private SteamLibrary? steamLib;
42

43
        private static readonly ILog log = LogManager.GetLogger(typeof (GameInstanceManager));
3✔
44

45
        private readonly SortedList<string, GameInstance> instances = new SortedList<string, GameInstance>();
3✔
46

47
        public SortedList<string, GameInstance> Instances => new SortedList<string, GameInstance>(instances);
3✔
48

49
        public GameInstanceManager(IUser          user,
3✔
50
                                   IConfiguration configuration,
51
                                   SteamLibrary?  steamLib = null)
52
        {
53
            User = user;
3✔
54
            Configuration = configuration;
3✔
55
            this.steamLib = steamLib;
3✔
56
            LoadInstances();
3✔
57
            LoadCacheSettings();
3✔
58
        }
3✔
59

60
        /// <summary>
61
        /// Returns the preferred game instance, or null if none can be found.
62
        ///
63
        /// This works by checking to see if we're in a game dir first, then the
64
        /// config for an autostart instance, then will try to auto-populate
65
        /// by scanning for the game.
66
        ///
67
        /// This *will not* touch the config if we find a portable install.
68
        ///
69
        /// This *will* run game instance autodetection if the config is empty.
70
        ///
71
        /// This *will* set the current instance, or throw an exception if it's already set.
72
        ///
73
        /// Returns null if we have multiple instances, but none of them are preferred.
74
        /// </summary>
75
        public GameInstance? GetPreferredInstance()
76
            => CurrentInstance ??= _GetPreferredInstance();
3✔
77

78
        private GameInstance? _GetPreferredInstance()
79
        {
80
            // First check if we're part of a portable install
81
            // Note that this *does not* register in the config.
82
            switch (KnownGames.knownGames.Select(g => GameInstance.PortableDir(g)
3✔
83
                                                      is string p
84
                                                          ? new GameInstance(g, p,
85
                                                                             Properties.Resources.GameInstanceManagerPortable,
86
                                                                             User)
87
                                                          : null)
88
                                         .OfType<GameInstance>()
89
                                         .Where(i => i.Valid)
×
90
                                         .ToArray())
91
            {
92
                case { Length: 1 } insts:
93
                    return insts.Single();
×
94

95
                case { Length: > 1 } insts:
96
                    if (User.RaiseSelectionDialog(
×
97
                            string.Format(Properties.Resources.GameInstanceManagerSelectGamePrompt,
98
                                          string.Join(", ", insts.Select(i => i.GameDir)
×
99
                                                                 .Distinct()
100
                                                                 .Select(Platform.FormatPath))),
101
                            insts.Select(i => i.Game.ShortName)
×
102
                                 .ToArray())
103
                        is int selection and >= 0)
104
                    {
105
                        return insts[selection];
×
106
                    }
107
                    break;
108
            }
109

110
            // If we only know of a single instance, return that.
111
            if (instances.Count == 1
3✔
112
                && instances.Values.Single() is { Valid: true } and var inst)
113
            {
114
                return inst;
3✔
115
            }
116

117
            // Return the autostart, if we can find it.
118
            // We check both null and "" as we can't write NULL to the config, so we write an empty string instead
119
            // This is necessary so we can indicate that the user wants to reset the current AutoStartInstance without clearing the config!
120
            if (Configuration.AutoStartInstance is { Length: > 0 } instName
3✔
121
                && instances.TryGetValue(instName, out GameInstance? autoInst)
122
                && autoInst.Valid)
123
            {
124
                return autoInst;
×
125
            }
126

127
            // If we know of no instances, try to find one.
128
            // Otherwise, we know of too many instances!
129
            // We don't know which one to pick, so we return null.
130
            return instances.Count == 0 ? FindAndRegisterDefaultInstances() : null;
3✔
131
        }
132

133
        /// <summary>
134
        /// Find and register default instances by running
135
        /// game autodetection code. Registers one per known game,
136
        /// uses first found as default.
137
        ///
138
        /// Returns the resulting game instance if found.
139
        /// </summary>
140
        public GameInstance? FindAndRegisterDefaultInstances()
141
        {
142
            if (instances.Count != 0)
3✔
143
            {
144
                throw new GameManagerKraken("Attempted to scan for defaults with instances");
×
145
            }
146
            var found = FindDefaultInstances();
3✔
147
            foreach (var inst in found)
9✔
148
            {
149
                log.DebugFormat("Registering {0} at {1}...",
3✔
150
                                inst.Name, inst.GameDir);
151
                AddInstance(inst);
3✔
152
            }
153
            return found.FirstOrDefault();
3✔
154
        }
155

156
        public GameInstance[] FindDefaultInstances()
157
        {
158
            var found = KnownGames.knownGames.SelectMany(g =>
3✔
159
                            SteamLibrary.Games
3✔
160
                                        .Select(sg => sg.Name is string name && sg.GameDir is DirectoryInfo dir
3✔
161
                                                          ? new Tuple<string, DirectoryInfo>(name, dir)
162
                                                          : null)
163
                                        .Append(g.MacPath() is DirectoryInfo dir
164
                                                    ? new Tuple<string, DirectoryInfo>(
165
                                                        string.Format(Properties.Resources.GameInstanceManagerAuto,
166
                                                                      g.ShortName),
167
                                                        dir)
168
                                                    : null)
169
                                        .OfType<Tuple<string, DirectoryInfo>>()
170
                                        .Select(tuple => tuple.Item1 != null && g.GameInFolder(tuple.Item2)
3✔
171
                                                       ? new GameInstance(g, tuple.Item2.FullName,
172
                                                                          tuple.Item1 ?? g.ShortName, User)
173
                                                       : null)
174
                                        .OfType<GameInstance>())
175
                                  .Where(inst => inst.Valid)
3✔
176
                                  .ToArray();
177
            foreach (var group in found.GroupBy(inst => inst.Name))
3✔
178
            {
179
                if (group.Count() > 1)
3✔
180
                {
181
                    // Make sure the names are unique
182
                    int index = 0;
×
183
                    foreach (var inst in group)
×
184
                    {
185
                        // Find an unused name
186
                        string name;
187
                        do
188
                        {
189
                            ++index;
×
190
                            name = $"{group.Key} ({++index})";
×
191
                        }
192
                        while (found.Any(other => other.Name == name));
×
193
                        inst.Name = name;
×
194
                    }
195
                }
196
            }
197
            return found;
3✔
198
        }
199

200
        /// <summary>
201
        /// Adds a game instance to config.
202
        /// </summary>
203
        /// <returns>The resulting GameInstance object</returns>
204
        /// <exception cref="NotGameDirKraken">Thrown if the instance is not a valid game instance.</exception>
205
        public GameInstance AddInstance(GameInstance instance)
206
        {
207
            if (instance.Valid)
3✔
208
            {
209
                string name = instance.Name;
3✔
210
                instances.Add(name, instance);
3✔
211
                Configuration.SetRegistryToInstances(instances);
3✔
212
            }
213
            else
214
            {
215
                throw new NotGameDirKraken(instance.GameDir);
×
216
            }
217
            return instance;
3✔
218
        }
219

220
        /// <summary>
221
        /// Adds a game instance to config.
222
        /// </summary>
223
        /// <param name="path">The path of the instance</param>
224
        /// <param name="name">The name of the instance</param>
225
        /// <param name="user">IUser object for interaction</param>
226
        /// <returns>The resulting GameInstance object</returns>
227
        /// <exception cref="NotGameDirKraken">Thrown if the instance is not a valid game instance.</exception>
228
        public GameInstance? AddInstance(string path, string name, IUser user)
229
        {
230
            var game = DetermineGame(new DirectoryInfo(path), user);
3✔
231
            return game == null ? null : AddInstance(new GameInstance(game, path, name, user));
3✔
232
        }
233

234
        /// <summary>
235
        /// Clones an existing game installation.
236
        /// </summary>
237
        /// <param name="existingInstance">The game instance to clone.</param>
238
        /// <param name="newName">The name for the new instance.</param>
239
        /// <param name="newPath">The path where the new instance should be located.</param>
240
        /// <param name="shareStockFolders">True to make junctions or symlinks to stock folders instead of copying</param>
241
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
242
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if something went wrong with copying.</exception>
243
        /// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
244
        /// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
245
        /// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
246
        public GameInstance CloneInstance(GameInstance existingInstance,
247
                                          string       newName,
248
                                          string       newPath,
249
                                          bool         shareStockFolders = false)
250
            => CloneInstance(existingInstance, newName, newPath,
3✔
251
                             existingInstance.Game.LeaveEmptyInClones,
252
                             shareStockFolders);
253

254
        /// <summary>
255
        /// Clones an existing game installation.
256
        /// </summary>
257
        /// <param name="existingInstance">The game instance to clone.</param>
258
        /// <param name="newName">The name for the new instance.</param>
259
        /// <param name="newPath">The path where the new instance should be located.</param>
260
        /// <param name="leaveEmpty">Dirs whose contents should not be copied</param>
261
        /// <param name="shareStockFolders">True to make junctions or symlinks to stock folders instead of copying</param>
262
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
263
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if something went wrong with copying.</exception>
264
        /// <exception cref="DirectoryNotFoundKraken">Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.</exception>
265
        /// <exception cref="PathErrorKraken">Thrown by CopyDirectory() if the target folder already exists and is not empty.</exception>
266
        /// <exception cref="IOException">Thrown by CopyDirectory() if something goes wrong during the process.</exception>
267
        public GameInstance CloneInstance(GameInstance existingInstance,
268
                                          string       newName,
269
                                          string       newPath,
270
                                          string[]     leaveEmpty,
271
                                          bool         shareStockFolders = false)
272
        {
273
            if (HasInstance(newName))
3✔
274
            {
275
                throw new InstanceNameTakenKraken(newName);
3✔
276
            }
277
            if (!existingInstance.Valid)
3✔
278
            {
279
                throw new NotGameDirKraken(existingInstance.GameDir, string.Format(
3✔
280
                    Properties.Resources.GameInstanceCloneInvalid, existingInstance.Game.ShortName));
281
            }
282

283
            CKANPathUtils.CheckFreeSpace(new DirectoryInfo(newPath) switch
3✔
284
                                         {
285
                                             { Exists: true } di => di,
3✔
286
                                             var di              => di.Parent ?? di.Root,
3✔
287
                                         },
288
                                         HardLink.GetDeviceIdentifiers(existingInstance.GameDir,
289
                                                                       newPath)
290
                                                 .Distinct()
291
                                                 .Count() > 1
292
                                             ? existingInstance.TotalSize
293
                                             : existingInstance.NonHardLinkableSize(leaveEmpty),
294
                                         Properties.Resources.GameInstanceManagerCloneNotEnoughFreeSpace);
295

296
            log.Debug("Copying directory.");
3✔
297
            Utilities.CopyDirectory(existingInstance.GameDir, newPath,
3✔
298
                                    new string[] { "CKAN/registry.locked", "CKAN/playtime.json", "CKAN/GUIConfig.json" },
299
                                    shareStockFolders ? existingInstance.Game.StockFolders
300
                                                      : Array.Empty<string>(),
301
                                    leaveEmpty,
302
                                    new string[] { "CKAN" });
303

304
            // Add the new instance to the config
305
            return AddInstance(new GameInstance(existingInstance.Game, newPath, newName, User));
3✔
306
        }
307

308
        /// <summary>
309
        /// Create a new fake game instance
310
        /// </summary>
311
        /// <param name="game">The game of the new instance.</param>
312
        /// <param name="newName">The name for the new instance.</param>
313
        /// <param name="newPath">The location of the new instance.</param>
314
        /// <param name="version">The version of the new instance. Should have a build number.</param>
315
        /// <param name="dlcs">The IDlcDetector implementations for the DLCs that should be faked and the requested dlc version as a dictionary.</param>
316
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
317
        /// <exception cref="NotGameDirKraken">Thrown by AddInstance() if created instance is not valid, e.g. if a write operation didn't complete for whatever reason.</exception>
318
        public GameInstance FakeInstance(IGame game, string newName, string newPath, GameVersion version,
319
                                         Dictionary<IDlcDetector, GameVersion>? dlcs = null)
320
        {
321
            var txFileMgr = new TxFileManager();
3✔
322
            using (TransactionScope transaction = CkanTransaction.CreateTransactionScope())
3✔
323
            {
324
                if (HasInstance(newName))
3✔
325
                {
326
                    throw new InstanceNameTakenKraken(newName);
3✔
327
                }
328

329
                if (!version.WithoutBuild.InBuildMap(game))
3✔
330
                {
331
                    throw new BadGameVersionKraken(string.Format(
3✔
332
                        Properties.Resources.GameInstanceFakeBadVersion, game.ShortName, version));
333
                }
334
                if (Directory.Exists(newPath) && (Directory.GetFiles(newPath).Length != 0 || Directory.GetDirectories(newPath).Length != 0))
3✔
335
                {
336
                    throw new BadInstallLocationKraken(Properties.Resources.GameInstanceFakeNotEmpty);
3✔
337
                }
338

339
                log.DebugFormat("Creating folder structure and text files at {0} for {1} version {2}", Path.GetFullPath(newPath), game.ShortName, version.ToString());
3✔
340

341
                // Create a game root directory, containing a GameData folder, a buildID.txt/buildID64.txt and a readme.txt
342
                txFileMgr.CreateDirectory(newPath);
3✔
343
                txFileMgr.CreateDirectory(Path.Combine(newPath, game.PrimaryModDirectoryRelative));
3✔
344
                game.RebuildSubdirectories(newPath);
3✔
345

346
                foreach (var anchor in game.InstanceAnchorFiles)
9✔
347
                {
348
                    txFileMgr.WriteAllText(Path.Combine(newPath, anchor),
3✔
349
                                           version.WithoutBuild.ToString(),
350
                                           Encoding.UTF8);
351
                }
352

353
                // Don't write the buildID.txts if we have no build, otherwise it would be -1.
354
                if (version.IsBuildDefined && game is KerbalSpaceProgram)
3✔
355
                {
356
                    foreach (var b in KspBuildIdVersionProvider.buildIDfilenames)
9✔
357
                    {
358
                        txFileMgr.WriteAllText(Path.Combine(newPath, b),
3✔
359
                                               string.Format("build id = {0}", version.Build),
360
                                               Encoding.UTF8);
361
                    }
362
                }
363

364
                // Create the readme.txt WITHOUT build number
365
                txFileMgr.WriteAllText(Path.Combine(newPath, "readme.txt"),
3✔
366
                                       string.Format("Version {0}",
367
                                                     version.WithoutBuild.ToString()),
368
                                       Encoding.UTF8);
369

370
                // Create the needed folder structure and the readme.txt for DLCs that should be simulated.
371
                if (dlcs != null)
3✔
372
                {
373
                    foreach ((IDlcDetector dlcDetector, GameVersion dlcVersion) in dlcs)
9✔
374
                    {
375
                        if (!dlcDetector.AllowedOnBaseVersion(version))
3✔
376
                        {
377
                            throw new WrongGameVersionKraken(
3✔
378
                                version,
379
                                string.Format(Properties.Resources.GameInstanceFakeDLCNotAllowed,
380
                                    game.ShortName,
381
                                    dlcDetector.ReleaseGameVersion,
382
                                    dlcDetector.IdentifierBaseName));
383
                        }
384

385
                        string dlcDir = Path.Combine(newPath, dlcDetector.InstallPath());
3✔
386
                        txFileMgr.CreateDirectory(dlcDir);
3✔
387
                        txFileMgr.WriteAllText(
3✔
388
                            Path.Combine(dlcDir, "readme.txt"),
389
                            string.Format("Version {0}", dlcVersion),
390
                            Encoding.UTF8);
391
                    }
392
                }
393

394
                // Add the new instance to the config
395
                GameInstance new_instance = new GameInstance(game, newPath, newName, User);
3✔
396
                AddInstance(new_instance);
3✔
397
                transaction.Complete();
3✔
398
                return new_instance;
3✔
399
            }
400
        }
3✔
401

402
        /// <summary>
403
        /// Given a string returns a unused valid instance name by postfixing the string
404
        /// </summary>
405
        /// <returns> A unused valid instance name.</returns>
406
        /// <param name="name">The name to use as a base.</param>
407
        /// <exception cref="Kraken">Could not find a valid name.</exception>
408
        public string GetNextValidInstanceName(string name)
409
        {
410
            // Check if the current name is valid
411
            if (InstanceNameIsValid(name))
3✔
412
            {
413
                return name;
×
414
            }
415

416
            // Try appending a number to the name
417
            var validName = Enumerable.Repeat(name, 1000)
3✔
418
                .Select((s, i) => s + " (" + i + ")")
3✔
419
                .FirstOrDefault(InstanceNameIsValid);
420
            if (validName != null)
3✔
421
            {
422
                return validName;
3✔
423
            }
424

425
            // Check if a name with the current timestamp is valid
426
            validName = name + " (" + DateTime.Now + ")";
×
427

428
            if (InstanceNameIsValid(validName))
×
429
            {
430
                return validName;
×
431
            }
432

433
            // Give up
434
            throw new Kraken(Properties.Resources.GameInstanceNoValidName);
×
435
        }
436

437
        /// <summary>
438
        /// Check if the instance name is valid.
439
        /// </summary>
440
        /// <returns><c>true</c>, if name is valid, <c>false</c> otherwise.</returns>
441
        /// <param name="name">Name to check.</param>
442
        private bool InstanceNameIsValid(string name)
443
        {
444
            // Discard null, empty strings and white space only strings.
445
            // Look for the current name in the list of loaded instances.
446
            return !string.IsNullOrWhiteSpace(name) && !HasInstance(name);
3✔
447
        }
448

449
        /// <summary>
450
        /// Removes the instance from the config and saves.
451
        /// </summary>
452
        public void RemoveInstance(string name)
453
        {
454
            instances.Remove(name);
3✔
455
            Configuration.SetRegistryToInstances(instances);
3✔
456
        }
3✔
457

458
        /// <summary>
459
        /// Renames an instance in the config and saves.
460
        /// </summary>
461
        public void RenameInstance(string from, string to)
462
        {
463
            if (from != to)
3✔
464
            {
465
                if (instances.ContainsKey(to))
3✔
466
                {
467
                    throw new InstanceNameTakenKraken(to);
3✔
468
                }
469
                var inst = instances[from];
3✔
470
                instances.Remove(from);
3✔
471
                inst.Name = to;
3✔
472
                instances.Add(to, inst);
3✔
473
                Configuration.SetRegistryToInstances(instances);
3✔
474
            }
475
        }
3✔
476

477
        /// <summary>
478
        /// Sets the current instance.
479
        /// Throws an InvalidGameInstanceKraken if not found.
480
        /// </summary>
481
        public void SetCurrentInstance(string name)
482
        {
483
            if (!instances.TryGetValue(name, out GameInstance? inst))
3✔
484
            {
485
                throw new InvalidGameInstanceKraken(name);
3✔
486
            }
487
            else if (!inst.Valid)
3✔
488
            {
489
                throw new NotGameDirKraken(inst.GameDir);
×
490
            }
491
            else
492
            {
493
                SetCurrentInstance(inst);
3✔
494
            }
495
        }
3✔
496

497
        public void SetCurrentInstance(GameInstance? instance)
498
        {
499
            var prev = CurrentInstance;
3✔
500
            // Don't try to Dispose a null CurrentInstance.
501
            if (prev != null && !prev.Equals(instance))
3✔
502
            {
503
                // Dispose of the old registry manager to release the registry
504
                // (without accidentally locking/loading/etc it).
505
                RegistryManager.DisposeInstance(prev);
3✔
506
            }
507
            CurrentInstance = instance;
3✔
508
            InstanceChanged?.Invoke(prev, instance);
3✔
UNCOV
509
        }
×
510

511
        public void SetCurrentInstanceByPath(string path)
512
        {
513
            if (InstanceAt(path) is GameInstance inst)
3✔
514
            {
515
                SetCurrentInstance(inst);
3✔
516
            }
517
        }
3✔
518

519
        public GameInstance? InstanceAt(string path)
520
        {
521
            var di = new DirectoryInfo(path);
3✔
522
            if (DetermineGame(di, User) is IGame game)
3✔
523
            {
524
                var inst = new GameInstance(game, path,
3✔
525
                                            Properties.Resources.GameInstanceByPathName,
526
                                            User);
527
                if (inst.Valid)
3✔
528
                {
529
                    return inst;
3✔
530
                }
531
                else
532
                {
533
                    throw new NotGameDirKraken(inst.GameDir);
×
534
                }
535
            }
536
            return null;
×
537
        }
538

539
        /// <summary>
540
        /// Sets the autostart instance in the config and saves it.
541
        /// </summary>
542
        public void SetAutoStart(string name)
543
        {
544
            if (!HasInstance(name))
3✔
545
            {
546
                throw new InvalidGameInstanceKraken(name);
3✔
547
            }
548
            else if (!instances[name].Valid)
3✔
549
            {
550
                throw new NotGameDirKraken(instances[name].GameDir);
×
551
            }
552
            Configuration.AutoStartInstance = name;
3✔
553
        }
3✔
554

555
        public bool HasInstance(string name)
556
            => instances.ContainsKey(name);
3✔
557

558
        public void ClearAutoStart()
559
        {
560
            Configuration.AutoStartInstance = null;
3✔
561
        }
3✔
562

563
        private void LoadInstances()
564
        {
565
            log.Info("Loading game instances");
3✔
566

567
            instances.Clear();
3✔
568

569
            foreach (Tuple<string, string, string> instance in Configuration.GetInstances())
9✔
570
            {
571
                var name = instance.Item1;
3✔
572
                var path = instance.Item2;
3✔
573
                var gameName = instance.Item3;
3✔
574
                try
575
                {
576
                    var game = KnownGames.knownGames.FirstOrDefault(g => g.ShortName == gameName)
3✔
577
                        ?? KnownGames.knownGames.First();
578
                    log.DebugFormat("Loading {0} from {1}", name, path);
3✔
579
                    // Add unconditionally, sort out invalid instances downstream
580
                    instances.Add(name, new GameInstance(game, path, name, User));
3✔
581
                }
3✔
582
                catch (Exception exc)
×
583
                {
584
                    // Skip malformed instances (e.g. empty path)
585
                    log.Error($"Failed to load game instance with name=\"{name}\" path=\"{path}\" game=\"{gameName}\"",
×
586
                        exc);
587
                }
×
588
            }
589
        }
3✔
590

591
        private void LoadCacheSettings()
592
        {
593
            if (Configuration.DownloadCacheDir != null
3✔
594
                && !Directory.Exists(Configuration.DownloadCacheDir))
595
            {
596
                try
597
                {
598
                    Directory.CreateDirectory(Configuration.DownloadCacheDir);
3✔
599
                }
3✔
600
                catch
×
601
                {
602
                    // Can't create the configured directory, try reverting it to the default
603
                    Configuration.DownloadCacheDir = null;
×
604
                    Directory.CreateDirectory(DefaultDownloadCacheDir);
×
605
                }
×
606
            }
607

608
            var progress = new ProgressImmediate<int>(p => {});
×
609
            if (!TrySetupCache(Configuration.DownloadCacheDir,
3✔
610
                               progress,
611
                               out string? failReason)
612
                && Configuration.DownloadCacheDir is { Length: > 0 })
613
            {
614
                log.ErrorFormat("Cache not found at configured path {0}: {1}",
×
615
                                Configuration.DownloadCacheDir, failReason ?? "");
616
                // Fall back to default path to minimize chance of ending up in an invalid state at startup
617
                TrySetupCache(null, progress, out _);
×
618
            }
619
        }
3✔
620

621
        /// <summary>
622
        /// Switch to using a download cache in a new location
623
        /// </summary>
624
        /// <param name="path">Location of folder for new cache</param>
625
        /// <param name="progress">Progress object to report progress to</param>
626
        /// <param name="failureReason">Contains a human readable failure message if the setup failed</param>
627
        /// <returns>
628
        /// true if successful, false otherwise
629
        /// </returns>
630
        public bool TrySetupCache(string?        path,
631
                                  IProgress<int> progress,
632
                                  [NotNullWhen(false)]
633
                                  out string?    failureReason)
634
        {
635
            var origPath  = Configuration.DownloadCacheDir;
3✔
636
            var origCache = Cache;
3✔
637
            try
638
            {
639
                if (path is { Length: > 0 })
3✔
640
                {
641
                    Cache = new NetModuleCache(this, path);
3✔
642
                    if (path != origPath)
3✔
643
                    {
644
                        Configuration.DownloadCacheDir = path;
3✔
645
                    }
646
                }
647
                else
648
                {
649
                    Directory.CreateDirectory(DefaultDownloadCacheDir);
3✔
650
                    Cache = new NetModuleCache(this, DefaultDownloadCacheDir);
3✔
651
                    Configuration.DownloadCacheDir = null;
3✔
652
                }
653
                if (origPath != null && origCache != null && path != origPath)
3✔
654
                {
655
                    origCache.GetSizeInfo(out _, out long oldNumBytes, out _);
3✔
656
                    Cache.GetSizeInfo(out _, out _, out long? bytesFree);
3✔
657

658
                    if (oldNumBytes > 0)
3✔
659
                    {
660
                        switch (User.RaiseSelectionDialog(
×
661
                                    bytesFree.HasValue
662
                                        ? string.Format(Properties.Resources.GameInstanceManagerCacheMigrationPrompt,
663
                                                        CkanModule.FmtSize(oldNumBytes),
664
                                                        CkanModule.FmtSize(bytesFree.Value))
665
                                        : string.Format(Properties.Resources.GameInstanceManagerCacheMigrationPromptFreeSpaceUnknown,
666
                                                        CkanModule.FmtSize(oldNumBytes)),
667
                                    oldNumBytes < (bytesFree ?? 0) ? 0 : 2,
668
                                    Properties.Resources.GameInstanceManagerCacheMigrationMove,
669
                                    Properties.Resources.GameInstanceManagerCacheMigrationDelete,
670
                                    Properties.Resources.GameInstanceManagerCacheMigrationOpen,
671
                                    Properties.Resources.GameInstanceManagerCacheMigrationNothing,
672
                                    Properties.Resources.GameInstanceManagerCacheMigrationRevert))
673
                        {
674
                            case 0:
675
                                if (oldNumBytes < bytesFree)
×
676
                                {
677
                                    Cache.MoveFrom(new DirectoryInfo(origPath), progress);
×
678
                                    CacheChanged?.Invoke(origCache);
×
679
                                    origCache.Dispose();
×
680
                                }
681
                                else
682
                                {
683
                                    User.RaiseError(Properties.Resources.GameInstanceManagerCacheMigrationNotEnoughFreeSpace);
×
684
                                    // Abort since the user picked an option that doesn't work
685
                                    Cache = origCache;
×
686
                                    Configuration.DownloadCacheDir = origPath;
×
687
                                    failureReason = "";
×
688
                                }
689
                                break;
×
690

691
                            case 1:
692
                                origCache.RemoveAll();
×
693
                                CacheChanged?.Invoke(origCache);
×
694
                                origCache.Dispose();
×
695
                                break;
×
696

697
                            case 2:
698
                                Utilities.OpenFileBrowser(origPath);
×
699
                                Utilities.OpenFileBrowser(Configuration.DownloadCacheDir ?? DefaultDownloadCacheDir);
×
700
                                CacheChanged?.Invoke(origCache);
×
701
                                origCache.Dispose();
×
702
                                break;
×
703

704
                            case 3:
705
                                CacheChanged?.Invoke(origCache);
×
706
                                origCache.Dispose();
×
707
                                break;
×
708

709
                            case -1:
710
                            case 4:
711
                                Cache = origCache;
×
712
                                Configuration.DownloadCacheDir = origPath;
×
713
                                failureReason = "";
×
714
                                return false;
×
715
                        }
716
                    }
717
                    else
718
                    {
719
                        CacheChanged?.Invoke(origCache);
3✔
720
                        origCache.Dispose();
3✔
721
                    }
722
                }
723
                failureReason = null;
3✔
724
                return true;
3✔
725
            }
726
            catch (DirectoryNotFoundKraken)
×
727
            {
728
                Cache = origCache;
×
729
                Configuration.DownloadCacheDir = origPath;
×
730
                failureReason = string.Format(Properties.Resources.GameInstancePathNotFound, path);
×
731
                return false;
×
732
            }
733
            catch (Exception ex)
×
734
            {
735
                Cache = origCache;
×
736
                Configuration.DownloadCacheDir = origPath;
×
737
                failureReason = ex.Message;
×
738
                return false;
×
739
            }
740
        }
3✔
741

742
        /// <summary>
743
        /// Releases all resource used by the <see cref="GameInstance"/> object.
744
        /// </summary>
745
        /// <remarks>Call <see cref="Dispose"/> when you are finished using the <see cref="GameInstance"/>. The <see cref="Dispose"/>
746
        /// method leaves the <see cref="GameInstance"/> in an unusable state. After calling <see cref="Dispose"/>, you must
747
        /// release all references to the <see cref="GameInstance"/> so the garbage collector can reclaim the memory that
748
        /// the <see cref="GameInstance"/> was occupying.</remarks>
749
        public void Dispose()
750
        {
751
            Cache?.Dispose();
3✔
752
            Cache = null;
3✔
753

754
            // Attempting to dispose of the related RegistryManager object here is a bad idea, it cause loads of failures
755
            GC.SuppressFinalize(this);
3✔
756
        }
3✔
757

758
        public static bool IsGameInstanceDir(DirectoryInfo path)
759
            => KnownGames.knownGames.Any(g => g.GameInFolder(path));
3✔
760

761
        /// <summary>
762
        /// Tries to determine the game that is installed at the given path
763
        /// </summary>
764
        /// <param name="path">A DirectoryInfo of the path to check</param>
765
        /// <param name="user">IUser object for interaction</param>
766
        /// <returns>An instance of the matching game or null if the user cancelled</returns>
767
        /// <exception cref="NotGameDirKraken">Thrown when no games found</exception>
768
        public IGame? DetermineGame(DirectoryInfo path, IUser user)
769
            => KnownGames.knownGames.Where(g => g.GameInFolder(path))
3✔
770
                                    .ToArray()
771
               switch
772
               {
773
                   { Length: 0 }       => throw new NotGameDirKraken(path.FullName),
3✔
774
                   { Length: 1 } games => games.Single(),
3✔
775
                   var games => user.RaiseSelectionDialog(
×
776
                                    string.Format(Properties.Resources.GameInstanceManagerSelectGamePrompt,
777
                                                  Platform.FormatPath(path.FullName)),
778
                                    games.Select(g => g.ShortName)
×
779
                                         .ToArray())
780
                                is int selection and >= 0
781
                                    ? games[selection]
782
                                    : null
783
               };
784

785
        public static readonly string DefaultDownloadCacheDir =
3✔
786
            Path.Combine(CKANPathUtils.AppDataPath, "downloads");
787
    }
788
}
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