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

KSP-CKAN / CKAN / 24488350928

16 Apr 2026 02:13AM UTC coverage: 85.544% (-0.005%) from 85.549%
24488350928

Pull #4570

github

web-flow
Merge 259aad834 into 7557a0047
Pull Request #4570: Silently skip Steam libraries on missing drives

2004 of 2163 branches covered (92.65%)

Branch coverage included in aggregate %.

2 of 6 new or added lines in 1 file covered. (33.33%)

18 existing lines in 2 files now uncovered.

12015 of 14225 relevant lines covered (84.46%)

1.76 hits per line

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

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

8
using log4net;
9
using ValveKeyValue;
10

11
using CKAN.Extensions;
12

13
namespace CKAN.IO
14
{
15
    public class SteamLibrary
16
    {
17
        [ExcludeFromCodeCoverage]
18
        public SteamLibrary()
19
            : this(SteamPaths.FirstOrDefault(p => !string.IsNullOrEmpty(p)
20
                                                  && Directory.Exists(p)
21
                                                  && File.Exists(LibraryFoldersConfigPath(p))))
22
        {
23
        }
24

25
        public SteamLibrary(string? libraryPath)
2✔
26
        {
2✔
27
            if (libraryPath != null
2✔
28
                && LibraryFoldersConfigPath(libraryPath) is string libFoldersConfigPath
29
                && OpenRead(libFoldersConfigPath) is FileStream stream)
30
            {
2✔
31
                log.InfoFormat("Found Steam at {0}", libraryPath);
2✔
32
                var txtParser     = KVSerializer.Create(KVSerializationFormat.KeyValues1Text);
2✔
33
                var appPaths      = (Utilities.DefaultIfThrows(
2✔
34
                                                   () => DeserializeAll<Dictionary<int, LibraryFolder>>(txtParser, stream),
2✔
35
                                                   exc =>
36
                                                   {
2✔
37
                                                       log.Warn($"Failed to parse {libFoldersConfigPath}", exc);
2✔
38
                                                       return null;
2✔
39
                                                   })
2✔
40
                                              ?.Values
41
                                               .Select(lf => lf.Path is string libPath
2✔
42
                                                             ? appRelPaths.Select(p => Path.Combine(libPath, p))
2✔
43
                                                                          .FirstOrDefault(Directory.Exists)
44
                                                             : null)
45
                                               .OfType<string>()
46
                                              ?? Enumerable.Empty<string>())
47
                                    .ToArray();
48
                var steamGames    = appPaths.SelectMany(p => LibraryPathGames(txtParser, p));
2✔
49
                var binParser     = KVSerializer.Create(KVSerializationFormat.KeyValues1Binary);
2✔
50
                var nonSteamGames = Directory.EnumerateDirectories(Path.Combine(libraryPath, "userdata"))
2✔
51
                                             .Select(dirName => Path.Combine(dirName, "config"))
2✔
52
                                             .Where(Directory.Exists)
53
                                             .Select(dirName => Path.Combine(dirName, "shortcuts.vdf"))
2✔
54
                                             .Where(File.Exists)
55
                                             .SelectMany(p => ShortcutsFileGames(binParser, p));
2✔
56
                Games = steamGames.Concat(nonSteamGames)
2✔
57
                                  .ToArray();
58
                log.DebugFormat("Games: {0}",
2✔
59
                                string.Join(", ", Games.Select(g => $"{g.LaunchUrl} ({g.GameDir})")));
2✔
60
            }
2✔
61
            else
62
            {
2✔
63
                log.Info("Steam not found");
2✔
64
                Games = Array.Empty<NonSteamGame>();
2✔
65
            }
2✔
66
        }
2✔
67

68
        public IEnumerable<Uri> GameAppURLs(DirectoryInfo gameDir)
69
            => Games.Where(g => gameDir.FullName.Equals(g.GameDir?.FullName, Platform.PathComparison))
2✔
70
                    .Select(g => g.LaunchUrl);
2✔
71

72
        public readonly GameBase[] Games;
73

74
        public static bool IsSteamCmdLine(string command)
75
            => command.StartsWith("steam://", StringComparison.InvariantCultureIgnoreCase);
2✔
76

77
        private static string LibraryFoldersConfigPath(string libraryPath)
78
            => Path.Combine(libraryPath, "config", "libraryfolders.vdf");
2✔
79

80
        private static FileStream? OpenRead(string path)
81
            => Utilities.DefaultIfThrows(() => File.OpenRead(path),
2✔
82
                                         exc =>
83
                                         {
2✔
84
                                             log.Warn($"Failed to open {path}", exc);
2✔
85
                                             return null;
2✔
86
                                         });
2✔
87

88
        private static IEnumerable<GameBase> LibraryPathGames(KVSerializer acfParser,
89
                                                              string       appPath)
90
            => Utilities.DefaultIfThrows(() => Directory.EnumerateFiles(appPath, "*.acf"),
2✔
NEW
91
                                         exc => {
×
NEW
92
                                             log.Warn($"Failed to enumerate files for {appPath}", exc);
×
NEW
93
                                             return null;
×
NEW
94
                                         })
×
95
                        ?.SelectWithCatch(acfFile => DeserializeAll<SteamGame>(acfParser, File.OpenRead(acfFile))
2✔
96
                                                        .NormalizeDir(Path.Combine(appPath, "common")),
97
                                         (acfFile, exc) =>
98
                                         {
2✔
99
                                             log.Warn($"Failed to parse {acfFile}:", exc);
2✔
100
                                             return default;
2✔
101
                                         })
2✔
102
                         .OfType<GameBase>()
103
                        ?? Enumerable.Empty<GameBase>();
104

105
        private static IEnumerable<GameBase> ShortcutsFileGames(KVSerializer vdfParser,
106
                                                                string       path)
107
            => Utilities.DefaultIfThrows(() => DeserializeAll<Dictionary<int, NonSteamGame>>(vdfParser, File.OpenRead(path)),
2✔
108
                                         exc =>
109
                                         {
2✔
110
                                             log.Warn($"Failed to parse {path}", exc);
2✔
111
                                             return null;
2✔
112
                                         })
2✔
113
                        ?.Values
114
                         .Select(nsg => nsg.NormalizeDir(path))
2✔
115
                        ?? Enumerable.Empty<NonSteamGame>();
116

117
        private static T DeserializeAll<T>(KVSerializer serializer, Stream stream)
118
        {
2✔
119
            using (stream)
2✔
120
            {
2✔
121
                return serializer.Deserialize<T>(stream);
2✔
122
            }
123
        }
2✔
124

125
        /// <summary>
126
        /// Find the location where the current user's application data resides. Specific to macOS.
127
        /// </summary>
128
        /// <returns>
129
        ///     The application data folder, e.g. <code>/Users/USER/Library/Application Support</code>
130
        /// </returns>
131
        [ExcludeFromCodeCoverage]
132
        private static string GetMacOSApplicationDataFolder()
133
        {
134
            Debug.Assert(Platform.IsMac);
135

136
#if NET8_0_OR_GREATER
137
                // https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/8.0/getfolderpath-unix
138
                return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
139
#else
140
            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
141
                "Library", "Application Support");
142
#endif
143
        }
144

145
        private const  string   registryKey   = @"HKEY_CURRENT_USER\Software\Valve\Steam";
146
        private const  string   registryValue = @"SteamPath";
147
        [ExcludeFromCodeCoverage]
148
        private static string[] SteamPaths
149
            => Platform.IsWindows
150
               // First check the registry
151
               && Microsoft.Win32.Registry.GetValue(registryKey, registryValue, "") is string val
152
               && !string.IsNullOrEmpty(val)
153
            ? new string[]
154
            {
155
                val,
156
            }
157
            : Platform.IsUnix ? new string[]
158
            {
159
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
160
                             "Steam"),
161
                Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
162
                             ".steam", "steam"),
