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

KSP-CKAN / CKAN / 20403951254

21 Dec 2025 03:20AM UTC coverage: 85.205% (-0.1%) from 85.303%
20403951254

push

github

HebaruSan
Merge #4473 Check free space before cloning

2004 of 2171 branches covered (92.31%)

Branch coverage included in aggregate %.

17 of 24 new or added lines in 7 files covered. (70.83%)

18 existing lines in 2 files now uncovered.

11927 of 14179 relevant lines covered (84.12%)

1.76 hits per line

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

72.62
/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();
2✔
41
        private SteamLibrary? steamLib;
42

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

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

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

49
        public GameInstanceManager(IUser          user,
2✔
50
                                   IConfiguration configuration,
51
                                   SteamLibrary?  steamLib = null)
52
        {
2✔
53
            User = user;
2✔
54
            Configuration = configuration;
2✔
55
            this.steamLib = steamLib;
2✔
56
            LoadInstances();
2✔
57
            LoadCacheSettings();
2✔
58
        }
2✔
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();
2✔
77

78
        private GameInstance? _GetPreferredInstance()
79
        {
2✔
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)
2✔
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
2✔
112
                && instances.Values.Single() is { Valid: true } and var inst)
113
            {
2✔
114
                return inst;
2✔
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
2✔
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;
2✔
131
        }
2✔
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
        {
2✔
142
            if (instances.Count != 0)
2✔
143
            {
×
144
                throw new GameManagerKraken("Attempted to scan for defaults with instances");
×
145
            }
146
            var found = FindDefaultInstances();
2✔
147
            foreach (var inst in found)
8✔
148
            {
2✔
149
                log.DebugFormat("Registering {0} at {1}...",
2✔
150
                                inst.Name, inst.GameDir);
151
                AddInstance(inst);
2✔
152
            }
2✔
153
            return found.FirstOrDefault();
2✔
154
        }
2✔
155

156
        public GameInstance[] FindDefaultInstances()
157
        {
2✔
158
            var found = KnownGames.knownGames.SelectMany(g =>
2✔
159
                            SteamLibrary.Games
2✔
160
                                        .Select(sg => sg.Name is string name && sg.GameDir is DirectoryInfo dir
2✔
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)
2✔
171
                                                       ? new GameInstance(g, tuple.Item2.FullName,
172
                                                                          tuple.Item1 ?? g.ShortName, User)
173
                                                       : null)
174
                                        .OfType<GameInstance>())
175
                                  .Where(inst => inst.Valid)
2✔
176
                                  .ToArray();
177
            foreach (var group in found.GroupBy(inst => inst.Name))
2✔
178
            {
2✔
179
                if (group.Count() > 1)
2✔
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
            }
2✔
197
            return found;
2✔
198
        }
2✔
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
        {
2✔
207
            if (instance.Valid)
2✔
208
            {
2✔
209
                string name = instance.Name;
2✔
210
                instances.Add(name, instance);
2✔
211
                Configuration.SetRegistryToInstances(instances);
2✔
212
            }
2✔
213
            else
214
            {
×
215
                throw new NotGameDirKraken(instance.GameDir);
×
216
            }
217
            return instance;
2✔
218
        }
2✔
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
        {
2✔
230
            var game = DetermineGame(new DirectoryInfo(path), user);
2✔
231
            return game == null ? null : AddInstance(new GameInstance(game, path, name, user));
2✔
232
        }
2✔
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 void CloneInstance(GameInstance existingInstance,
247
                                  string       newName,
248
                                  string       newPath,
249
                                  bool         shareStockFolders = false)
250
        {
2✔
251
            CloneInstance(existingInstance, newName, newPath,
2✔
252
                          existingInstance.Game.LeaveEmptyInClones,
253
                          shareStockFolders);
254
        }
2✔
255

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

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

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

306
            // Add the new instance to the config
307
            AddInstance(new GameInstance(existingInstance.Game, newPath, newName, User));
2✔
308
        }
2✔
309

310
        /// <summary>
311
        /// Create a new fake game instance
312
        /// </summary>
313
        /// <param name="game">The game of the new instance.</param>
314
        /// <param name="newName">The name for the new instance.</param>
315
        /// <param name="newPath">The location of the new instance.</param>
316
        /// <param name="version">The version of the new instance. Should have a build number.</param>
317
        /// <param name="dlcs">The IDlcDetector implementations for the DLCs that should be faked and the requested dlc version as a dictionary.</param>
318
        /// <exception cref="InstanceNameTakenKraken">Thrown if the instance name is already in use.</exception>
319
        /// <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>
320
        public GameInstance FakeInstance(IGame game, string newName, string newPath, GameVersion version,
321
                                         Dictionary<IDlcDetector, GameVersion>? dlcs = null)
322
        {
2✔
323
            var txFileMgr = new TxFileManager();
2✔
324
            using (TransactionScope transaction = CkanTransaction.CreateTransactionScope())
2✔
325
            {
2✔
326
                if (HasInstance(newName))
2✔
327
                {
2✔
328
                    throw new InstanceNameTakenKraken(newName);
2✔
329
                }
330

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

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

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

348
                foreach (var anchor in game.InstanceAnchorFiles)
8✔
349
                {
2✔
350
                    txFileMgr.WriteAllText(Path.Combine(newPath, anchor),
2✔
351
                                         version.WithoutBuild.ToString());
352
                }
2✔
353

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

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

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

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

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

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

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

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

426
            if (InstanceNameIsValid(validName))
×
427
            {
×
428
                return validName;
×
429
            }
430

431
            // Give up
432
            throw new Kraken(Properties.Resources.GameInstanceNoValidName);
×
433
        }
2✔
434

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

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

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

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

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

509
        public void SetCurrentInstanceByPath(string path)
510
        {
2✔
511
            if (InstanceAt(path) is GameInstance inst)
2✔
512
            {
2✔
513
                SetCurrentInstance(inst);
2✔
514
            }
2✔
515
        }
2✔
516

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

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

553
        public bool HasInstance(string name)
554
            => instances.ContainsKey(name);
2✔
555

556
        public void ClearAutoStart()
557
        {
2✔
558
            Configuration.AutoStartInstance = null;
2✔
559
        }
2✔
560

561
        private void LoadInstances()
562
        {
2✔
563
            log.Info("Loading game instances");
2✔
564

565
            instances.Clear();
2✔
566

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

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

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

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

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

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

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

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

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

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

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

756
        public static bool IsGameInstanceDir(DirectoryInfo path)
757
            => KnownGames.knownGames.Any(g => g.GameInFolder(path));
2✔
758

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

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