summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
blob: 24485620f8f50d828366ec19ef6869c4c6bf5f73 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Serialization.Models;
using StardewModdingAPI.Toolkit.Utilities;

namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
    /// <summary>Scans folders for mod data.</summary>
    public class ModScanner
    {
        /*********
        ** Fields
        *********/
        /// <summary>The JSON helper with which to read manifests.</summary>
        private readonly JsonHelper JsonHelper;

        /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary>
        private readonly HashSet<Regex> IgnoreFilesystemNames = new()
        {
            new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager
            new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // macOS
            new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // Windows
        };

        /// <summary>A list of file extensions to ignore when searching for mod files.</summary>
        private readonly HashSet<string> IgnoreFileExtensions = new(StringComparer.OrdinalIgnoreCase)
        {
            // text
            ".doc",
            ".docx",
            ".md",
            ".rtf",
            ".txt",

            // images
            ".bmp",
            ".gif",
            ".ico",
            ".jpeg",
            ".jpg",
            ".png",
            ".psd",
            ".tif",

            // archives
            ".rar",
            ".zip",

            // backup files
            ".backup",
            ".bak",
            ".old",

            // Windows shortcut files
            ".url",
            ".lnk"
        };

        /// <summary>The extensions for packed content files.</summary>
        private readonly HashSet<string> StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase)
        {
            ".xgs",
            ".xnb",
            ".xsb",
            ".xwb"
        };

        /// <summary>The extensions for files which an XNB mod may contain, in addition to <see cref="StrictXnbModExtensions"/>.</summary>
        private readonly HashSet<string> PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase)
        {
            ".json",
            ".yaml"
        };

        /// <summary>The name of the marker file added by Vortex to indicate it's managing the folder.</summary>
        private readonly string VortexMarkerFileName = "__folder_managed_by_vortex";

        /// <summary>The name for a mod's configuration JSON file.</summary>
        private readonly string ConfigFileName = "config.json";


        /*********
        ** Public methods
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
        public ModScanner(JsonHelper jsonHelper)
        {
            this.JsonHelper = jsonHelper;
        }

        /// <summary>Extract information about all mods in the given folder.</summary>
        /// <param name="rootPath">The root folder containing mods.</param>
        public IEnumerable<ModFolder> GetModFolders(string rootPath)
        {
            DirectoryInfo root = new(rootPath);
            return this.GetModFolders(root, root);
        }

        /// <summary>Extract information about all mods in the given folder.</summary>
        /// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param>
        /// <param name="modPath">The mod path to search.</param>
        // /// <param name="tryConsolidateMod">If the folder contains multiple XNB mods, treat them as subfolders of a single mod. This is useful when reading a single mod archive, as opposed to a mods folder.</param>
        public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath)
        {
            return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath));
        }

        /// <summary>Extract information from a mod folder.</summary>
        /// <param name="root">The root folder containing mods.</param>
        /// <param name="searchFolder">The folder to search for a mod.</param>
        public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
        {
            // find manifest.json
            FileInfo? manifestFile = this.FindManifest(searchFolder);

            // set appropriate invalid-mod error
            if (manifestFile == null)
            {
                FileInfo[] files = this.RecursivelyGetFiles(searchFolder).ToArray();
                FileInfo[] relevantFiles = files.Where(this.IsRelevant).ToArray();

                // empty Vortex folder
                // (this filters relevant files internally so it can check for the normally-ignored Vortex marker file)
                if (this.IsEmptyVortexFolder(files))
                    return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyVortexFolder, "it's an empty Vortex folder (is the mod disabled in Vortex?).");

                // empty folder
                if (!relevantFiles.Any())
                    return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder.");

                // XNB mod
                if (this.IsXnbMod(relevantFiles))
                    return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info).");

                // SMAPI installer
                if (relevantFiles.Any(p => p.Name is "install on Linux.sh" or "install on macOS.command" or "install on Windows.bat"))
                    return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file).");

                // not a mod?
                return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json.");
            }

            // read mod info
            Manifest? manifest = null;
            ModParseError error = ModParseError.None;
            string? errorText = null;
            {
                try
                {
                    if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest))
                    {
                        error = ModParseError.ManifestInvalid;
                        errorText = "its manifest is invalid.";
                    }
                }
                catch (SParseException ex)
                {
                    error = ModParseError.ManifestInvalid;
                    errorText = $"parsing its manifest failed: {ex.Message}";
                }
                catch (Exception ex)
                {
                    error = ModParseError.ManifestInvalid;
                    errorText = $"parsing its manifest failed:\n{ex}";
                }
            }

            // get mod type
            ModType type;
            {
                bool isContentPack = !string.IsNullOrWhiteSpace(manifest?.ContentPackFor?.UniqueID);
                bool isSmapi = !string.IsNullOrWhiteSpace(manifest?.EntryDll);

                if (isContentPack == isSmapi)
                    type = ModType.Invalid;
                else if (isContentPack)
                    type = ModType.ContentPack;
                else
                    type = ModType.Smapi;
            }

            // build result
            return new ModFolder(root, manifestFile.Directory!, type, manifest, error, errorText);
        }


        /*********
        ** Private methods
        *********/
        /// <summary>Recursively extract information about all mods in the given folder.</summary>
        /// <param name="root">The root mod folder.</param>
        /// <param name="folder">The folder to search for mods.</param>
        private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder)
        {
            bool isRoot = folder.FullName == root.FullName;

            // skip
            if (!isRoot)
            {
                if (folder.Name.StartsWith("."))
                {
                    yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot.");
                    yield break;
                }
                if (!this.IsRelevant(folder))
                    yield break;
            }

            // find mods in subfolders
            if (this.IsModSearchFolder(root, folder))
            {
                IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub));
                if (!isRoot)
                    subfolders = this.TryConsolidate(root, folder, subfolders.ToArray());
                foreach (ModFolder subfolder in subfolders)
                    yield return subfolder;
            }

            // treat as mod folder
            else
                yield return this.ReadFolder(root, folder);
        }

        /// <summary>Consolidate adjacent folders into one mod folder, if possible.</summary>
        /// <param name="root">The folder containing both parent and subfolders.</param>
        /// <param name="parentFolder">The parent folder to consolidate, if possible.</param>
        /// <param name="subfolders">The subfolders to consolidate, if possible.</param>
        private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders)
        {
            if (subfolders.Length > 1)
            {
                // a collection of empty folders
                if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder))
                    return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) };

                // an XNB mod
                if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder))
                    return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) };
            }

            return subfolders;
        }

        /// <summary>Find the manifest for a mod folder.</summary>
        /// <param name="folder">The folder to search.</param>
        private FileInfo? FindManifest(DirectoryInfo folder)
        {
            // check for conventional manifest in current folder
            const string defaultName = "manifest.json";
            FileInfo file = new(Path.Combine(folder.FullName, defaultName));
            if (file.Exists)
                return file;

            // check for manifest with incorrect capitalization
            {
                CaseInsensitivePathLookup pathLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily
                string realName = pathLookup.GetFilePath(defaultName);
                if (realName != defaultName)
                    file = new(Path.Combine(folder.FullName, realName));
            }
            if (file.Exists)
                return file;

            // not found
            return null;
        }

        /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary>
        /// <param name="root">The root mod folder.</param>
        /// <param name="folder">The folder to search for mods.</param>
        private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder)
        {
            if (root.FullName == folder.FullName)
                return true;

            DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray();
            FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray();
            return subfolders.Any() && !files.Any();
        }

        /// <summary>Recursively get all files in a folder.</summary>
        /// <param name="folder">The root folder to search.</param>
        private IEnumerable<FileInfo> RecursivelyGetFiles(DirectoryInfo folder)
        {
            foreach (FileSystemInfo entry in folder.GetFileSystemInfos())
            {
                if (entry is DirectoryInfo && !this.IsRelevant(entry))
                    continue;

                if (entry is FileInfo file)
                    yield return file;

                if (entry is DirectoryInfo subfolder)
                {
                    foreach (FileInfo subfolderFile in this.RecursivelyGetFiles(subfolder))
                        yield return subfolderFile;
                }
            }
        }

        /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary>
        /// <param name="entry">The file or folder.</param>
        private bool IsRelevant(FileSystemInfo entry)
        {
            // ignored file extensions and any files starting with "."
            if ((entry is FileInfo file) && (this.IgnoreFileExtensions.Contains(file.Extension) || file.Name.StartsWith(".")))
                return false;

            // ignored entry name
            return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name));
        }

        /// <summary>Get whether a set of files looks like an XNB mod.</summary>
        /// <param name="files">The files in the mod.</param>
        private bool IsXnbMod(IEnumerable<FileInfo> files)
        {
            bool hasXnbFile = false;

            foreach (FileInfo file in files.Where(this.IsRelevant))
            {
                if (this.StrictXnbModExtensions.Contains(file.Extension))
                {
                    hasXnbFile = true;
                    continue;
                }

                if (!this.PotentialXnbModExtensions.Contains(file.Extension))
                    return false;
            }

            return hasXnbFile;
        }

        /// <summary>Get whether a set of files looks like an XNB mod.</summary>
        /// <param name="files">The files in the mod.</param>
        private bool IsEmptyVortexFolder(IEnumerable<FileInfo> files)
        {
            bool hasVortexMarker = false;

            foreach (FileInfo file in files)
            {
                if (file.Name == this.VortexMarkerFileName)
                {
                    hasVortexMarker = true;
                    continue;
                }

                if (this.IsRelevant(file) && file.Name != this.ConfigFileName)
                    return false;
            }

            return hasVortexMarker;
        }
    }
}