163
            }
164
            : Platform.IsMac ? new string[]
165
            {
166
                Path.Combine(GetMacOSApplicationDataFolder(), "Steam"),
167
            }
168
            : Array.Empty<string>();
169

170
        private static readonly string[] appRelPaths = new string[] { "SteamApps", "steamapps" };
2✔
171

172
        private static readonly ILog log = LogManager.GetLogger(typeof(SteamLibrary));
2✔
173
    }
174

175
    public class LibraryFolder
176
    {
177
        [KVProperty("path")] public string? Path { get; set; }
178
    }
179

180
    public abstract class GameBase
181
    {
182
        public abstract string? Name { get; set; }
183

184
        [KVIgnore] public          DirectoryInfo? GameDir   { get; set; }
185
        [KVIgnore] public abstract Uri            LaunchUrl { get;      }
186

187
        public abstract GameBase NormalizeDir(string appPath);
188
    }
189

190
    public class SteamGame : GameBase
191
    {
192
        [KVProperty("appid")]      public          ulong   AppId      { get; set; }
193
        [KVProperty("name")]       public override string? Name       { get; set; }
194
        [KVProperty("installdir")] public          string? InstallDir { get; set; }
195

196
        [KVIgnore]
197
        public override Uri LaunchUrl => new Uri($"steam://rungameid/{AppId}");
2✔
198

199
        public override GameBase NormalizeDir(string commonPath)
200
        {
2✔
201
            if (InstallDir != null)
2✔
202
            {
2✔
203
                GameDir = new DirectoryInfo(CKANPathUtils.NormalizePath(Path.Combine(commonPath, InstallDir)));
2✔
204
            }
2✔
205
            return this;
2✔
206
        }
2✔
207
    }
208

209
    public class NonSteamGame : GameBase
210
    {
211
        [KVProperty("appid")]
212
        public          int     AppId    { get; set; }
213
        [KVProperty("AppName")]
214
        public override string? Name     { get; set; }
215
        public          string? Exe      { get; set; }
216
        public          string? StartDir { get; set; }
217

218
        [KVIgnore]
219
        private ulong UrlId => (unchecked((ulong)AppId) << 32) | 0x02000000;
2✔
220

221
        [KVIgnore]
222
        public override Uri LaunchUrl => new Uri($"steam://rungameid/{UrlId}");
2✔
223

224
        public override GameBase NormalizeDir(string appPath)
225
        {
2✔
226
            GameDir = StartDir == null ? null
2✔
227
                                       : Utilities.DefaultIfThrows(() =>
228
                                           new DirectoryInfo(CKANPathUtils.NormalizePath(StartDir.Trim('"'))));
2✔
229
            return this;
2✔
230
        }
2✔
231
    }
232
}
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