Convert SteamCMD VDF to JSON to prepare for compatibility with web API

This commit is contained in:
pointfeev 2024-07-21 21:35:25 -04:00
parent a198b7f9e5
commit eb8c75d249
8 changed files with 172 additions and 118 deletions

View file

@ -154,6 +154,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Gameloop.Vdf" Version="0.6.2" /> <PackageReference Include="Gameloop.Vdf" Version="0.6.2" />
<PackageReference Include="Gameloop.Vdf.JsonConverter" Version="0.2.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.ServiceModel.Primitives" Version="8.0.0" /> <PackageReference Include="System.ServiceModel.Primitives" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View file

@ -201,10 +201,10 @@ internal sealed partial class SelectForm : CustomForm
if (Program.Canceled) if (Program.Canceled)
return; return;
AppData appData = await SteamStore.QueryStoreAPI(appId); StoreAppData storeAppData = await SteamStore.QueryStoreAPI(appId);
_ = Interlocked.Decrement(ref steamGamesToCheck); _ = Interlocked.Decrement(ref steamGamesToCheck);
VProperty appInfo = await SteamCMD.GetAppInfo(appId, branch, buildId); CmdAppData cmdAppData = await SteamCMD.GetAppInfo(appId, branch, buildId);
if (appData is null && appInfo is null) if (storeAppData is null && cmdAppData is null)
{ {
RemoveFromRemainingGames(name); RemoveFromRemainingGames(name);
return; return;
@ -213,13 +213,13 @@ internal sealed partial class SelectForm : CustomForm
if (Program.Canceled) if (Program.Canceled)
return; return;
ConcurrentDictionary<SelectionDLC, byte> dlc = new(); ConcurrentDictionary<SelectionDLC, byte> dlc = new();
List<Task> dlcTasks = new(); List<Task> dlcTasks = [];
HashSet<string> dlcIds = new(); HashSet<string> dlcIds = [];
if (appData is not null) if (storeAppData is not null)
foreach (string dlcId in await SteamStore.ParseDlcAppIds(appData)) foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData))
_ = dlcIds.Add(dlcId); _ = dlcIds.Add(dlcId);
if (appInfo is not null) if (cmdAppData is not null)
foreach (string dlcId in await SteamCMD.ParseDlcAppIds(appInfo)) foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData))
_ = dlcIds.Add(dlcId); _ = dlcIds.Add(dlcId);
if (dlcIds.Count > 0) if (dlcIds.Count > 0)
foreach (string dlcAppId in dlcIds) foreach (string dlcAppId in dlcIds)
@ -240,31 +240,27 @@ internal sealed partial class SelectForm : CustomForm
string dlcName = null; string dlcName = null;
string dlcIcon = null; string dlcIcon = null;
bool onSteamStore = false; bool onSteamStore = false;
AppData dlcAppData = await SteamStore.QueryStoreAPI(dlcAppId, true); StoreAppData dlcStoreAppData = await SteamStore.QueryStoreAPI(dlcAppId, true);
if (dlcAppData is not null) if (dlcStoreAppData is not null)
{ {
dlcName = dlcAppData.Name; dlcName = dlcStoreAppData.Name;
dlcIcon = dlcAppData.HeaderImage; dlcIcon = dlcStoreAppData.HeaderImage;
onSteamStore = true; onSteamStore = true;
fullGameAppId = dlcAppData.FullGame?.AppId; fullGameAppId = dlcStoreAppData.FullGame?.AppId;
} }
else else
{ {
VProperty dlcAppInfo = await SteamCMD.GetAppInfo(dlcAppId); CmdAppData dlcCmdAppData = await SteamCMD.GetAppInfo(dlcAppId);
if (dlcAppInfo is not null) if (dlcCmdAppData is not null)
{ {
dlcName = dlcAppInfo.Value.GetChild("common")?.GetChild("name")?.ToString(); dlcName = dlcCmdAppData.Common?.Name;
string dlcIconStaticId = dlcAppInfo.Value.GetChild("common")?.GetChild("icon") string dlcIconStaticId = dlcCmdAppData.Common?.Icon;
?.ToString(); dlcIconStaticId ??= dlcCmdAppData.Common?.LogoSmall;
dlcIconStaticId ??= dlcAppInfo.Value.GetChild("common")?.GetChild("logo_small") dlcIconStaticId ??= dlcCmdAppData.Common?.Logo;
?.ToString();
dlcIconStaticId ??= dlcAppInfo.Value.GetChild("common")?.GetChild("logo")
?.ToString();
if (dlcIconStaticId is not null) if (dlcIconStaticId is not null)
dlcIcon = IconGrabber.SteamAppImagesPath + dlcIcon = IconGrabber.SteamAppImagesPath +
@$"\{dlcAppId}\{dlcIconStaticId}.jpg"; @$"\{dlcAppId}\{dlcIconStaticId}.jpg";
fullGameAppId = dlcAppInfo.Value.GetChild("common")?.GetChild("parent") fullGameAppId = dlcCmdAppData.Common?.Parent;
?.ToString();
} }
} }
@ -273,26 +269,23 @@ internal sealed partial class SelectForm : CustomForm
string fullGameName = null; string fullGameName = null;
string fullGameIcon = null; string fullGameIcon = null;
bool fullGameOnSteamStore = false; bool fullGameOnSteamStore = false;
AppData fullGameAppData = await SteamStore.QueryStoreAPI(fullGameAppId, true); StoreAppData fullGameStoreAppData =
if (fullGameAppData is not null) await SteamStore.QueryStoreAPI(fullGameAppId, true);
if (fullGameStoreAppData is not null)
{ {
fullGameName = fullGameAppData.Name; fullGameName = fullGameStoreAppData.Name;
fullGameIcon = fullGameAppData.HeaderImage; fullGameIcon = fullGameStoreAppData.HeaderImage;
fullGameOnSteamStore = true; fullGameOnSteamStore = true;
} }
else else
{ {
VProperty fullGameAppInfo = await SteamCMD.GetAppInfo(fullGameAppId); CmdAppData fullGameAppInfo = await SteamCMD.GetAppInfo(fullGameAppId);
if (fullGameAppInfo is not null) if (fullGameAppInfo is not null)
{ {
fullGameName = fullGameAppInfo.Value.GetChild("common")?.GetChild("name") fullGameName = fullGameAppInfo.Common?.Name;
?.ToString(); string fullGameIconStaticId = fullGameAppInfo.Common?.Icon;
string fullGameIconStaticId = fullGameAppInfo.Value.GetChild("common") fullGameIconStaticId ??= fullGameAppInfo.Common?.LogoSmall;
?.GetChild("icon")?.ToString(); fullGameIconStaticId ??= fullGameAppInfo.Common?.Logo;
fullGameIconStaticId ??= fullGameAppInfo.Value.GetChild("common")
?.GetChild("logo_small")?.ToString();
fullGameIconStaticId ??= fullGameAppInfo.Value.GetChild("common")
?.GetChild("logo")?.ToString();
if (fullGameIconStaticId is not null) if (fullGameIconStaticId is not null)
dlcIcon = IconGrabber.SteamAppImagesPath + dlcIcon = IconGrabber.SteamAppImagesPath +
@$"\{fullGameAppId}\{fullGameIconStaticId}.jpg"; @$"\{fullGameAppId}\{fullGameIconStaticId}.jpg";
@ -345,17 +338,15 @@ internal sealed partial class SelectForm : CustomForm
return; return;
} }
Selection selection = Selection.GetOrCreate(Platform.Steam, appId, appData?.Name ?? name, Selection selection = Selection.GetOrCreate(Platform.Steam, appId, storeAppData?.Name ?? name,
gameDirectory, dllDirectories, gameDirectory, dllDirectories,
await gameDirectory.GetExecutableDirectories(true)); await gameDirectory.GetExecutableDirectories(true));
selection.Product = "https://store.steampowered.com/app/" + appId; selection.Product = "https://store.steampowered.com/app/" + appId;
selection.Icon = IconGrabber.SteamAppImagesPath + selection.Icon = IconGrabber.SteamAppImagesPath + @$"\{appId}\{cmdAppData?.Common?.Icon}.jpg";
@$"\{appId}\{appInfo?.Value.GetChild("common")?.GetChild("icon")}.jpg"; selection.SubIcon = storeAppData?.HeaderImage ?? IconGrabber.SteamAppImagesPath
selection.SubIcon = appData?.HeaderImage ?? IconGrabber.SteamAppImagesPath + @$"\{appId}\{cmdAppData?.Common?.ClientIcon}.ico";
+ @$"\{appId}\{appInfo?.Value.GetChild("common")?.GetChild("clienticon")}.ico"; selection.Publisher = storeAppData?.Publishers[0] ?? cmdAppData?.Extended?.Publisher;
selection.Publisher = appData?.Publishers[0] ?? selection.Website = storeAppData?.Website;
appInfo?.Value.GetChild("extended")?.GetChild("publisher")?.ToString();
selection.Website = appData?.Website;
if (Program.Canceled) if (Program.Canceled)
return; return;
Invoke(delegate Invoke(delegate

View file

@ -0,0 +1,53 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CreamInstaller.Platforms.Steam;
public class CmdAppCommon
{
[JsonProperty(PropertyName = "type")] public string Type { get; set; }
[JsonProperty(PropertyName = "name")] public string Name { get; set; }
[JsonProperty(PropertyName = "icon")] public string Icon { get; set; }
[JsonProperty(PropertyName = "clienticon")]
public string ClientIcon { get; set; }
[JsonProperty(PropertyName = "logo_small")]
public string LogoSmall { get; set; }
[JsonProperty(PropertyName = "logo")] public string Logo { set; get; }
[JsonProperty(PropertyName = "parent")]
public string Parent { set; get; }
}
public class CmdAppExtended
{
[JsonProperty(PropertyName = "listofdlc")]
public string Dlc { get; set; }
[JsonProperty(PropertyName = "publisher")]
public string Publisher { get; set; }
}
public class CmdAppData
{
[JsonProperty(PropertyName = "common")]
public CmdAppCommon Common { get; set; }
[JsonProperty(PropertyName = "depots")]
public Dictionary<string, dynamic> Depots { get; set; }
[JsonProperty(PropertyName = "extended")]
public CmdAppExtended Extended { get; set; }
}
public class CmdAppDetails
{
[JsonProperty(PropertyName = "status")]
public string Status { get; set; }
[JsonProperty(PropertyName = "data")] public Dictionary<string, CmdAppData> Data { get; set; }
}

View file

@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CreamInstaller.Resources; using CreamInstaller.Resources;
using CreamInstaller.Utility; using CreamInstaller.Utility;
using Gameloop.Vdf.JsonConverter;
using Gameloop.Vdf.Linq; using Gameloop.Vdf.Linq;
#if DEBUG #if DEBUG
using CreamInstaller.Forms; using CreamInstaller.Forms;
@ -17,7 +18,7 @@ using CreamInstaller.Forms;
namespace CreamInstaller.Platforms.Steam; namespace CreamInstaller.Platforms.Steam;
internal static class SteamCMD internal static partial class SteamCMD
{ {
private const int ProcessLimit = 20; private const int ProcessLimit = 20;
@ -32,10 +33,6 @@ internal static class SteamCMD
private static readonly string DllPath = DirectoryPath + @"\steamclient.dll"; private static readonly string DllPath = DirectoryPath + @"\steamclient.dll";
private static readonly string AppCachePath = DirectoryPath + @"\appcache"; private static readonly string AppCachePath = DirectoryPath + @"\appcache";
private static readonly string ConfigPath = DirectoryPath + @"\config";
private static readonly string DumpsPath = DirectoryPath + @"\dumps";
private static readonly string LogsPath = DirectoryPath + @"\logs";
private static readonly string SteamAppsPath = DirectoryPath + @"\steamapps";
private static string DirectoryPath => ProgramData.DirectoryPath; private static string DirectoryPath => ProgramData.DirectoryPath;
internal static string AppInfoPath => ProgramData.AppInfoPath; internal static string AppInfoPath => ProgramData.AppInfoPath;
@ -179,22 +176,7 @@ internal static class SteamCMD
await Kill(); await Kill();
try try
{ {
if (ConfigPath.DirectoryExists()) AppCachePath.DeleteDirectory();
foreach (string file in ConfigPath.EnumerateDirectory("*.tmp"))
file.DeleteFile();
foreach (string file in DirectoryPath.EnumerateDirectory("*.old"))
file.DeleteFile();
foreach (string file in DirectoryPath.EnumerateDirectory("*.delete"))
file.DeleteFile();
foreach (string file in DirectoryPath.EnumerateDirectory("*.crash"))
file.DeleteFile();
foreach (string file in DirectoryPath.EnumerateDirectory("*.ntfs_transaction_failed"))
file.DeleteFile();
AppCachePath
.DeleteDirectory(); // this is definitely needed, so SteamCMD gets the latest information for us
DumpsPath.DeleteDirectory();
LogsPath.DeleteDirectory();
SteamAppsPath.DeleteDirectory(); // this is just a useless folder created from +app_update 4
} }
catch catch
{ {
@ -202,7 +184,7 @@ internal static class SteamCMD
} }
}); });
internal static async Task<VProperty> GetAppInfo(string appId, string branch = "public", int buildId = 0) internal static async Task<CmdAppData> GetAppInfo(string appId, string branch = "public", int buildId = 0)
{ {
int attempts = 0; int attempts = 0;
while (!Program.Canceled) while (!Program.Canceled)
@ -254,18 +236,49 @@ internal static class SteamCMD
continue; continue;
} }
if (!appInfo.Value.Children().Any()) CmdAppData appData;
return appInfo; try
VToken type = appInfo.Value.GetChild("common")?.GetChild("type"); {
if (type is not null && type.ToString() != "Game") if (appInfo.ToJson().Value.ToObject<CmdAppData>() is not { } cmdAppData)
return appInfo; {
string buildid = appInfo.Value.GetChild("depots")?.GetChild("branches")?.GetChild(branch) appUpdateFile.DeleteFile();
?.GetChild("buildid")?.ToString(); #if DEBUG
DebugForm.Current.Log(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): VDF-JSON conversion failed",
LogTextBox.Warning);
#endif
continue;
}
appData = cmdAppData;
}
catch
#if DEBUG
(Exception e)
#endif
{
appUpdateFile.DeleteFile();
#if DEBUG
DebugForm.Current.Log(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): VDF-JSON conversion failed (" + e.Message + ")",
LogTextBox.Warning);
#endif
continue;
}
string type = appData.Common?.Type;
if (type is not null && type != "Game")
return appData;
if (appData.Depots is null || !appData.Depots.TryGetValue("branches", out dynamic appBranch))
return appData;
string buildid = appBranch?[branch]?.buildid;
if (buildid is null && type is not null) if (buildid is null && type is not null)
return appInfo; return appData;
if (type is not null && (!int.TryParse(buildid, out int gamebuildId) || gamebuildId >= buildId)) if (type is not null && (!int.TryParse(buildid, out int gamebuildId) || gamebuildId >= buildId))
return appInfo; return appData;
HashSet<string> dlcAppIds = await ParseDlcAppIds(appInfo); HashSet<string> dlcAppIds = await ParseDlcAppIds(appData);
foreach (string dlcAppUpdateFile in dlcAppIds.Select(id => $@"{AppInfoPath}\{id}.vdf")) foreach (string dlcAppUpdateFile in dlcAppIds.Select(id => $@"{AppInfoPath}\{id}.vdf"))
dlcAppUpdateFile.DeleteFile(); dlcAppUpdateFile.DeleteFile();
appUpdateFile.DeleteFile(); appUpdateFile.DeleteFile();
@ -279,30 +292,27 @@ internal static class SteamCMD
return null; return null;
} }
internal static async Task<HashSet<string>> ParseDlcAppIds(VProperty appInfo) internal static async Task<HashSet<string>> ParseDlcAppIds(CmdAppData appData)
=> await Task.Run(() => => await Task.Run(() =>
{ {
HashSet<string> dlcIds = []; HashSet<string> dlcIds = [];
if (Program.Canceled || appInfo is null) if (Program.Canceled || appData is null)
return dlcIds; return dlcIds;
VToken extended = appInfo.Value.GetChild("extended");
if (extended is not null)
foreach (VToken vToken in extended.Where(p => p is VProperty { Key: "listofdlc" }))
{
VProperty property = (VProperty)vToken;
foreach (string id in property.Value.ToString().Split(","))
if (int.TryParse(id, out int appId) && appId > 0)
_ = dlcIds.Add("" + appId);
}
VToken depots = appInfo.Value.GetChild("depots"); CmdAppExtended extended = appData.Extended;
if (extended?.Dlc != null)
foreach (string id in extended.Dlc.Split(","))
if (int.TryParse(id, out int appId) && appId > 0)
_ = dlcIds.Add("" + appId);
Dictionary<string, dynamic> depots = appData.Depots;
if (depots is null) if (depots is null)
return dlcIds; return dlcIds;
foreach (VToken vToken in depots.Where(
p => p is VProperty property && int.TryParse(property.Key, out int _))) foreach ((_, dynamic depot) in depots.Where(p => int.TryParse(p.Key, out _)))
{ {
VProperty property = (VProperty)vToken; string dlcAppId = depot.dlcappid;
if (int.TryParse(property.Value.GetChild("dlcappid")?.ToString(), out int appId) && appId > 0) if (dlcAppId is not null && int.TryParse(dlcAppId, out int appId) && appId > 0)
_ = dlcIds.Add("" + appId); _ = dlcIds.Add("" + appId);
} }

View file

@ -18,20 +18,20 @@ internal static class SteamStore
private const int CooldownGame = 600; private const int CooldownGame = 600;
private const int CooldownDlc = 1200; private const int CooldownDlc = 1200;
internal static async Task<HashSet<string>> ParseDlcAppIds(AppData appData) internal static async Task<HashSet<string>> ParseDlcAppIds(StoreAppData storeAppData)
=> await Task.Run(() => => await Task.Run(() =>
{ {
HashSet<string> dlcIds = new(); HashSet<string> dlcIds = new();
if (appData.DLC is null) if (storeAppData.DLC is null)
return dlcIds; return dlcIds;
foreach (string dlcId in from appId in appData.DLC foreach (string dlcId in from appId in storeAppData.DLC
where appId > 0 where appId > 0
select appId.ToString(CultureInfo.InvariantCulture)) select appId.ToString(CultureInfo.InvariantCulture))
_ = dlcIds.Add(dlcId); _ = dlcIds.Add(dlcId);
return dlcIds; return dlcIds;
}); });
internal static async Task<AppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0) internal static async Task<StoreAppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0)
{ {
while (!Program.Canceled) while (!Program.Canceled)
{ {
@ -50,11 +50,12 @@ internal static class SteamStore
foreach (KeyValuePair<string, JToken> app in apps) foreach (KeyValuePair<string, JToken> app in apps)
try try
{ {
AppDetails appDetails = JsonConvert.DeserializeObject<AppDetails>(app.Value.ToString()); StoreAppDetails storeAppDetails =
if (appDetails is not null) JsonConvert.DeserializeObject<StoreAppDetails>(app.Value.ToString());
if (storeAppDetails is not null)
{ {
AppData data = appDetails.Data; StoreAppData data = storeAppDetails.Data;
if (!appDetails.Success) if (!storeAppDetails.Success)
{ {
#if DEBUG #if DEBUG
DebugForm.Current.Log( DebugForm.Current.Log(
@ -123,21 +124,19 @@ internal static class SteamStore
+ ": Response deserialization null"); + ": Response deserialization null");
#endif #endif
} }
else
{
#if DEBUG #if DEBUG
else
DebugForm.Current.Log( DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") + "Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") +
": Response null", ": Response null",
LogTextBox.Warning); LogTextBox.Warning);
#endif #endif
}
} }
if (cachedExists) if (cachedExists)
try try
{ {
return JsonConvert.DeserializeObject<AppData>(cacheFile.ReadFile()); return JsonConvert.DeserializeObject<StoreAppData>(cacheFile.ReadFile());
} }
catch catch
{ {

View file

@ -3,14 +3,14 @@ using Newtonsoft.Json;
namespace CreamInstaller.Platforms.Steam; namespace CreamInstaller.Platforms.Steam;
public class AppFullGame public class StoreAppFullGame
{ {
[JsonProperty(PropertyName = "appid")] public string AppId { get; set; } [JsonProperty(PropertyName = "appid")] public string AppId { get; set; }
[JsonProperty(PropertyName = "name")] public string Name { get; set; } [JsonProperty(PropertyName = "name")] public string Name { get; set; }
} }
public class AppData public class StoreAppData
{ {
[JsonProperty(PropertyName = "type")] public string Type { get; set; } [JsonProperty(PropertyName = "type")] public string Type { get; set; }
@ -20,7 +20,7 @@ public class AppData
public int SteamAppId { get; set; } public int SteamAppId { get; set; }
[JsonProperty(PropertyName = "fullgame")] [JsonProperty(PropertyName = "fullgame")]
public AppFullGame FullGame { get; set; } public StoreAppFullGame FullGame { get; set; }
[JsonProperty(PropertyName = "dlc")] public List<int> DLC { get; set; } [JsonProperty(PropertyName = "dlc")] public List<int> DLC { get; set; }
@ -40,10 +40,10 @@ public class AppData
public List<int> Packages { get; set; } public List<int> Packages { get; set; }
} }
public class AppDetails public class StoreAppDetails
{ {
[JsonProperty(PropertyName = "success")] [JsonProperty(PropertyName = "success")]
public bool Success { get; set; } public bool Success { get; set; }
[JsonProperty(PropertyName = "data")] public AppData Data { get; set; } [JsonProperty(PropertyName = "data")] public StoreAppData Data { get; set; }
} }

View file

@ -111,7 +111,7 @@ internal static class ProgramData
// ignored // ignored
} }
return Enumerable.Empty<(Platform platform, string id)>(); return [];
} }
internal static void WriteProgramChoices(IEnumerable<(Platform platform, string id)> choices) internal static void WriteProgramChoices(IEnumerable<(Platform platform, string id)> choices)
@ -144,7 +144,7 @@ internal static class ProgramData
// ignored // ignored
} }
return Enumerable.Empty<(Platform platform, string gameId, string dlcId)>(); return [];
} }
internal static void WriteDlcChoices(List<(Platform platform, string gameId, string dlcId)> choices) internal static void WriteDlcChoices(List<(Platform platform, string gameId, string dlcId)> choices)
@ -177,7 +177,7 @@ internal static class ProgramData
// ignored // ignored
} }
return Enumerable.Empty<(Platform platform, string id, string proxy, bool enabled)>(); return [];
} }
internal static void WriteProxyChoices( internal static void WriteProxyChoices(

View file

@ -75,7 +75,7 @@ internal static class SafeIO
Form form = null) Form form = null)
{ {
if (!directoryPath.DirectoryExists()) if (!directoryPath.DirectoryExists())
return Enumerable.Empty<string>(); return [];
while (!Program.Canceled) while (!Program.Canceled)
try try
{ {
@ -92,7 +92,7 @@ internal static class SafeIO
break; break;
} }
return Enumerable.Empty<string>(); return [];
} }
internal static IEnumerable<string> EnumerateSubdirectories(this string directoryPath, string directoryPattern, internal static IEnumerable<string> EnumerateSubdirectories(this string directoryPath, string directoryPattern,
@ -100,7 +100,7 @@ internal static class SafeIO
bool crucial = false, Form form = null) bool crucial = false, Form form = null)
{ {
if (!directoryPath.DirectoryExists()) if (!directoryPath.DirectoryExists())
return Enumerable.Empty<string>(); return [];
while (!Program.Canceled) while (!Program.Canceled)
try try
{ {
@ -117,7 +117,7 @@ internal static class SafeIO
break; break;
} }
return Enumerable.Empty<string>(); return [];
} }
internal static bool FileExists(this string filePath) => File.Exists(filePath); internal static bool FileExists(this string filePath) => File.Exists(filePath);