CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class AccountService
|
||||
{
|
||||
const string ProfilePrefix = "cvrm-";
|
||||
|
||||
public record Found(string Name, string Username, string UserId, string AccessKey);
|
||||
|
||||
public static string ProfileName(GameAccount a) => ProfilePrefix + a.Id;
|
||||
|
||||
static string ProfilePath(string gameDir, GameAccount a) => Path.Combine(gameDir, "ChilloutVR_Data", $"autologin-{ProfileName(a)}.profile");
|
||||
|
||||
public static string WriteProfile(string gameDir, GameAccount a)
|
||||
{
|
||||
var key = TokenVault.Unprotect(a.AccessKey);
|
||||
if (key.Length == 0) throw new InvalidOperationException($"The access key for {a.Name} could not be read; add the account again.");
|
||||
var doc = new XElement("LoginProfile",
|
||||
new XElement("UserId", a.UserId),
|
||||
new XElement("Username", a.Username),
|
||||
new XElement("AccessKey", key));
|
||||
File.WriteAllText(ProfilePath(gameDir, a), doc.ToString());
|
||||
return ProfileName(a);
|
||||
}
|
||||
|
||||
public static IEnumerable<Found> Scan(IEnumerable<string> gameDirs)
|
||||
{
|
||||
foreach (var dir in gameDirs)
|
||||
{
|
||||
var data = Path.Combine(dir, "ChilloutVR_Data");
|
||||
if (!Directory.Exists(data)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(data, "autologin*.profile"))
|
||||
{
|
||||
if (Path.GetFileName(file).StartsWith("autologin-" + ProfilePrefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (Read(file) is { } found) yield return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Found? Read(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = XDocument.Load(path).Root;
|
||||
if (root == null || root.Name != "LoginProfile") return null;
|
||||
string Value(string name) => root.Element(name)?.Value.Trim() ?? "";
|
||||
var username = Value("Username");
|
||||
var key = Value("AccessKey");
|
||||
if (username.Length == 0 || key.Length == 0) return null;
|
||||
var stem = Path.GetFileNameWithoutExtension(path)["autologin".Length..].TrimStart('_', '-');
|
||||
return new Found(stem.Length > 0 ? stem : username, username, Value("UserId"), key);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException or XmlException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class AppManifest
|
||||
{
|
||||
public static string LocalBuildId(InstallInfo i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Paths.Manifest(i);
|
||||
if (!File.Exists(path)) return "";
|
||||
return Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Str("buildid") ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public static string LocalBranch(InstallInfo i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Paths.Manifest(i);
|
||||
if (!File.Exists(path)) return "";
|
||||
var key = Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Dict("UserConfig")?.Str("BetaKey") ?? "";
|
||||
return key.Length == 0 ? "public" : key;
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public static void Write(string gameDir, string buildId, string branch)
|
||||
{
|
||||
var dir = Path.Combine(gameDir, "steamapps");
|
||||
Directory.CreateDirectory(dir);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("\"AppState\"");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine($"\t\"appid\"\t\t\"{Paths.AppId}\"");
|
||||
sb.AppendLine("\t\"Universe\"\t\t\"1\"");
|
||||
sb.AppendLine("\t\"name\"\t\t\"ChilloutVR\"");
|
||||
sb.AppendLine("\t\"StateFlags\"\t\t\"4\"");
|
||||
sb.AppendLine($"\t\"installdir\"\t\t\"{Path.GetFileName(gameDir)}\"");
|
||||
sb.AppendLine($"\t\"buildid\"\t\t\"{buildId}\"");
|
||||
sb.AppendLine("\t\"UserConfig\"");
|
||||
sb.AppendLine("\t{");
|
||||
sb.AppendLine("\t\t\"language\"\t\t\"english\"");
|
||||
sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\"");
|
||||
sb.AppendLine("\t}");
|
||||
sb.AppendLine("\t\"MountedConfig\"");
|
||||
sb.AppendLine("\t{");
|
||||
sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\"");
|
||||
sb.AppendLine("\t}");
|
||||
sb.AppendLine("}");
|
||||
var file = Path.Combine(dir, $"appmanifest_{Paths.AppId}.acf");
|
||||
File.WriteAllText(file + ".tmp", sb.ToString());
|
||||
File.Move(file + ".tmp", file, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record AssemblyDetails(string FileName, string AssemblyName, string Version, string MelonName, string MelonVersion, string MelonAuthor, string MelonLink, string Sha256);
|
||||
|
||||
public static class AssemblyReader
|
||||
{
|
||||
public static AssemblyDetails Read(string path)
|
||||
{
|
||||
var file = Path.GetFileName(path);
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var hash = Convert.ToBase64String(SHA256.HashData(bytes));
|
||||
string asmName = Path.GetFileNameWithoutExtension(file), version = "", mName = "", mVer = "", mAuthor = "", mLink = "";
|
||||
try
|
||||
{
|
||||
using var pe = new PEReader(new MemoryStream(bytes));
|
||||
if (pe.HasMetadata)
|
||||
{
|
||||
var md = pe.GetMetadataReader();
|
||||
var def = md.GetAssemblyDefinition();
|
||||
asmName = md.GetString(def.Name);
|
||||
version = def.Version.ToString();
|
||||
foreach (var h in def.GetCustomAttributes())
|
||||
{
|
||||
var attr = md.GetCustomAttribute(h);
|
||||
if (!IsMelonInfo(md, attr.Constructor)) continue;
|
||||
var strings = attr.DecodeValue(new Provider()).FixedArguments
|
||||
.Where(a => a.Type is PrimitiveTypeCode.String && a.Value is string).Select(a => (string)a.Value!).ToList();
|
||||
if (strings.Count > 0) mName = strings[0];
|
||||
if (strings.Count > 1) mVer = strings[1];
|
||||
if (strings.Count > 2) mAuthor = strings[2];
|
||||
if (strings.Count > 3) mLink = strings[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is BadImageFormatException or InvalidOperationException or ArgumentException) { }
|
||||
var fv = System.Diagnostics.FileVersionInfo.GetVersionInfo(path).ProductVersion?.Split('+', '-')[0];
|
||||
return new AssemblyDetails(file, asmName, string.IsNullOrEmpty(fv) ? version : fv, mName, mVer, mAuthor, mLink, hash);
|
||||
}
|
||||
|
||||
static bool IsMelonInfo(MetadataReader md, EntityHandle ctor)
|
||||
{
|
||||
if (ctor.Kind != HandleKind.MemberReference) return false;
|
||||
var member = md.GetMemberReference((MemberReferenceHandle)ctor);
|
||||
if (member.Parent.Kind != HandleKind.TypeReference) return false;
|
||||
var type = md.GetTypeReference((TypeReferenceHandle)member.Parent);
|
||||
return md.GetString(type.Name) == "MelonInfoAttribute";
|
||||
}
|
||||
|
||||
sealed class Provider : ICustomAttributeTypeProvider<object?>
|
||||
{
|
||||
const string SystemType = "System.Type";
|
||||
public object? GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode;
|
||||
public object? GetSystemType() => SystemType;
|
||||
public object? GetSZArrayType(object? elementType) => elementType;
|
||||
public object? GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => new object();
|
||||
public object? GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind)
|
||||
{
|
||||
var t = reader.GetTypeReference(handle);
|
||||
return reader.GetString(t.Name) == "Type" && reader.GetString(t.Namespace) == "System" ? SystemType : new object();
|
||||
}
|
||||
public object? GetTypeFromSerializedName(string name) => name;
|
||||
public PrimitiveTypeCode GetUnderlyingEnumType(object? type) => PrimitiveTypeCode.Int32;
|
||||
public bool IsSystemType(object? type) => type is string s && s == SystemType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record RepoOffer(string Repo, string Asset, List<ReleaseInfo> Releases);
|
||||
|
||||
public class AvailableEntry
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public HashSet<string> Keys { get; } = new();
|
||||
public string Name { get; set; } = "";
|
||||
public CvrmgMod? Cvrmg { get; set; }
|
||||
public List<RepoOffer> Repos { get; } = new();
|
||||
}
|
||||
|
||||
public static class AvailableModsService
|
||||
{
|
||||
public static async Task<List<AvailableEntry>> BuildAsync(
|
||||
CvrmgService cvrmg, ModImportService importer, IReadOnlyList<SourceDef> sources, bool force, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (force) importer.Invalidate();
|
||||
var byKey = new Dictionary<string, AvailableEntry>();
|
||||
var entries = new List<AvailableEntry>();
|
||||
|
||||
if (sources.Any(s => s.Kind == "cvrmg"))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var m in await cvrmg.GetAllAsync(ct, force))
|
||||
{
|
||||
var e = new AvailableEntry { Key = CvrmgService.Normalize(m.Name), Name = m.Name, Cvrmg = m };
|
||||
foreach (var n in new[] { m.Name }.Concat(m.Aliases)) e.Keys.Add(CvrmgService.Normalize(n));
|
||||
e.Keys.RemoveWhere(k => k.Length == 0);
|
||||
if (e.Keys.Count == 0) continue;
|
||||
entries.Add(e);
|
||||
foreach (var k in e.Keys) byKey.TryAdd(k, e);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
log($"CVRMG could not be loaded: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0))
|
||||
{
|
||||
var releases = await importer.ReleasesAsync(src.Repo, log, ct, force);
|
||||
var assets = new Dictionary<string, string>();
|
||||
foreach (var r in releases)
|
||||
foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
|
||||
assets.TryAdd(CvrmgService.Normalize(Path.GetFileNameWithoutExtension(a.Name)), a.Name);
|
||||
|
||||
foreach (var (key, asset) in assets)
|
||||
{
|
||||
if (key.Length == 0) continue;
|
||||
if (!byKey.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = new AvailableEntry { Key = key, Name = Path.GetFileNameWithoutExtension(asset) };
|
||||
entry.Keys.Add(key);
|
||||
byKey[key] = entry;
|
||||
entries.Add(entry);
|
||||
}
|
||||
if (entry.Repos.All(o => !o.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase)))
|
||||
entry.Repos.Add(new RepoOffer(src.Repo, asset, ModImportService.FilterReleases(releases, asset)));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using SteamKit2;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class BranchCodeService
|
||||
{
|
||||
public static async Task<Dictionary<string, byte[]>> ResolveAsync(string code, string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
return await ResolveAsync(link, code);
|
||||
}
|
||||
|
||||
public static async Task<Dictionary<string, byte[]>> ResolveAsync(SteamLink link, string code)
|
||||
{
|
||||
var call = link.Apps.CheckAppBetaPassword((uint)Paths.AppId, code).ToTask();
|
||||
if (await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(25))) != call)
|
||||
throw new TimeoutException("Steam did not answer the code check. It limits how often codes can be checked; try again in a few minutes.");
|
||||
var reply = await call;
|
||||
if (reply.Result == EResult.OK) return new Dictionary<string, byte[]>(reply.BetaPasswords);
|
||||
throw new InvalidOperationException(reply.Result switch
|
||||
{
|
||||
EResult.InvalidPassword => "Steam did not accept that access code.",
|
||||
EResult.RateLimitExceeded => "Steam is rate limiting code checks. Wait a few minutes and try again.",
|
||||
_ => $"Steam could not check the code ({reply.Result}){(link.IsAnonymous ? "; sign in to Steam in Settings and try again" : "")}.",
|
||||
});
|
||||
}
|
||||
|
||||
public static string ProtectKey(byte[] key) => TokenVault.Protect(Convert.ToBase64String(key));
|
||||
|
||||
public static byte[]? UnprotectKey(string stored)
|
||||
{
|
||||
var text = TokenVault.Unprotect(stored);
|
||||
try { return text.Length == 0 ? null : Convert.FromBase64String(text); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class CacheGuardService
|
||||
{
|
||||
const string AssemblyName = "CVRCacheGuard";
|
||||
const string AssemblyFile = AssemblyName + ".dll";
|
||||
const int UserAssemblyType = 16;
|
||||
|
||||
static string Source => Path.Combine(AppContext.BaseDirectory, "CacheGuard");
|
||||
|
||||
static string Managed(string gameDir) => Path.Combine(gameDir, "ChilloutVR_Data", "Managed");
|
||||
|
||||
public static void Deploy(string gameDir, Action<string> log, string? source = null)
|
||||
{
|
||||
var managed = Managed(gameDir);
|
||||
var scripting = Path.Combine(gameDir, "ChilloutVR_Data", "ScriptingAssemblies.json");
|
||||
var init = Path.Combine(gameDir, "ChilloutVR_Data", "RuntimeInitializeOnLoads.json");
|
||||
source ??= Source;
|
||||
if (!Directory.Exists(source) || !Directory.Exists(managed) || !File.Exists(scripting) || !File.Exists(init)) return;
|
||||
|
||||
var changed = false;
|
||||
var deps = Path.Combine(managed, AssemblyName);
|
||||
Directory.CreateDirectory(deps);
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*.dll"))
|
||||
{
|
||||
var name = Path.GetFileName(file);
|
||||
var target = name == AssemblyFile ? Path.Combine(managed, name) : Path.Combine(deps, name);
|
||||
changed |= CopyIfDifferent(file, target);
|
||||
}
|
||||
|
||||
var scriptingJson = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject();
|
||||
var names = scriptingJson["names"]!.AsArray();
|
||||
var types = scriptingJson["types"]!.AsArray();
|
||||
if (!names.Any(n => n?.GetValue<string>() == AssemblyFile))
|
||||
{
|
||||
names.Add(AssemblyFile);
|
||||
types.Add(UserAssemblyType);
|
||||
WriteJson(scripting, scriptingJson);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var initJson = JsonNode.Parse(File.ReadAllText(init))!.AsObject();
|
||||
var root = initJson["root"]!.AsArray();
|
||||
if (!root.Any(e => e?["assemblyName"]?.GetValue<string>() == AssemblyName))
|
||||
{
|
||||
root.Add(new JsonObject
|
||||
{
|
||||
["assemblyName"] = AssemblyName,
|
||||
["nameSpace"] = AssemblyName,
|
||||
["className"] = "Entry",
|
||||
["methodName"] = "Init",
|
||||
["loadTypes"] = 2,
|
||||
["isUnityClass"] = false,
|
||||
});
|
||||
WriteJson(init, initJson);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) log("Multi-instance cache guard added to this install.");
|
||||
}
|
||||
|
||||
public static void Remove(string gameDir, Action<string> log)
|
||||
{
|
||||
var data = Path.Combine(gameDir, "ChilloutVR_Data");
|
||||
var scripting = Path.Combine(data, "ScriptingAssemblies.json");
|
||||
var init = Path.Combine(data, "RuntimeInitializeOnLoads.json");
|
||||
var changed = false;
|
||||
|
||||
if (File.Exists(scripting))
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject();
|
||||
var names = json["names"]!.AsArray();
|
||||
var types = json["types"]!.AsArray();
|
||||
for (var i = names.Count - 1; i >= 0; i--)
|
||||
if (names[i]?.GetValue<string>() == AssemblyFile)
|
||||
{
|
||||
names.RemoveAt(i);
|
||||
if (i < types.Count) types.RemoveAt(i);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) WriteJson(scripting, json);
|
||||
}
|
||||
if (File.Exists(init))
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(init))!.AsObject();
|
||||
var root = json["root"]!.AsArray();
|
||||
var before = root.Count;
|
||||
for (var i = root.Count - 1; i >= 0; i--)
|
||||
if (root[i]?["assemblyName"]?.GetValue<string>() == AssemblyName) root.RemoveAt(i);
|
||||
if (root.Count != before)
|
||||
{
|
||||
WriteJson(init, json);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var managed = Managed(gameDir);
|
||||
try
|
||||
{
|
||||
File.Delete(Path.Combine(managed, AssemblyFile));
|
||||
var deps = Path.Combine(managed, AssemblyName);
|
||||
if (Directory.Exists(deps)) Directory.Delete(deps, true);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
if (changed) log("Multi-instance cache guard removed from this install.");
|
||||
}
|
||||
|
||||
static bool CopyIfDifferent(string source, string target)
|
||||
{
|
||||
var s = new FileInfo(source);
|
||||
var t = new FileInfo(target);
|
||||
if (t.Exists && t.Length == s.Length && t.LastWriteTimeUtc == s.LastWriteTimeUtc) return false;
|
||||
try
|
||||
{
|
||||
File.Copy(source, target, true);
|
||||
File.SetLastWriteTimeUtc(target, s.LastWriteTimeUtc);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (t.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteJson(string path, JsonNode node) =>
|
||||
File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), new System.Text.UTF8Encoding(false));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public enum CacheState { Missing, Shared, Local, LinkedElsewhere }
|
||||
|
||||
public static class CacheService
|
||||
{
|
||||
public static CacheState GetState(InstallInfo install)
|
||||
{
|
||||
var path = Paths.CacheDir(install);
|
||||
var info = new DirectoryInfo(path);
|
||||
if (!info.Exists) return CacheState.Missing;
|
||||
if (!info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return CacheState.Local;
|
||||
var target = info.LinkTarget;
|
||||
return target != null && SamePath(target, Paths.SharedCache) ? CacheState.Shared : CacheState.LinkedElsewhere;
|
||||
}
|
||||
|
||||
static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static Task LinkAsync(InstallInfo install, Action<string> log, CancellationToken ct) => Task.Run(() =>
|
||||
{
|
||||
var path = Paths.CacheDir(install);
|
||||
Directory.CreateDirectory(Paths.SharedCache);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
switch (GetState(install))
|
||||
{
|
||||
case CacheState.Shared:
|
||||
return;
|
||||
case CacheState.Local:
|
||||
MergeInto(path, Paths.SharedCache, log, ct);
|
||||
if (Directory.EnumerateFileSystemEntries(path).Any())
|
||||
throw new IOException($"Cache folder still has content after merge: {path}");
|
||||
Directory.Delete(path);
|
||||
break;
|
||||
case CacheState.LinkedElsewhere:
|
||||
Directory.Delete(path, false);
|
||||
break;
|
||||
}
|
||||
CreateJunction(path, Paths.SharedCache);
|
||||
log("Cache now points at the shared cache.");
|
||||
}, ct);
|
||||
|
||||
public static void MergeInto(string source, string shared, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(shared);
|
||||
var moved = 0;
|
||||
var dupes = 0;
|
||||
var sameRoot = string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(shared)), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(source).ToList())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(entry);
|
||||
var dest = Path.Combine(shared, name);
|
||||
var isDir = Directory.Exists(entry);
|
||||
|
||||
if (!(isDir ? Directory.Exists(dest) : File.Exists(dest)))
|
||||
{
|
||||
if (isDir)
|
||||
{
|
||||
if (sameRoot) Directory.Move(entry, dest);
|
||||
else
|
||||
{
|
||||
CopyTree(entry, dest, ct);
|
||||
Directory.Delete(entry, true);
|
||||
}
|
||||
}
|
||||
else File.Move(entry, dest);
|
||||
moved++;
|
||||
}
|
||||
else if (isDir)
|
||||
{
|
||||
if (MergeTree(entry, dest, ct)) Directory.Delete(entry, true);
|
||||
dupes++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (new FileInfo(entry).Length == new FileInfo(dest).Length) File.Delete(entry);
|
||||
dupes++;
|
||||
}
|
||||
}
|
||||
log($"Cache merge: {moved} entries moved, {dupes} duplicates removed.");
|
||||
}
|
||||
|
||||
public static void CopyInto(string source, string shared, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(shared);
|
||||
var copied = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var target = Path.Combine(shared, Path.GetRelativePath(source, file));
|
||||
if (File.Exists(target)) continue;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target);
|
||||
copied++;
|
||||
}
|
||||
log($"Cache copy: {copied} files added to the shared cache.");
|
||||
}
|
||||
|
||||
static bool MergeTree(string src, string dst, CancellationToken ct)
|
||||
{
|
||||
var clean = true;
|
||||
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories).ToList())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var rel = Path.GetRelativePath(src, file);
|
||||
var target = Path.Combine(dst, rel);
|
||||
if (!File.Exists(target))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Move(file, target);
|
||||
}
|
||||
else if (new FileInfo(file).Length == new FileInfo(target).Length) File.Delete(file);
|
||||
else clean = false;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
static void CopyTree(string src, string dst, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dst);
|
||||
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var target = Path.Combine(dst, Path.GetRelativePath(src, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target, true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CreateJunction(string link, string target)
|
||||
{
|
||||
var psi = new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("/c");
|
||||
psi.ArgumentList.Add("mklink");
|
||||
psi.ArgumentList.Add("/J");
|
||||
psi.ArgumentList.Add(link);
|
||||
psi.ArgumentList.Add(target);
|
||||
using var p = Process.Start(psi)!;
|
||||
var err = p.StandardError.ReadToEnd() + p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0) throw new IOException($"mklink failed: {err.Trim()}");
|
||||
}
|
||||
|
||||
public static void RemoveLinkIfJunction(string path)
|
||||
{
|
||||
var info = new DirectoryInfo(path);
|
||||
if (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint)) Directory.Delete(path, false);
|
||||
}
|
||||
|
||||
public static long SizeOf(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return 0;
|
||||
long total = 0;
|
||||
foreach (var f in new DirectoryInfo(path).EnumerateFiles("*", SearchOption.AllDirectories)) total += f.Length;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class ConfigStore
|
||||
{
|
||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
AppConfig cfg;
|
||||
try
|
||||
{
|
||||
cfg = File.Exists(Paths.ConfigFile)
|
||||
? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(Paths.ConfigFile), Options) ?? new AppConfig()
|
||||
: new AppConfig();
|
||||
}
|
||||
catch
|
||||
{
|
||||
File.Copy(Paths.ConfigFile, Paths.ConfigFile + ".broken", true);
|
||||
cfg = new AppConfig();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cfg.LibraryRoot)) cfg.LibraryRoot = Paths.Root;
|
||||
Paths.Root = cfg.LibraryRoot;
|
||||
|
||||
if (cfg.Mods.All(m => m.Id != ModDef.MelonLoaderId))
|
||||
cfg.Mods.Insert(0, new ModDef
|
||||
{
|
||||
Id = ModDef.MelonLoaderId,
|
||||
Name = "MelonLoader",
|
||||
Repo = "LavaGang/MelonLoader",
|
||||
Type = "Root",
|
||||
AssetFilter = "MelonLoader.x64.zip",
|
||||
});
|
||||
EnsureSources(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
static readonly (string Name, string Repo)[] DefaultRepos =
|
||||
{
|
||||
("NAK CVR Mods", "NotAKidoS/NAK_CVR_Mods"),
|
||||
("Kafe CVR Mods", "kafeijao/Kafe_CVR_Mods"),
|
||||
("SDraw mods", "SDraw/ml_mods_cvr"),
|
||||
("Nirv mods", "Nirv-git/CVRMods-Nirv"),
|
||||
("Daky mods", "dakyneko/DakyModsCVR"),
|
||||
("ddakebono mods", "ddakebono/Chillout-VR-Mods"),
|
||||
};
|
||||
|
||||
static void EnsureSources(AppConfig cfg)
|
||||
{
|
||||
if (cfg.Sources.All(s => s.Id != SourceDef.CvrmgId))
|
||||
cfg.Sources.Insert(0, new SourceDef { Id = SourceDef.CvrmgId, Name = "CVRMG (approved mods)", Kind = "cvrmg", Builtin = true });
|
||||
foreach (var (name, repo) in DefaultRepos)
|
||||
if (cfg.Sources.All(s => !s.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase)) && !cfg.SeededSources.Contains(repo))
|
||||
{
|
||||
cfg.Sources.Add(new SourceDef { Id = repo.Replace('/', '_'), Name = name, Kind = "github", Repo = repo, Builtin = true });
|
||||
cfg.SeededSources.Add(repo);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<BranchInfo> LoadBranches()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(Paths.BranchCache)
|
||||
? JsonSerializer.Deserialize<List<BranchInfo>>(File.ReadAllText(Paths.BranchCache), Options) ?? new()
|
||||
: new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public static void SaveBranches(IEnumerable<BranchInfo> branches)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Paths.BranchCache)!);
|
||||
File.WriteAllText(Paths.BranchCache, JsonSerializer.Serialize(branches, Options));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void Save(AppConfig cfg)
|
||||
{
|
||||
Directory.CreateDirectory(Paths.ConfigDir);
|
||||
var tmp = Paths.ConfigFile + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(cfg, Options));
|
||||
File.Move(tmp, Paths.ConfigFile, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record CvrmgMod(
|
||||
int Id, string Name, string[] Aliases, string Category, string ModVersion, string GameVersion, string LoaderVersion,
|
||||
string Type, string Author, string Description, string DownloadLink, string SourceLink, string Hash, int ApprovalStatus, DateTime Updated)
|
||||
{
|
||||
public string StatusText => ApprovalStatus switch { 1 => "approved", 2 => "outdated", 3 => "obsolete", _ => "unknown" };
|
||||
public string ModType => Type.Equals("Plugin", StringComparison.OrdinalIgnoreCase) ? "Plugins" : "Mods";
|
||||
}
|
||||
|
||||
public class CvrmgService
|
||||
{
|
||||
const string ListUrl = "https://api.cvrmg.com/v1/mods";
|
||||
readonly HttpClient http = new();
|
||||
List<CvrmgMod>? cache;
|
||||
DateTime fetched;
|
||||
|
||||
public CvrmgService()
|
||||
{
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0"));
|
||||
}
|
||||
|
||||
public static string Normalize(string s) => Regex.Replace(s ?? "", "[^a-z0-9]", "", RegexOptions.IgnoreCase).ToLowerInvariant();
|
||||
|
||||
public async Task<List<CvrmgMod>> GetAllAsync(CancellationToken ct, bool force = false)
|
||||
{
|
||||
if (!force && cache != null && DateTime.UtcNow - fetched < TimeSpan.FromMinutes(10)) return cache;
|
||||
var path = Path.Combine(Paths.Root, "sources", "cvrmg.json");
|
||||
string? json = null;
|
||||
if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1))
|
||||
json = await File.ReadAllTextAsync(path, ct);
|
||||
if (json == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await http.GetAsync(ListUrl, ct);
|
||||
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CVRMG returned {(int)resp.StatusCode}.");
|
||||
json = await resp.Content.ReadAsStringAsync(ct);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, json, ct);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException && File.Exists(path))
|
||||
{
|
||||
json = await File.ReadAllTextAsync(path, ct);
|
||||
}
|
||||
}
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var list = new List<CvrmgMod>();
|
||||
foreach (var m in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (!m.TryGetProperty("versions", out var versions) || versions.ValueKind != JsonValueKind.Array) continue;
|
||||
JsonElement? best = null;
|
||||
var bestNo = int.MinValue;
|
||||
foreach (var v in versions.EnumerateArray())
|
||||
{
|
||||
var no = v.TryGetProperty("_version", out var n) && n.ValueKind == JsonValueKind.Number ? n.GetInt32() : 0;
|
||||
if (no >= bestNo) { best = v; bestNo = no; }
|
||||
}
|
||||
if (best is not { } ver) continue;
|
||||
|
||||
string S(JsonElement e, string k) => e.TryGetProperty(k, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? "" : "";
|
||||
var aliases = m.TryGetProperty("aliases", out var al) && al.ValueKind == JsonValueKind.Array
|
||||
? al.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.String).Select(a => a.GetString()!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
DateTime.TryParse(S(ver, "updateDate"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var updated);
|
||||
list.Add(new CvrmgMod(
|
||||
m.GetProperty("_id").GetInt32(), S(ver, "name"), aliases, S(m, "category"),
|
||||
S(ver, "modVersion"), S(ver, "gameVersion"), S(ver, "loaderVersion").TrimStart('v'), S(ver, "modType"),
|
||||
S(ver, "author"), S(ver, "description"), S(ver, "downloadLink"), S(ver, "sourceLink"), S(ver, "hash"),
|
||||
ver.TryGetProperty("approvalStatus", out var st) && st.ValueKind == JsonValueKind.Number ? st.GetInt32() : 0, updated));
|
||||
}
|
||||
cache = list;
|
||||
fetched = DateTime.UtcNow;
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ReleaseInfo ToRelease(CvrmgMod m, string fileName) => new()
|
||||
{
|
||||
Tag = m.ModVersion,
|
||||
Published = m.Updated,
|
||||
GameVersion = m.GameVersion,
|
||||
LoaderVersion = m.LoaderVersion,
|
||||
Assets = { new AssetInfo { Name = fileName, Url = m.DownloadLink, Hash = m.Hash } },
|
||||
};
|
||||
|
||||
public static void ApplyMeta(ModDef def, CvrmgMod m)
|
||||
{
|
||||
def.CvrmgId = m.Id;
|
||||
if (def.Author.Length == 0) def.Author = m.Author;
|
||||
def.Description = m.Description;
|
||||
def.LatestVersion = m.ModVersion;
|
||||
def.Category = m.Category;
|
||||
def.Status = m.StatusText;
|
||||
if (def.SourceLink.Length == 0) def.SourceLink = m.SourceLink;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class FileOps
|
||||
{
|
||||
public static void CopyTree(string src, string dst, Func<string, bool> skip, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dst);
|
||||
var count = 0;
|
||||
foreach (var entry in new DirectoryInfo(src).EnumerateFileSystemInfos())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (skip(entry.FullName)) continue;
|
||||
var target = Path.Combine(dst, entry.Name);
|
||||
if (entry is DirectoryInfo d)
|
||||
{
|
||||
if (d.Attributes.HasFlag(FileAttributes.ReparsePoint)) continue;
|
||||
CopyTree(d.FullName, target, skip, log, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Copy(entry.FullName, target, true);
|
||||
if (++count % 200 == 0) log($"Copying {Path.GetFileName(src)}...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void DeleteDirectorySafe(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
foreach (var d in new DirectoryInfo(path).EnumerateDirectories("*", SearchOption.AllDirectories).ToList())
|
||||
if (d.Exists && d.Attributes.HasFlag(FileAttributes.ReparsePoint)) d.Delete(false);
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
|
||||
public static string Slug(string name)
|
||||
{
|
||||
var s = new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()).Trim('-');
|
||||
return s.Length == 0 ? "install" : s;
|
||||
}
|
||||
|
||||
public static string FormatSize(long bytes) =>
|
||||
bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):0.0} GB" : $"{bytes / (double)(1L << 20):0} MB";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class GameWindow
|
||||
{
|
||||
const uint WM_SETICON = 0x0080;
|
||||
const int ICON_SMALL = 0;
|
||||
const int ICON_BIG = 1;
|
||||
const int SM_CXICON = 11;
|
||||
const int SM_CXSMICON = 49;
|
||||
|
||||
delegate bool EnumProc(IntPtr hwnd, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool EnumWindows(EnumProc proc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool IsWindowVisible(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern int GetClassName(IntPtr hwnd, StringBuilder name, int max);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam, uint flags, uint timeout, out IntPtr result);
|
||||
|
||||
const uint SMTO_ABORTIFHUNG = 2;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern int GetSystemMetrics(int index);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern uint PrivateExtractIcons(string file, int index, int cx, int cy, IntPtr[] icons, uint[] ids, uint count, uint flags);
|
||||
|
||||
static IntPtr FindUnityWindow(int pid)
|
||||
{
|
||||
var found = IntPtr.Zero;
|
||||
var name = new StringBuilder(64);
|
||||
EnumWindows((hwnd, _) =>
|
||||
{
|
||||
GetWindowThreadProcessId(hwnd, out var owner);
|
||||
if (owner != pid || !IsWindowVisible(hwnd)) return true;
|
||||
name.Clear();
|
||||
GetClassName(hwnd, name, name.Capacity);
|
||||
if (name.ToString() == "UnityWndClass") found = hwnd;
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return found;
|
||||
}
|
||||
|
||||
static void SetIcons(IntPtr hwnd, string exe)
|
||||
{
|
||||
foreach (var (kind, size) in new[] { (ICON_SMALL, GetSystemMetrics(SM_CXSMICON)), (ICON_BIG, GetSystemMetrics(SM_CXICON)) })
|
||||
{
|
||||
var icons = new IntPtr[1];
|
||||
var ids = new uint[1];
|
||||
if (PrivateExtractIcons(exe, 0, size, size, icons, ids, 1, 0) > 0 && icons[0] != IntPtr.Zero)
|
||||
SendMessageTimeout(hwnd, WM_SETICON, (IntPtr)kind, icons[0], SMTO_ABORTIFHUNG, 1000, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task ApplyIconWhenReadyAsync(Process proc, string exe)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
try
|
||||
{
|
||||
var hwnd = IntPtr.Zero;
|
||||
for (var i = 0; i < 240 && hwnd == IntPtr.Zero; i++)
|
||||
{
|
||||
if (proc.HasExited) return;
|
||||
hwnd = FindUnityWindow(proc.Id);
|
||||
if (hwnd == IntPtr.Zero) await Task.Delay(250);
|
||||
}
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
|
||||
foreach (var wait in new[] { 0, 3000, 7000, 20000 })
|
||||
{
|
||||
await Task.Delay(wait);
|
||||
if (proc.HasExited) return;
|
||||
var current = FindUnityWindow(proc.Id);
|
||||
if (current != IntPtr.Zero) SetIcons(current, exe);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public class GitHubService
|
||||
{
|
||||
readonly HttpClient http = new();
|
||||
readonly Func<string> token;
|
||||
|
||||
public GitHubService(Func<string> token)
|
||||
{
|
||||
this.token = token;
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0"));
|
||||
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
}
|
||||
|
||||
public static string? ParseRepo(string input)
|
||||
{
|
||||
input = input.Trim();
|
||||
var m = Regex.Match(input, @"^(?:https?://github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?(?:/.*)?$");
|
||||
return m.Success ? $"{m.Groups[1].Value}/{m.Groups[2].Value}" : null;
|
||||
}
|
||||
|
||||
HttpRequestMessage Request(string url)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var t = token();
|
||||
if (!string.IsNullOrWhiteSpace(t)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t);
|
||||
return req;
|
||||
}
|
||||
|
||||
public async Task<List<ReleaseInfo>> GetReleasesAsync(string repo, CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.SendAsync(Request($"https://api.github.com/repos/{repo}/releases?per_page=40"), ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var hint = (int)resp.StatusCode is 403 or 429 ? " (rate limited: add a GitHub token in Settings)" : "";
|
||||
throw new InvalidOperationException($"GitHub returned {(int)resp.StatusCode} for {repo}{hint}.");
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var list = new List<ReleaseInfo>();
|
||||
foreach (var r in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (r.GetProperty("draft").GetBoolean()) continue;
|
||||
var rel = new ReleaseInfo
|
||||
{
|
||||
Tag = r.GetProperty("tag_name").GetString() ?? "",
|
||||
Prerelease = r.GetProperty("prerelease").GetBoolean(),
|
||||
Published = r.TryGetProperty("published_at", out var p) && p.ValueKind == JsonValueKind.String ? p.GetDateTime() : default,
|
||||
};
|
||||
foreach (var a in r.GetProperty("assets").EnumerateArray())
|
||||
rel.Assets.Add(new AssetInfo
|
||||
{
|
||||
Name = a.GetProperty("name").GetString() ?? "",
|
||||
Url = a.GetProperty("browser_download_url").GetString() ?? "",
|
||||
Size = a.GetProperty("size").GetInt64(),
|
||||
});
|
||||
list.Add(rel);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<ReleaseInfo?> GetMelonLoaderNightlyAsync(CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.SendAsync(Request(
|
||||
"https://api.github.com/repos/LavaGang/MelonLoader/actions/workflows/build.yml/runs?branch=master&status=success&per_page=1"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var runs = doc.RootElement.GetProperty("workflow_runs");
|
||||
if (runs.GetArrayLength() == 0) return null;
|
||||
var run = runs[0];
|
||||
var sha = run.GetProperty("head_sha").GetString() ?? "";
|
||||
var id = run.GetProperty("id").GetInt64();
|
||||
var rel = new ReleaseInfo
|
||||
{
|
||||
Tag = $"nightly-{sha[..Math.Min(7, sha.Length)]}",
|
||||
Prerelease = true,
|
||||
Published = run.GetProperty("created_at").GetDateTime(),
|
||||
};
|
||||
rel.Assets.Add(new AssetInfo
|
||||
{
|
||||
Name = "MelonLoader.x64.zip",
|
||||
Url = $"https://nightly.link/LavaGang/MelonLoader/actions/runs/{id}/MelonLoader.Windows.x64.CI.Release.zip",
|
||||
});
|
||||
return rel;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(string url, string path, CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(path);
|
||||
await src.CopyToAsync(dst, ct);
|
||||
var last = resp.RequestMessage?.RequestUri?.Segments.LastOrDefault() ?? "";
|
||||
return Uri.UnescapeDataString(last.TrimEnd('/'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class HookService
|
||||
{
|
||||
static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static async Task<List<string>> RunEventAsync(
|
||||
IEnumerable<HookDef> hooks, InstallInfo install, string evt, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
foreach (var hook in hooks.Where(h => install.HookIds.Contains(h.Id) && h.Runs(evt)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await RunAsync(hook, install, buildId, log, ct)) failures.Add(hook.Name);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception e)
|
||||
{
|
||||
log($"[{hook.Name}] failed: {e.Message}");
|
||||
failures.Add(hook.Name);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
public static async Task<bool> RunAsync(HookDef hook, InstallInfo install, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var game = Paths.GameDir(install);
|
||||
if (!Directory.Exists(Path.Combine(game, "ChilloutVR_Data")))
|
||||
{
|
||||
log($"[{hook.Name}] skipped: the game files are not installed yet.");
|
||||
return true;
|
||||
}
|
||||
log($"[{hook.Name}] running for {install.Name}...");
|
||||
return await RunCommandAsync(hook, install, game, buildId, log, ct);
|
||||
}
|
||||
|
||||
static async Task<bool> RunCommandAsync(HookDef hook, InstallInfo install, string game, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hook.Command)) throw new InvalidOperationException("The hook has no command set.");
|
||||
string Expand(string s) => s
|
||||
.Replace("{game}", game, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{install}", install.Name, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{branch}", install.Branch, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{build}", buildId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var command = Expand(hook.Command.Trim());
|
||||
var args = SplitArgs(Expand(hook.Arguments)).ToList();
|
||||
var ext = Path.GetExtension(command).ToLowerInvariant();
|
||||
ProcessStartInfo psi;
|
||||
if (ext == ".ps1")
|
||||
{
|
||||
psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"));
|
||||
foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command }) psi.ArgumentList.Add(a);
|
||||
}
|
||||
else if (ext is ".bat" or ".cmd")
|
||||
{
|
||||
psi = new ProcessStartInfo("cmd.exe");
|
||||
psi.ArgumentList.Add("/c");
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
else psi = new ProcessStartInfo(command);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
psi.Environment["CVR_GAME_DIR"] = game;
|
||||
psi.Environment["CVR_INSTALL_NAME"] = install.Name;
|
||||
psi.Environment["CVR_BRANCH"] = install.Branch;
|
||||
psi.Environment["CVR_BUILD"] = buildId;
|
||||
|
||||
var code = await RunProcessAsync(psi, game, hook.Name, log, ct);
|
||||
if (code != 0) log($"[{hook.Name}] exited with code {code}");
|
||||
return code == 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunProcessAsync(ProcessStartInfo psi, string workDir, string label, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
psi.WorkingDirectory = workDir;
|
||||
psi.UseShellExecute = false;
|
||||
psi.CreateNoWindow = true;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
psi.RedirectStandardInput = true;
|
||||
psi.StandardOutputEncoding = Encoding.UTF8;
|
||||
psi.StandardErrorEncoding = Encoding.UTF8;
|
||||
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); };
|
||||
proc.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); };
|
||||
proc.Start();
|
||||
proc.StandardInput.Close();
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Timeout);
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
throw new TimeoutException($"timed out after {Timeout.TotalMinutes:0} minutes");
|
||||
}
|
||||
proc.WaitForExit();
|
||||
return proc.ExitCode;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> SplitArgs(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var quoted = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (c == '"') quoted = !quoted;
|
||||
else if (char.IsWhiteSpace(c) && !quoted)
|
||||
{
|
||||
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
|
||||
}
|
||||
else sb.Append(c);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class InstallFolders
|
||||
{
|
||||
public static string UniqueFolder(IEnumerable<InstallInfo> all, InstallInfo self, string name)
|
||||
{
|
||||
var stem = $"{Paths.SafeFolderName(name)}-{self.Id[..4]}";
|
||||
var candidate = stem;
|
||||
for (var n = 2; Taken(all, self, candidate); n++) candidate = $"{stem}-{n}";
|
||||
return candidate;
|
||||
}
|
||||
|
||||
static bool Taken(IEnumerable<InstallInfo> all, InstallInfo self, string candidate)
|
||||
{
|
||||
if (all.Any(i => !ReferenceEquals(i, self) && i.Folder.Equals(candidate, StringComparison.OrdinalIgnoreCase))) return true;
|
||||
if (candidate.Equals(self.Folder, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return Directory.Exists(Path.Combine(Paths.InstallsRoot, candidate));
|
||||
}
|
||||
|
||||
public static void Rename(InstallInfo info, string desiredFolder)
|
||||
{
|
||||
var oldDir = Paths.InstallDir(info);
|
||||
var newDir = Path.Combine(Paths.InstallsRoot, desiredFolder);
|
||||
if (oldDir.Equals(newDir, StringComparison.Ordinal)) return;
|
||||
if (oldDir.Equals(newDir, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var temp = oldDir + ".renaming";
|
||||
Directory.Move(oldDir, temp);
|
||||
Directory.Move(temp, newDir);
|
||||
}
|
||||
else Directory.Move(oldDir, newDir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class MlLogColours
|
||||
{
|
||||
const string Esc = "\u001b[";
|
||||
const string Reset = "\u001b[0m";
|
||||
const long MaxScan = 16L << 20;
|
||||
|
||||
static readonly Regex Sgr = new(@"\u001b\[([0-9;]*)m", RegexOptions.Compiled);
|
||||
static readonly Regex AnyEsc = new(@"\u001b\[[0-9;?]*[A-Za-z]", RegexOptions.Compiled);
|
||||
static readonly Regex Stamp = new(@"^\[(\d\d:\d\d:\d\d\.\d{3})\] (.*)$", RegexOptions.Compiled);
|
||||
|
||||
sealed class FileIndex
|
||||
{
|
||||
public long Offset;
|
||||
public string? State;
|
||||
public readonly Dictionary<string, string> Lines = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, FileIndex> Files = new(StringComparer.OrdinalIgnoreCase);
|
||||
static readonly object Gate = new();
|
||||
static long version;
|
||||
|
||||
public static long Refresh(params string[] paths)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (var path in paths) Scan(path);
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
static void Scan(string path)
|
||||
{
|
||||
if (!Files.TryGetValue(path, out var index)) Files[path] = index = new FileIndex();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
if (index.Offset != 0 || index.Lines.Count > 0) { Files[path] = new FileIndex(); version++; }
|
||||
return;
|
||||
}
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
var length = fs.Length;
|
||||
if (length < index.Offset) { Files[path] = index = new FileIndex(); version++; }
|
||||
if (length == index.Offset) return;
|
||||
var start = Math.Max(index.Offset, length - MaxScan);
|
||||
var buffer = new byte[length - start];
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
fs.ReadExactly(buffer);
|
||||
var end = Array.LastIndexOf(buffer, (byte)'\n');
|
||||
if (end < 0) return;
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, end + 1);
|
||||
if (start > index.Offset) index.State = null;
|
||||
index.Offset = start + end + 1;
|
||||
foreach (var raw in text.Split('\n')) Add(index, raw.TrimEnd('\r'));
|
||||
version++;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
static void Add(FileIndex index, string line)
|
||||
{
|
||||
var hasEsc = line.IndexOf('\u001b') >= 0;
|
||||
if (!hasEsc && index.State == null) return;
|
||||
var styled = index.State == null ? line : Esc + index.State + "m" + line;
|
||||
foreach (Match m in Sgr.Matches(line))
|
||||
{
|
||||
var codes = m.Groups[1].Value;
|
||||
index.State = codes is "" or "0" or "39" ? null : codes;
|
||||
}
|
||||
if (index.State != null) styled += Reset;
|
||||
var plain = AnyEsc.Replace(line, "");
|
||||
if (plain.Trim().Length > 0) index.Lines[plain] = styled;
|
||||
}
|
||||
|
||||
public static string Colour(string mlText, params string[] paths)
|
||||
{
|
||||
var sb = new StringBuilder(mlText.Length * 2);
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (var raw in mlText.Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
string? styled = null;
|
||||
for (var i = paths.Length - 1; i >= 0 && styled == null; i--)
|
||||
if (Files.TryGetValue(paths[i], out var index)) index.Lines.TryGetValue(line, out styled);
|
||||
if (styled == null && Stamp.Match(line) is { Success: true } m)
|
||||
styled = $"{Esc}38;2;211;211;211m[{Reset}{Esc}38;2;50;205;50m{m.Groups[1].Value}{Reset}{Esc}38;2;211;211;211m] {m.Groups[2].Value}{Reset}";
|
||||
sb.Append(styled ?? line).Append('\n');
|
||||
}
|
||||
}
|
||||
if (sb.Length > 0 && !mlText.EndsWith('\n')) sb.Length--;
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record Identified(AssemblyDetails File, string Folder, string Source, string Repo, string AssetName, CvrmgMod? Cvrmg, string Why);
|
||||
|
||||
public class ModImportService
|
||||
{
|
||||
static readonly string[] Folders = { "Mods", "Plugins", "UserLibs" };
|
||||
|
||||
readonly GitHubService github;
|
||||
readonly CvrmgService cvrmg;
|
||||
readonly Dictionary<string, List<ReleaseInfo>> repoReleases = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ModImportService(GitHubService github, CvrmgService cvrmg)
|
||||
{
|
||||
this.github = github;
|
||||
this.cvrmg = cvrmg;
|
||||
}
|
||||
|
||||
public static string Norm(string s) => CvrmgService.Normalize(s);
|
||||
|
||||
public async Task<List<Identified>> IdentifyAsync(string gameDir, IEnumerable<SourceDef> sources, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var files = new List<(AssemblyDetails Details, string Folder)>();
|
||||
foreach (var folder in Folders)
|
||||
{
|
||||
var dir = Path.Combine(gameDir, folder);
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
|
||||
files.Add((AssemblyReader.Read(f), folder));
|
||||
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||
log($"Skipping folder {folder}\\{Path.GetFileName(sub)} (only loose DLLs are imported).");
|
||||
}
|
||||
log($"Found {files.Count} mod files.");
|
||||
|
||||
List<CvrmgMod> catalog = new();
|
||||
try { catalog = await cvrmg.GetAllAsync(ct); }
|
||||
catch (Exception e) when (e is not OperationCanceledException) { log($"CVRMG unavailable ({e.Message}); matching without it."); }
|
||||
|
||||
var index = new Dictionary<string, List<(string Repo, string Asset)>>();
|
||||
foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0))
|
||||
{
|
||||
var releases = await ReleasesAsync(src.Repo, log, ct);
|
||||
foreach (var r in releases)
|
||||
foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var key = Norm(a.Name);
|
||||
if (!index.TryGetValue(key, out var l)) index[key] = l = new();
|
||||
if (l.All(x => !x.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) l.Add((src.Repo, a.Name));
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<Identified>();
|
||||
foreach (var (d, folder) in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var cm = catalog.FirstOrDefault(m => m.Hash.Length > 0 && m.Hash == d.Sha256);
|
||||
var cmWhy = cm != null ? "same file as the current CVRMG release" : "";
|
||||
if (cm == null)
|
||||
{
|
||||
var keys = new[] { d.MelonName, d.AssemblyName, Path.GetFileNameWithoutExtension(d.FileName) }
|
||||
.Select(Norm).Where(k => k.Length > 0).Distinct().ToList();
|
||||
cm = catalog.Where(m => new[] { m.Name }.Concat(m.Aliases).Select(Norm).Any(keys.Contains))
|
||||
.OrderBy(m => m.ApprovalStatus == 1 ? 0 : 1).ThenByDescending(m => m.Updated).FirstOrDefault();
|
||||
if (cm != null) cmWhy = "name match in CVRMG";
|
||||
}
|
||||
|
||||
string? repo = null, asset = null, why = "";
|
||||
var link = GitHubService.ParseRepo(d.MelonLink.Contains("github.com", StringComparison.OrdinalIgnoreCase) ? d.MelonLink : "");
|
||||
if (link != null)
|
||||
{
|
||||
var rel = await ReleasesAsync(link, log, ct);
|
||||
var hit = rel.SelectMany(r => r.Assets).FirstOrDefault(a => Norm(a.Name) == Norm(d.FileName));
|
||||
if (hit != null) { repo = link; asset = hit.Name; why = "download link in the mod's own metadata"; }
|
||||
}
|
||||
if (repo == null && index.TryGetValue(Norm(d.FileName), out var cands))
|
||||
{
|
||||
var pick = cands.FirstOrDefault(c => d.MelonAuthor.Length > 0 && c.Repo.Split('/')[0].Equals(d.MelonAuthor, StringComparison.OrdinalIgnoreCase));
|
||||
if (pick == default) pick = cands[0];
|
||||
repo = pick.Repo;
|
||||
asset = pick.Asset;
|
||||
why = "release assets of " + repo;
|
||||
}
|
||||
|
||||
if (repo != null) result.Add(new Identified(d, folder, "github", repo, asset!, cm, why));
|
||||
else if (cm != null) result.Add(new Identified(d, folder, "cvrmg", "", "", cm, cmWhy));
|
||||
else result.Add(new Identified(d, folder, "local", "", "", null,
|
||||
d.MelonLink.Length > 0 ? $"no published release contains it; its metadata points at {d.MelonLink}" : "no known source"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate() => repoReleases.Clear();
|
||||
|
||||
public async Task<List<ReleaseInfo>> ReleasesAsync(string repo, Action<string> log, CancellationToken ct, bool force = false)
|
||||
{
|
||||
if (!force && repoReleases.TryGetValue(repo, out var cached)) return cached;
|
||||
var path = Path.Combine(Paths.Root, "sources", "gh_" + Paths.Safe(repo.Replace('/', '_')) + ".json");
|
||||
try
|
||||
{
|
||||
if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1))
|
||||
{
|
||||
var fromDisk = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
|
||||
if (fromDisk != null) return repoReleases[repo] = fromDisk;
|
||||
}
|
||||
var list = await github.GetReleasesAsync(repo, ct);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(list), ct);
|
||||
return repoReleases[repo] = list;
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var stale = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
|
||||
if (stale != null)
|
||||
{
|
||||
log($"{repo}: using cached releases ({e.Message})");
|
||||
return repoReleases[repo] = stale;
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
}
|
||||
log($"{repo}: {e.Message}");
|
||||
return repoReleases[repo] = new();
|
||||
}
|
||||
}
|
||||
|
||||
public static string SnapshotTag(AssemblyDetails d) =>
|
||||
$"installed {d.Version} ({Convert.ToHexString(Convert.FromBase64String(d.Sha256))[..6].ToLowerInvariant()})";
|
||||
|
||||
public int Apply(AppConfig cfg, InstallInfo? install, IEnumerable<Identified> found, Action<string> log, string? gameDir = null)
|
||||
{
|
||||
var game = gameDir ?? Paths.GameDir(install!);
|
||||
var added = 0;
|
||||
foreach (var it in found)
|
||||
{
|
||||
var rel = $"{it.Folder}/{it.File.FileName}";
|
||||
if (install != null && install.Deployed.Values.Any(l => l.Contains(rel, StringComparer.OrdinalIgnoreCase))) continue;
|
||||
|
||||
var def = FindOrCreate(cfg, it);
|
||||
var tag = SnapshotTag(it.File);
|
||||
if (def.Releases.All(r => r.Tag != tag))
|
||||
def.Releases.Add(new ReleaseInfo { Tag = tag, Snapshot = true, Published = File.GetLastWriteTime(Path.Combine(game, it.Folder, it.File.FileName)) });
|
||||
|
||||
var filesDir = Paths.ModFilesDir(def, tag);
|
||||
if (!ModStore.IsDownloaded(def, tag))
|
||||
{
|
||||
Directory.CreateDirectory(filesDir);
|
||||
File.Copy(Path.Combine(game, it.Folder, it.File.FileName), Path.Combine(filesDir, it.File.FileName), true);
|
||||
File.WriteAllText(Path.Combine(Paths.ModVersionDir(def, tag), ".complete"), "");
|
||||
}
|
||||
|
||||
if (install != null)
|
||||
{
|
||||
install.Mods.RemoveAll(a => a.ModId == def.Id);
|
||||
install.Mods.Add(new ModAssignment { ModId = def.Id, Version = tag, Enabled = true });
|
||||
install.Deployed[def.Id] = ModStore.MapFiles(def, tag).Keys.ToList();
|
||||
}
|
||||
added++;
|
||||
log($"{def.Name} {it.File.Version}: {Describe(it)}");
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
static string Describe(Identified it) => it.Source switch
|
||||
{
|
||||
"github" => $"GitHub {it.Repo} ({it.Why})",
|
||||
"cvrmg" => $"CVRMG ({it.Why})",
|
||||
_ => $"kept as a local snapshot ({it.Why})",
|
||||
};
|
||||
|
||||
ModDef FindOrCreate(AppConfig cfg, Identified it)
|
||||
{
|
||||
ModDef? def = it.Source switch
|
||||
{
|
||||
"github" => cfg.Mods.FirstOrDefault(m => m.Source == "github" && m.Repo.Equals(it.Repo, StringComparison.OrdinalIgnoreCase) && Norm(m.AssetFilter) == Norm(it.AssetName)),
|
||||
"cvrmg" => cfg.Mods.FirstOrDefault(m => m.CvrmgId == it.Cvrmg!.Id && m.Source == "cvrmg"),
|
||||
_ => cfg.Mods.FirstOrDefault(m => m.Local && Norm(m.FileName) == Norm(it.File.FileName)),
|
||||
};
|
||||
if (def != null) return def;
|
||||
|
||||
def = new ModDef
|
||||
{
|
||||
Name = it.Cvrmg?.Name is { Length: > 0 } n ? n : it.File.MelonName.Length > 0 ? it.File.MelonName : it.File.AssemblyName,
|
||||
Type = it.Folder,
|
||||
FileName = it.File.FileName,
|
||||
Author = it.File.MelonAuthor,
|
||||
Source = it.Source,
|
||||
Local = it.Source == "local",
|
||||
};
|
||||
if (it.Cvrmg != null) CvrmgService.ApplyMeta(def, it.Cvrmg);
|
||||
if (def.SourceLink.Length == 0 && it.File.MelonLink.Length > 0) def.SourceLink = it.File.MelonLink;
|
||||
switch (it.Source)
|
||||
{
|
||||
case "github":
|
||||
def.Repo = it.Repo;
|
||||
def.AssetFilter = it.AssetName;
|
||||
def.SourceLink = $"https://github.com/{it.Repo}";
|
||||
def.Releases = FilterReleases(repoReleases.GetValueOrDefault(it.Repo) ?? new(), def.AssetFilter);
|
||||
break;
|
||||
case "cvrmg":
|
||||
def.Releases = new() { CvrmgService.ToRelease(it.Cvrmg!, it.File.FileName) };
|
||||
break;
|
||||
}
|
||||
cfg.Mods.Add(def);
|
||||
return def;
|
||||
}
|
||||
|
||||
public static List<ReleaseInfo> FilterReleases(IEnumerable<ReleaseInfo> releases, string assetFilter) =>
|
||||
string.IsNullOrWhiteSpace(assetFilter)
|
||||
? releases.ToList()
|
||||
: releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public class ModStore
|
||||
{
|
||||
static readonly string[] KnownRoots = { "Mods", "Plugins", "UserLibs", "UserData", "MelonLoader" };
|
||||
const string Marker = ".complete";
|
||||
|
||||
readonly GitHubService github;
|
||||
|
||||
public ModStore(GitHubService github) => this.github = github;
|
||||
|
||||
public static string ResolveTag(ModDef mod, string version) =>
|
||||
version == "latest" ? mod.LatestTag ?? "" : version;
|
||||
|
||||
public static bool IsDownloaded(ModDef mod, string tag) =>
|
||||
tag.Length > 0 && File.Exists(Path.Combine(Paths.ModVersionDir(mod, tag), Marker));
|
||||
|
||||
public async Task EnsureDownloadedAsync(ModDef mod, string tag, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (IsDownloaded(mod, tag)) return;
|
||||
if (mod.Local) throw new InvalidOperationException($"Local files for {mod.Name} are missing from the store.");
|
||||
|
||||
var rel = mod.Releases.FirstOrDefault(r => r.Tag == tag)
|
||||
?? throw new InvalidOperationException($"{mod.Name} has no release {tag}; refresh versions.");
|
||||
var assets = PickAssets(mod, rel);
|
||||
if (assets.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"{mod.Name} {tag}: no downloadable .dll or .zip asset matched. Assets: {string.Join(", ", rel.Assets.Select(a => a.Name))}. Set an asset filter on the mod.");
|
||||
|
||||
var dir = Paths.ModVersionDir(mod, tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
var files = Paths.ModFilesDir(mod, tag);
|
||||
Directory.CreateDirectory(files);
|
||||
|
||||
foreach (var a in assets)
|
||||
{
|
||||
log($"Downloading {a.Name} ({a.Size / 1024} KB)...");
|
||||
var tmp = Path.Combine(dir, Paths.Safe(a.Name));
|
||||
var served = await github.DownloadAsync(a.Url, tmp, ct);
|
||||
if (a.Hash.Length > 0)
|
||||
{
|
||||
var actual = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(tmp, ct)));
|
||||
if (actual != a.Hash)
|
||||
{
|
||||
File.Delete(tmp);
|
||||
throw new InvalidOperationException($"{a.Name}: the downloaded file does not match the hash its source published.");
|
||||
}
|
||||
}
|
||||
var isZip = a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || served.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
|
||||
if (isZip)
|
||||
{
|
||||
ZipFile.ExtractToDirectory(tmp, files, true);
|
||||
File.Delete(tmp);
|
||||
}
|
||||
else
|
||||
{
|
||||
var name = mod.Source == "cvrmg" && served.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? served : a.Name;
|
||||
File.Move(tmp, Path.Combine(files, Paths.Safe(name)), true);
|
||||
}
|
||||
}
|
||||
File.WriteAllText(Path.Combine(dir, Marker), "");
|
||||
}
|
||||
|
||||
static List<AssetInfo> PickAssets(ModDef mod, ReleaseInfo rel)
|
||||
{
|
||||
if (mod.Source == "cvrmg") return rel.Assets.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(mod.AssetFilter))
|
||||
{
|
||||
return rel.Assets.Where(a => AssetMatches(a.Name, mod.AssetFilter)).ToList();
|
||||
}
|
||||
var dlls = rel.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (dlls.Count > 0) return dlls;
|
||||
return rel.Assets.Where(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).Take(1).ToList();
|
||||
}
|
||||
|
||||
public static bool AssetMatches(string assetName, string filter)
|
||||
{
|
||||
foreach (var f in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (f.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (CvrmgService.Normalize(assetName) == CvrmgService.Normalize(f)) return true;
|
||||
}
|
||||
else if (assetName.Contains(f, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ImportLocal(ModDef mod, IEnumerable<string> paths)
|
||||
{
|
||||
var dir = Paths.ModVersionDir(mod, "local");
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
var files = Paths.ModFilesDir(mod, "local");
|
||||
Directory.CreateDirectory(files);
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) ZipFile.ExtractToDirectory(p, files, true);
|
||||
else File.Copy(p, Path.Combine(files, Path.GetFileName(p)), true);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(dir, Marker), "");
|
||||
}
|
||||
|
||||
public static void DeleteVersion(ModDef mod, string tag)
|
||||
{
|
||||
var dir = Paths.ModVersionDir(mod, tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> MapFiles(ModDef mod, string tag)
|
||||
{
|
||||
var root = Paths.ModFilesDir(mod, tag);
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!Directory.Exists(root)) return map;
|
||||
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, file).Replace('\\', '/');
|
||||
map[Target(mod, rel)] = file;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static string Target(ModDef mod, string rel)
|
||||
{
|
||||
if (mod.Type == "Root") return rel;
|
||||
var first = rel.Split('/')[0];
|
||||
if (rel.Contains('/') && KnownRoots.Any(k => k.Equals(first, StringComparison.OrdinalIgnoreCase))) return rel;
|
||||
if (rel.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return $"{mod.Type}/{Path.GetFileName(rel)}";
|
||||
return $"{mod.Type}/{rel}";
|
||||
}
|
||||
|
||||
public async Task ApplyAsync(InstallInfo install, IReadOnlyList<ModDef> catalog, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var game = Paths.GameDir(install);
|
||||
if (!Directory.Exists(game)) throw new InvalidOperationException("The install has no game files yet. Update it first.");
|
||||
|
||||
var desired = new Dictionary<string, Dictionary<string, string>>();
|
||||
foreach (var a in install.Mods.Where(a => a.Enabled))
|
||||
{
|
||||
var mod = catalog.FirstOrDefault(m => m.Id == a.ModId);
|
||||
if (mod == null) continue;
|
||||
var tag = ResolveTag(mod, a.Version);
|
||||
if (tag.Length == 0) throw new InvalidOperationException($"{mod.Name} has no versions; refresh versions in the Mods tab.");
|
||||
await EnsureDownloadedAsync(mod, tag, log, ct);
|
||||
desired[mod.Id] = MapFiles(mod, tag);
|
||||
}
|
||||
|
||||
foreach (var modId in install.Deployed.Keys.ToList())
|
||||
{
|
||||
var keep = desired.TryGetValue(modId, out var map) ? map.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet<string>();
|
||||
foreach (var rel in install.Deployed[modId].Where(r => !keep.Contains(r)))
|
||||
DeleteTracked(game, rel);
|
||||
if (!desired.ContainsKey(modId)) install.Deployed.Remove(modId);
|
||||
}
|
||||
|
||||
foreach (var (modId, map) in desired)
|
||||
{
|
||||
foreach (var (rel, src) in map)
|
||||
{
|
||||
var dest = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
File.Copy(src, dest, true);
|
||||
}
|
||||
install.Deployed[modId] = map.Keys.ToList();
|
||||
log($"Deployed {catalog.First(m => m.Id == modId).Name} ({map.Count} files)");
|
||||
}
|
||||
}
|
||||
|
||||
static void DeleteTracked(string game, string rel)
|
||||
{
|
||||
var path = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(dir) && dir.Length > game.Length && Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any())
|
||||
{
|
||||
Directory.Delete(dir);
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class MultiInstance
|
||||
{
|
||||
const int KeepLogs = 7;
|
||||
|
||||
static readonly Regex SingleInstanceLine = new(@"^single-instance[^\r\n]*(\r?\n|$)", RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
public static void Prepare(string gameDir, Action<string> log)
|
||||
{
|
||||
RemoveSingleInstanceFlag(gameDir, log);
|
||||
PruneMelonLoaderLogs(gameDir, log);
|
||||
}
|
||||
|
||||
static void RemoveSingleInstanceFlag(string gameDir, Action<string> log)
|
||||
{
|
||||
var boot = Path.Combine(gameDir, "ChilloutVR_Data", "boot.config");
|
||||
if (!File.Exists(boot)) return;
|
||||
var text = File.ReadAllText(boot);
|
||||
var cleaned = SingleInstanceLine.Replace(text, "");
|
||||
if (cleaned == text) return;
|
||||
File.WriteAllText(boot, cleaned, new UTF8Encoding(false));
|
||||
log("Removed 'single-instance' from boot.config so more than one instance can run.");
|
||||
}
|
||||
|
||||
static void PruneMelonLoaderLogs(string gameDir, Action<string> log)
|
||||
{
|
||||
var logs = Path.Combine(gameDir, "MelonLoader", "Logs");
|
||||
if (!Directory.Exists(logs)) return;
|
||||
var files = new DirectoryInfo(logs).GetFiles("*.log").OrderBy(f => f.LastWriteTimeUtc).ToList();
|
||||
var excess = files.Count - KeepLogs;
|
||||
var removed = 0;
|
||||
foreach (var f in files)
|
||||
{
|
||||
if (removed >= excess) break;
|
||||
try
|
||||
{
|
||||
f.Delete();
|
||||
removed++;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
if (removed > 0) log($"Removed {removed} old MelonLoader log(s) so a second instance does not trip MelonLoader's log cleanup.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class Paths
|
||||
{
|
||||
public const int AppId = 661130;
|
||||
|
||||
public static string ConfigDir => Environment.GetEnvironmentVariable("CVRMANAGER_CONFIG_DIR") is { Length: > 0 } custom
|
||||
? custom
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CVRManager");
|
||||
public static string ConfigFile => Path.Combine(ConfigDir, "config.json");
|
||||
|
||||
public static string Root { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CVRManager");
|
||||
|
||||
public static string SharedCache => Path.Combine(Root, "cache");
|
||||
public static string ModStore => Path.Combine(Root, "mods");
|
||||
public static string BranchCache => Path.Combine(Root, "sources", "branches.json");
|
||||
|
||||
public static string InstallsRoot => Path.Combine(Root, "installs");
|
||||
public static string InstallDir(InstallInfo i) => Path.Combine(InstallsRoot, i.Folder);
|
||||
public static string GameDir(InstallInfo i) => InstallDir(i);
|
||||
|
||||
public static string SafeFolderName(string name)
|
||||
{
|
||||
var bad = Path.GetInvalidFileNameChars();
|
||||
var cleaned = new string(name.Select(c => bad.Contains(c) ? ' ' : c).ToArray());
|
||||
while (cleaned.Contains(" ")) cleaned = cleaned.Replace(" ", " ");
|
||||
cleaned = cleaned.Trim().TrimEnd('.');
|
||||
if (cleaned.Length > 60) cleaned = cleaned[..60].TrimEnd();
|
||||
return cleaned.Length == 0 ? "Install" : cleaned;
|
||||
}
|
||||
public static string GameDataDir(string? gameDir = null)
|
||||
{
|
||||
string company = "ChilloutVR", product = "ChilloutVR";
|
||||
try
|
||||
{
|
||||
var info = gameDir == null ? null : Path.Combine(gameDir, "ChilloutVR_Data", "app.info");
|
||||
if (info != null && File.Exists(info) && File.ReadAllLines(info) is { Length: >= 2 } l && l[0].Trim().Length > 0 && l[1].Trim().Length > 0)
|
||||
(company, product) = (l[0].Trim(), l[1].Trim());
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", company, product);
|
||||
}
|
||||
|
||||
public static string CacheDir(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR_Data", "Cache");
|
||||
public static string GameExe(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR.exe");
|
||||
public static string Manifest(InstallInfo i) => Path.Combine(GameDir(i), "steamapps", $"appmanifest_{AppId}.acf");
|
||||
|
||||
public static string ModVersionDir(ModDef m, string tag) => Path.Combine(ModStore, m.Id, Safe(tag));
|
||||
public static string ModFilesDir(ModDef m, string tag) => Path.Combine(ModVersionDir(m, tag), "files");
|
||||
|
||||
public static string Safe(string s)
|
||||
{
|
||||
var bad = Path.GetInvalidFileNameChars();
|
||||
var r = new string(s.Select(c => bad.Contains(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
|
||||
return r.Length == 0 ? "_" : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// The depot and chunk download flow follows DepotDownloader (https://github.com/SteamRE/DepotDownloader,
|
||||
// commit 989f37b, GPL-2.0, (c) SteamRE). This file is therefore under GPL-2.0.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using CVRManager.Models;
|
||||
using SteamKit2;
|
||||
using SteamKit2.CDN;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class SteamDownloader
|
||||
{
|
||||
const uint AppId = (uint)Paths.AppId;
|
||||
const int MaxDownloads = 8;
|
||||
const string StateFolder = ".CVRManager";
|
||||
|
||||
public sealed record PrivateBranch(string Name, string Code, byte[]? Key);
|
||||
|
||||
const int MaxCodeChecksPerRefresh = 3;
|
||||
|
||||
public static async Task<List<BranchInfo>> GetBranchesAsync(
|
||||
string account, string refreshToken, IReadOnlyList<PrivateBranch> privateBranches,
|
||||
Action<string, byte[]> keyResolved, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
var (root, appToken) = await RequestAppInfoAsync(link);
|
||||
var depots = root["depots"];
|
||||
var branches = depots["branches"];
|
||||
if (branches == KeyValue.Invalid) throw new InvalidOperationException("Steam returned no branch data for ChilloutVR.");
|
||||
var list = branches.Children
|
||||
.Select(b => new BranchInfo(b.Name ?? "", b["buildid"].Value ?? "", b["pwdrequired"].Value == "1", b["description"].Value ?? ""))
|
||||
.ToList();
|
||||
|
||||
var known = list.Select(b => b.Name).ToHashSet();
|
||||
var keys = new Dictionary<string, byte[]>();
|
||||
var checks = 0;
|
||||
var limited = false;
|
||||
foreach (var pb in privateBranches.Where(p => p.Code.Length > 0 && !known.Contains(p.Name)))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var key = pb.Key ?? keys.GetValueOrDefault(pb.Name);
|
||||
if (key == null)
|
||||
{
|
||||
if (limited || checks >= MaxCodeChecksPerRefresh) continue;
|
||||
checks++;
|
||||
try
|
||||
{
|
||||
foreach (var (name, k) in await BranchCodeService.ResolveAsync(link, pb.Code))
|
||||
{
|
||||
keys[name] = k;
|
||||
keyResolved(name, k);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
log($"Could not check the code for '{pb.Name}': {e.Message}");
|
||||
limited = e.Message.Contains("RateLimit");
|
||||
continue;
|
||||
}
|
||||
key = keys.GetValueOrDefault(pb.Name);
|
||||
if (key == null) { log($"The saved code no longer unlocks '{pb.Name}'."); continue; }
|
||||
}
|
||||
|
||||
var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, pb.Name, key);
|
||||
if (priv.Result != EResult.OK) { log($"Steam would not list the files of '{pb.Name}' ({priv.Result})."); continue; }
|
||||
var selection = SelectDepots(depots, priv.DepotSection, pb.Name);
|
||||
if (selection.Count == 0) continue;
|
||||
list.Add(new BranchInfo(pb.Name, PrivateMarker(selection), true, ""));
|
||||
}
|
||||
|
||||
return list.OrderBy(b => b.Name == "public" ? 0 : 1).ThenBy(b => b.Name).ToList();
|
||||
}
|
||||
|
||||
static string PrivateMarker(IEnumerable<(uint DepotId, ulong ManifestId)> depots)
|
||||
{
|
||||
var text = string.Join(";", depots.OrderBy(d => d.DepotId).Select(d => $"{d.DepotId}:{d.ManifestId}"));
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.ASCII.GetBytes(text)))[..12].ToLowerInvariant();
|
||||
}
|
||||
|
||||
static List<(uint DepotId, ulong ManifestId)> SelectDepots(KeyValue depotsSection, KeyValue? privateDepots, string branch)
|
||||
{
|
||||
var result = new List<(uint, ulong)>();
|
||||
foreach (var depot in depotsSection.Children)
|
||||
{
|
||||
if (!uint.TryParse(depot.Name, out var depotId) || depot.Children.Count == 0) continue;
|
||||
var cfg = depot["config"];
|
||||
if (cfg != KeyValue.Invalid)
|
||||
{
|
||||
var os = cfg["oslist"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(os) && !os.Split(',').Contains("windows")) continue;
|
||||
var arch = cfg["osarch"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(arch) && arch != "64") continue;
|
||||
var lang = cfg["language"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(lang) && lang != "english") continue;
|
||||
if (cfg["lowviolence"].AsBoolean()) continue;
|
||||
}
|
||||
|
||||
var gid = (privateDepots?[depot.Name!]["manifests"][branch]["gid"].Value) ?? depot["manifests"][branch]["gid"].Value;
|
||||
if (gid == null && branch != "public") gid = depot["manifests"]["public"]["gid"].Value;
|
||||
if (gid == null) continue;
|
||||
result.Add((depotId, ulong.Parse(gid)));
|
||||
}
|
||||
return result.OrderBy(d => d.Item1).ToList();
|
||||
}
|
||||
|
||||
static async Task<(KeyValue Root, ulong Token)> RequestAppInfoAsync(SteamLink link)
|
||||
{
|
||||
var tokens = await link.Apps.PICSGetAccessTokens(new List<uint> { AppId }, new List<uint>());
|
||||
tokens.AppTokens.TryGetValue(AppId, out var token);
|
||||
var request = new SteamApps.PICSRequest(AppId) { AccessToken = token };
|
||||
var result = await link.Apps.PICSGetProductInfo(new List<SteamApps.PICSRequest> { request }, new List<SteamApps.PICSRequest>());
|
||||
var info = result.Results?.SelectMany(r => r.Apps).Where(a => a.Key == AppId).Select(a => a.Value).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Steam returned no app info for ChilloutVR.");
|
||||
return (info.KeyValues, token);
|
||||
}
|
||||
|
||||
sealed record DepotPlan(uint DepotId, ulong ManifestId, byte[] Key);
|
||||
|
||||
sealed class InstallState
|
||||
{
|
||||
public bool Complete { get; set; }
|
||||
public Dictionary<string, ulong> Manifests { get; set; } = new();
|
||||
}
|
||||
|
||||
public static async Task InstallAsync(
|
||||
string gameDir, string branch, string branchCode, byte[]? branchKey, Action<string, byte[]> keyResolved,
|
||||
string account, string refreshToken, bool validate, Action<string> log, Action<double> progress, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(branch)) branch = "public";
|
||||
Directory.CreateDirectory(gameDir);
|
||||
var stateDir = Path.Combine(gameDir, StateFolder);
|
||||
Directory.CreateDirectory(stateDir);
|
||||
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
log(link.IsAnonymous ? "Connected to Steam anonymously." : $"Signed in to Steam as {account}.");
|
||||
|
||||
var (root, appToken) = await RequestAppInfoAsync(link);
|
||||
var depotsSection = root["depots"];
|
||||
var branchNode = depotsSection["branches"][branch];
|
||||
var listed = branchNode != KeyValue.Invalid;
|
||||
var buildId = listed ? branchNode["buildid"].Value ?? "" : "";
|
||||
|
||||
KeyValue? privateDepots = null;
|
||||
if (!listed || branchNode["pwdrequired"].Value == "1")
|
||||
{
|
||||
if (string.IsNullOrEmpty(branchCode))
|
||||
throw new InvalidOperationException($"The branch '{branch}' is not public and needs its beta code. Enter the code from the branch list.");
|
||||
if (branchKey == null)
|
||||
{
|
||||
var keys = await BranchCodeService.ResolveAsync(link, branchCode);
|
||||
foreach (var (name, k) in keys) keyResolved(name, k);
|
||||
if (!keys.TryGetValue(branch, out branchKey))
|
||||
throw new InvalidOperationException($"The saved code does not unlock '{branch}'. Enter the code again from the branch list.");
|
||||
}
|
||||
var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, branch, branchKey);
|
||||
if (priv.Result != EResult.OK) throw new InvalidOperationException($"Steam would not give out the files for '{branch}' ({priv.Result}).");
|
||||
privateDepots = priv.DepotSection;
|
||||
}
|
||||
|
||||
var plans = new List<DepotPlan>();
|
||||
foreach (var (depotId, manifestId) in SelectDepots(depotsSection, privateDepots, branch))
|
||||
{
|
||||
var keyReply = await link.Apps.GetDepotDecryptionKey(depotId, AppId);
|
||||
if (keyReply.Result != EResult.OK) throw new InvalidOperationException($"Steam refused the key for depot {depotId} ({keyReply.Result}).");
|
||||
plans.Add(new DepotPlan(depotId, manifestId, keyReply.DepotKey));
|
||||
}
|
||||
if (plans.Count == 0) throw new InvalidOperationException("Steam listed no downloadable files for this branch.");
|
||||
if (buildId.Length == 0) buildId = PrivateMarker(plans.Select(p => (p.DepotId, p.ManifestId)));
|
||||
|
||||
var statePath = Path.Combine(stateDir, "state.json");
|
||||
var state = new InstallState();
|
||||
try { if (File.Exists(statePath)) state = JsonSerializer.Deserialize<InstallState>(File.ReadAllText(statePath)) ?? new(); } catch { }
|
||||
var forceValidate = validate || !state.Complete;
|
||||
state.Complete = false;
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
|
||||
var servers = await LoadServersAsync(link);
|
||||
var cdn = new Client(link.Client);
|
||||
var authTokens = new ConcurrentDictionary<(uint, string), string?>();
|
||||
var serverCursor = 0;
|
||||
|
||||
Server NextServer() => servers[Math.Abs(Interlocked.Increment(ref serverCursor)) % servers.Count];
|
||||
|
||||
async Task<string?> TokenFor(uint depotId, Server server)
|
||||
{
|
||||
if (authTokens.TryGetValue((depotId, server.Host!), out var cached)) return cached;
|
||||
var reply = await link.Content.GetCDNAuthToken(AppId, depotId, server.Host!);
|
||||
var token = reply.Result == EResult.OK ? reply.Token : null;
|
||||
authTokens[(depotId, server.Host!)] = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
var fullGameDir = Path.GetFullPath(gameDir) + Path.DirectorySeparatorChar;
|
||||
string Target(string fileName)
|
||||
{
|
||||
var full = Path.GetFullPath(Path.Combine(gameDir, fileName));
|
||||
if (!full.StartsWith(fullGameDir, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Refusing to write outside the install folder: {fileName}");
|
||||
return full;
|
||||
}
|
||||
|
||||
var newManifests = new Dictionary<uint, DepotManifest>();
|
||||
var oldManifests = new Dictionary<uint, DepotManifest?>();
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
DepotManifest? old = null;
|
||||
var oldFile = Path.Combine(stateDir, $"{plan.DepotId}.manifest");
|
||||
if (state.Manifests.ContainsKey(plan.DepotId.ToString()) && File.Exists(oldFile))
|
||||
try { old = DepotManifest.LoadFromFile(oldFile); } catch { }
|
||||
oldManifests[plan.DepotId] = old;
|
||||
|
||||
var manifest = old is { } o && o.ManifestGID == plan.ManifestId ? o : await DownloadManifestAsync(plan);
|
||||
newManifests[plan.DepotId] = manifest;
|
||||
log($"Depot {plan.DepotId}: {manifest.Files!.Count} files, {FileOps.FormatSize((long)manifest.TotalUncompressedSize)}.");
|
||||
}
|
||||
|
||||
async Task<DepotManifest> DownloadManifestAsync(DepotPlan plan)
|
||||
{
|
||||
for (var attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var server = NextServer();
|
||||
try
|
||||
{
|
||||
var code = await link.Content.GetManifestRequestCode(plan.DepotId, AppId, plan.ManifestId, branch);
|
||||
if (code == 0) throw new InvalidOperationException($"Steam gave no manifest code for depot {plan.DepotId}.");
|
||||
return await cdn.DownloadManifestAsync(plan.DepotId, plan.ManifestId, code, server, plan.Key, null, authTokens.GetValueOrDefault((plan.DepotId, server.Host!)));
|
||||
}
|
||||
catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((plan.DepotId, server.Host!)))
|
||||
{
|
||||
await TokenFor(plan.DepotId, server);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException && e is not InvalidOperationException)
|
||||
{
|
||||
log($"Manifest download from {server.Host} failed ({e.Message}); trying another server.");
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException($"Could not download the file list for depot {plan.DepotId}.");
|
||||
}
|
||||
|
||||
var work = new List<(uint DepotId, byte[] Key, DepotManifest.FileData File, string Path, List<DepotManifest.ChunkData> Chunks)>();
|
||||
long totalFiles = newManifests.Values.Sum(m => m.Files!.Count(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)));
|
||||
long scanned = 0;
|
||||
log(forceValidate ? "Checking every file against Steam..." : "Looking for changed files...");
|
||||
progress(0);
|
||||
|
||||
var workLock = new object();
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var manifest = newManifests[plan.DepotId];
|
||||
var old = oldManifests[plan.DepotId];
|
||||
var oldByName = old?.Files!.ToDictionary(f => f.FileName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var dir in manifest.Files!.Where(f => f.Flags.HasFlag(EDepotFileFlag.Directory)))
|
||||
Directory.CreateDirectory(Target(dir.FileName));
|
||||
|
||||
await Parallel.ForEachAsync(manifest.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct },
|
||||
async (file, token) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
var path = Target(file.FileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var needed = NeededChunks(path, file, oldByName, forceValidate);
|
||||
if (needed.Count > 0) lock (workLock) work.Add((plan.DepotId, plan.Key, file, path, needed));
|
||||
var done = Interlocked.Increment(ref scanned);
|
||||
if (done % 25 == 0 || done == totalFiles) progress(done * 100.0 / totalFiles);
|
||||
});
|
||||
}
|
||||
|
||||
var totalBytes = work.Sum(w => w.Chunks.Sum(c => (long)c.UncompressedLength));
|
||||
if (work.Count == 0) log("Everything is already up to date.");
|
||||
else
|
||||
{
|
||||
log($"Downloading {FileOps.FormatSize(totalBytes)} in {work.Count} file(s)...");
|
||||
progress(0);
|
||||
long downloaded = 0;
|
||||
var queue = work.SelectMany(w => w.Chunks.Select(c => (w.DepotId, w.Key, w.Path, Chunk: c))).ToList();
|
||||
await Parallel.ForEachAsync(queue, new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct }, async (item, token) =>
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)item.Chunk.UncompressedLength);
|
||||
try
|
||||
{
|
||||
var written = 0;
|
||||
for (var attempt = 0; attempt < 10 && written == 0; attempt++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var server = NextServer();
|
||||
try
|
||||
{
|
||||
written = await cdn.DownloadDepotChunkAsync(item.DepotId, item.Chunk, server, buffer, item.Key, null, authTokens.GetValueOrDefault((item.DepotId, server.Host!)));
|
||||
}
|
||||
catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((item.DepotId, server.Host!)))
|
||||
{
|
||||
await TokenFor(item.DepotId, server);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
if (attempt >= 4) log($"Chunk download from {server.Host} failed ({e.Message}).");
|
||||
}
|
||||
}
|
||||
if (written == 0) throw new InvalidOperationException("No Steam download server could deliver a file piece. Try again in a minute.");
|
||||
|
||||
await using var fs = new FileStream(item.Path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
fs.Seek((long)item.Chunk.Offset, SeekOrigin.Begin);
|
||||
await fs.WriteAsync(buffer.AsMemory(0, written), token);
|
||||
progress(Interlocked.Add(ref downloaded, written) * 100.0 / totalBytes);
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var old = oldManifests[plan.DepotId];
|
||||
if (old != null)
|
||||
{
|
||||
var keep = newManifests[plan.DepotId].Files!.Select(f => f.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var gone in old.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory) && !keep.Contains(f.FileName)))
|
||||
{
|
||||
var path = Target(gone.FileName);
|
||||
if (File.Exists(path)) { File.Delete(path); log($"Removed {gone.FileName}"); }
|
||||
}
|
||||
}
|
||||
newManifests[plan.DepotId].SaveToFile(Path.Combine(stateDir, $"{plan.DepotId}.manifest"));
|
||||
state.Manifests[plan.DepotId.ToString()] = plan.ManifestId;
|
||||
}
|
||||
state.Complete = true;
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
AppManifest.Write(gameDir, buildId, branch);
|
||||
progress(100);
|
||||
log($"Installed build {buildId} of '{branch}'.");
|
||||
}
|
||||
|
||||
static List<DepotManifest.ChunkData> NeededChunks(
|
||||
string path, DepotManifest.FileData file, Dictionary<string, DepotManifest.FileData>? oldByName, bool forceValidate)
|
||||
{
|
||||
var chunks = file.Chunks!.OrderBy(c => c.Offset).ToList();
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
using var created = File.Create(path);
|
||||
created.SetLength((long)file.TotalSize);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
if (!forceValidate && info.Length == (long)file.TotalSize
|
||||
&& oldByName != null && oldByName.TryGetValue(file.FileName, out var before) && before.FileHash.AsSpan().SequenceEqual(file.FileHash))
|
||||
return new();
|
||||
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
|
||||
if (fs.Length != (long)file.TotalSize) fs.SetLength((long)file.TotalSize);
|
||||
var needed = new List<DepotManifest.ChunkData>();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(chunks.Count == 0 ? 1 : (int)chunks.Max(c => c.UncompressedLength));
|
||||
try
|
||||
{
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
fs.Seek((long)chunk.Offset, SeekOrigin.Begin);
|
||||
var length = (int)chunk.UncompressedLength;
|
||||
var read = 0;
|
||||
while (read < length)
|
||||
{
|
||||
var n = fs.Read(buffer, read, length - read);
|
||||
if (n == 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < length || Adler(buffer, length) != chunk.Checksum) needed.Add(chunk);
|
||||
}
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
return needed;
|
||||
}
|
||||
|
||||
static uint Adler(byte[] data, int length)
|
||||
{
|
||||
uint a = 0, b = 0;
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
a = (a + data[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return a | (b << 16);
|
||||
}
|
||||
|
||||
static async Task<List<Server>> LoadServersAsync(SteamLink link)
|
||||
{
|
||||
var all = await link.Content.GetServersForSteamPipe();
|
||||
var list = new List<Server>();
|
||||
foreach (var s in all.Where(s => (s.AllowedAppIds.Length == 0 || s.AllowedAppIds.Contains(AppId)) && (s.Type == "SteamCache" || s.Type == "CDN"))
|
||||
.OrderBy(s => s.WeightedLoad))
|
||||
for (var i = 0; i < s.NumEntries; i++) list.Add(s);
|
||||
if (list.Count == 0) throw new InvalidOperationException("Steam gave no download servers.");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class SteamIntegration
|
||||
{
|
||||
public static string? SteamPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam", "SteamPath", null) as string;
|
||||
return p == null ? null : Path.GetFullPath(p);
|
||||
}
|
||||
}
|
||||
|
||||
public static string? SteamExe => SteamPath is { } p ? Path.Combine(p, "steam.exe") : null;
|
||||
|
||||
public static bool IsRunning => Process.GetProcessesByName("steam").Length > 0;
|
||||
|
||||
public static bool IsAppRunning =>
|
||||
Registry.GetValue($@"HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{Paths.AppId}", "Running", 0) is 1;
|
||||
|
||||
public static async Task<bool> EnsureRunningAsync(Action<string> log)
|
||||
{
|
||||
if (IsRunning) return true;
|
||||
var exe = SteamExe;
|
||||
if (exe == null || !File.Exists(exe)) return false;
|
||||
log("Steam is not running; starting it in the background so playtime is counted...");
|
||||
Process.Start(new ProcessStartInfo(exe, "-silent") { UseShellExecute = true });
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
var pid = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam\ActiveProcess", "pid", 0) as int? ?? 0;
|
||||
if (pid != 0 && IsRunning) { await Task.Delay(2000); return true; }
|
||||
}
|
||||
return IsRunning;
|
||||
}
|
||||
|
||||
public static List<(string GameDir, string Manifest)> FindExistingInstalls()
|
||||
{
|
||||
var result = new List<(string, string)>();
|
||||
var steam = SteamPath;
|
||||
if (steam == null) return result;
|
||||
var libs = new List<string> { steam };
|
||||
try
|
||||
{
|
||||
var vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf");
|
||||
if (File.Exists(vdf))
|
||||
{
|
||||
var doc = Vdf.ParseDocument(File.ReadAllText(vdf));
|
||||
if (doc.Dict("libraryfolders") is { } lf)
|
||||
foreach (var e in lf.Values.OfType<Dictionary<string, object>>())
|
||||
if (e.Str("path") is { Length: > 0 } p) libs.Add(p);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (var lib in libs.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var manifest = Path.Combine(lib, "steamapps", $"appmanifest_{Paths.AppId}.acf");
|
||||
if (!File.Exists(manifest)) continue;
|
||||
try
|
||||
{
|
||||
var dir = Vdf.ParseDocument(File.ReadAllText(manifest)).Dict("AppState")?.Str("installdir") ?? "ChilloutVR";
|
||||
var game = Path.Combine(lib, "steamapps", "common", dir);
|
||||
if (Directory.Exists(game)) result.Add((game, manifest));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public sealed class SteamLink : IAsyncDisposable
|
||||
{
|
||||
readonly CallbackManager manager;
|
||||
readonly CancellationTokenSource stop = new();
|
||||
readonly TaskCompletionSource connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
TaskCompletionSource<SteamUser.LoggedOnCallback>? loggedOn;
|
||||
Task pump = Task.CompletedTask;
|
||||
|
||||
public SteamClient Client { get; } = new();
|
||||
public SteamUser User { get; }
|
||||
public SteamApps Apps { get; }
|
||||
public SteamContent Content { get; }
|
||||
public bool IsAnonymous { get; private set; }
|
||||
|
||||
readonly uint loginId = (uint)Random.Shared.NextInt64(0x10000, uint.MaxValue);
|
||||
|
||||
SteamLink()
|
||||
{
|
||||
manager = new CallbackManager(Client);
|
||||
User = Client.GetHandler<SteamUser>()!;
|
||||
Apps = Client.GetHandler<SteamApps>()!;
|
||||
Content = Client.GetHandler<SteamContent>()!;
|
||||
manager.Subscribe<SteamClient.ConnectedCallback>(_ => connected.TrySetResult());
|
||||
manager.Subscribe<SteamClient.DisconnectedCallback>(_ =>
|
||||
{
|
||||
var e = new InvalidOperationException("Lost the connection to Steam.");
|
||||
connected.TrySetException(e);
|
||||
loggedOn?.TrySetException(e);
|
||||
});
|
||||
manager.Subscribe<SteamUser.LoggedOnCallback>(cb => loggedOn?.TrySetResult(cb));
|
||||
}
|
||||
|
||||
public static async Task<SteamLink> ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var link = new SteamLink();
|
||||
link.pump = Task.Run(() =>
|
||||
{
|
||||
while (!link.stop.IsCancellationRequested) link.manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(200));
|
||||
});
|
||||
link.Client.Connect();
|
||||
try
|
||||
{
|
||||
await WaitAsync(link.connected.Task, TimeSpan.FromSeconds(20), "Could not reach Steam.", ct);
|
||||
return link;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await link.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<SteamLink> OpenAsync(string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
var link = await ConnectAsync(ct);
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(refreshToken)) await link.LogOnAnonymousAsync(ct);
|
||||
else await link.LogOnAsync(account, refreshToken, ct);
|
||||
return link;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await link.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogOnAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
IsAnonymous = true;
|
||||
loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
User.LogOnAnonymous();
|
||||
Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct));
|
||||
}
|
||||
|
||||
public async Task LogOnAsync(string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
User.LogOn(new SteamUser.LogOnDetails { Username = account, AccessToken = refreshToken, ShouldRememberPassword = true, LoginID = loginId });
|
||||
Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct));
|
||||
}
|
||||
|
||||
static void Check(SteamUser.LoggedOnCallback cb)
|
||||
{
|
||||
if (cb.Result != EResult.OK)
|
||||
throw new InvalidOperationException(cb.Result is EResult.AccessDenied or EResult.InvalidPassword or EResult.Expired or EResult.LoggedInElsewhere
|
||||
? $"Steam refused the saved sign-in ({cb.Result}); sign in again."
|
||||
: $"Steam login failed: {cb.Result}");
|
||||
}
|
||||
|
||||
public static async Task<string> SignInAsync(
|
||||
string account, string password, Action onDeviceConfirmation, Func<bool, Task<string>> deviceCode, Func<string, bool, Task<string>> emailCode, CancellationToken ct)
|
||||
{
|
||||
await using var link = await ConnectAsync(ct);
|
||||
var session = await link.Client.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails
|
||||
{
|
||||
Username = account,
|
||||
Password = password,
|
||||
IsPersistentSession = true,
|
||||
DeviceFriendlyName = "CVRManager",
|
||||
Authenticator = new Authenticator(onDeviceConfirmation, deviceCode, emailCode),
|
||||
});
|
||||
var result = await session.PollingWaitForResultAsync(ct);
|
||||
return result.RefreshToken;
|
||||
}
|
||||
|
||||
sealed class Authenticator(Action confirmation, Func<bool, Task<string>> device, Func<string, bool, Task<string>> email) : IAuthenticator
|
||||
{
|
||||
public Task<string> GetDeviceCodeAsync(bool previousCodeWasIncorrect) => device(previousCodeWasIncorrect);
|
||||
public Task<string> GetEmailCodeAsync(string emailAddress, bool previousCodeWasIncorrect) => email(emailAddress, previousCodeWasIncorrect);
|
||||
|
||||
public Task<bool> AcceptDeviceConfirmationAsync()
|
||||
{
|
||||
confirmation();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<T> WaitAsync<T>(Task<T> task, TimeSpan timeout, string message, CancellationToken ct)
|
||||
{
|
||||
var delay = Task.Delay(timeout, ct);
|
||||
if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message);
|
||||
return await task;
|
||||
}
|
||||
|
||||
static async Task WaitAsync(Task task, TimeSpan timeout, string message, CancellationToken ct)
|
||||
{
|
||||
var delay = Task.Delay(timeout, ct);
|
||||
if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message);
|
||||
await task;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel();
|
||||
try { Client.Disconnect(); } catch { }
|
||||
await pump;
|
||||
stop.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public sealed record ThemePreset(string Name, Color Primary, Color Secondary);
|
||||
|
||||
public static class ThemeService
|
||||
{
|
||||
public const string DefaultName = "Default";
|
||||
public const string GameColoursName = "My in-game colours";
|
||||
|
||||
static Color F(double r, double g, double b) => Color.FromRgb((byte)Math.Round(r * 255), (byte)Math.Round(g * 255), (byte)Math.Round(b * 255));
|
||||
|
||||
public static IReadOnlyList<ThemePreset> Presets { get; } = new[]
|
||||
{
|
||||
new ThemePreset(DefaultName, Color.Parse("#5B8CFF"), Color.Parse("#7AA2FF")),
|
||||
new ThemePreset("ChilloutVR", F(0.894, 0.212, 0), F(1, 0.992, 0.988)),
|
||||
new ThemePreset("Ocean Mist", F(0, 0.5, 0.7), F(0.4, 0.8, 1)),
|
||||
new ThemePreset("Sunset Glow", F(1, 0.4, 0.3), F(1, 0.6, 0.4)),
|
||||
new ThemePreset("Forest Whisper", F(0.2, 0.6, 0.2), F(0.5, 0.8, 0.5)),
|
||||
new ThemePreset("Royal Dusk", F(0.5, 0.2, 0.7), F(0.7, 0.4, 1)),
|
||||
new ThemePreset("Desert Bloom", F(1, 0.7, 0.3), F(1, 0.9, 0.5)),
|
||||
new ThemePreset("Blossom Veil", F(1, 0.5, 0.7), F(1, 0.7, 0.9)),
|
||||
new ThemePreset("Titanium White", F(1, 1, 1), F(1, 1, 1)),
|
||||
new ThemePreset("Where Is My Shader", F(1, 0, 1), F(0, 1, 1)),
|
||||
new ThemePreset("Cotton Candy", F(0.357, 0.812, 0.98), F(0.961, 0.671, 0.725)),
|
||||
new ThemePreset("Basis", F(0.063, 0.059, 0.153), F(0.91, 0.071, 0.212)),
|
||||
new ThemePreset("Unit 01", F(0.435, 0.098, 0.522), F(0.345, 0.722, 0.388)),
|
||||
new ThemePreset("Spooky", F(1, 0.404, 0), F(0.545, 0, 0.545)),
|
||||
new ThemePreset("Cherry", F(0.824, 0.129, 0.204), F(0.278, 0.478, 0)),
|
||||
new ThemePreset("Bongo", F(0.616, 0.035, 0.906), F(0.498, 0, 0.078)),
|
||||
new ThemePreset("Kaffee", F(0.482, 0.259, 0.255), F(0.843, 0.647, 0.545)),
|
||||
};
|
||||
|
||||
public static IEnumerable<string> Names => new[] { GameColoursName }.Concat(Presets.Select(p => p.Name));
|
||||
|
||||
static string GameConfigPath => Path.Combine(Paths.GameDataDir(), "game.config");
|
||||
|
||||
public static (Color Primary, Color Secondary)? ReadGameColours()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(GameConfigPath)) return null;
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(GameConfigPath));
|
||||
var values = new Dictionary<string, double>();
|
||||
foreach (var e in doc.RootElement.GetProperty("settings").EnumerateArray())
|
||||
{
|
||||
var name = e.GetProperty("name").GetString() ?? "";
|
||||
if (!name.StartsWith("PlayerColorPrimary", StringComparison.Ordinal) && !name.StartsWith("PlayerColorSecondary", StringComparison.Ordinal)) continue;
|
||||
if (double.TryParse(e.GetProperty("value").GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) values[name] = v;
|
||||
}
|
||||
Color? Read(string key)
|
||||
{
|
||||
if (!values.TryGetValue(key + "R", out var r) || !values.TryGetValue(key + "G", out var g) || !values.TryGetValue(key + "B", out var b)) return null;
|
||||
var scale = r > 1 || g > 1 || b > 1 ? 1.0 : 255.0;
|
||||
byte B(double x) => (byte)Math.Clamp(Math.Round(x * scale), 0, 255);
|
||||
return Color.FromRgb(B(r), B(g), B(b));
|
||||
}
|
||||
var p = Read("PlayerColorPrimary");
|
||||
var s = Read("PlayerColorSecondary");
|
||||
return p is { } pc && s is { } sc ? (pc, sc) : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public static bool Apply(string name)
|
||||
{
|
||||
Color primary, secondary;
|
||||
var ok = true;
|
||||
if (name == GameColoursName)
|
||||
{
|
||||
if (ReadGameColours() is { } g) (primary, secondary) = g;
|
||||
else
|
||||
{
|
||||
ok = false;
|
||||
(primary, secondary) = (Presets[1].Primary, Presets[1].Secondary);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var preset = Presets.FirstOrDefault(p => p.Name == name) ?? Presets[0];
|
||||
(primary, secondary) = (preset.Primary, preset.Secondary);
|
||||
}
|
||||
Set(primary, secondary);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static (double H, double S, double L) ToHsl(Color c)
|
||||
{
|
||||
double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0;
|
||||
var max = Math.Max(r, Math.Max(g, b));
|
||||
var min = Math.Min(r, Math.Min(g, b));
|
||||
var l = (max + min) / 2;
|
||||
if (max == min) return (0, 0, l);
|
||||
var d = max - min;
|
||||
var s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
double h;
|
||||
if (max == r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max == g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
return (h * 60, s, l);
|
||||
}
|
||||
|
||||
static Color FromHsl(double h, double s, double l)
|
||||
{
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = Math.Clamp(s, 0, 1);
|
||||
l = Math.Clamp(l, 0, 1);
|
||||
var c = (1 - Math.Abs(2 * l - 1)) * s;
|
||||
var x = c * (1 - Math.Abs(h / 60 % 2 - 1));
|
||||
var m = l - c / 2;
|
||||
(double r, double g, double b) = (int)(h / 60) switch
|
||||
{
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
|
||||
}
|
||||
|
||||
static double Luma(Color c) => (0.2126 * c.R + 0.7152 * c.G + 0.0722 * c.B) / 255.0;
|
||||
|
||||
static Color PickAccent(Color primary, Color secondary)
|
||||
{
|
||||
bool Usable(Color c) { var (_, s, l) = ToHsl(c); return Luma(c) >= 0.18 && (s >= 0.25 || l >= 0.8); }
|
||||
if (Usable(primary)) return primary;
|
||||
if (Usable(secondary)) return secondary;
|
||||
return Luma(primary) >= Luma(secondary) ? primary : secondary;
|
||||
}
|
||||
|
||||
static void Set(Color primary, Color secondary)
|
||||
{
|
||||
var app = Application.Current;
|
||||
if (app == null) return;
|
||||
var accent = PickAccent(primary, secondary);
|
||||
var (h, s, l) = ToHsl(accent);
|
||||
var accentL = Math.Clamp(l, 0.50, 0.68);
|
||||
var accentS = s < 0.1 ? s : Math.Max(s, 0.45);
|
||||
var accentColor = FromHsl(h, accentS, accentL);
|
||||
var tint = Math.Min(s, 0.32);
|
||||
var dimS = Math.Min(s, 0.14);
|
||||
|
||||
void Brush(string key, Color c) => app.Resources[key] = new SolidColorBrush(c);
|
||||
void Surface(string key, double lightness, double extraSat = 0) => Brush(key, FromHsl(h, Math.Min(tint + extraSat, 0.5), lightness));
|
||||
|
||||
app.Resources["SystemAccentColor"] = accentColor;
|
||||
app.Resources["SystemAccentColorLight1"] = FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85));
|
||||
app.Resources["SystemAccentColorLight2"] = FromHsl(h, accentS, Math.Min(accentL + 0.16, 0.9));
|
||||
app.Resources["SystemAccentColorLight3"] = FromHsl(h, accentS, Math.Min(accentL + 0.24, 0.94));
|
||||
app.Resources["SystemAccentColorDark1"] = FromHsl(h, accentS, accentL - 0.08);
|
||||
app.Resources["SystemAccentColorDark2"] = FromHsl(h, accentS, accentL - 0.16);
|
||||
app.Resources["SystemAccentColorDark3"] = FromHsl(h, accentS, accentL - 0.24);
|
||||
|
||||
Brush("AccentBrush", accentColor);
|
||||
Brush("AccentHoverBrush", FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85)));
|
||||
Brush("AccentPressedBrush", FromHsl(h, accentS, accentL - 0.08));
|
||||
Brush("AccentDisabledBrush", FromHsl(h, accentS * 0.4, 0.22));
|
||||
Brush("AccentFgBrush", Luma(accentColor) > 0.62 ? Color.Parse("#101114") : Colors.White);
|
||||
|
||||
Surface("AppBg", 0.055);
|
||||
Surface("SidebarBg", 0.04);
|
||||
Surface("CardBg", 0.09);
|
||||
Surface("CardBorder", 0.15);
|
||||
Surface("RowHover", 0.125);
|
||||
Surface("ChipBg", 0.16);
|
||||
Surface("SelectedBg", 0.17, 0.08);
|
||||
Surface("OverlayBg", 0.105);
|
||||
Surface("ToastBg", 0.135);
|
||||
Surface("GroupHeaderBg", 0.11);
|
||||
Surface("GhostHover", 0.16);
|
||||
Brush("DimText", FromHsl(h, dimS, 0.64));
|
||||
|
||||
Surface("ButtonBackground", 0.16);
|
||||
Surface("ButtonBackgroundPointerOver", 0.2);
|
||||
Surface("ButtonBackgroundPressed", 0.24);
|
||||
Surface("ButtonBackgroundDisabled", 0.11);
|
||||
Surface("TextControlBackground", 0.075);
|
||||
Surface("TextControlBackgroundPointerOver", 0.095);
|
||||
Surface("TextControlBackgroundFocused", 0.075);
|
||||
Surface("TextControlBorderBrush", 0.18);
|
||||
Surface("TextControlBorderBrushPointerOver", 0.25);
|
||||
Brush("TextControlBorderBrushFocused", accentColor);
|
||||
Surface("ComboBoxBackground", 0.075);
|
||||
Surface("ComboBoxBackgroundPointerOver", 0.095);
|
||||
Surface("ComboBoxBackgroundPressed", 0.095);
|
||||
Surface("ComboBoxBorderBrush", 0.18);
|
||||
Surface("ComboBoxBorderBrushPointerOver", 0.25);
|
||||
Brush("ComboBoxBorderBrushPressed", accentColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class TokenVault
|
||||
{
|
||||
public static string Protect(string token) =>
|
||||
Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser));
|
||||
|
||||
public static string Unprotect(string stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stored)) return "";
|
||||
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(stored), null, DataProtectionScope.CurrentUser)); }
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record UpdateInfo(Version Version, string Tag, string ZipUrl, string HashUrl, long Size);
|
||||
|
||||
public static class UpdateService
|
||||
{
|
||||
public static string Host { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_HOST") is { Length: > 0 } h ? h : "https://git.koneko.cat";
|
||||
public static string Repo { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_REPO") is { Length: > 0 } r ? r : "Koneko/CVRManager";
|
||||
public const string AssetName = "CVRManager-win-x64.zip";
|
||||
|
||||
static readonly HttpClient Http = CreateClient();
|
||||
|
||||
static HttpClient CreateClient()
|
||||
{
|
||||
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", Current.ToString(3)));
|
||||
return http;
|
||||
}
|
||||
|
||||
public static Version Current { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0));
|
||||
|
||||
static Version Normalize(Version v) => new(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0));
|
||||
|
||||
public static bool TryParseTag(string tag, out Version version)
|
||||
{
|
||||
var ok = Version.TryParse(tag.TrimStart('v', 'V'), out var v);
|
||||
version = ok ? Normalize(v!) : new Version(0, 0, 0);
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static async Task<UpdateInfo?> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
using var resp = await Http.GetAsync($"{Host}/api/v1/repos/{Repo}/releases/latest", ct);
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var root = doc.RootElement;
|
||||
var tag = root.GetProperty("tag_name").GetString() ?? "";
|
||||
if (!TryParseTag(tag, out var version) || version <= Current) return null;
|
||||
string? zip = null, hash = null;
|
||||
long size = 0;
|
||||
foreach (var a in root.GetProperty("assets").EnumerateArray())
|
||||
{
|
||||
var name = a.GetProperty("name").GetString();
|
||||
var url = a.GetProperty("browser_download_url").GetString();
|
||||
if (name == AssetName) { zip = url; size = a.GetProperty("size").GetInt64(); }
|
||||
else if (name == AssetName + ".sha256") hash = url;
|
||||
}
|
||||
if (zip == null || hash == null) return null;
|
||||
return new UpdateInfo(version, tag, zip, hash, size);
|
||||
}
|
||||
|
||||
public static async Task<string> DownloadAsync(UpdateInfo info, Action<double> progress, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(Paths.Root, "update", info.Tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
Directory.CreateDirectory(dir);
|
||||
var zip = Path.Combine(dir, AssetName);
|
||||
|
||||
var expected = (await Http.GetStringAsync(info.HashUrl, ct)).Trim().Split(' ', '\t')[0].ToLowerInvariant();
|
||||
using (var resp = await Http.GetAsync(info.ZipUrl, HttpCompletionOption.ResponseHeadersRead, ct))
|
||||
{
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var total = resp.Content.Headers.ContentLength ?? info.Size;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(zip);
|
||||
var buffer = new byte[81920];
|
||||
long done = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct);
|
||||
done += n;
|
||||
if (total > 0) progress(done * 100.0 / total);
|
||||
}
|
||||
}
|
||||
|
||||
string actual;
|
||||
await using (var fs = File.OpenRead(zip)) actual = Convert.ToHexString(await SHA256.HashDataAsync(fs, ct)).ToLowerInvariant();
|
||||
if (actual != expected) throw new InvalidDataException("The downloaded update is corrupted (checksum mismatch).");
|
||||
|
||||
var staged = Path.Combine(dir, "files");
|
||||
ZipFile.ExtractToDirectory(zip, staged);
|
||||
File.Delete(zip);
|
||||
if (!File.Exists(Path.Combine(staged, "CVRManager.exe"))) throw new InvalidDataException("The update package has no CVRManager.exe.");
|
||||
return staged;
|
||||
}
|
||||
|
||||
public static void ApplyAndRestart(string staged)
|
||||
{
|
||||
var exe = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot find the running executable.");
|
||||
var appDir = Path.GetDirectoryName(exe)!;
|
||||
var script = Path.Combine(Path.GetDirectoryName(staged)!, "apply.ps1");
|
||||
File.WriteAllText(script, """
|
||||
param([int]$ProcessId, [string]$Source, [string]$Target, [string]$Exe)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch { }
|
||||
$copied = $false
|
||||
for ($i = 0; $i -lt 20 -and -not $copied; $i++) {
|
||||
robocopy $Source $Target /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8) { $copied = $true } else { Start-Sleep -Milliseconds 500 }
|
||||
}
|
||||
if ($copied) { Remove-Item -LiteralPath $Source -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
Start-Process -FilePath $Exe -WorkingDirectory $Target
|
||||
""");
|
||||
var psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-ProcessId", Environment.ProcessId.ToString(), "-Source", staged, "-Target", appDir, "-Exe", exe })
|
||||
psi.ArgumentList.Add(a);
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
public static bool CanWriteAppDir()
|
||||
{
|
||||
try
|
||||
{
|
||||
var probe = Path.Combine(AppContext.BaseDirectory, $".w{Environment.ProcessId}");
|
||||
File.WriteAllText(probe, "");
|
||||
File.Delete(probe);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class Vdf
|
||||
{
|
||||
public static Dictionary<string, object> Parse(string text, int start = 0)
|
||||
{
|
||||
var pos = start;
|
||||
var root = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
var key = ReadToken(text, ref pos);
|
||||
if (key == null) return root;
|
||||
var next = Peek(text, pos);
|
||||
if (next == '{')
|
||||
{
|
||||
pos = text.IndexOf('{', pos) + 1;
|
||||
root[key] = ParseBlock(text, ref pos);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
public static Dictionary<string, object> ParseDocument(string text)
|
||||
{
|
||||
var pos = 0;
|
||||
return ParseBlock(text, ref pos, true);
|
||||
}
|
||||
|
||||
static Dictionary<string, object> ParseBlock(string text, ref int pos, bool toEnd = false)
|
||||
{
|
||||
var dict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
while (true)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos >= text.Length) return dict;
|
||||
if (text[pos] == '}')
|
||||
{
|
||||
pos++;
|
||||
return dict;
|
||||
}
|
||||
var key = ReadToken(text, ref pos);
|
||||
if (key == null) return dict;
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos < text.Length && text[pos] == '{')
|
||||
{
|
||||
pos++;
|
||||
dict[key] = ParseBlock(text, ref pos);
|
||||
}
|
||||
else
|
||||
{
|
||||
var val = ReadToken(text, ref pos);
|
||||
if (val == null) return dict;
|
||||
dict[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static char Peek(string text, int pos)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
return pos < text.Length ? text[pos] : '\0';
|
||||
}
|
||||
|
||||
static void SkipSpace(string text, ref int pos)
|
||||
{
|
||||
while (pos < text.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(text[pos])) pos++;
|
||||
else if (text[pos] == '/' && pos + 1 < text.Length && text[pos + 1] == '/')
|
||||
while (pos < text.Length && text[pos] != '\n') pos++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
static string? ReadToken(string text, ref int pos)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos >= text.Length || text[pos] != '"') return null;
|
||||
pos++;
|
||||
var sb = new StringBuilder();
|
||||
while (pos < text.Length && text[pos] != '"')
|
||||
{
|
||||
if (text[pos] == '\\' && pos + 1 < text.Length)
|
||||
{
|
||||
pos++;
|
||||
sb.Append(text[pos] switch { 'n' => '\n', 't' => '\t', var c => c });
|
||||
}
|
||||
else sb.Append(text[pos]);
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static Dictionary<string, object>? Dict(this Dictionary<string, object> d, string key) =>
|
||||
d.TryGetValue(key, out var v) ? v as Dictionary<string, object> : null;
|
||||
|
||||
public static string Str(this Dictionary<string, object> d, string key) =>
|
||||
d.TryGetValue(key, out var v) && v is string s ? s : "";
|
||||
}
|
||||
Reference in New Issue
Block a user