CVR Manager 1.0.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Exterrata
2026-10-25 02:46:35 -04:00
commit 9bb4f85802
59 changed files with 7913 additions and 0 deletions
+58
View File
@@ -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; }
}
}
+58
View File
@@ -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);
}
}
+70
View File
@@ -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;
}
}
+69
View File
@@ -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;
}
}
+36
View File
@@ -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; }
}
}
+129
View File
@@ -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));
}
+169
View File
@@ -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;
}
}
+91
View File
@@ -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);
}
}
+101
View File
@@ -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;
}
}
+43
View File
@@ -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";
}
+91
View File
@@ -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) { }
}
}
+102
View File
@@ -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('/'));
}
}
+130
View File
@@ -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();
}
}
+35
View File
@@ -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);
}
}
+99
View File
@@ -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();
}
}
+217
View File
@@ -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();
}
+183
View File
@@ -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);
}
}
}
+48
View File
@@ -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.");
}
}
+59
View File
@@ -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;
}
}
+399
View File
@@ -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;
}
}
+73
View File
@@ -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;
}
}
+146
View File
@@ -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();
}
}
+196
View File
@@ -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);
}
}
+17
View File
@@ -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 ""; }
}
}
+135
View File
@@ -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; }
}
}
+99
View File
@@ -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 : "";
}