CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
*.user
|
||||
.vs/
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using CVRManager.Views;
|
||||
|
||||
namespace CVRManager;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainWindow();
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<AssemblyName>CVRManager</AssemblyName>
|
||||
<ApplicationIcon>Assets/app.ico</ApplicationIcon>
|
||||
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="CacheGuard/**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="SteamKit2" Version="3.*" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.*" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
namespace CVRManager.Models;
|
||||
|
||||
public class AppConfig
|
||||
{
|
||||
public string LibraryRoot { get; set; } = "";
|
||||
public string SteamUser { get; set; } = "";
|
||||
public string SteamToken { get; set; } = "";
|
||||
public string GitHubToken { get; set; } = "";
|
||||
public string Theme { get; set; } = "Default";
|
||||
public bool CacheGuard { get; set; } = true;
|
||||
public bool CheckUpdates { get; set; } = true;
|
||||
public List<InstallInfo> Installs { get; set; } = new();
|
||||
public List<ModDef> Mods { get; set; } = new();
|
||||
public List<UnlockedBranch> Unlocked { get; set; } = new();
|
||||
public List<HookDef> Hooks { get; set; } = new();
|
||||
public List<GameAccount> Accounts { get; set; } = new();
|
||||
public List<SourceDef> Sources { get; set; } = new();
|
||||
public List<string> SeededSources { get; set; } = new();
|
||||
}
|
||||
|
||||
public class GameAccount
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Name { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string AccessKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public record AccountChoice(string Id, string Name);
|
||||
|
||||
public class SourceDef
|
||||
{
|
||||
public const string CvrmgId = "cvrmg";
|
||||
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Name { get; set; } = "";
|
||||
public string Kind { get; set; } = "github";
|
||||
public string Repo { get; set; } = "";
|
||||
public bool Builtin { get; set; }
|
||||
}
|
||||
|
||||
public class HookDef
|
||||
{
|
||||
public const string BeforeUpdateEvent = "BeforeUpdate";
|
||||
public const string AfterUpdateEvent = "AfterUpdate";
|
||||
public const string BeforeLaunchEvent = "BeforeLaunch";
|
||||
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Name { get; set; } = "New hook";
|
||||
public string Command { get; set; } = "";
|
||||
public string Arguments { get; set; } = "";
|
||||
public bool BeforeUpdate { get; set; }
|
||||
public bool AfterUpdate { get; set; } = true;
|
||||
public bool BeforeLaunch { get; set; }
|
||||
public bool DefaultOn { get; set; } = true;
|
||||
|
||||
public bool Runs(string evt) => evt switch
|
||||
{
|
||||
BeforeUpdateEvent => BeforeUpdate,
|
||||
AfterUpdateEvent => AfterUpdate,
|
||||
BeforeLaunchEvent => BeforeLaunch,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public class UnlockedBranch
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Code { get; set; } = "";
|
||||
public string Key { get; set; } = "";
|
||||
}
|
||||
|
||||
public class InstallInfo
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Name { get; set; } = "";
|
||||
public string Folder { get; set; } = "";
|
||||
public string Branch { get; set; } = "public";
|
||||
public string BranchPassword { get; set; } = "";
|
||||
public string LaunchArgs { get; set; } = "";
|
||||
public string LaunchMode { get; set; } = "desktop";
|
||||
public string AccountId { get; set; } = "";
|
||||
public List<ModAssignment> Mods { get; set; } = new();
|
||||
public List<string> HookIds { get; set; } = new();
|
||||
public Dictionary<string, List<string>> Deployed { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ModAssignment
|
||||
{
|
||||
public string ModId { get; set; } = "";
|
||||
public string Version { get; set; } = "latest";
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class ModDef
|
||||
{
|
||||
public const string MelonLoaderId = "melonloader";
|
||||
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Name { get; set; } = "";
|
||||
public string Repo { get; set; } = "";
|
||||
public string Type { get; set; } = "Mods";
|
||||
public string AssetFilter { get; set; } = "";
|
||||
public bool Local { get; set; }
|
||||
public string Source { get; set; } = "github";
|
||||
public int CvrmgId { get; set; }
|
||||
public string FileName { get; set; } = "";
|
||||
public string Author { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string LatestVersion { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string SourceLink { get; set; } = "";
|
||||
public List<ReleaseInfo> Releases { get; set; } = new();
|
||||
|
||||
public void SetReleases(List<ReleaseInfo> fresh)
|
||||
{
|
||||
var keep = Releases.Where(r => fresh.All(f => f.Tag != r.Tag) && (r.Snapshot || Source == "cvrmg")).ToList();
|
||||
Releases = fresh.Concat(keep).ToList();
|
||||
}
|
||||
|
||||
public string? LatestTag =>
|
||||
(Releases.FirstOrDefault(r => !r.Prerelease) ?? Releases.FirstOrDefault())?.Tag;
|
||||
}
|
||||
|
||||
public class ReleaseInfo
|
||||
{
|
||||
public string Tag { get; set; } = "";
|
||||
public bool Prerelease { get; set; }
|
||||
public bool Snapshot { get; set; }
|
||||
public string GameVersion { get; set; } = "";
|
||||
public string LoaderVersion { get; set; } = "";
|
||||
public DateTime Published { get; set; }
|
||||
public List<AssetInfo> Assets { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AssetInfo
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Url { get; set; } = "";
|
||||
public long Size { get; set; }
|
||||
public string Hash { get; set; } = "";
|
||||
}
|
||||
|
||||
public record BranchInfo(string Name, string BuildId, bool PasswordRequired, string Description);
|
||||
|
||||
public record BranchChoice(string Name, string Code)
|
||||
{
|
||||
public string Display => Name;
|
||||
}
|
||||
|
||||
public record LaunchArgOption(string Flag, string Description, bool TakesValue)
|
||||
{
|
||||
public string Insert => TakesValue ? Flag[..(Flag.IndexOf('=') + 1)] : Flag;
|
||||
|
||||
public static IReadOnlyList<LaunchArgOption> All { get; } = new[]
|
||||
{
|
||||
new LaunchArgOption("-vr", "Launch in VR.", false),
|
||||
new LaunchArgOption("--force-openxr", "Force OpenXR for VR sessions.", false),
|
||||
new LaunchArgOption("--no-steam", "Skip Steam API initialization.", false),
|
||||
new LaunchArgOption("--safe-mode", "Load the default Hub world and avatar, and disable OSC.", false),
|
||||
new LaunchArgOption("--profile=<name>", "Profile used to save and load the autologin file.", true),
|
||||
new LaunchArgOption("--affinity=<hex mask>", "CPU cores the game may run on, as a hexadecimal mask.", true),
|
||||
new LaunchArgOption("--content-cache-path=<path>", "Use a different directory for the content cache.", true),
|
||||
new LaunchArgOption("--allow-aggressive-memory-clearing", "Reclaim unused memory when content unloads.", false),
|
||||
new LaunchArgOption("--disable-videoplayers", "Disable video players completely.", false),
|
||||
new LaunchArgOption("--disable-vphd", "Turn off video player hardware decoding.", false),
|
||||
new LaunchArgOption("--enable-libvlc", "Use LibVLC instead of AVPro in video players.", false),
|
||||
new LaunchArgOption("--osc-listener-port=<port>", "UDP port that receives OSC messages.", true),
|
||||
new LaunchArgOption("--osc-sender-port=<port>", "UDP port OSC messages are sent to.", true),
|
||||
new LaunchArgOption("--osc-sender-ip=<ip or host>", "Target address for sent OSC messages.", true),
|
||||
new LaunchArgOption("--osc-query-prefix=<prefix>", "Prefix for OSC query parameters.", true),
|
||||
new LaunchArgOption("--melonloader.hideconsole", "MelonLoader: hide the console window.", false),
|
||||
new LaunchArgOption("-screen-fullscreen 0", "Unity: start windowed.", false),
|
||||
new LaunchArgOption("-popupwindow", "Unity: borderless window.", false),
|
||||
new LaunchArgOption("-screen-width 1280", "Unity: window width.", false),
|
||||
new LaunchArgOption("-screen-height 720", "Unity: window height.", false),
|
||||
new LaunchArgOption("-monitor 2", "Unity: open on this display (1 is the primary).", false),
|
||||
new LaunchArgOption("-force-d3d11", "Unity: force the Direct3D 11 renderer.", false),
|
||||
new LaunchArgOption("-force-vulkan", "Unity: force the Vulkan renderer.", false),
|
||||
};
|
||||
}
|
||||
|
||||
public record LaunchModeOption(string Key, string Label, string Arguments)
|
||||
{
|
||||
public static IReadOnlyList<LaunchModeOption> All { get; } = new[]
|
||||
{
|
||||
new LaunchModeOption("desktop", "Desktop", ""),
|
||||
new LaunchModeOption("vr", "VR", "-vr"),
|
||||
new LaunchModeOption("safe", "Safe mode", "--safe-mode"),
|
||||
};
|
||||
|
||||
public static LaunchModeOption Find(string key) => All.FirstOrDefault(m => m.Key == key) ?? All[0];
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace CVRManager;
|
||||
|
||||
class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class AccountService
|
||||
{
|
||||
const string ProfilePrefix = "cvrm-";
|
||||
|
||||
public record Found(string Name, string Username, string UserId, string AccessKey);
|
||||
|
||||
public static string ProfileName(GameAccount a) => ProfilePrefix + a.Id;
|
||||
|
||||
static string ProfilePath(string gameDir, GameAccount a) => Path.Combine(gameDir, "ChilloutVR_Data", $"autologin-{ProfileName(a)}.profile");
|
||||
|
||||
public static string WriteProfile(string gameDir, GameAccount a)
|
||||
{
|
||||
var key = TokenVault.Unprotect(a.AccessKey);
|
||||
if (key.Length == 0) throw new InvalidOperationException($"The access key for {a.Name} could not be read; add the account again.");
|
||||
var doc = new XElement("LoginProfile",
|
||||
new XElement("UserId", a.UserId),
|
||||
new XElement("Username", a.Username),
|
||||
new XElement("AccessKey", key));
|
||||
File.WriteAllText(ProfilePath(gameDir, a), doc.ToString());
|
||||
return ProfileName(a);
|
||||
}
|
||||
|
||||
public static IEnumerable<Found> Scan(IEnumerable<string> gameDirs)
|
||||
{
|
||||
foreach (var dir in gameDirs)
|
||||
{
|
||||
var data = Path.Combine(dir, "ChilloutVR_Data");
|
||||
if (!Directory.Exists(data)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(data, "autologin*.profile"))
|
||||
{
|
||||
if (Path.GetFileName(file).StartsWith("autologin-" + ProfilePrefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (Read(file) is { } found) yield return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Found? Read(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = XDocument.Load(path).Root;
|
||||
if (root == null || root.Name != "LoginProfile") return null;
|
||||
string Value(string name) => root.Element(name)?.Value.Trim() ?? "";
|
||||
var username = Value("Username");
|
||||
var key = Value("AccessKey");
|
||||
if (username.Length == 0 || key.Length == 0) return null;
|
||||
var stem = Path.GetFileNameWithoutExtension(path)["autologin".Length..].TrimStart('_', '-');
|
||||
return new Found(stem.Length > 0 ? stem : username, username, Value("UserId"), key);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException or XmlException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class AppManifest
|
||||
{
|
||||
public static string LocalBuildId(InstallInfo i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Paths.Manifest(i);
|
||||
if (!File.Exists(path)) return "";
|
||||
return Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Str("buildid") ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public static string LocalBranch(InstallInfo i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Paths.Manifest(i);
|
||||
if (!File.Exists(path)) return "";
|
||||
var key = Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Dict("UserConfig")?.Str("BetaKey") ?? "";
|
||||
return key.Length == 0 ? "public" : key;
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public static void Write(string gameDir, string buildId, string branch)
|
||||
{
|
||||
var dir = Path.Combine(gameDir, "steamapps");
|
||||
Directory.CreateDirectory(dir);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("\"AppState\"");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine($"\t\"appid\"\t\t\"{Paths.AppId}\"");
|
||||
sb.AppendLine("\t\"Universe\"\t\t\"1\"");
|
||||
sb.AppendLine("\t\"name\"\t\t\"ChilloutVR\"");
|
||||
sb.AppendLine("\t\"StateFlags\"\t\t\"4\"");
|
||||
sb.AppendLine($"\t\"installdir\"\t\t\"{Path.GetFileName(gameDir)}\"");
|
||||
sb.AppendLine($"\t\"buildid\"\t\t\"{buildId}\"");
|
||||
sb.AppendLine("\t\"UserConfig\"");
|
||||
sb.AppendLine("\t{");
|
||||
sb.AppendLine("\t\t\"language\"\t\t\"english\"");
|
||||
sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\"");
|
||||
sb.AppendLine("\t}");
|
||||
sb.AppendLine("\t\"MountedConfig\"");
|
||||
sb.AppendLine("\t{");
|
||||
sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\"");
|
||||
sb.AppendLine("\t}");
|
||||
sb.AppendLine("}");
|
||||
var file = Path.Combine(dir, $"appmanifest_{Paths.AppId}.acf");
|
||||
File.WriteAllText(file + ".tmp", sb.ToString());
|
||||
File.Move(file + ".tmp", file, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record AssemblyDetails(string FileName, string AssemblyName, string Version, string MelonName, string MelonVersion, string MelonAuthor, string MelonLink, string Sha256);
|
||||
|
||||
public static class AssemblyReader
|
||||
{
|
||||
public static AssemblyDetails Read(string path)
|
||||
{
|
||||
var file = Path.GetFileName(path);
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
var hash = Convert.ToBase64String(SHA256.HashData(bytes));
|
||||
string asmName = Path.GetFileNameWithoutExtension(file), version = "", mName = "", mVer = "", mAuthor = "", mLink = "";
|
||||
try
|
||||
{
|
||||
using var pe = new PEReader(new MemoryStream(bytes));
|
||||
if (pe.HasMetadata)
|
||||
{
|
||||
var md = pe.GetMetadataReader();
|
||||
var def = md.GetAssemblyDefinition();
|
||||
asmName = md.GetString(def.Name);
|
||||
version = def.Version.ToString();
|
||||
foreach (var h in def.GetCustomAttributes())
|
||||
{
|
||||
var attr = md.GetCustomAttribute(h);
|
||||
if (!IsMelonInfo(md, attr.Constructor)) continue;
|
||||
var strings = attr.DecodeValue(new Provider()).FixedArguments
|
||||
.Where(a => a.Type is PrimitiveTypeCode.String && a.Value is string).Select(a => (string)a.Value!).ToList();
|
||||
if (strings.Count > 0) mName = strings[0];
|
||||
if (strings.Count > 1) mVer = strings[1];
|
||||
if (strings.Count > 2) mAuthor = strings[2];
|
||||
if (strings.Count > 3) mLink = strings[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is BadImageFormatException or InvalidOperationException or ArgumentException) { }
|
||||
var fv = System.Diagnostics.FileVersionInfo.GetVersionInfo(path).ProductVersion?.Split('+', '-')[0];
|
||||
return new AssemblyDetails(file, asmName, string.IsNullOrEmpty(fv) ? version : fv, mName, mVer, mAuthor, mLink, hash);
|
||||
}
|
||||
|
||||
static bool IsMelonInfo(MetadataReader md, EntityHandle ctor)
|
||||
{
|
||||
if (ctor.Kind != HandleKind.MemberReference) return false;
|
||||
var member = md.GetMemberReference((MemberReferenceHandle)ctor);
|
||||
if (member.Parent.Kind != HandleKind.TypeReference) return false;
|
||||
var type = md.GetTypeReference((TypeReferenceHandle)member.Parent);
|
||||
return md.GetString(type.Name) == "MelonInfoAttribute";
|
||||
}
|
||||
|
||||
sealed class Provider : ICustomAttributeTypeProvider<object?>
|
||||
{
|
||||
const string SystemType = "System.Type";
|
||||
public object? GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode;
|
||||
public object? GetSystemType() => SystemType;
|
||||
public object? GetSZArrayType(object? elementType) => elementType;
|
||||
public object? GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => new object();
|
||||
public object? GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind)
|
||||
{
|
||||
var t = reader.GetTypeReference(handle);
|
||||
return reader.GetString(t.Name) == "Type" && reader.GetString(t.Namespace) == "System" ? SystemType : new object();
|
||||
}
|
||||
public object? GetTypeFromSerializedName(string name) => name;
|
||||
public PrimitiveTypeCode GetUnderlyingEnumType(object? type) => PrimitiveTypeCode.Int32;
|
||||
public bool IsSystemType(object? type) => type is string s && s == SystemType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record RepoOffer(string Repo, string Asset, List<ReleaseInfo> Releases);
|
||||
|
||||
public class AvailableEntry
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
public HashSet<string> Keys { get; } = new();
|
||||
public string Name { get; set; } = "";
|
||||
public CvrmgMod? Cvrmg { get; set; }
|
||||
public List<RepoOffer> Repos { get; } = new();
|
||||
}
|
||||
|
||||
public static class AvailableModsService
|
||||
{
|
||||
public static async Task<List<AvailableEntry>> BuildAsync(
|
||||
CvrmgService cvrmg, ModImportService importer, IReadOnlyList<SourceDef> sources, bool force, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (force) importer.Invalidate();
|
||||
var byKey = new Dictionary<string, AvailableEntry>();
|
||||
var entries = new List<AvailableEntry>();
|
||||
|
||||
if (sources.Any(s => s.Kind == "cvrmg"))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var m in await cvrmg.GetAllAsync(ct, force))
|
||||
{
|
||||
var e = new AvailableEntry { Key = CvrmgService.Normalize(m.Name), Name = m.Name, Cvrmg = m };
|
||||
foreach (var n in new[] { m.Name }.Concat(m.Aliases)) e.Keys.Add(CvrmgService.Normalize(n));
|
||||
e.Keys.RemoveWhere(k => k.Length == 0);
|
||||
if (e.Keys.Count == 0) continue;
|
||||
entries.Add(e);
|
||||
foreach (var k in e.Keys) byKey.TryAdd(k, e);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
log($"CVRMG could not be loaded: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0))
|
||||
{
|
||||
var releases = await importer.ReleasesAsync(src.Repo, log, ct, force);
|
||||
var assets = new Dictionary<string, string>();
|
||||
foreach (var r in releases)
|
||||
foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
|
||||
assets.TryAdd(CvrmgService.Normalize(Path.GetFileNameWithoutExtension(a.Name)), a.Name);
|
||||
|
||||
foreach (var (key, asset) in assets)
|
||||
{
|
||||
if (key.Length == 0) continue;
|
||||
if (!byKey.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = new AvailableEntry { Key = key, Name = Path.GetFileNameWithoutExtension(asset) };
|
||||
entry.Keys.Add(key);
|
||||
byKey[key] = entry;
|
||||
entries.Add(entry);
|
||||
}
|
||||
if (entry.Repos.All(o => !o.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase)))
|
||||
entry.Repos.Add(new RepoOffer(src.Repo, asset, ModImportService.FilterReleases(releases, asset)));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using SteamKit2;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class BranchCodeService
|
||||
{
|
||||
public static async Task<Dictionary<string, byte[]>> ResolveAsync(string code, string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
return await ResolveAsync(link, code);
|
||||
}
|
||||
|
||||
public static async Task<Dictionary<string, byte[]>> ResolveAsync(SteamLink link, string code)
|
||||
{
|
||||
var call = link.Apps.CheckAppBetaPassword((uint)Paths.AppId, code).ToTask();
|
||||
if (await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(25))) != call)
|
||||
throw new TimeoutException("Steam did not answer the code check. It limits how often codes can be checked; try again in a few minutes.");
|
||||
var reply = await call;
|
||||
if (reply.Result == EResult.OK) return new Dictionary<string, byte[]>(reply.BetaPasswords);
|
||||
throw new InvalidOperationException(reply.Result switch
|
||||
{
|
||||
EResult.InvalidPassword => "Steam did not accept that access code.",
|
||||
EResult.RateLimitExceeded => "Steam is rate limiting code checks. Wait a few minutes and try again.",
|
||||
_ => $"Steam could not check the code ({reply.Result}){(link.IsAnonymous ? "; sign in to Steam in Settings and try again" : "")}.",
|
||||
});
|
||||
}
|
||||
|
||||
public static string ProtectKey(byte[] key) => TokenVault.Protect(Convert.ToBase64String(key));
|
||||
|
||||
public static byte[]? UnprotectKey(string stored)
|
||||
{
|
||||
var text = TokenVault.Unprotect(stored);
|
||||
try { return text.Length == 0 ? null : Convert.FromBase64String(text); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class CacheGuardService
|
||||
{
|
||||
const string AssemblyName = "CVRCacheGuard";
|
||||
const string AssemblyFile = AssemblyName + ".dll";
|
||||
const int UserAssemblyType = 16;
|
||||
|
||||
static string Source => Path.Combine(AppContext.BaseDirectory, "CacheGuard");
|
||||
|
||||
static string Managed(string gameDir) => Path.Combine(gameDir, "ChilloutVR_Data", "Managed");
|
||||
|
||||
public static void Deploy(string gameDir, Action<string> log, string? source = null)
|
||||
{
|
||||
var managed = Managed(gameDir);
|
||||
var scripting = Path.Combine(gameDir, "ChilloutVR_Data", "ScriptingAssemblies.json");
|
||||
var init = Path.Combine(gameDir, "ChilloutVR_Data", "RuntimeInitializeOnLoads.json");
|
||||
source ??= Source;
|
||||
if (!Directory.Exists(source) || !Directory.Exists(managed) || !File.Exists(scripting) || !File.Exists(init)) return;
|
||||
|
||||
var changed = false;
|
||||
var deps = Path.Combine(managed, AssemblyName);
|
||||
Directory.CreateDirectory(deps);
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*.dll"))
|
||||
{
|
||||
var name = Path.GetFileName(file);
|
||||
var target = name == AssemblyFile ? Path.Combine(managed, name) : Path.Combine(deps, name);
|
||||
changed |= CopyIfDifferent(file, target);
|
||||
}
|
||||
|
||||
var scriptingJson = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject();
|
||||
var names = scriptingJson["names"]!.AsArray();
|
||||
var types = scriptingJson["types"]!.AsArray();
|
||||
if (!names.Any(n => n?.GetValue<string>() == AssemblyFile))
|
||||
{
|
||||
names.Add(AssemblyFile);
|
||||
types.Add(UserAssemblyType);
|
||||
WriteJson(scripting, scriptingJson);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var initJson = JsonNode.Parse(File.ReadAllText(init))!.AsObject();
|
||||
var root = initJson["root"]!.AsArray();
|
||||
if (!root.Any(e => e?["assemblyName"]?.GetValue<string>() == AssemblyName))
|
||||
{
|
||||
root.Add(new JsonObject
|
||||
{
|
||||
["assemblyName"] = AssemblyName,
|
||||
["nameSpace"] = AssemblyName,
|
||||
["className"] = "Entry",
|
||||
["methodName"] = "Init",
|
||||
["loadTypes"] = 2,
|
||||
["isUnityClass"] = false,
|
||||
});
|
||||
WriteJson(init, initJson);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) log("Multi-instance cache guard added to this install.");
|
||||
}
|
||||
|
||||
public static void Remove(string gameDir, Action<string> log)
|
||||
{
|
||||
var data = Path.Combine(gameDir, "ChilloutVR_Data");
|
||||
var scripting = Path.Combine(data, "ScriptingAssemblies.json");
|
||||
var init = Path.Combine(data, "RuntimeInitializeOnLoads.json");
|
||||
var changed = false;
|
||||
|
||||
if (File.Exists(scripting))
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject();
|
||||
var names = json["names"]!.AsArray();
|
||||
var types = json["types"]!.AsArray();
|
||||
for (var i = names.Count - 1; i >= 0; i--)
|
||||
if (names[i]?.GetValue<string>() == AssemblyFile)
|
||||
{
|
||||
names.RemoveAt(i);
|
||||
if (i < types.Count) types.RemoveAt(i);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) WriteJson(scripting, json);
|
||||
}
|
||||
if (File.Exists(init))
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(init))!.AsObject();
|
||||
var root = json["root"]!.AsArray();
|
||||
var before = root.Count;
|
||||
for (var i = root.Count - 1; i >= 0; i--)
|
||||
if (root[i]?["assemblyName"]?.GetValue<string>() == AssemblyName) root.RemoveAt(i);
|
||||
if (root.Count != before)
|
||||
{
|
||||
WriteJson(init, json);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var managed = Managed(gameDir);
|
||||
try
|
||||
{
|
||||
File.Delete(Path.Combine(managed, AssemblyFile));
|
||||
var deps = Path.Combine(managed, AssemblyName);
|
||||
if (Directory.Exists(deps)) Directory.Delete(deps, true);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
if (changed) log("Multi-instance cache guard removed from this install.");
|
||||
}
|
||||
|
||||
static bool CopyIfDifferent(string source, string target)
|
||||
{
|
||||
var s = new FileInfo(source);
|
||||
var t = new FileInfo(target);
|
||||
if (t.Exists && t.Length == s.Length && t.LastWriteTimeUtc == s.LastWriteTimeUtc) return false;
|
||||
try
|
||||
{
|
||||
File.Copy(source, target, true);
|
||||
File.SetLastWriteTimeUtc(target, s.LastWriteTimeUtc);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (t.Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteJson(string path, JsonNode node) =>
|
||||
File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), new System.Text.UTF8Encoding(false));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public enum CacheState { Missing, Shared, Local, LinkedElsewhere }
|
||||
|
||||
public static class CacheService
|
||||
{
|
||||
public static CacheState GetState(InstallInfo install)
|
||||
{
|
||||
var path = Paths.CacheDir(install);
|
||||
var info = new DirectoryInfo(path);
|
||||
if (!info.Exists) return CacheState.Missing;
|
||||
if (!info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return CacheState.Local;
|
||||
var target = info.LinkTarget;
|
||||
return target != null && SamePath(target, Paths.SharedCache) ? CacheState.Shared : CacheState.LinkedElsewhere;
|
||||
}
|
||||
|
||||
static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static Task LinkAsync(InstallInfo install, Action<string> log, CancellationToken ct) => Task.Run(() =>
|
||||
{
|
||||
var path = Paths.CacheDir(install);
|
||||
Directory.CreateDirectory(Paths.SharedCache);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
switch (GetState(install))
|
||||
{
|
||||
case CacheState.Shared:
|
||||
return;
|
||||
case CacheState.Local:
|
||||
MergeInto(path, Paths.SharedCache, log, ct);
|
||||
if (Directory.EnumerateFileSystemEntries(path).Any())
|
||||
throw new IOException($"Cache folder still has content after merge: {path}");
|
||||
Directory.Delete(path);
|
||||
break;
|
||||
case CacheState.LinkedElsewhere:
|
||||
Directory.Delete(path, false);
|
||||
break;
|
||||
}
|
||||
CreateJunction(path, Paths.SharedCache);
|
||||
log("Cache now points at the shared cache.");
|
||||
}, ct);
|
||||
|
||||
public static void MergeInto(string source, string shared, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(shared);
|
||||
var moved = 0;
|
||||
var dupes = 0;
|
||||
var sameRoot = string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(shared)), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(source).ToList())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var name = Path.GetFileName(entry);
|
||||
var dest = Path.Combine(shared, name);
|
||||
var isDir = Directory.Exists(entry);
|
||||
|
||||
if (!(isDir ? Directory.Exists(dest) : File.Exists(dest)))
|
||||
{
|
||||
if (isDir)
|
||||
{
|
||||
if (sameRoot) Directory.Move(entry, dest);
|
||||
else
|
||||
{
|
||||
CopyTree(entry, dest, ct);
|
||||
Directory.Delete(entry, true);
|
||||
}
|
||||
}
|
||||
else File.Move(entry, dest);
|
||||
moved++;
|
||||
}
|
||||
else if (isDir)
|
||||
{
|
||||
if (MergeTree(entry, dest, ct)) Directory.Delete(entry, true);
|
||||
dupes++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (new FileInfo(entry).Length == new FileInfo(dest).Length) File.Delete(entry);
|
||||
dupes++;
|
||||
}
|
||||
}
|
||||
log($"Cache merge: {moved} entries moved, {dupes} duplicates removed.");
|
||||
}
|
||||
|
||||
public static void CopyInto(string source, string shared, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(shared);
|
||||
var copied = 0;
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var target = Path.Combine(shared, Path.GetRelativePath(source, file));
|
||||
if (File.Exists(target)) continue;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target);
|
||||
copied++;
|
||||
}
|
||||
log($"Cache copy: {copied} files added to the shared cache.");
|
||||
}
|
||||
|
||||
static bool MergeTree(string src, string dst, CancellationToken ct)
|
||||
{
|
||||
var clean = true;
|
||||
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories).ToList())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var rel = Path.GetRelativePath(src, file);
|
||||
var target = Path.Combine(dst, rel);
|
||||
if (!File.Exists(target))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Move(file, target);
|
||||
}
|
||||
else if (new FileInfo(file).Length == new FileInfo(target).Length) File.Delete(file);
|
||||
else clean = false;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
static void CopyTree(string src, string dst, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dst);
|
||||
foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var target = Path.Combine(dst, Path.GetRelativePath(src, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target, true);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CreateJunction(string link, string target)
|
||||
{
|
||||
var psi = new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("/c");
|
||||
psi.ArgumentList.Add("mklink");
|
||||
psi.ArgumentList.Add("/J");
|
||||
psi.ArgumentList.Add(link);
|
||||
psi.ArgumentList.Add(target);
|
||||
using var p = Process.Start(psi)!;
|
||||
var err = p.StandardError.ReadToEnd() + p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
if (p.ExitCode != 0) throw new IOException($"mklink failed: {err.Trim()}");
|
||||
}
|
||||
|
||||
public static void RemoveLinkIfJunction(string path)
|
||||
{
|
||||
var info = new DirectoryInfo(path);
|
||||
if (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint)) Directory.Delete(path, false);
|
||||
}
|
||||
|
||||
public static long SizeOf(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return 0;
|
||||
long total = 0;
|
||||
foreach (var f in new DirectoryInfo(path).EnumerateFiles("*", SearchOption.AllDirectories)) total += f.Length;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class ConfigStore
|
||||
{
|
||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||
|
||||
public static AppConfig Load()
|
||||
{
|
||||
AppConfig cfg;
|
||||
try
|
||||
{
|
||||
cfg = File.Exists(Paths.ConfigFile)
|
||||
? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(Paths.ConfigFile), Options) ?? new AppConfig()
|
||||
: new AppConfig();
|
||||
}
|
||||
catch
|
||||
{
|
||||
File.Copy(Paths.ConfigFile, Paths.ConfigFile + ".broken", true);
|
||||
cfg = new AppConfig();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cfg.LibraryRoot)) cfg.LibraryRoot = Paths.Root;
|
||||
Paths.Root = cfg.LibraryRoot;
|
||||
|
||||
if (cfg.Mods.All(m => m.Id != ModDef.MelonLoaderId))
|
||||
cfg.Mods.Insert(0, new ModDef
|
||||
{
|
||||
Id = ModDef.MelonLoaderId,
|
||||
Name = "MelonLoader",
|
||||
Repo = "LavaGang/MelonLoader",
|
||||
Type = "Root",
|
||||
AssetFilter = "MelonLoader.x64.zip",
|
||||
});
|
||||
EnsureSources(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
static readonly (string Name, string Repo)[] DefaultRepos =
|
||||
{
|
||||
("NAK CVR Mods", "NotAKidoS/NAK_CVR_Mods"),
|
||||
("Kafe CVR Mods", "kafeijao/Kafe_CVR_Mods"),
|
||||
("SDraw mods", "SDraw/ml_mods_cvr"),
|
||||
("Nirv mods", "Nirv-git/CVRMods-Nirv"),
|
||||
("Daky mods", "dakyneko/DakyModsCVR"),
|
||||
("ddakebono mods", "ddakebono/Chillout-VR-Mods"),
|
||||
};
|
||||
|
||||
static void EnsureSources(AppConfig cfg)
|
||||
{
|
||||
if (cfg.Sources.All(s => s.Id != SourceDef.CvrmgId))
|
||||
cfg.Sources.Insert(0, new SourceDef { Id = SourceDef.CvrmgId, Name = "CVRMG (approved mods)", Kind = "cvrmg", Builtin = true });
|
||||
foreach (var (name, repo) in DefaultRepos)
|
||||
if (cfg.Sources.All(s => !s.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase)) && !cfg.SeededSources.Contains(repo))
|
||||
{
|
||||
cfg.Sources.Add(new SourceDef { Id = repo.Replace('/', '_'), Name = name, Kind = "github", Repo = repo, Builtin = true });
|
||||
cfg.SeededSources.Add(repo);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<BranchInfo> LoadBranches()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(Paths.BranchCache)
|
||||
? JsonSerializer.Deserialize<List<BranchInfo>>(File.ReadAllText(Paths.BranchCache), Options) ?? new()
|
||||
: new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public static void SaveBranches(IEnumerable<BranchInfo> branches)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Paths.BranchCache)!);
|
||||
File.WriteAllText(Paths.BranchCache, JsonSerializer.Serialize(branches, Options));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void Save(AppConfig cfg)
|
||||
{
|
||||
Directory.CreateDirectory(Paths.ConfigDir);
|
||||
var tmp = Paths.ConfigFile + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(cfg, Options));
|
||||
File.Move(tmp, Paths.ConfigFile, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record CvrmgMod(
|
||||
int Id, string Name, string[] Aliases, string Category, string ModVersion, string GameVersion, string LoaderVersion,
|
||||
string Type, string Author, string Description, string DownloadLink, string SourceLink, string Hash, int ApprovalStatus, DateTime Updated)
|
||||
{
|
||||
public string StatusText => ApprovalStatus switch { 1 => "approved", 2 => "outdated", 3 => "obsolete", _ => "unknown" };
|
||||
public string ModType => Type.Equals("Plugin", StringComparison.OrdinalIgnoreCase) ? "Plugins" : "Mods";
|
||||
}
|
||||
|
||||
public class CvrmgService
|
||||
{
|
||||
const string ListUrl = "https://api.cvrmg.com/v1/mods";
|
||||
readonly HttpClient http = new();
|
||||
List<CvrmgMod>? cache;
|
||||
DateTime fetched;
|
||||
|
||||
public CvrmgService()
|
||||
{
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0"));
|
||||
}
|
||||
|
||||
public static string Normalize(string s) => Regex.Replace(s ?? "", "[^a-z0-9]", "", RegexOptions.IgnoreCase).ToLowerInvariant();
|
||||
|
||||
public async Task<List<CvrmgMod>> GetAllAsync(CancellationToken ct, bool force = false)
|
||||
{
|
||||
if (!force && cache != null && DateTime.UtcNow - fetched < TimeSpan.FromMinutes(10)) return cache;
|
||||
var path = Path.Combine(Paths.Root, "sources", "cvrmg.json");
|
||||
string? json = null;
|
||||
if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1))
|
||||
json = await File.ReadAllTextAsync(path, ct);
|
||||
if (json == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var resp = await http.GetAsync(ListUrl, ct);
|
||||
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CVRMG returned {(int)resp.StatusCode}.");
|
||||
json = await resp.Content.ReadAsStringAsync(ct);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, json, ct);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException && File.Exists(path))
|
||||
{
|
||||
json = await File.ReadAllTextAsync(path, ct);
|
||||
}
|
||||
}
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var list = new List<CvrmgMod>();
|
||||
foreach (var m in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (!m.TryGetProperty("versions", out var versions) || versions.ValueKind != JsonValueKind.Array) continue;
|
||||
JsonElement? best = null;
|
||||
var bestNo = int.MinValue;
|
||||
foreach (var v in versions.EnumerateArray())
|
||||
{
|
||||
var no = v.TryGetProperty("_version", out var n) && n.ValueKind == JsonValueKind.Number ? n.GetInt32() : 0;
|
||||
if (no >= bestNo) { best = v; bestNo = no; }
|
||||
}
|
||||
if (best is not { } ver) continue;
|
||||
|
||||
string S(JsonElement e, string k) => e.TryGetProperty(k, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? "" : "";
|
||||
var aliases = m.TryGetProperty("aliases", out var al) && al.ValueKind == JsonValueKind.Array
|
||||
? al.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.String).Select(a => a.GetString()!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
DateTime.TryParse(S(ver, "updateDate"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var updated);
|
||||
list.Add(new CvrmgMod(
|
||||
m.GetProperty("_id").GetInt32(), S(ver, "name"), aliases, S(m, "category"),
|
||||
S(ver, "modVersion"), S(ver, "gameVersion"), S(ver, "loaderVersion").TrimStart('v'), S(ver, "modType"),
|
||||
S(ver, "author"), S(ver, "description"), S(ver, "downloadLink"), S(ver, "sourceLink"), S(ver, "hash"),
|
||||
ver.TryGetProperty("approvalStatus", out var st) && st.ValueKind == JsonValueKind.Number ? st.GetInt32() : 0, updated));
|
||||
}
|
||||
cache = list;
|
||||
fetched = DateTime.UtcNow;
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ReleaseInfo ToRelease(CvrmgMod m, string fileName) => new()
|
||||
{
|
||||
Tag = m.ModVersion,
|
||||
Published = m.Updated,
|
||||
GameVersion = m.GameVersion,
|
||||
LoaderVersion = m.LoaderVersion,
|
||||
Assets = { new AssetInfo { Name = fileName, Url = m.DownloadLink, Hash = m.Hash } },
|
||||
};
|
||||
|
||||
public static void ApplyMeta(ModDef def, CvrmgMod m)
|
||||
{
|
||||
def.CvrmgId = m.Id;
|
||||
if (def.Author.Length == 0) def.Author = m.Author;
|
||||
def.Description = m.Description;
|
||||
def.LatestVersion = m.ModVersion;
|
||||
def.Category = m.Category;
|
||||
def.Status = m.StatusText;
|
||||
if (def.SourceLink.Length == 0) def.SourceLink = m.SourceLink;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class FileOps
|
||||
{
|
||||
public static void CopyTree(string src, string dst, Func<string, bool> skip, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(dst);
|
||||
var count = 0;
|
||||
foreach (var entry in new DirectoryInfo(src).EnumerateFileSystemInfos())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (skip(entry.FullName)) continue;
|
||||
var target = Path.Combine(dst, entry.Name);
|
||||
if (entry is DirectoryInfo d)
|
||||
{
|
||||
if (d.Attributes.HasFlag(FileAttributes.ReparsePoint)) continue;
|
||||
CopyTree(d.FullName, target, skip, log, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Copy(entry.FullName, target, true);
|
||||
if (++count % 200 == 0) log($"Copying {Path.GetFileName(src)}...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void DeleteDirectorySafe(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
foreach (var d in new DirectoryInfo(path).EnumerateDirectories("*", SearchOption.AllDirectories).ToList())
|
||||
if (d.Exists && d.Attributes.HasFlag(FileAttributes.ReparsePoint)) d.Delete(false);
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
|
||||
public static string Slug(string name)
|
||||
{
|
||||
var s = new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()).Trim('-');
|
||||
return s.Length == 0 ? "install" : s;
|
||||
}
|
||||
|
||||
public static string FormatSize(long bytes) =>
|
||||
bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):0.0} GB" : $"{bytes / (double)(1L << 20):0} MB";
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class GameWindow
|
||||
{
|
||||
const uint WM_SETICON = 0x0080;
|
||||
const int ICON_SMALL = 0;
|
||||
const int ICON_BIG = 1;
|
||||
const int SM_CXICON = 11;
|
||||
const int SM_CXSMICON = 49;
|
||||
|
||||
delegate bool EnumProc(IntPtr hwnd, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool EnumWindows(EnumProc proc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern bool IsWindowVisible(IntPtr hwnd);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern int GetClassName(IntPtr hwnd, StringBuilder name, int max);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam, uint flags, uint timeout, out IntPtr result);
|
||||
|
||||
const uint SMTO_ABORTIFHUNG = 2;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern int GetSystemMetrics(int index);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
static extern uint PrivateExtractIcons(string file, int index, int cx, int cy, IntPtr[] icons, uint[] ids, uint count, uint flags);
|
||||
|
||||
static IntPtr FindUnityWindow(int pid)
|
||||
{
|
||||
var found = IntPtr.Zero;
|
||||
var name = new StringBuilder(64);
|
||||
EnumWindows((hwnd, _) =>
|
||||
{
|
||||
GetWindowThreadProcessId(hwnd, out var owner);
|
||||
if (owner != pid || !IsWindowVisible(hwnd)) return true;
|
||||
name.Clear();
|
||||
GetClassName(hwnd, name, name.Capacity);
|
||||
if (name.ToString() == "UnityWndClass") found = hwnd;
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return found;
|
||||
}
|
||||
|
||||
static void SetIcons(IntPtr hwnd, string exe)
|
||||
{
|
||||
foreach (var (kind, size) in new[] { (ICON_SMALL, GetSystemMetrics(SM_CXSMICON)), (ICON_BIG, GetSystemMetrics(SM_CXICON)) })
|
||||
{
|
||||
var icons = new IntPtr[1];
|
||||
var ids = new uint[1];
|
||||
if (PrivateExtractIcons(exe, 0, size, size, icons, ids, 1, 0) > 0 && icons[0] != IntPtr.Zero)
|
||||
SendMessageTimeout(hwnd, WM_SETICON, (IntPtr)kind, icons[0], SMTO_ABORTIFHUNG, 1000, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task ApplyIconWhenReadyAsync(Process proc, string exe)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
try
|
||||
{
|
||||
var hwnd = IntPtr.Zero;
|
||||
for (var i = 0; i < 240 && hwnd == IntPtr.Zero; i++)
|
||||
{
|
||||
if (proc.HasExited) return;
|
||||
hwnd = FindUnityWindow(proc.Id);
|
||||
if (hwnd == IntPtr.Zero) await Task.Delay(250);
|
||||
}
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
|
||||
foreach (var wait in new[] { 0, 3000, 7000, 20000 })
|
||||
{
|
||||
await Task.Delay(wait);
|
||||
if (proc.HasExited) return;
|
||||
var current = FindUnityWindow(proc.Id);
|
||||
if (current != IntPtr.Zero) SetIcons(current, exe);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public class GitHubService
|
||||
{
|
||||
readonly HttpClient http = new();
|
||||
readonly Func<string> token;
|
||||
|
||||
public GitHubService(Func<string> token)
|
||||
{
|
||||
this.token = token;
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0"));
|
||||
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
}
|
||||
|
||||
public static string? ParseRepo(string input)
|
||||
{
|
||||
input = input.Trim();
|
||||
var m = Regex.Match(input, @"^(?:https?://github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?(?:/.*)?$");
|
||||
return m.Success ? $"{m.Groups[1].Value}/{m.Groups[2].Value}" : null;
|
||||
}
|
||||
|
||||
HttpRequestMessage Request(string url)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
var t = token();
|
||||
if (!string.IsNullOrWhiteSpace(t)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t);
|
||||
return req;
|
||||
}
|
||||
|
||||
public async Task<List<ReleaseInfo>> GetReleasesAsync(string repo, CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.SendAsync(Request($"https://api.github.com/repos/{repo}/releases?per_page=40"), ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var hint = (int)resp.StatusCode is 403 or 429 ? " (rate limited: add a GitHub token in Settings)" : "";
|
||||
throw new InvalidOperationException($"GitHub returned {(int)resp.StatusCode} for {repo}{hint}.");
|
||||
}
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var list = new List<ReleaseInfo>();
|
||||
foreach (var r in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (r.GetProperty("draft").GetBoolean()) continue;
|
||||
var rel = new ReleaseInfo
|
||||
{
|
||||
Tag = r.GetProperty("tag_name").GetString() ?? "",
|
||||
Prerelease = r.GetProperty("prerelease").GetBoolean(),
|
||||
Published = r.TryGetProperty("published_at", out var p) && p.ValueKind == JsonValueKind.String ? p.GetDateTime() : default,
|
||||
};
|
||||
foreach (var a in r.GetProperty("assets").EnumerateArray())
|
||||
rel.Assets.Add(new AssetInfo
|
||||
{
|
||||
Name = a.GetProperty("name").GetString() ?? "",
|
||||
Url = a.GetProperty("browser_download_url").GetString() ?? "",
|
||||
Size = a.GetProperty("size").GetInt64(),
|
||||
});
|
||||
list.Add(rel);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<ReleaseInfo?> GetMelonLoaderNightlyAsync(CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.SendAsync(Request(
|
||||
"https://api.github.com/repos/LavaGang/MelonLoader/actions/workflows/build.yml/runs?branch=master&status=success&per_page=1"), ct);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var runs = doc.RootElement.GetProperty("workflow_runs");
|
||||
if (runs.GetArrayLength() == 0) return null;
|
||||
var run = runs[0];
|
||||
var sha = run.GetProperty("head_sha").GetString() ?? "";
|
||||
var id = run.GetProperty("id").GetInt64();
|
||||
var rel = new ReleaseInfo
|
||||
{
|
||||
Tag = $"nightly-{sha[..Math.Min(7, sha.Length)]}",
|
||||
Prerelease = true,
|
||||
Published = run.GetProperty("created_at").GetDateTime(),
|
||||
};
|
||||
rel.Assets.Add(new AssetInfo
|
||||
{
|
||||
Name = "MelonLoader.x64.zip",
|
||||
Url = $"https://nightly.link/LavaGang/MelonLoader/actions/runs/{id}/MelonLoader.Windows.x64.CI.Release.zip",
|
||||
});
|
||||
return rel;
|
||||
}
|
||||
|
||||
public async Task<string> DownloadAsync(string url, string path, CancellationToken ct)
|
||||
{
|
||||
using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(path);
|
||||
await src.CopyToAsync(dst, ct);
|
||||
var last = resp.RequestMessage?.RequestUri?.Segments.LastOrDefault() ?? "";
|
||||
return Uri.UnescapeDataString(last.TrimEnd('/'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class HookService
|
||||
{
|
||||
static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static async Task<List<string>> RunEventAsync(
|
||||
IEnumerable<HookDef> hooks, InstallInfo install, string evt, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
foreach (var hook in hooks.Where(h => install.HookIds.Contains(h.Id) && h.Runs(evt)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await RunAsync(hook, install, buildId, log, ct)) failures.Add(hook.Name);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception e)
|
||||
{
|
||||
log($"[{hook.Name}] failed: {e.Message}");
|
||||
failures.Add(hook.Name);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
public static async Task<bool> RunAsync(HookDef hook, InstallInfo install, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var game = Paths.GameDir(install);
|
||||
if (!Directory.Exists(Path.Combine(game, "ChilloutVR_Data")))
|
||||
{
|
||||
log($"[{hook.Name}] skipped: the game files are not installed yet.");
|
||||
return true;
|
||||
}
|
||||
log($"[{hook.Name}] running for {install.Name}...");
|
||||
return await RunCommandAsync(hook, install, game, buildId, log, ct);
|
||||
}
|
||||
|
||||
static async Task<bool> RunCommandAsync(HookDef hook, InstallInfo install, string game, string buildId, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hook.Command)) throw new InvalidOperationException("The hook has no command set.");
|
||||
string Expand(string s) => s
|
||||
.Replace("{game}", game, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{install}", install.Name, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{branch}", install.Branch, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{build}", buildId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var command = Expand(hook.Command.Trim());
|
||||
var args = SplitArgs(Expand(hook.Arguments)).ToList();
|
||||
var ext = Path.GetExtension(command).ToLowerInvariant();
|
||||
ProcessStartInfo psi;
|
||||
if (ext == ".ps1")
|
||||
{
|
||||
psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"));
|
||||
foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command }) psi.ArgumentList.Add(a);
|
||||
}
|
||||
else if (ext is ".bat" or ".cmd")
|
||||
{
|
||||
psi = new ProcessStartInfo("cmd.exe");
|
||||
psi.ArgumentList.Add("/c");
|
||||
psi.ArgumentList.Add(command);
|
||||
}
|
||||
else psi = new ProcessStartInfo(command);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
psi.Environment["CVR_GAME_DIR"] = game;
|
||||
psi.Environment["CVR_INSTALL_NAME"] = install.Name;
|
||||
psi.Environment["CVR_BRANCH"] = install.Branch;
|
||||
psi.Environment["CVR_BUILD"] = buildId;
|
||||
|
||||
var code = await RunProcessAsync(psi, game, hook.Name, log, ct);
|
||||
if (code != 0) log($"[{hook.Name}] exited with code {code}");
|
||||
return code == 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunProcessAsync(ProcessStartInfo psi, string workDir, string label, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
psi.WorkingDirectory = workDir;
|
||||
psi.UseShellExecute = false;
|
||||
psi.CreateNoWindow = true;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
psi.RedirectStandardInput = true;
|
||||
psi.StandardOutputEncoding = Encoding.UTF8;
|
||||
psi.StandardErrorEncoding = Encoding.UTF8;
|
||||
|
||||
using var proc = new Process { StartInfo = psi };
|
||||
proc.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); };
|
||||
proc.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); };
|
||||
proc.Start();
|
||||
proc.StandardInput.Close();
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Timeout);
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
throw new TimeoutException($"timed out after {Timeout.TotalMinutes:0} minutes");
|
||||
}
|
||||
proc.WaitForExit();
|
||||
return proc.ExitCode;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> SplitArgs(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var quoted = false;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (c == '"') quoted = !quoted;
|
||||
else if (char.IsWhiteSpace(c) && !quoted)
|
||||
{
|
||||
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
|
||||
}
|
||||
else sb.Append(c);
|
||||
}
|
||||
if (sb.Length > 0) yield return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class InstallFolders
|
||||
{
|
||||
public static string UniqueFolder(IEnumerable<InstallInfo> all, InstallInfo self, string name)
|
||||
{
|
||||
var stem = $"{Paths.SafeFolderName(name)}-{self.Id[..4]}";
|
||||
var candidate = stem;
|
||||
for (var n = 2; Taken(all, self, candidate); n++) candidate = $"{stem}-{n}";
|
||||
return candidate;
|
||||
}
|
||||
|
||||
static bool Taken(IEnumerable<InstallInfo> all, InstallInfo self, string candidate)
|
||||
{
|
||||
if (all.Any(i => !ReferenceEquals(i, self) && i.Folder.Equals(candidate, StringComparison.OrdinalIgnoreCase))) return true;
|
||||
if (candidate.Equals(self.Folder, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return Directory.Exists(Path.Combine(Paths.InstallsRoot, candidate));
|
||||
}
|
||||
|
||||
public static void Rename(InstallInfo info, string desiredFolder)
|
||||
{
|
||||
var oldDir = Paths.InstallDir(info);
|
||||
var newDir = Path.Combine(Paths.InstallsRoot, desiredFolder);
|
||||
if (oldDir.Equals(newDir, StringComparison.Ordinal)) return;
|
||||
if (oldDir.Equals(newDir, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var temp = oldDir + ".renaming";
|
||||
Directory.Move(oldDir, temp);
|
||||
Directory.Move(temp, newDir);
|
||||
}
|
||||
else Directory.Move(oldDir, newDir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class MlLogColours
|
||||
{
|
||||
const string Esc = "\u001b[";
|
||||
const string Reset = "\u001b[0m";
|
||||
const long MaxScan = 16L << 20;
|
||||
|
||||
static readonly Regex Sgr = new(@"\u001b\[([0-9;]*)m", RegexOptions.Compiled);
|
||||
static readonly Regex AnyEsc = new(@"\u001b\[[0-9;?]*[A-Za-z]", RegexOptions.Compiled);
|
||||
static readonly Regex Stamp = new(@"^\[(\d\d:\d\d:\d\d\.\d{3})\] (.*)$", RegexOptions.Compiled);
|
||||
|
||||
sealed class FileIndex
|
||||
{
|
||||
public long Offset;
|
||||
public string? State;
|
||||
public readonly Dictionary<string, string> Lines = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, FileIndex> Files = new(StringComparer.OrdinalIgnoreCase);
|
||||
static readonly object Gate = new();
|
||||
static long version;
|
||||
|
||||
public static long Refresh(params string[] paths)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (var path in paths) Scan(path);
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
static void Scan(string path)
|
||||
{
|
||||
if (!Files.TryGetValue(path, out var index)) Files[path] = index = new FileIndex();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
if (index.Offset != 0 || index.Lines.Count > 0) { Files[path] = new FileIndex(); version++; }
|
||||
return;
|
||||
}
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
var length = fs.Length;
|
||||
if (length < index.Offset) { Files[path] = index = new FileIndex(); version++; }
|
||||
if (length == index.Offset) return;
|
||||
var start = Math.Max(index.Offset, length - MaxScan);
|
||||
var buffer = new byte[length - start];
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
fs.ReadExactly(buffer);
|
||||
var end = Array.LastIndexOf(buffer, (byte)'\n');
|
||||
if (end < 0) return;
|
||||
var text = Encoding.UTF8.GetString(buffer, 0, end + 1);
|
||||
if (start > index.Offset) index.State = null;
|
||||
index.Offset = start + end + 1;
|
||||
foreach (var raw in text.Split('\n')) Add(index, raw.TrimEnd('\r'));
|
||||
version++;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
static void Add(FileIndex index, string line)
|
||||
{
|
||||
var hasEsc = line.IndexOf('\u001b') >= 0;
|
||||
if (!hasEsc && index.State == null) return;
|
||||
var styled = index.State == null ? line : Esc + index.State + "m" + line;
|
||||
foreach (Match m in Sgr.Matches(line))
|
||||
{
|
||||
var codes = m.Groups[1].Value;
|
||||
index.State = codes is "" or "0" or "39" ? null : codes;
|
||||
}
|
||||
if (index.State != null) styled += Reset;
|
||||
var plain = AnyEsc.Replace(line, "");
|
||||
if (plain.Trim().Length > 0) index.Lines[plain] = styled;
|
||||
}
|
||||
|
||||
public static string Colour(string mlText, params string[] paths)
|
||||
{
|
||||
var sb = new StringBuilder(mlText.Length * 2);
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (var raw in mlText.Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
string? styled = null;
|
||||
for (var i = paths.Length - 1; i >= 0 && styled == null; i--)
|
||||
if (Files.TryGetValue(paths[i], out var index)) index.Lines.TryGetValue(line, out styled);
|
||||
if (styled == null && Stamp.Match(line) is { Success: true } m)
|
||||
styled = $"{Esc}38;2;211;211;211m[{Reset}{Esc}38;2;50;205;50m{m.Groups[1].Value}{Reset}{Esc}38;2;211;211;211m] {m.Groups[2].Value}{Reset}";
|
||||
sb.Append(styled ?? line).Append('\n');
|
||||
}
|
||||
}
|
||||
if (sb.Length > 0 && !mlText.EndsWith('\n')) sb.Length--;
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record Identified(AssemblyDetails File, string Folder, string Source, string Repo, string AssetName, CvrmgMod? Cvrmg, string Why);
|
||||
|
||||
public class ModImportService
|
||||
{
|
||||
static readonly string[] Folders = { "Mods", "Plugins", "UserLibs" };
|
||||
|
||||
readonly GitHubService github;
|
||||
readonly CvrmgService cvrmg;
|
||||
readonly Dictionary<string, List<ReleaseInfo>> repoReleases = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ModImportService(GitHubService github, CvrmgService cvrmg)
|
||||
{
|
||||
this.github = github;
|
||||
this.cvrmg = cvrmg;
|
||||
}
|
||||
|
||||
public static string Norm(string s) => CvrmgService.Normalize(s);
|
||||
|
||||
public async Task<List<Identified>> IdentifyAsync(string gameDir, IEnumerable<SourceDef> sources, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var files = new List<(AssemblyDetails Details, string Folder)>();
|
||||
foreach (var folder in Folders)
|
||||
{
|
||||
var dir = Path.Combine(gameDir, folder);
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var f in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
|
||||
files.Add((AssemblyReader.Read(f), folder));
|
||||
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||
log($"Skipping folder {folder}\\{Path.GetFileName(sub)} (only loose DLLs are imported).");
|
||||
}
|
||||
log($"Found {files.Count} mod files.");
|
||||
|
||||
List<CvrmgMod> catalog = new();
|
||||
try { catalog = await cvrmg.GetAllAsync(ct); }
|
||||
catch (Exception e) when (e is not OperationCanceledException) { log($"CVRMG unavailable ({e.Message}); matching without it."); }
|
||||
|
||||
var index = new Dictionary<string, List<(string Repo, string Asset)>>();
|
||||
foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0))
|
||||
{
|
||||
var releases = await ReleasesAsync(src.Repo, log, ct);
|
||||
foreach (var r in releases)
|
||||
foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var key = Norm(a.Name);
|
||||
if (!index.TryGetValue(key, out var l)) index[key] = l = new();
|
||||
if (l.All(x => !x.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) l.Add((src.Repo, a.Name));
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<Identified>();
|
||||
foreach (var (d, folder) in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var cm = catalog.FirstOrDefault(m => m.Hash.Length > 0 && m.Hash == d.Sha256);
|
||||
var cmWhy = cm != null ? "same file as the current CVRMG release" : "";
|
||||
if (cm == null)
|
||||
{
|
||||
var keys = new[] { d.MelonName, d.AssemblyName, Path.GetFileNameWithoutExtension(d.FileName) }
|
||||
.Select(Norm).Where(k => k.Length > 0).Distinct().ToList();
|
||||
cm = catalog.Where(m => new[] { m.Name }.Concat(m.Aliases).Select(Norm).Any(keys.Contains))
|
||||
.OrderBy(m => m.ApprovalStatus == 1 ? 0 : 1).ThenByDescending(m => m.Updated).FirstOrDefault();
|
||||
if (cm != null) cmWhy = "name match in CVRMG";
|
||||
}
|
||||
|
||||
string? repo = null, asset = null, why = "";
|
||||
var link = GitHubService.ParseRepo(d.MelonLink.Contains("github.com", StringComparison.OrdinalIgnoreCase) ? d.MelonLink : "");
|
||||
if (link != null)
|
||||
{
|
||||
var rel = await ReleasesAsync(link, log, ct);
|
||||
var hit = rel.SelectMany(r => r.Assets).FirstOrDefault(a => Norm(a.Name) == Norm(d.FileName));
|
||||
if (hit != null) { repo = link; asset = hit.Name; why = "download link in the mod's own metadata"; }
|
||||
}
|
||||
if (repo == null && index.TryGetValue(Norm(d.FileName), out var cands))
|
||||
{
|
||||
var pick = cands.FirstOrDefault(c => d.MelonAuthor.Length > 0 && c.Repo.Split('/')[0].Equals(d.MelonAuthor, StringComparison.OrdinalIgnoreCase));
|
||||
if (pick == default) pick = cands[0];
|
||||
repo = pick.Repo;
|
||||
asset = pick.Asset;
|
||||
why = "release assets of " + repo;
|
||||
}
|
||||
|
||||
if (repo != null) result.Add(new Identified(d, folder, "github", repo, asset!, cm, why));
|
||||
else if (cm != null) result.Add(new Identified(d, folder, "cvrmg", "", "", cm, cmWhy));
|
||||
else result.Add(new Identified(d, folder, "local", "", "", null,
|
||||
d.MelonLink.Length > 0 ? $"no published release contains it; its metadata points at {d.MelonLink}" : "no known source"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Invalidate() => repoReleases.Clear();
|
||||
|
||||
public async Task<List<ReleaseInfo>> ReleasesAsync(string repo, Action<string> log, CancellationToken ct, bool force = false)
|
||||
{
|
||||
if (!force && repoReleases.TryGetValue(repo, out var cached)) return cached;
|
||||
var path = Path.Combine(Paths.Root, "sources", "gh_" + Paths.Safe(repo.Replace('/', '_')) + ".json");
|
||||
try
|
||||
{
|
||||
if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1))
|
||||
{
|
||||
var fromDisk = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
|
||||
if (fromDisk != null) return repoReleases[repo] = fromDisk;
|
||||
}
|
||||
var list = await github.GetReleasesAsync(repo, ct);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(list), ct);
|
||||
return repoReleases[repo] = list;
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var stale = System.Text.Json.JsonSerializer.Deserialize<List<ReleaseInfo>>(await File.ReadAllTextAsync(path, ct));
|
||||
if (stale != null)
|
||||
{
|
||||
log($"{repo}: using cached releases ({e.Message})");
|
||||
return repoReleases[repo] = stale;
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
}
|
||||
log($"{repo}: {e.Message}");
|
||||
return repoReleases[repo] = new();
|
||||
}
|
||||
}
|
||||
|
||||
public static string SnapshotTag(AssemblyDetails d) =>
|
||||
$"installed {d.Version} ({Convert.ToHexString(Convert.FromBase64String(d.Sha256))[..6].ToLowerInvariant()})";
|
||||
|
||||
public int Apply(AppConfig cfg, InstallInfo? install, IEnumerable<Identified> found, Action<string> log, string? gameDir = null)
|
||||
{
|
||||
var game = gameDir ?? Paths.GameDir(install!);
|
||||
var added = 0;
|
||||
foreach (var it in found)
|
||||
{
|
||||
var rel = $"{it.Folder}/{it.File.FileName}";
|
||||
if (install != null && install.Deployed.Values.Any(l => l.Contains(rel, StringComparer.OrdinalIgnoreCase))) continue;
|
||||
|
||||
var def = FindOrCreate(cfg, it);
|
||||
var tag = SnapshotTag(it.File);
|
||||
if (def.Releases.All(r => r.Tag != tag))
|
||||
def.Releases.Add(new ReleaseInfo { Tag = tag, Snapshot = true, Published = File.GetLastWriteTime(Path.Combine(game, it.Folder, it.File.FileName)) });
|
||||
|
||||
var filesDir = Paths.ModFilesDir(def, tag);
|
||||
if (!ModStore.IsDownloaded(def, tag))
|
||||
{
|
||||
Directory.CreateDirectory(filesDir);
|
||||
File.Copy(Path.Combine(game, it.Folder, it.File.FileName), Path.Combine(filesDir, it.File.FileName), true);
|
||||
File.WriteAllText(Path.Combine(Paths.ModVersionDir(def, tag), ".complete"), "");
|
||||
}
|
||||
|
||||
if (install != null)
|
||||
{
|
||||
install.Mods.RemoveAll(a => a.ModId == def.Id);
|
||||
install.Mods.Add(new ModAssignment { ModId = def.Id, Version = tag, Enabled = true });
|
||||
install.Deployed[def.Id] = ModStore.MapFiles(def, tag).Keys.ToList();
|
||||
}
|
||||
added++;
|
||||
log($"{def.Name} {it.File.Version}: {Describe(it)}");
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
static string Describe(Identified it) => it.Source switch
|
||||
{
|
||||
"github" => $"GitHub {it.Repo} ({it.Why})",
|
||||
"cvrmg" => $"CVRMG ({it.Why})",
|
||||
_ => $"kept as a local snapshot ({it.Why})",
|
||||
};
|
||||
|
||||
ModDef FindOrCreate(AppConfig cfg, Identified it)
|
||||
{
|
||||
ModDef? def = it.Source switch
|
||||
{
|
||||
"github" => cfg.Mods.FirstOrDefault(m => m.Source == "github" && m.Repo.Equals(it.Repo, StringComparison.OrdinalIgnoreCase) && Norm(m.AssetFilter) == Norm(it.AssetName)),
|
||||
"cvrmg" => cfg.Mods.FirstOrDefault(m => m.CvrmgId == it.Cvrmg!.Id && m.Source == "cvrmg"),
|
||||
_ => cfg.Mods.FirstOrDefault(m => m.Local && Norm(m.FileName) == Norm(it.File.FileName)),
|
||||
};
|
||||
if (def != null) return def;
|
||||
|
||||
def = new ModDef
|
||||
{
|
||||
Name = it.Cvrmg?.Name is { Length: > 0 } n ? n : it.File.MelonName.Length > 0 ? it.File.MelonName : it.File.AssemblyName,
|
||||
Type = it.Folder,
|
||||
FileName = it.File.FileName,
|
||||
Author = it.File.MelonAuthor,
|
||||
Source = it.Source,
|
||||
Local = it.Source == "local",
|
||||
};
|
||||
if (it.Cvrmg != null) CvrmgService.ApplyMeta(def, it.Cvrmg);
|
||||
if (def.SourceLink.Length == 0 && it.File.MelonLink.Length > 0) def.SourceLink = it.File.MelonLink;
|
||||
switch (it.Source)
|
||||
{
|
||||
case "github":
|
||||
def.Repo = it.Repo;
|
||||
def.AssetFilter = it.AssetName;
|
||||
def.SourceLink = $"https://github.com/{it.Repo}";
|
||||
def.Releases = FilterReleases(repoReleases.GetValueOrDefault(it.Repo) ?? new(), def.AssetFilter);
|
||||
break;
|
||||
case "cvrmg":
|
||||
def.Releases = new() { CvrmgService.ToRelease(it.Cvrmg!, it.File.FileName) };
|
||||
break;
|
||||
}
|
||||
cfg.Mods.Add(def);
|
||||
return def;
|
||||
}
|
||||
|
||||
public static List<ReleaseInfo> FilterReleases(IEnumerable<ReleaseInfo> releases, string assetFilter) =>
|
||||
string.IsNullOrWhiteSpace(assetFilter)
|
||||
? releases.ToList()
|
||||
: releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public class ModStore
|
||||
{
|
||||
static readonly string[] KnownRoots = { "Mods", "Plugins", "UserLibs", "UserData", "MelonLoader" };
|
||||
const string Marker = ".complete";
|
||||
|
||||
readonly GitHubService github;
|
||||
|
||||
public ModStore(GitHubService github) => this.github = github;
|
||||
|
||||
public static string ResolveTag(ModDef mod, string version) =>
|
||||
version == "latest" ? mod.LatestTag ?? "" : version;
|
||||
|
||||
public static bool IsDownloaded(ModDef mod, string tag) =>
|
||||
tag.Length > 0 && File.Exists(Path.Combine(Paths.ModVersionDir(mod, tag), Marker));
|
||||
|
||||
public async Task EnsureDownloadedAsync(ModDef mod, string tag, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
if (IsDownloaded(mod, tag)) return;
|
||||
if (mod.Local) throw new InvalidOperationException($"Local files for {mod.Name} are missing from the store.");
|
||||
|
||||
var rel = mod.Releases.FirstOrDefault(r => r.Tag == tag)
|
||||
?? throw new InvalidOperationException($"{mod.Name} has no release {tag}; refresh versions.");
|
||||
var assets = PickAssets(mod, rel);
|
||||
if (assets.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"{mod.Name} {tag}: no downloadable .dll or .zip asset matched. Assets: {string.Join(", ", rel.Assets.Select(a => a.Name))}. Set an asset filter on the mod.");
|
||||
|
||||
var dir = Paths.ModVersionDir(mod, tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
var files = Paths.ModFilesDir(mod, tag);
|
||||
Directory.CreateDirectory(files);
|
||||
|
||||
foreach (var a in assets)
|
||||
{
|
||||
log($"Downloading {a.Name} ({a.Size / 1024} KB)...");
|
||||
var tmp = Path.Combine(dir, Paths.Safe(a.Name));
|
||||
var served = await github.DownloadAsync(a.Url, tmp, ct);
|
||||
if (a.Hash.Length > 0)
|
||||
{
|
||||
var actual = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(tmp, ct)));
|
||||
if (actual != a.Hash)
|
||||
{
|
||||
File.Delete(tmp);
|
||||
throw new InvalidOperationException($"{a.Name}: the downloaded file does not match the hash its source published.");
|
||||
}
|
||||
}
|
||||
var isZip = a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || served.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
|
||||
if (isZip)
|
||||
{
|
||||
ZipFile.ExtractToDirectory(tmp, files, true);
|
||||
File.Delete(tmp);
|
||||
}
|
||||
else
|
||||
{
|
||||
var name = mod.Source == "cvrmg" && served.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? served : a.Name;
|
||||
File.Move(tmp, Path.Combine(files, Paths.Safe(name)), true);
|
||||
}
|
||||
}
|
||||
File.WriteAllText(Path.Combine(dir, Marker), "");
|
||||
}
|
||||
|
||||
static List<AssetInfo> PickAssets(ModDef mod, ReleaseInfo rel)
|
||||
{
|
||||
if (mod.Source == "cvrmg") return rel.Assets.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(mod.AssetFilter))
|
||||
{
|
||||
return rel.Assets.Where(a => AssetMatches(a.Name, mod.AssetFilter)).ToList();
|
||||
}
|
||||
var dlls = rel.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (dlls.Count > 0) return dlls;
|
||||
return rel.Assets.Where(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).Take(1).ToList();
|
||||
}
|
||||
|
||||
public static bool AssetMatches(string assetName, string filter)
|
||||
{
|
||||
foreach (var f in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (f.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (CvrmgService.Normalize(assetName) == CvrmgService.Normalize(f)) return true;
|
||||
}
|
||||
else if (assetName.Contains(f, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ImportLocal(ModDef mod, IEnumerable<string> paths)
|
||||
{
|
||||
var dir = Paths.ModVersionDir(mod, "local");
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
var files = Paths.ModFilesDir(mod, "local");
|
||||
Directory.CreateDirectory(files);
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) ZipFile.ExtractToDirectory(p, files, true);
|
||||
else File.Copy(p, Path.Combine(files, Path.GetFileName(p)), true);
|
||||
}
|
||||
File.WriteAllText(Path.Combine(dir, Marker), "");
|
||||
}
|
||||
|
||||
public static void DeleteVersion(ModDef mod, string tag)
|
||||
{
|
||||
var dir = Paths.ModVersionDir(mod, tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> MapFiles(ModDef mod, string tag)
|
||||
{
|
||||
var root = Paths.ModFilesDir(mod, tag);
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!Directory.Exists(root)) return map;
|
||||
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, file).Replace('\\', '/');
|
||||
map[Target(mod, rel)] = file;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static string Target(ModDef mod, string rel)
|
||||
{
|
||||
if (mod.Type == "Root") return rel;
|
||||
var first = rel.Split('/')[0];
|
||||
if (rel.Contains('/') && KnownRoots.Any(k => k.Equals(first, StringComparison.OrdinalIgnoreCase))) return rel;
|
||||
if (rel.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return $"{mod.Type}/{Path.GetFileName(rel)}";
|
||||
return $"{mod.Type}/{rel}";
|
||||
}
|
||||
|
||||
public async Task ApplyAsync(InstallInfo install, IReadOnlyList<ModDef> catalog, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
var game = Paths.GameDir(install);
|
||||
if (!Directory.Exists(game)) throw new InvalidOperationException("The install has no game files yet. Update it first.");
|
||||
|
||||
var desired = new Dictionary<string, Dictionary<string, string>>();
|
||||
foreach (var a in install.Mods.Where(a => a.Enabled))
|
||||
{
|
||||
var mod = catalog.FirstOrDefault(m => m.Id == a.ModId);
|
||||
if (mod == null) continue;
|
||||
var tag = ResolveTag(mod, a.Version);
|
||||
if (tag.Length == 0) throw new InvalidOperationException($"{mod.Name} has no versions; refresh versions in the Mods tab.");
|
||||
await EnsureDownloadedAsync(mod, tag, log, ct);
|
||||
desired[mod.Id] = MapFiles(mod, tag);
|
||||
}
|
||||
|
||||
foreach (var modId in install.Deployed.Keys.ToList())
|
||||
{
|
||||
var keep = desired.TryGetValue(modId, out var map) ? map.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet<string>();
|
||||
foreach (var rel in install.Deployed[modId].Where(r => !keep.Contains(r)))
|
||||
DeleteTracked(game, rel);
|
||||
if (!desired.ContainsKey(modId)) install.Deployed.Remove(modId);
|
||||
}
|
||||
|
||||
foreach (var (modId, map) in desired)
|
||||
{
|
||||
foreach (var (rel, src) in map)
|
||||
{
|
||||
var dest = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
|
||||
File.Copy(src, dest, true);
|
||||
}
|
||||
install.Deployed[modId] = map.Keys.ToList();
|
||||
log($"Deployed {catalog.First(m => m.Id == modId).Name} ({map.Count} files)");
|
||||
}
|
||||
}
|
||||
|
||||
static void DeleteTracked(string game, string rel)
|
||||
{
|
||||
var path = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(dir) && dir.Length > game.Length && Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any())
|
||||
{
|
||||
Directory.Delete(dir);
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class MultiInstance
|
||||
{
|
||||
const int KeepLogs = 7;
|
||||
|
||||
static readonly Regex SingleInstanceLine = new(@"^single-instance[^\r\n]*(\r?\n|$)", RegexOptions.Multiline | RegexOptions.Compiled);
|
||||
|
||||
public static void Prepare(string gameDir, Action<string> log)
|
||||
{
|
||||
RemoveSingleInstanceFlag(gameDir, log);
|
||||
PruneMelonLoaderLogs(gameDir, log);
|
||||
}
|
||||
|
||||
static void RemoveSingleInstanceFlag(string gameDir, Action<string> log)
|
||||
{
|
||||
var boot = Path.Combine(gameDir, "ChilloutVR_Data", "boot.config");
|
||||
if (!File.Exists(boot)) return;
|
||||
var text = File.ReadAllText(boot);
|
||||
var cleaned = SingleInstanceLine.Replace(text, "");
|
||||
if (cleaned == text) return;
|
||||
File.WriteAllText(boot, cleaned, new UTF8Encoding(false));
|
||||
log("Removed 'single-instance' from boot.config so more than one instance can run.");
|
||||
}
|
||||
|
||||
static void PruneMelonLoaderLogs(string gameDir, Action<string> log)
|
||||
{
|
||||
var logs = Path.Combine(gameDir, "MelonLoader", "Logs");
|
||||
if (!Directory.Exists(logs)) return;
|
||||
var files = new DirectoryInfo(logs).GetFiles("*.log").OrderBy(f => f.LastWriteTimeUtc).ToList();
|
||||
var excess = files.Count - KeepLogs;
|
||||
var removed = 0;
|
||||
foreach (var f in files)
|
||||
{
|
||||
if (removed >= excess) break;
|
||||
try
|
||||
{
|
||||
f.Delete();
|
||||
removed++;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
}
|
||||
if (removed > 0) log($"Removed {removed} old MelonLoader log(s) so a second instance does not trip MelonLoader's log cleanup.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using CVRManager.Models;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class Paths
|
||||
{
|
||||
public const int AppId = 661130;
|
||||
|
||||
public static string ConfigDir => Environment.GetEnvironmentVariable("CVRMANAGER_CONFIG_DIR") is { Length: > 0 } custom
|
||||
? custom
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CVRManager");
|
||||
public static string ConfigFile => Path.Combine(ConfigDir, "config.json");
|
||||
|
||||
public static string Root { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CVRManager");
|
||||
|
||||
public static string SharedCache => Path.Combine(Root, "cache");
|
||||
public static string ModStore => Path.Combine(Root, "mods");
|
||||
public static string BranchCache => Path.Combine(Root, "sources", "branches.json");
|
||||
|
||||
public static string InstallsRoot => Path.Combine(Root, "installs");
|
||||
public static string InstallDir(InstallInfo i) => Path.Combine(InstallsRoot, i.Folder);
|
||||
public static string GameDir(InstallInfo i) => InstallDir(i);
|
||||
|
||||
public static string SafeFolderName(string name)
|
||||
{
|
||||
var bad = Path.GetInvalidFileNameChars();
|
||||
var cleaned = new string(name.Select(c => bad.Contains(c) ? ' ' : c).ToArray());
|
||||
while (cleaned.Contains(" ")) cleaned = cleaned.Replace(" ", " ");
|
||||
cleaned = cleaned.Trim().TrimEnd('.');
|
||||
if (cleaned.Length > 60) cleaned = cleaned[..60].TrimEnd();
|
||||
return cleaned.Length == 0 ? "Install" : cleaned;
|
||||
}
|
||||
public static string GameDataDir(string? gameDir = null)
|
||||
{
|
||||
string company = "ChilloutVR", product = "ChilloutVR";
|
||||
try
|
||||
{
|
||||
var info = gameDir == null ? null : Path.Combine(gameDir, "ChilloutVR_Data", "app.info");
|
||||
if (info != null && File.Exists(info) && File.ReadAllLines(info) is { Length: >= 2 } l && l[0].Trim().Length > 0 && l[1].Trim().Length > 0)
|
||||
(company, product) = (l[0].Trim(), l[1].Trim());
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { }
|
||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", company, product);
|
||||
}
|
||||
|
||||
public static string CacheDir(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR_Data", "Cache");
|
||||
public static string GameExe(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR.exe");
|
||||
public static string Manifest(InstallInfo i) => Path.Combine(GameDir(i), "steamapps", $"appmanifest_{AppId}.acf");
|
||||
|
||||
public static string ModVersionDir(ModDef m, string tag) => Path.Combine(ModStore, m.Id, Safe(tag));
|
||||
public static string ModFilesDir(ModDef m, string tag) => Path.Combine(ModVersionDir(m, tag), "files");
|
||||
|
||||
public static string Safe(string s)
|
||||
{
|
||||
var bad = Path.GetInvalidFileNameChars();
|
||||
var r = new string(s.Select(c => bad.Contains(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
|
||||
return r.Length == 0 ? "_" : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// The depot and chunk download flow follows DepotDownloader (https://github.com/SteamRE/DepotDownloader,
|
||||
// commit 989f37b, GPL-2.0, (c) SteamRE). This file is therefore under GPL-2.0.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using CVRManager.Models;
|
||||
using SteamKit2;
|
||||
using SteamKit2.CDN;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class SteamDownloader
|
||||
{
|
||||
const uint AppId = (uint)Paths.AppId;
|
||||
const int MaxDownloads = 8;
|
||||
const string StateFolder = ".CVRManager";
|
||||
|
||||
public sealed record PrivateBranch(string Name, string Code, byte[]? Key);
|
||||
|
||||
const int MaxCodeChecksPerRefresh = 3;
|
||||
|
||||
public static async Task<List<BranchInfo>> GetBranchesAsync(
|
||||
string account, string refreshToken, IReadOnlyList<PrivateBranch> privateBranches,
|
||||
Action<string, byte[]> keyResolved, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
var (root, appToken) = await RequestAppInfoAsync(link);
|
||||
var depots = root["depots"];
|
||||
var branches = depots["branches"];
|
||||
if (branches == KeyValue.Invalid) throw new InvalidOperationException("Steam returned no branch data for ChilloutVR.");
|
||||
var list = branches.Children
|
||||
.Select(b => new BranchInfo(b.Name ?? "", b["buildid"].Value ?? "", b["pwdrequired"].Value == "1", b["description"].Value ?? ""))
|
||||
.ToList();
|
||||
|
||||
var known = list.Select(b => b.Name).ToHashSet();
|
||||
var keys = new Dictionary<string, byte[]>();
|
||||
var checks = 0;
|
||||
var limited = false;
|
||||
foreach (var pb in privateBranches.Where(p => p.Code.Length > 0 && !known.Contains(p.Name)))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var key = pb.Key ?? keys.GetValueOrDefault(pb.Name);
|
||||
if (key == null)
|
||||
{
|
||||
if (limited || checks >= MaxCodeChecksPerRefresh) continue;
|
||||
checks++;
|
||||
try
|
||||
{
|
||||
foreach (var (name, k) in await BranchCodeService.ResolveAsync(link, pb.Code))
|
||||
{
|
||||
keys[name] = k;
|
||||
keyResolved(name, k);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
log($"Could not check the code for '{pb.Name}': {e.Message}");
|
||||
limited = e.Message.Contains("RateLimit");
|
||||
continue;
|
||||
}
|
||||
key = keys.GetValueOrDefault(pb.Name);
|
||||
if (key == null) { log($"The saved code no longer unlocks '{pb.Name}'."); continue; }
|
||||
}
|
||||
|
||||
var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, pb.Name, key);
|
||||
if (priv.Result != EResult.OK) { log($"Steam would not list the files of '{pb.Name}' ({priv.Result})."); continue; }
|
||||
var selection = SelectDepots(depots, priv.DepotSection, pb.Name);
|
||||
if (selection.Count == 0) continue;
|
||||
list.Add(new BranchInfo(pb.Name, PrivateMarker(selection), true, ""));
|
||||
}
|
||||
|
||||
return list.OrderBy(b => b.Name == "public" ? 0 : 1).ThenBy(b => b.Name).ToList();
|
||||
}
|
||||
|
||||
static string PrivateMarker(IEnumerable<(uint DepotId, ulong ManifestId)> depots)
|
||||
{
|
||||
var text = string.Join(";", depots.OrderBy(d => d.DepotId).Select(d => $"{d.DepotId}:{d.ManifestId}"));
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.ASCII.GetBytes(text)))[..12].ToLowerInvariant();
|
||||
}
|
||||
|
||||
static List<(uint DepotId, ulong ManifestId)> SelectDepots(KeyValue depotsSection, KeyValue? privateDepots, string branch)
|
||||
{
|
||||
var result = new List<(uint, ulong)>();
|
||||
foreach (var depot in depotsSection.Children)
|
||||
{
|
||||
if (!uint.TryParse(depot.Name, out var depotId) || depot.Children.Count == 0) continue;
|
||||
var cfg = depot["config"];
|
||||
if (cfg != KeyValue.Invalid)
|
||||
{
|
||||
var os = cfg["oslist"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(os) && !os.Split(',').Contains("windows")) continue;
|
||||
var arch = cfg["osarch"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(arch) && arch != "64") continue;
|
||||
var lang = cfg["language"].Value;
|
||||
if (!string.IsNullOrWhiteSpace(lang) && lang != "english") continue;
|
||||
if (cfg["lowviolence"].AsBoolean()) continue;
|
||||
}
|
||||
|
||||
var gid = (privateDepots?[depot.Name!]["manifests"][branch]["gid"].Value) ?? depot["manifests"][branch]["gid"].Value;
|
||||
if (gid == null && branch != "public") gid = depot["manifests"]["public"]["gid"].Value;
|
||||
if (gid == null) continue;
|
||||
result.Add((depotId, ulong.Parse(gid)));
|
||||
}
|
||||
return result.OrderBy(d => d.Item1).ToList();
|
||||
}
|
||||
|
||||
static async Task<(KeyValue Root, ulong Token)> RequestAppInfoAsync(SteamLink link)
|
||||
{
|
||||
var tokens = await link.Apps.PICSGetAccessTokens(new List<uint> { AppId }, new List<uint>());
|
||||
tokens.AppTokens.TryGetValue(AppId, out var token);
|
||||
var request = new SteamApps.PICSRequest(AppId) { AccessToken = token };
|
||||
var result = await link.Apps.PICSGetProductInfo(new List<SteamApps.PICSRequest> { request }, new List<SteamApps.PICSRequest>());
|
||||
var info = result.Results?.SelectMany(r => r.Apps).Where(a => a.Key == AppId).Select(a => a.Value).FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Steam returned no app info for ChilloutVR.");
|
||||
return (info.KeyValues, token);
|
||||
}
|
||||
|
||||
sealed record DepotPlan(uint DepotId, ulong ManifestId, byte[] Key);
|
||||
|
||||
sealed class InstallState
|
||||
{
|
||||
public bool Complete { get; set; }
|
||||
public Dictionary<string, ulong> Manifests { get; set; } = new();
|
||||
}
|
||||
|
||||
public static async Task InstallAsync(
|
||||
string gameDir, string branch, string branchCode, byte[]? branchKey, Action<string, byte[]> keyResolved,
|
||||
string account, string refreshToken, bool validate, Action<string> log, Action<double> progress, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(branch)) branch = "public";
|
||||
Directory.CreateDirectory(gameDir);
|
||||
var stateDir = Path.Combine(gameDir, StateFolder);
|
||||
Directory.CreateDirectory(stateDir);
|
||||
|
||||
await using var link = await SteamLink.OpenAsync(account, refreshToken, ct);
|
||||
log(link.IsAnonymous ? "Connected to Steam anonymously." : $"Signed in to Steam as {account}.");
|
||||
|
||||
var (root, appToken) = await RequestAppInfoAsync(link);
|
||||
var depotsSection = root["depots"];
|
||||
var branchNode = depotsSection["branches"][branch];
|
||||
var listed = branchNode != KeyValue.Invalid;
|
||||
var buildId = listed ? branchNode["buildid"].Value ?? "" : "";
|
||||
|
||||
KeyValue? privateDepots = null;
|
||||
if (!listed || branchNode["pwdrequired"].Value == "1")
|
||||
{
|
||||
if (string.IsNullOrEmpty(branchCode))
|
||||
throw new InvalidOperationException($"The branch '{branch}' is not public and needs its beta code. Enter the code from the branch list.");
|
||||
if (branchKey == null)
|
||||
{
|
||||
var keys = await BranchCodeService.ResolveAsync(link, branchCode);
|
||||
foreach (var (name, k) in keys) keyResolved(name, k);
|
||||
if (!keys.TryGetValue(branch, out branchKey))
|
||||
throw new InvalidOperationException($"The saved code does not unlock '{branch}'. Enter the code again from the branch list.");
|
||||
}
|
||||
var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, branch, branchKey);
|
||||
if (priv.Result != EResult.OK) throw new InvalidOperationException($"Steam would not give out the files for '{branch}' ({priv.Result}).");
|
||||
privateDepots = priv.DepotSection;
|
||||
}
|
||||
|
||||
var plans = new List<DepotPlan>();
|
||||
foreach (var (depotId, manifestId) in SelectDepots(depotsSection, privateDepots, branch))
|
||||
{
|
||||
var keyReply = await link.Apps.GetDepotDecryptionKey(depotId, AppId);
|
||||
if (keyReply.Result != EResult.OK) throw new InvalidOperationException($"Steam refused the key for depot {depotId} ({keyReply.Result}).");
|
||||
plans.Add(new DepotPlan(depotId, manifestId, keyReply.DepotKey));
|
||||
}
|
||||
if (plans.Count == 0) throw new InvalidOperationException("Steam listed no downloadable files for this branch.");
|
||||
if (buildId.Length == 0) buildId = PrivateMarker(plans.Select(p => (p.DepotId, p.ManifestId)));
|
||||
|
||||
var statePath = Path.Combine(stateDir, "state.json");
|
||||
var state = new InstallState();
|
||||
try { if (File.Exists(statePath)) state = JsonSerializer.Deserialize<InstallState>(File.ReadAllText(statePath)) ?? new(); } catch { }
|
||||
var forceValidate = validate || !state.Complete;
|
||||
state.Complete = false;
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
|
||||
var servers = await LoadServersAsync(link);
|
||||
var cdn = new Client(link.Client);
|
||||
var authTokens = new ConcurrentDictionary<(uint, string), string?>();
|
||||
var serverCursor = 0;
|
||||
|
||||
Server NextServer() => servers[Math.Abs(Interlocked.Increment(ref serverCursor)) % servers.Count];
|
||||
|
||||
async Task<string?> TokenFor(uint depotId, Server server)
|
||||
{
|
||||
if (authTokens.TryGetValue((depotId, server.Host!), out var cached)) return cached;
|
||||
var reply = await link.Content.GetCDNAuthToken(AppId, depotId, server.Host!);
|
||||
var token = reply.Result == EResult.OK ? reply.Token : null;
|
||||
authTokens[(depotId, server.Host!)] = token;
|
||||
return token;
|
||||
}
|
||||
|
||||
var fullGameDir = Path.GetFullPath(gameDir) + Path.DirectorySeparatorChar;
|
||||
string Target(string fileName)
|
||||
{
|
||||
var full = Path.GetFullPath(Path.Combine(gameDir, fileName));
|
||||
if (!full.StartsWith(fullGameDir, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Refusing to write outside the install folder: {fileName}");
|
||||
return full;
|
||||
}
|
||||
|
||||
var newManifests = new Dictionary<uint, DepotManifest>();
|
||||
var oldManifests = new Dictionary<uint, DepotManifest?>();
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
DepotManifest? old = null;
|
||||
var oldFile = Path.Combine(stateDir, $"{plan.DepotId}.manifest");
|
||||
if (state.Manifests.ContainsKey(plan.DepotId.ToString()) && File.Exists(oldFile))
|
||||
try { old = DepotManifest.LoadFromFile(oldFile); } catch { }
|
||||
oldManifests[plan.DepotId] = old;
|
||||
|
||||
var manifest = old is { } o && o.ManifestGID == plan.ManifestId ? o : await DownloadManifestAsync(plan);
|
||||
newManifests[plan.DepotId] = manifest;
|
||||
log($"Depot {plan.DepotId}: {manifest.Files!.Count} files, {FileOps.FormatSize((long)manifest.TotalUncompressedSize)}.");
|
||||
}
|
||||
|
||||
async Task<DepotManifest> DownloadManifestAsync(DepotPlan plan)
|
||||
{
|
||||
for (var attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var server = NextServer();
|
||||
try
|
||||
{
|
||||
var code = await link.Content.GetManifestRequestCode(plan.DepotId, AppId, plan.ManifestId, branch);
|
||||
if (code == 0) throw new InvalidOperationException($"Steam gave no manifest code for depot {plan.DepotId}.");
|
||||
return await cdn.DownloadManifestAsync(plan.DepotId, plan.ManifestId, code, server, plan.Key, null, authTokens.GetValueOrDefault((plan.DepotId, server.Host!)));
|
||||
}
|
||||
catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((plan.DepotId, server.Host!)))
|
||||
{
|
||||
await TokenFor(plan.DepotId, server);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException && e is not InvalidOperationException)
|
||||
{
|
||||
log($"Manifest download from {server.Host} failed ({e.Message}); trying another server.");
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException($"Could not download the file list for depot {plan.DepotId}.");
|
||||
}
|
||||
|
||||
var work = new List<(uint DepotId, byte[] Key, DepotManifest.FileData File, string Path, List<DepotManifest.ChunkData> Chunks)>();
|
||||
long totalFiles = newManifests.Values.Sum(m => m.Files!.Count(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)));
|
||||
long scanned = 0;
|
||||
log(forceValidate ? "Checking every file against Steam..." : "Looking for changed files...");
|
||||
progress(0);
|
||||
|
||||
var workLock = new object();
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var manifest = newManifests[plan.DepotId];
|
||||
var old = oldManifests[plan.DepotId];
|
||||
var oldByName = old?.Files!.ToDictionary(f => f.FileName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var dir in manifest.Files!.Where(f => f.Flags.HasFlag(EDepotFileFlag.Directory)))
|
||||
Directory.CreateDirectory(Target(dir.FileName));
|
||||
|
||||
await Parallel.ForEachAsync(manifest.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct },
|
||||
async (file, token) =>
|
||||
{
|
||||
await Task.Yield();
|
||||
var path = Target(file.FileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var needed = NeededChunks(path, file, oldByName, forceValidate);
|
||||
if (needed.Count > 0) lock (workLock) work.Add((plan.DepotId, plan.Key, file, path, needed));
|
||||
var done = Interlocked.Increment(ref scanned);
|
||||
if (done % 25 == 0 || done == totalFiles) progress(done * 100.0 / totalFiles);
|
||||
});
|
||||
}
|
||||
|
||||
var totalBytes = work.Sum(w => w.Chunks.Sum(c => (long)c.UncompressedLength));
|
||||
if (work.Count == 0) log("Everything is already up to date.");
|
||||
else
|
||||
{
|
||||
log($"Downloading {FileOps.FormatSize(totalBytes)} in {work.Count} file(s)...");
|
||||
progress(0);
|
||||
long downloaded = 0;
|
||||
var queue = work.SelectMany(w => w.Chunks.Select(c => (w.DepotId, w.Key, w.Path, Chunk: c))).ToList();
|
||||
await Parallel.ForEachAsync(queue, new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct }, async (item, token) =>
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)item.Chunk.UncompressedLength);
|
||||
try
|
||||
{
|
||||
var written = 0;
|
||||
for (var attempt = 0; attempt < 10 && written == 0; attempt++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var server = NextServer();
|
||||
try
|
||||
{
|
||||
written = await cdn.DownloadDepotChunkAsync(item.DepotId, item.Chunk, server, buffer, item.Key, null, authTokens.GetValueOrDefault((item.DepotId, server.Host!)));
|
||||
}
|
||||
catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((item.DepotId, server.Host!)))
|
||||
{
|
||||
await TokenFor(item.DepotId, server);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
if (attempt >= 4) log($"Chunk download from {server.Host} failed ({e.Message}).");
|
||||
}
|
||||
}
|
||||
if (written == 0) throw new InvalidOperationException("No Steam download server could deliver a file piece. Try again in a minute.");
|
||||
|
||||
await using var fs = new FileStream(item.Path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
fs.Seek((long)item.Chunk.Offset, SeekOrigin.Begin);
|
||||
await fs.WriteAsync(buffer.AsMemory(0, written), token);
|
||||
progress(Interlocked.Add(ref downloaded, written) * 100.0 / totalBytes);
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var old = oldManifests[plan.DepotId];
|
||||
if (old != null)
|
||||
{
|
||||
var keep = newManifests[plan.DepotId].Files!.Select(f => f.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var gone in old.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory) && !keep.Contains(f.FileName)))
|
||||
{
|
||||
var path = Target(gone.FileName);
|
||||
if (File.Exists(path)) { File.Delete(path); log($"Removed {gone.FileName}"); }
|
||||
}
|
||||
}
|
||||
newManifests[plan.DepotId].SaveToFile(Path.Combine(stateDir, $"{plan.DepotId}.manifest"));
|
||||
state.Manifests[plan.DepotId.ToString()] = plan.ManifestId;
|
||||
}
|
||||
state.Complete = true;
|
||||
File.WriteAllText(statePath, JsonSerializer.Serialize(state));
|
||||
AppManifest.Write(gameDir, buildId, branch);
|
||||
progress(100);
|
||||
log($"Installed build {buildId} of '{branch}'.");
|
||||
}
|
||||
|
||||
static List<DepotManifest.ChunkData> NeededChunks(
|
||||
string path, DepotManifest.FileData file, Dictionary<string, DepotManifest.FileData>? oldByName, bool forceValidate)
|
||||
{
|
||||
var chunks = file.Chunks!.OrderBy(c => c.Offset).ToList();
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
using var created = File.Create(path);
|
||||
created.SetLength((long)file.TotalSize);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
if (!forceValidate && info.Length == (long)file.TotalSize
|
||||
&& oldByName != null && oldByName.TryGetValue(file.FileName, out var before) && before.FileHash.AsSpan().SequenceEqual(file.FileHash))
|
||||
return new();
|
||||
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
|
||||
if (fs.Length != (long)file.TotalSize) fs.SetLength((long)file.TotalSize);
|
||||
var needed = new List<DepotManifest.ChunkData>();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(chunks.Count == 0 ? 1 : (int)chunks.Max(c => c.UncompressedLength));
|
||||
try
|
||||
{
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
fs.Seek((long)chunk.Offset, SeekOrigin.Begin);
|
||||
var length = (int)chunk.UncompressedLength;
|
||||
var read = 0;
|
||||
while (read < length)
|
||||
{
|
||||
var n = fs.Read(buffer, read, length - read);
|
||||
if (n == 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < length || Adler(buffer, length) != chunk.Checksum) needed.Add(chunk);
|
||||
}
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
return needed;
|
||||
}
|
||||
|
||||
static uint Adler(byte[] data, int length)
|
||||
{
|
||||
uint a = 0, b = 0;
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
a = (a + data[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return a | (b << 16);
|
||||
}
|
||||
|
||||
static async Task<List<Server>> LoadServersAsync(SteamLink link)
|
||||
{
|
||||
var all = await link.Content.GetServersForSteamPipe();
|
||||
var list = new List<Server>();
|
||||
foreach (var s in all.Where(s => (s.AllowedAppIds.Length == 0 || s.AllowedAppIds.Contains(AppId)) && (s.Type == "SteamCache" || s.Type == "CDN"))
|
||||
.OrderBy(s => s.WeightedLoad))
|
||||
for (var i = 0; i < s.NumEntries; i++) list.Add(s);
|
||||
if (list.Count == 0) throw new InvalidOperationException("Steam gave no download servers.");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class SteamIntegration
|
||||
{
|
||||
public static string? SteamPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var p = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam", "SteamPath", null) as string;
|
||||
return p == null ? null : Path.GetFullPath(p);
|
||||
}
|
||||
}
|
||||
|
||||
public static string? SteamExe => SteamPath is { } p ? Path.Combine(p, "steam.exe") : null;
|
||||
|
||||
public static bool IsRunning => Process.GetProcessesByName("steam").Length > 0;
|
||||
|
||||
public static bool IsAppRunning =>
|
||||
Registry.GetValue($@"HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{Paths.AppId}", "Running", 0) is 1;
|
||||
|
||||
public static async Task<bool> EnsureRunningAsync(Action<string> log)
|
||||
{
|
||||
if (IsRunning) return true;
|
||||
var exe = SteamExe;
|
||||
if (exe == null || !File.Exists(exe)) return false;
|
||||
log("Steam is not running; starting it in the background so playtime is counted...");
|
||||
Process.Start(new ProcessStartInfo(exe, "-silent") { UseShellExecute = true });
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
var pid = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam\ActiveProcess", "pid", 0) as int? ?? 0;
|
||||
if (pid != 0 && IsRunning) { await Task.Delay(2000); return true; }
|
||||
}
|
||||
return IsRunning;
|
||||
}
|
||||
|
||||
public static List<(string GameDir, string Manifest)> FindExistingInstalls()
|
||||
{
|
||||
var result = new List<(string, string)>();
|
||||
var steam = SteamPath;
|
||||
if (steam == null) return result;
|
||||
var libs = new List<string> { steam };
|
||||
try
|
||||
{
|
||||
var vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf");
|
||||
if (File.Exists(vdf))
|
||||
{
|
||||
var doc = Vdf.ParseDocument(File.ReadAllText(vdf));
|
||||
if (doc.Dict("libraryfolders") is { } lf)
|
||||
foreach (var e in lf.Values.OfType<Dictionary<string, object>>())
|
||||
if (e.Str("path") is { Length: > 0 } p) libs.Add(p);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (var lib in libs.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var manifest = Path.Combine(lib, "steamapps", $"appmanifest_{Paths.AppId}.acf");
|
||||
if (!File.Exists(manifest)) continue;
|
||||
try
|
||||
{
|
||||
var dir = Vdf.ParseDocument(File.ReadAllText(manifest)).Dict("AppState")?.Str("installdir") ?? "ChilloutVR";
|
||||
var game = Path.Combine(lib, "steamapps", "common", dir);
|
||||
if (Directory.Exists(game)) result.Add((game, manifest));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using SteamKit2;
|
||||
using SteamKit2.Authentication;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public sealed class SteamLink : IAsyncDisposable
|
||||
{
|
||||
readonly CallbackManager manager;
|
||||
readonly CancellationTokenSource stop = new();
|
||||
readonly TaskCompletionSource connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
TaskCompletionSource<SteamUser.LoggedOnCallback>? loggedOn;
|
||||
Task pump = Task.CompletedTask;
|
||||
|
||||
public SteamClient Client { get; } = new();
|
||||
public SteamUser User { get; }
|
||||
public SteamApps Apps { get; }
|
||||
public SteamContent Content { get; }
|
||||
public bool IsAnonymous { get; private set; }
|
||||
|
||||
readonly uint loginId = (uint)Random.Shared.NextInt64(0x10000, uint.MaxValue);
|
||||
|
||||
SteamLink()
|
||||
{
|
||||
manager = new CallbackManager(Client);
|
||||
User = Client.GetHandler<SteamUser>()!;
|
||||
Apps = Client.GetHandler<SteamApps>()!;
|
||||
Content = Client.GetHandler<SteamContent>()!;
|
||||
manager.Subscribe<SteamClient.ConnectedCallback>(_ => connected.TrySetResult());
|
||||
manager.Subscribe<SteamClient.DisconnectedCallback>(_ =>
|
||||
{
|
||||
var e = new InvalidOperationException("Lost the connection to Steam.");
|
||||
connected.TrySetException(e);
|
||||
loggedOn?.TrySetException(e);
|
||||
});
|
||||
manager.Subscribe<SteamUser.LoggedOnCallback>(cb => loggedOn?.TrySetResult(cb));
|
||||
}
|
||||
|
||||
public static async Task<SteamLink> ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var link = new SteamLink();
|
||||
link.pump = Task.Run(() =>
|
||||
{
|
||||
while (!link.stop.IsCancellationRequested) link.manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(200));
|
||||
});
|
||||
link.Client.Connect();
|
||||
try
|
||||
{
|
||||
await WaitAsync(link.connected.Task, TimeSpan.FromSeconds(20), "Could not reach Steam.", ct);
|
||||
return link;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await link.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<SteamLink> OpenAsync(string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
var link = await ConnectAsync(ct);
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(refreshToken)) await link.LogOnAnonymousAsync(ct);
|
||||
else await link.LogOnAsync(account, refreshToken, ct);
|
||||
return link;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await link.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogOnAnonymousAsync(CancellationToken ct)
|
||||
{
|
||||
IsAnonymous = true;
|
||||
loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
User.LogOnAnonymous();
|
||||
Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct));
|
||||
}
|
||||
|
||||
public async Task LogOnAsync(string account, string refreshToken, CancellationToken ct)
|
||||
{
|
||||
loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
User.LogOn(new SteamUser.LogOnDetails { Username = account, AccessToken = refreshToken, ShouldRememberPassword = true, LoginID = loginId });
|
||||
Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct));
|
||||
}
|
||||
|
||||
static void Check(SteamUser.LoggedOnCallback cb)
|
||||
{
|
||||
if (cb.Result != EResult.OK)
|
||||
throw new InvalidOperationException(cb.Result is EResult.AccessDenied or EResult.InvalidPassword or EResult.Expired or EResult.LoggedInElsewhere
|
||||
? $"Steam refused the saved sign-in ({cb.Result}); sign in again."
|
||||
: $"Steam login failed: {cb.Result}");
|
||||
}
|
||||
|
||||
public static async Task<string> SignInAsync(
|
||||
string account, string password, Action onDeviceConfirmation, Func<bool, Task<string>> deviceCode, Func<string, bool, Task<string>> emailCode, CancellationToken ct)
|
||||
{
|
||||
await using var link = await ConnectAsync(ct);
|
||||
var session = await link.Client.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails
|
||||
{
|
||||
Username = account,
|
||||
Password = password,
|
||||
IsPersistentSession = true,
|
||||
DeviceFriendlyName = "CVRManager",
|
||||
Authenticator = new Authenticator(onDeviceConfirmation, deviceCode, emailCode),
|
||||
});
|
||||
var result = await session.PollingWaitForResultAsync(ct);
|
||||
return result.RefreshToken;
|
||||
}
|
||||
|
||||
sealed class Authenticator(Action confirmation, Func<bool, Task<string>> device, Func<string, bool, Task<string>> email) : IAuthenticator
|
||||
{
|
||||
public Task<string> GetDeviceCodeAsync(bool previousCodeWasIncorrect) => device(previousCodeWasIncorrect);
|
||||
public Task<string> GetEmailCodeAsync(string emailAddress, bool previousCodeWasIncorrect) => email(emailAddress, previousCodeWasIncorrect);
|
||||
|
||||
public Task<bool> AcceptDeviceConfirmationAsync()
|
||||
{
|
||||
confirmation();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<T> WaitAsync<T>(Task<T> task, TimeSpan timeout, string message, CancellationToken ct)
|
||||
{
|
||||
var delay = Task.Delay(timeout, ct);
|
||||
if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message);
|
||||
return await task;
|
||||
}
|
||||
|
||||
static async Task WaitAsync(Task task, TimeSpan timeout, string message, CancellationToken ct)
|
||||
{
|
||||
var delay = Task.Delay(timeout, ct);
|
||||
if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message);
|
||||
await task;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel();
|
||||
try { Client.Disconnect(); } catch { }
|
||||
await pump;
|
||||
stop.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public sealed record ThemePreset(string Name, Color Primary, Color Secondary);
|
||||
|
||||
public static class ThemeService
|
||||
{
|
||||
public const string DefaultName = "Default";
|
||||
public const string GameColoursName = "My in-game colours";
|
||||
|
||||
static Color F(double r, double g, double b) => Color.FromRgb((byte)Math.Round(r * 255), (byte)Math.Round(g * 255), (byte)Math.Round(b * 255));
|
||||
|
||||
public static IReadOnlyList<ThemePreset> Presets { get; } = new[]
|
||||
{
|
||||
new ThemePreset(DefaultName, Color.Parse("#5B8CFF"), Color.Parse("#7AA2FF")),
|
||||
new ThemePreset("ChilloutVR", F(0.894, 0.212, 0), F(1, 0.992, 0.988)),
|
||||
new ThemePreset("Ocean Mist", F(0, 0.5, 0.7), F(0.4, 0.8, 1)),
|
||||
new ThemePreset("Sunset Glow", F(1, 0.4, 0.3), F(1, 0.6, 0.4)),
|
||||
new ThemePreset("Forest Whisper", F(0.2, 0.6, 0.2), F(0.5, 0.8, 0.5)),
|
||||
new ThemePreset("Royal Dusk", F(0.5, 0.2, 0.7), F(0.7, 0.4, 1)),
|
||||
new ThemePreset("Desert Bloom", F(1, 0.7, 0.3), F(1, 0.9, 0.5)),
|
||||
new ThemePreset("Blossom Veil", F(1, 0.5, 0.7), F(1, 0.7, 0.9)),
|
||||
new ThemePreset("Titanium White", F(1, 1, 1), F(1, 1, 1)),
|
||||
new ThemePreset("Where Is My Shader", F(1, 0, 1), F(0, 1, 1)),
|
||||
new ThemePreset("Cotton Candy", F(0.357, 0.812, 0.98), F(0.961, 0.671, 0.725)),
|
||||
new ThemePreset("Basis", F(0.063, 0.059, 0.153), F(0.91, 0.071, 0.212)),
|
||||
new ThemePreset("Unit 01", F(0.435, 0.098, 0.522), F(0.345, 0.722, 0.388)),
|
||||
new ThemePreset("Spooky", F(1, 0.404, 0), F(0.545, 0, 0.545)),
|
||||
new ThemePreset("Cherry", F(0.824, 0.129, 0.204), F(0.278, 0.478, 0)),
|
||||
new ThemePreset("Bongo", F(0.616, 0.035, 0.906), F(0.498, 0, 0.078)),
|
||||
new ThemePreset("Kaffee", F(0.482, 0.259, 0.255), F(0.843, 0.647, 0.545)),
|
||||
};
|
||||
|
||||
public static IEnumerable<string> Names => new[] { GameColoursName }.Concat(Presets.Select(p => p.Name));
|
||||
|
||||
static string GameConfigPath => Path.Combine(Paths.GameDataDir(), "game.config");
|
||||
|
||||
public static (Color Primary, Color Secondary)? ReadGameColours()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(GameConfigPath)) return null;
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(GameConfigPath));
|
||||
var values = new Dictionary<string, double>();
|
||||
foreach (var e in doc.RootElement.GetProperty("settings").EnumerateArray())
|
||||
{
|
||||
var name = e.GetProperty("name").GetString() ?? "";
|
||||
if (!name.StartsWith("PlayerColorPrimary", StringComparison.Ordinal) && !name.StartsWith("PlayerColorSecondary", StringComparison.Ordinal)) continue;
|
||||
if (double.TryParse(e.GetProperty("value").GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) values[name] = v;
|
||||
}
|
||||
Color? Read(string key)
|
||||
{
|
||||
if (!values.TryGetValue(key + "R", out var r) || !values.TryGetValue(key + "G", out var g) || !values.TryGetValue(key + "B", out var b)) return null;
|
||||
var scale = r > 1 || g > 1 || b > 1 ? 1.0 : 255.0;
|
||||
byte B(double x) => (byte)Math.Clamp(Math.Round(x * scale), 0, 255);
|
||||
return Color.FromRgb(B(r), B(g), B(b));
|
||||
}
|
||||
var p = Read("PlayerColorPrimary");
|
||||
var s = Read("PlayerColorSecondary");
|
||||
return p is { } pc && s is { } sc ? (pc, sc) : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public static bool Apply(string name)
|
||||
{
|
||||
Color primary, secondary;
|
||||
var ok = true;
|
||||
if (name == GameColoursName)
|
||||
{
|
||||
if (ReadGameColours() is { } g) (primary, secondary) = g;
|
||||
else
|
||||
{
|
||||
ok = false;
|
||||
(primary, secondary) = (Presets[1].Primary, Presets[1].Secondary);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var preset = Presets.FirstOrDefault(p => p.Name == name) ?? Presets[0];
|
||||
(primary, secondary) = (preset.Primary, preset.Secondary);
|
||||
}
|
||||
Set(primary, secondary);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static (double H, double S, double L) ToHsl(Color c)
|
||||
{
|
||||
double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0;
|
||||
var max = Math.Max(r, Math.Max(g, b));
|
||||
var min = Math.Min(r, Math.Min(g, b));
|
||||
var l = (max + min) / 2;
|
||||
if (max == min) return (0, 0, l);
|
||||
var d = max - min;
|
||||
var s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
double h;
|
||||
if (max == r) h = (g - b) / d + (g < b ? 6 : 0);
|
||||
else if (max == g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
return (h * 60, s, l);
|
||||
}
|
||||
|
||||
static Color FromHsl(double h, double s, double l)
|
||||
{
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = Math.Clamp(s, 0, 1);
|
||||
l = Math.Clamp(l, 0, 1);
|
||||
var c = (1 - Math.Abs(2 * l - 1)) * s;
|
||||
var x = c * (1 - Math.Abs(h / 60 % 2 - 1));
|
||||
var m = l - c / 2;
|
||||
(double r, double g, double b) = (int)(h / 60) switch
|
||||
{
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
|
||||
}
|
||||
|
||||
static double Luma(Color c) => (0.2126 * c.R + 0.7152 * c.G + 0.0722 * c.B) / 255.0;
|
||||
|
||||
static Color PickAccent(Color primary, Color secondary)
|
||||
{
|
||||
bool Usable(Color c) { var (_, s, l) = ToHsl(c); return Luma(c) >= 0.18 && (s >= 0.25 || l >= 0.8); }
|
||||
if (Usable(primary)) return primary;
|
||||
if (Usable(secondary)) return secondary;
|
||||
return Luma(primary) >= Luma(secondary) ? primary : secondary;
|
||||
}
|
||||
|
||||
static void Set(Color primary, Color secondary)
|
||||
{
|
||||
var app = Application.Current;
|
||||
if (app == null) return;
|
||||
var accent = PickAccent(primary, secondary);
|
||||
var (h, s, l) = ToHsl(accent);
|
||||
var accentL = Math.Clamp(l, 0.50, 0.68);
|
||||
var accentS = s < 0.1 ? s : Math.Max(s, 0.45);
|
||||
var accentColor = FromHsl(h, accentS, accentL);
|
||||
var tint = Math.Min(s, 0.32);
|
||||
var dimS = Math.Min(s, 0.14);
|
||||
|
||||
void Brush(string key, Color c) => app.Resources[key] = new SolidColorBrush(c);
|
||||
void Surface(string key, double lightness, double extraSat = 0) => Brush(key, FromHsl(h, Math.Min(tint + extraSat, 0.5), lightness));
|
||||
|
||||
app.Resources["SystemAccentColor"] = accentColor;
|
||||
app.Resources["SystemAccentColorLight1"] = FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85));
|
||||
app.Resources["SystemAccentColorLight2"] = FromHsl(h, accentS, Math.Min(accentL + 0.16, 0.9));
|
||||
app.Resources["SystemAccentColorLight3"] = FromHsl(h, accentS, Math.Min(accentL + 0.24, 0.94));
|
||||
app.Resources["SystemAccentColorDark1"] = FromHsl(h, accentS, accentL - 0.08);
|
||||
app.Resources["SystemAccentColorDark2"] = FromHsl(h, accentS, accentL - 0.16);
|
||||
app.Resources["SystemAccentColorDark3"] = FromHsl(h, accentS, accentL - 0.24);
|
||||
|
||||
Brush("AccentBrush", accentColor);
|
||||
Brush("AccentHoverBrush", FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85)));
|
||||
Brush("AccentPressedBrush", FromHsl(h, accentS, accentL - 0.08));
|
||||
Brush("AccentDisabledBrush", FromHsl(h, accentS * 0.4, 0.22));
|
||||
Brush("AccentFgBrush", Luma(accentColor) > 0.62 ? Color.Parse("#101114") : Colors.White);
|
||||
|
||||
Surface("AppBg", 0.055);
|
||||
Surface("SidebarBg", 0.04);
|
||||
Surface("CardBg", 0.09);
|
||||
Surface("CardBorder", 0.15);
|
||||
Surface("RowHover", 0.125);
|
||||
Surface("ChipBg", 0.16);
|
||||
Surface("SelectedBg", 0.17, 0.08);
|
||||
Surface("OverlayBg", 0.105);
|
||||
Surface("ToastBg", 0.135);
|
||||
Surface("GroupHeaderBg", 0.11);
|
||||
Surface("GhostHover", 0.16);
|
||||
Brush("DimText", FromHsl(h, dimS, 0.64));
|
||||
|
||||
Surface("ButtonBackground", 0.16);
|
||||
Surface("ButtonBackgroundPointerOver", 0.2);
|
||||
Surface("ButtonBackgroundPressed", 0.24);
|
||||
Surface("ButtonBackgroundDisabled", 0.11);
|
||||
Surface("TextControlBackground", 0.075);
|
||||
Surface("TextControlBackgroundPointerOver", 0.095);
|
||||
Surface("TextControlBackgroundFocused", 0.075);
|
||||
Surface("TextControlBorderBrush", 0.18);
|
||||
Surface("TextControlBorderBrushPointerOver", 0.25);
|
||||
Brush("TextControlBorderBrushFocused", accentColor);
|
||||
Surface("ComboBoxBackground", 0.075);
|
||||
Surface("ComboBoxBackgroundPointerOver", 0.095);
|
||||
Surface("ComboBoxBackgroundPressed", 0.095);
|
||||
Surface("ComboBoxBorderBrush", 0.18);
|
||||
Surface("ComboBoxBorderBrushPointerOver", 0.25);
|
||||
Brush("ComboBoxBorderBrushPressed", accentColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class TokenVault
|
||||
{
|
||||
public static string Protect(string token) =>
|
||||
Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser));
|
||||
|
||||
public static string Unprotect(string stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stored)) return "";
|
||||
try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(stored), null, DataProtectionScope.CurrentUser)); }
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public record UpdateInfo(Version Version, string Tag, string ZipUrl, string HashUrl, long Size);
|
||||
|
||||
public static class UpdateService
|
||||
{
|
||||
public static string Host { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_HOST") is { Length: > 0 } h ? h : "https://git.koneko.cat";
|
||||
public static string Repo { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_REPO") is { Length: > 0 } r ? r : "Koneko/CVRManager";
|
||||
public const string AssetName = "CVRManager-win-x64.zip";
|
||||
|
||||
static readonly HttpClient Http = CreateClient();
|
||||
|
||||
static HttpClient CreateClient()
|
||||
{
|
||||
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", Current.ToString(3)));
|
||||
return http;
|
||||
}
|
||||
|
||||
public static Version Current { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0));
|
||||
|
||||
static Version Normalize(Version v) => new(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0));
|
||||
|
||||
public static bool TryParseTag(string tag, out Version version)
|
||||
{
|
||||
var ok = Version.TryParse(tag.TrimStart('v', 'V'), out var v);
|
||||
version = ok ? Normalize(v!) : new Version(0, 0, 0);
|
||||
return ok;
|
||||
}
|
||||
|
||||
public static async Task<UpdateInfo?> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
using var resp = await Http.GetAsync($"{Host}/api/v1/repos/{Repo}/releases/latest", ct);
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
|
||||
var root = doc.RootElement;
|
||||
var tag = root.GetProperty("tag_name").GetString() ?? "";
|
||||
if (!TryParseTag(tag, out var version) || version <= Current) return null;
|
||||
string? zip = null, hash = null;
|
||||
long size = 0;
|
||||
foreach (var a in root.GetProperty("assets").EnumerateArray())
|
||||
{
|
||||
var name = a.GetProperty("name").GetString();
|
||||
var url = a.GetProperty("browser_download_url").GetString();
|
||||
if (name == AssetName) { zip = url; size = a.GetProperty("size").GetInt64(); }
|
||||
else if (name == AssetName + ".sha256") hash = url;
|
||||
}
|
||||
if (zip == null || hash == null) return null;
|
||||
return new UpdateInfo(version, tag, zip, hash, size);
|
||||
}
|
||||
|
||||
public static async Task<string> DownloadAsync(UpdateInfo info, Action<double> progress, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.Combine(Paths.Root, "update", info.Tag);
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, true);
|
||||
Directory.CreateDirectory(dir);
|
||||
var zip = Path.Combine(dir, AssetName);
|
||||
|
||||
var expected = (await Http.GetStringAsync(info.HashUrl, ct)).Trim().Split(' ', '\t')[0].ToLowerInvariant();
|
||||
using (var resp = await Http.GetAsync(info.ZipUrl, HttpCompletionOption.ResponseHeadersRead, ct))
|
||||
{
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var total = resp.Content.Headers.ContentLength ?? info.Size;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(zip);
|
||||
var buffer = new byte[81920];
|
||||
long done = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct);
|
||||
done += n;
|
||||
if (total > 0) progress(done * 100.0 / total);
|
||||
}
|
||||
}
|
||||
|
||||
string actual;
|
||||
await using (var fs = File.OpenRead(zip)) actual = Convert.ToHexString(await SHA256.HashDataAsync(fs, ct)).ToLowerInvariant();
|
||||
if (actual != expected) throw new InvalidDataException("The downloaded update is corrupted (checksum mismatch).");
|
||||
|
||||
var staged = Path.Combine(dir, "files");
|
||||
ZipFile.ExtractToDirectory(zip, staged);
|
||||
File.Delete(zip);
|
||||
if (!File.Exists(Path.Combine(staged, "CVRManager.exe"))) throw new InvalidDataException("The update package has no CVRManager.exe.");
|
||||
return staged;
|
||||
}
|
||||
|
||||
public static void ApplyAndRestart(string staged)
|
||||
{
|
||||
var exe = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot find the running executable.");
|
||||
var appDir = Path.GetDirectoryName(exe)!;
|
||||
var script = Path.Combine(Path.GetDirectoryName(staged)!, "apply.ps1");
|
||||
File.WriteAllText(script, """
|
||||
param([int]$ProcessId, [string]$Source, [string]$Target, [string]$Exe)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch { }
|
||||
$copied = $false
|
||||
for ($i = 0; $i -lt 20 -and -not $copied; $i++) {
|
||||
robocopy $Source $Target /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8) { $copied = $true } else { Start-Sleep -Milliseconds 500 }
|
||||
}
|
||||
if ($copied) { Remove-Item -LiteralPath $Source -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
Start-Process -FilePath $Exe -WorkingDirectory $Target
|
||||
""");
|
||||
var psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
};
|
||||
foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-ProcessId", Environment.ProcessId.ToString(), "-Source", staged, "-Target", appDir, "-Exe", exe })
|
||||
psi.ArgumentList.Add(a);
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
public static bool CanWriteAppDir()
|
||||
{
|
||||
try
|
||||
{
|
||||
var probe = Path.Combine(AppContext.BaseDirectory, $".w{Environment.ProcessId}");
|
||||
File.WriteAllText(probe, "");
|
||||
File.Delete(probe);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
|
||||
namespace CVRManager.Services;
|
||||
|
||||
public static class Vdf
|
||||
{
|
||||
public static Dictionary<string, object> Parse(string text, int start = 0)
|
||||
{
|
||||
var pos = start;
|
||||
var root = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
var key = ReadToken(text, ref pos);
|
||||
if (key == null) return root;
|
||||
var next = Peek(text, pos);
|
||||
if (next == '{')
|
||||
{
|
||||
pos = text.IndexOf('{', pos) + 1;
|
||||
root[key] = ParseBlock(text, ref pos);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
public static Dictionary<string, object> ParseDocument(string text)
|
||||
{
|
||||
var pos = 0;
|
||||
return ParseBlock(text, ref pos, true);
|
||||
}
|
||||
|
||||
static Dictionary<string, object> ParseBlock(string text, ref int pos, bool toEnd = false)
|
||||
{
|
||||
var dict = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
while (true)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos >= text.Length) return dict;
|
||||
if (text[pos] == '}')
|
||||
{
|
||||
pos++;
|
||||
return dict;
|
||||
}
|
||||
var key = ReadToken(text, ref pos);
|
||||
if (key == null) return dict;
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos < text.Length && text[pos] == '{')
|
||||
{
|
||||
pos++;
|
||||
dict[key] = ParseBlock(text, ref pos);
|
||||
}
|
||||
else
|
||||
{
|
||||
var val = ReadToken(text, ref pos);
|
||||
if (val == null) return dict;
|
||||
dict[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static char Peek(string text, int pos)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
return pos < text.Length ? text[pos] : '\0';
|
||||
}
|
||||
|
||||
static void SkipSpace(string text, ref int pos)
|
||||
{
|
||||
while (pos < text.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(text[pos])) pos++;
|
||||
else if (text[pos] == '/' && pos + 1 < text.Length && text[pos + 1] == '/')
|
||||
while (pos < text.Length && text[pos] != '\n') pos++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
static string? ReadToken(string text, ref int pos)
|
||||
{
|
||||
SkipSpace(text, ref pos);
|
||||
if (pos >= text.Length || text[pos] != '"') return null;
|
||||
pos++;
|
||||
var sb = new StringBuilder();
|
||||
while (pos < text.Length && text[pos] != '"')
|
||||
{
|
||||
if (text[pos] == '\\' && pos + 1 < text.Length)
|
||||
{
|
||||
pos++;
|
||||
sb.Append(text[pos] switch { 'n' => '\n', 't' => '\t', var c => c });
|
||||
}
|
||||
else sb.Append(text[pos]);
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static Dictionary<string, object>? Dict(this Dictionary<string, object> d, string key) =>
|
||||
d.TryGetValue(key, out var v) ? v as Dictionary<string, object> : null;
|
||||
|
||||
public static string Str(this Dictionary<string, object> d, string key) =>
|
||||
d.TryGetValue(key, out var v) && v is string s ? s : "";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace CVRManager.ViewModels;
|
||||
|
||||
public abstract class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void Raise([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
public class RelayCommand : ICommand
|
||||
{
|
||||
readonly Func<Task> run;
|
||||
|
||||
public RelayCommand(Func<Task> run) => this.run = run;
|
||||
public RelayCommand(Action run) => this.run = () => { run(); return Task.CompletedTask; };
|
||||
|
||||
public event EventHandler? CanExecuteChanged { add { } remove { } }
|
||||
public bool CanExecute(object? parameter) => true;
|
||||
public async void Execute(object? parameter) => await run();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.VisualBasic.FileIO;
|
||||
|
||||
namespace CVRManager.ViewModels;
|
||||
|
||||
public class GalleryItemVM : ObservableObject
|
||||
{
|
||||
Bitmap? thumb;
|
||||
|
||||
public GalleryItemVM(GalleryViewModel owner, string path)
|
||||
{
|
||||
Path = path;
|
||||
Name = System.IO.Path.GetFileName(path);
|
||||
DateText = File.GetLastWriteTime(path).ToString("yyyy-MM-dd HH:mm");
|
||||
CopyCommand = new RelayCommand(() => owner.CopyAsync(this));
|
||||
DeleteCommand = new RelayCommand(() => owner.DeleteAsync(this));
|
||||
OpenCommand = new RelayCommand(() => owner.Open(this));
|
||||
ShowCommand = new RelayCommand(() => owner.Show(this));
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string Name { get; }
|
||||
public string DateText { get; }
|
||||
public Bitmap? Thumb { get => thumb; set => Set(ref thumb, value); }
|
||||
public ICommand CopyCommand { get; }
|
||||
public ICommand DeleteCommand { get; }
|
||||
public ICommand OpenCommand { get; }
|
||||
public ICommand ShowCommand { get; }
|
||||
}
|
||||
|
||||
public class GalleryViewModel : ObservableObject
|
||||
{
|
||||
const int PageSize = 48;
|
||||
readonly MainViewModel main;
|
||||
List<string> all = new();
|
||||
int shown;
|
||||
string status = "";
|
||||
bool loading;
|
||||
|
||||
public GalleryViewModel(MainViewModel main)
|
||||
{
|
||||
this.main = main;
|
||||
RefreshCommand = new RelayCommand(RefreshAsync);
|
||||
MoreCommand = new RelayCommand(LoadMore);
|
||||
OpenFolderCommand = new RelayCommand(() =>
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", $"\"{Root}\"") { UseShellExecute = true });
|
||||
});
|
||||
}
|
||||
|
||||
public static string Root => System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "ChilloutVR");
|
||||
|
||||
public ObservableCollection<GalleryItemVM> Items { get; } = new();
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand MoreCommand { get; }
|
||||
public ICommand OpenFolderCommand { get; }
|
||||
public string Status { get => status; private set => Set(ref status, value); }
|
||||
public bool HasMore => shown < all.Count;
|
||||
public bool IsEmpty => !loading && all.Count == 0;
|
||||
public string FolderText => Root;
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
loading = true;
|
||||
Status = "Looking for screenshots...";
|
||||
var found = await Task.Run(() =>
|
||||
{
|
||||
if (!Directory.Exists(Root)) return new List<string>();
|
||||
return Directory.EnumerateFiles(Root, "*.png", System.IO.SearchOption.AllDirectories)
|
||||
.Select(f => (f, t: File.GetLastWriteTimeUtc(f)))
|
||||
.OrderByDescending(x => x.t)
|
||||
.Select(x => x.f)
|
||||
.ToList();
|
||||
});
|
||||
loading = false;
|
||||
all = found;
|
||||
shown = 0;
|
||||
Items.Clear();
|
||||
LoadMore();
|
||||
Raise(nameof(IsEmpty));
|
||||
}
|
||||
|
||||
void LoadMore()
|
||||
{
|
||||
var batch = all.Skip(shown).Take(PageSize).ToList();
|
||||
shown += batch.Count;
|
||||
foreach (var path in batch)
|
||||
{
|
||||
var item = new GalleryItemVM(this, path);
|
||||
Items.Add(item);
|
||||
_ = LoadThumbAsync(item);
|
||||
}
|
||||
Status = all.Count == 0 ? "No screenshots yet." : $"Showing {shown} of {all.Count} screenshots";
|
||||
Raise(nameof(HasMore));
|
||||
}
|
||||
|
||||
static readonly SemaphoreSlim DecodeGate = new(3);
|
||||
|
||||
static async Task LoadThumbAsync(GalleryItemVM item)
|
||||
{
|
||||
await DecodeGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var bmp = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var fs = File.OpenRead(item.Path);
|
||||
return Bitmap.DecodeToWidth(fs, 360);
|
||||
}
|
||||
catch { return null; }
|
||||
});
|
||||
if (bmp != null) Dispatcher.UIThread.Post(() => item.Thumb = bmp);
|
||||
}
|
||||
finally { DecodeGate.Release(); }
|
||||
}
|
||||
|
||||
public async Task CopyAsync(GalleryItemVM item)
|
||||
{
|
||||
if (main.CopyImage == null) return;
|
||||
try
|
||||
{
|
||||
await main.CopyImage(item.Path);
|
||||
main.ReportStatus($"Copied {item.Name} to the clipboard.");
|
||||
}
|
||||
catch (Exception e) { main.ReportStatus($"Could not copy the image: {e.Message}"); }
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(GalleryItemVM item)
|
||||
{
|
||||
if (main.Confirm != null && !await main.Confirm($"Delete {item.Name}?\nIt is moved to the Recycle Bin.")) return;
|
||||
try
|
||||
{
|
||||
await Task.Run(() => FileSystem.DeleteFile(item.Path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin));
|
||||
Items.Remove(item);
|
||||
all.Remove(item.Path);
|
||||
shown = Math.Max(0, shown - 1);
|
||||
Status = $"Showing {shown} of {all.Count} screenshots";
|
||||
Raise(nameof(HasMore));
|
||||
Raise(nameof(IsEmpty));
|
||||
}
|
||||
catch (Exception e) { main.ReportStatus($"Could not delete the image: {e.Message}"); }
|
||||
}
|
||||
|
||||
public void Open(GalleryItemVM item) =>
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(item.Path) { UseShellExecute = true });
|
||||
|
||||
public void Show(GalleryItemVM item) =>
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", $"/select,\"{item.Path}\"") { UseShellExecute = true });
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using CVRManager.Models;
|
||||
using CVRManager.Services;
|
||||
|
||||
namespace CVRManager.ViewModels;
|
||||
|
||||
public class AssignmentVM : ObservableObject
|
||||
{
|
||||
readonly InstallVM owner;
|
||||
public ModAssignment Info { get; }
|
||||
public ModDef Mod { get; }
|
||||
|
||||
public AssignmentVM(InstallVM owner, ModAssignment info, ModDef mod)
|
||||
{
|
||||
this.owner = owner;
|
||||
Info = info;
|
||||
Mod = mod;
|
||||
RemoveCommand = new RelayCommand(() => owner.RemoveAssignment(this));
|
||||
}
|
||||
|
||||
public string Name => Mod.Name;
|
||||
public List<string> Versions => new[] { "latest" }.Concat(Mod.Releases.Select(r => r.Tag)).Concat(Mod.Local ? new[] { "local" } : Array.Empty<string>()).Distinct().ToList();
|
||||
|
||||
public string Version
|
||||
{
|
||||
get => Info.Version;
|
||||
set
|
||||
{
|
||||
if (value == null || Info.Version == value) return;
|
||||
Info.Version = value;
|
||||
Raise();
|
||||
Raise(nameof(Resolved));
|
||||
owner.MarkPending();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Info.Enabled;
|
||||
set
|
||||
{
|
||||
if (Info.Enabled == value) return;
|
||||
Info.Enabled = value;
|
||||
Raise();
|
||||
owner.MarkPending();
|
||||
}
|
||||
}
|
||||
|
||||
public string Resolved
|
||||
{
|
||||
get
|
||||
{
|
||||
var tag = ModStore.ResolveTag(Mod, Info.Version);
|
||||
if (tag.Length == 0) return "no versions";
|
||||
var deployed = ModStore.IsDownloaded(Mod, tag) ? "downloaded" : "not downloaded";
|
||||
return Info.Version == "latest" ? $"= {tag} ({deployed})" : deployed;
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand RemoveCommand { get; }
|
||||
public void RefreshVersions() { Raise(nameof(Versions)); Raise(nameof(Resolved)); }
|
||||
}
|
||||
|
||||
public class InstallVM : ObservableObject
|
||||
{
|
||||
public MainViewModel Main { get; }
|
||||
public InstallInfo Info { get; }
|
||||
public ObservableCollection<AssignmentVM> Mods { get; } = new();
|
||||
public ObservableCollection<InstallHookVM> Hooks { get; } = new();
|
||||
|
||||
string installedBuild = "";
|
||||
string cacheText = "";
|
||||
bool pending;
|
||||
string? mlVersionChoice;
|
||||
ModDef? addChoice;
|
||||
|
||||
public InstallVM(MainViewModel main, InstallInfo info)
|
||||
{
|
||||
Main = main;
|
||||
Info = info;
|
||||
ImportModsCommand = new RelayCommand(() => Main.ImportInstallModsAsync(this));
|
||||
LaunchCommand = new RelayCommand(() => Main.LaunchAsync(this));
|
||||
StopCommand = new RelayCommand(() => Main.StopAsync(this));
|
||||
LaunchDesktopCommand = new RelayCommand(() => Main.LaunchAsync(this, "desktop"));
|
||||
LaunchVrCommand = new RelayCommand(() => Main.LaunchAsync(this, "vr"));
|
||||
UpdateCommand = new RelayCommand(() => Main.UpdateInstallAsync(this, false));
|
||||
VerifyCommand = new RelayCommand(() => Main.UpdateInstallAsync(this, true));
|
||||
OpenFolderCommand = new RelayCommand(() => Main.OpenFolder(this));
|
||||
ApplyCommand = new RelayCommand(() => Main.ApplyModsAsync(this));
|
||||
DeleteCommand = new RelayCommand(() => Main.DeleteInstallAsync(this));
|
||||
DuplicateCommand = new RelayCommand(() => Main.DuplicateInstallAsync(this));
|
||||
LinkCacheCommand = new RelayCommand(() => Main.LinkCacheAsync(this));
|
||||
AddModCommand = new RelayCommand(AddMod);
|
||||
EnterCodeCommand = new RelayCommand(() => Main.BeginCodeEntry(this));
|
||||
ToggleMlCommand = new RelayCommand(() => Main.ToggleMelonLoaderAsync(this));
|
||||
SetMlVersionCommand = new RelayCommand(() => Main.SetMelonLoaderVersionAsync(this));
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public ICommand ImportModsCommand { get; }
|
||||
public ICommand LaunchCommand { get; }
|
||||
public ICommand StopCommand { get; }
|
||||
public ICommand LaunchDesktopCommand { get; }
|
||||
public ICommand LaunchVrCommand { get; }
|
||||
public ICommand UpdateCommand { get; }
|
||||
public ICommand VerifyCommand { get; }
|
||||
public ICommand OpenFolderCommand { get; }
|
||||
public ICommand ApplyCommand { get; }
|
||||
public ICommand DeleteCommand { get; }
|
||||
public ICommand DuplicateCommand { get; }
|
||||
public ICommand LinkCacheCommand { get; }
|
||||
public ICommand AddModCommand { get; }
|
||||
public ICommand EnterCodeCommand { get; }
|
||||
public ICommand ToggleMlCommand { get; }
|
||||
public ICommand SetMlVersionCommand { get; }
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => Info.Name;
|
||||
set { if (Info.Name != value) { Info.Name = value; Raise(); Raise(nameof(Title)); Main.Save(); Main.RefreshModUsage(); Main.ScheduleFolderRename(this); } }
|
||||
}
|
||||
|
||||
public string Branch => Info.Branch;
|
||||
|
||||
public IReadOnlyList<LaunchModeOption> LaunchModes => LaunchModeOption.All;
|
||||
|
||||
public LaunchModeOption LaunchModeChoice
|
||||
{
|
||||
get => LaunchModeOption.Find(Info.LaunchMode);
|
||||
set
|
||||
{
|
||||
if (value == null || Info.LaunchMode == value.Key) return;
|
||||
Info.LaunchMode = value.Key;
|
||||
Raise();
|
||||
Raise(nameof(LaunchTip));
|
||||
Main.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public string LaunchTip => $"Launch ({LaunchModeChoice.Label})";
|
||||
|
||||
public AccountChoice? AccountChoice
|
||||
{
|
||||
get => Main.AccountChoices.FirstOrDefault(c => c.Id == Info.AccountId) ?? Main.AccountChoices.FirstOrDefault();
|
||||
set
|
||||
{
|
||||
if (value == null || value.Id == Info.AccountId) return;
|
||||
Info.AccountId = value.Id;
|
||||
Raise();
|
||||
Main.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void RaiseAccount() => Raise(nameof(AccountChoice));
|
||||
|
||||
public string LaunchArgs
|
||||
{
|
||||
get => Info.LaunchArgs;
|
||||
set { if (Info.LaunchArgs != value) { Info.LaunchArgs = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
LaunchArgOption? argChoice;
|
||||
public LaunchArgOption? ArgChoice
|
||||
{
|
||||
get => argChoice;
|
||||
set
|
||||
{
|
||||
if (value == null) return;
|
||||
var text = value.Insert;
|
||||
var current = Info.LaunchArgs.Trim();
|
||||
if (!(" " + current + " ").Contains(" " + text.TrimEnd() + " ", StringComparison.Ordinal))
|
||||
LaunchArgs = current.Length == 0 ? text : current + " " + text;
|
||||
argChoice = value;
|
||||
Raise();
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => { argChoice = null; Raise(); });
|
||||
}
|
||||
}
|
||||
|
||||
public BranchChoice? BranchHint
|
||||
{
|
||||
get => Main.BranchChoices.FirstOrDefault(c => c.Name == Info.Branch);
|
||||
set
|
||||
{
|
||||
if (value == null || value.Name == Info.Branch) return;
|
||||
Info.Branch = value.Name;
|
||||
Info.BranchPassword = value.Code;
|
||||
Raise();
|
||||
Raise(nameof(Branch));
|
||||
Raise(nameof(Title));
|
||||
RaiseStatus();
|
||||
Main.Save();
|
||||
Main.RefreshModUsage();
|
||||
}
|
||||
}
|
||||
|
||||
string subTab = "overview";
|
||||
public bool ShowOverview { get => subTab == "overview"; set { if (value) SetSub("overview"); } }
|
||||
public bool ShowModsTab { get => subTab == "mods"; set { if (value) SetSub("mods"); } }
|
||||
public bool ShowLogs { get => subTab == "logs"; set { if (value) SetSub("logs"); } }
|
||||
|
||||
void SetSub(string t)
|
||||
{
|
||||
if (subTab == t) return;
|
||||
subTab = t;
|
||||
Raise(nameof(ShowOverview));
|
||||
Raise(nameof(ShowModsTab));
|
||||
Raise(nameof(ShowLogs));
|
||||
mlStamp = unityStamp = default;
|
||||
RefreshLogs();
|
||||
}
|
||||
|
||||
string logTab = "ml";
|
||||
public bool ShowMlLog { get => logTab == "ml"; set { if (value) { logTab = "ml"; Raise(); Raise(nameof(ShowUnityLog)); } } }
|
||||
public bool ShowUnityLog { get => logTab == "unity"; set { if (value) { logTab = "unity"; Raise(); Raise(nameof(ShowMlLog)); } } }
|
||||
|
||||
string mlLog = "";
|
||||
string unityLog = "";
|
||||
string mlRaw = "";
|
||||
long mlColourVersion = -1;
|
||||
(long, long) mlStamp, unityStamp;
|
||||
public string MlLogText { get => mlLog; private set => Set(ref mlLog, value); }
|
||||
public string UnityLogText { get => unityLog; private set => Set(ref unityLog, value); }
|
||||
|
||||
bool logReading;
|
||||
|
||||
public void RefreshLogs()
|
||||
{
|
||||
if (!ShowLogs || logReading) return;
|
||||
logReading = true;
|
||||
var gameDir = Paths.GameDir(Info);
|
||||
var ml = Path.Combine(gameDir, "MelonLoader", "Latest.log");
|
||||
var ms = mlStamp;
|
||||
var us = unityStamp;
|
||||
var raw = mlRaw;
|
||||
var seen = mlColourVersion;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
string? a = null, b = null;
|
||||
var version = seen;
|
||||
try
|
||||
{
|
||||
var dataDir = Paths.GameDataDir(gameDir);
|
||||
var unity = Path.Combine(dataDir, "Player.log");
|
||||
var prev = Path.Combine(dataDir, "Player-prev.log");
|
||||
b = ReadTail(unity, ref us, "No Unity log yet.");
|
||||
var fresh = ReadTail(ml, ref ms, "");
|
||||
version = MlLogColours.Refresh(prev, unity);
|
||||
if (fresh != null) raw = fresh;
|
||||
if (fresh != null || version != seen)
|
||||
a = raw.Length == 0 ? "No MelonLoader log yet." : MlLogColours.Colour(raw, prev, unity);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
logReading = false;
|
||||
mlStamp = ms;
|
||||
unityStamp = us;
|
||||
mlRaw = raw;
|
||||
mlColourVersion = version;
|
||||
if (a != null) MlLogText = a;
|
||||
if (b != null) UnityLogText = b;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static string? ReadTail(string path, ref (long, long) stamp, string empty)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
if (stamp == (-1, 0)) return null;
|
||||
stamp = (-1, 0);
|
||||
return empty;
|
||||
}
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
var s = (fs.Length, 0L);
|
||||
if (s == stamp) return null;
|
||||
stamp = s;
|
||||
const int max = 262144;
|
||||
if (fs.Length > max) fs.Seek(-max, SeekOrigin.End);
|
||||
using var r = new StreamReader(fs);
|
||||
return r.ReadToEnd();
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; }
|
||||
}
|
||||
|
||||
public string Title => $"{Info.Name} · {Info.Branch}";
|
||||
public string InstalledBuild { get => installedBuild; private set { if (Set(ref installedBuild, value)) { Raise(nameof(UpdateText)); Raise(nameof(HasGame)); } } }
|
||||
public bool HasGame => File.Exists(Paths.GameExe(Info));
|
||||
|
||||
public string UpdateText
|
||||
{
|
||||
get
|
||||
{
|
||||
var remote = Main.Branches.FirstOrDefault(b => b.Name == Info.Branch)?.BuildId ?? "";
|
||||
var local = string.IsNullOrEmpty(InstalledBuild) ? "not installed" : $"build {InstalledBuild}";
|
||||
if (remote.Length == 0) return $"{local} · remote build unknown";
|
||||
if (InstalledBuild.Length == 0) return $"{local} · remote build {remote}";
|
||||
return InstalledBuild == remote ? $"{local} · up to date" : $"{local} · update available (build {remote})";
|
||||
}
|
||||
}
|
||||
|
||||
string installedBranch = "";
|
||||
public string InstalledBranch { get => installedBranch; private set => Set(ref installedBranch, value); }
|
||||
|
||||
public string RemoteBuild => Main.Branches.FirstOrDefault(b => b.Name == Info.Branch)?.BuildId ?? "";
|
||||
public bool NotInstalled => InstalledBuild.Length == 0;
|
||||
public bool BranchMismatch => InstalledBuild.Length > 0 && InstalledBranch.Length > 0 && !InstalledBranch.Equals(Info.Branch, StringComparison.OrdinalIgnoreCase);
|
||||
public bool UpdateAvailable => BranchMismatch || (InstalledBuild.Length > 0 && RemoteBuild.Length > 0 && InstalledBuild != RemoteBuild);
|
||||
public bool UpdateUnknown => InstalledBuild.Length > 0 && !BranchMismatch && RemoteBuild.Length == 0;
|
||||
public bool UpToDate => InstalledBuild.Length > 0 && RemoteBuild.Length > 0 && !UpdateAvailable;
|
||||
public string UpdateChipText => BranchMismatch ? $"Files are from {InstalledBranch}: update to switch to {Info.Branch}" : $"Update available: build {RemoteBuild}";
|
||||
public string BuildText => InstalledBuild.Length > 0 ? $"Build {InstalledBuild}" : "Not installed";
|
||||
public string CardStatus => NotInstalled ? "Not installed"
|
||||
: BranchMismatch ? $"Out of date: {InstalledBranch} files"
|
||||
: UpdateAvailable ? $"Out of date: build {InstalledBuild}"
|
||||
: UpdateUnknown ? $"Build {InstalledBuild}: latest unknown"
|
||||
: $"Build {InstalledBuild}";
|
||||
|
||||
void RaiseStatus()
|
||||
{
|
||||
Raise(nameof(UpdateText));
|
||||
Raise(nameof(RemoteBuild));
|
||||
Raise(nameof(NotInstalled));
|
||||
Raise(nameof(BranchMismatch));
|
||||
Raise(nameof(UpdateAvailable));
|
||||
Raise(nameof(UpdateUnknown));
|
||||
Raise(nameof(UpToDate));
|
||||
Raise(nameof(UpdateChipText));
|
||||
Raise(nameof(BuildText));
|
||||
Raise(nameof(CardStatus));
|
||||
}
|
||||
|
||||
int instances;
|
||||
public bool IsRunning => instances > 0;
|
||||
public int InstanceCount => instances;
|
||||
public string RunningText => instances > 1 ? $"Running ×{instances}" : instances == 1 ? "Running" : "";
|
||||
public string StopText => instances > 1 ? $"■ Stop all ({instances})" : "■ Stop";
|
||||
public string StopShortText => instances > 1 ? $"■ ×{instances}" : "■";
|
||||
public string StopTip => instances > 1 ? "Stop all running instances of this install" : "Stop the game";
|
||||
|
||||
public void InstanceStarted() { instances++; NotifyInstances(); }
|
||||
public void InstanceEnded() { if (instances > 0) instances--; NotifyInstances(); }
|
||||
public void ResetInstances() { instances = 0; NotifyInstances(); }
|
||||
|
||||
void NotifyInstances()
|
||||
{
|
||||
Raise(nameof(IsRunning));
|
||||
Raise(nameof(InstanceCount));
|
||||
Raise(nameof(RunningText));
|
||||
Raise(nameof(StopText));
|
||||
Raise(nameof(StopShortText));
|
||||
Raise(nameof(StopTip));
|
||||
}
|
||||
public string CacheText { get => cacheText; private set => Set(ref cacheText, value); }
|
||||
public bool Pending { get => pending; private set { if (Set(ref pending, value)) Raise(nameof(PendingText)); } }
|
||||
public string PendingText => pending ? "Unapplied mod changes" : "";
|
||||
|
||||
public bool MlPresent => File.Exists(Path.Combine(Paths.GameDir(Info), "version.dll"));
|
||||
public bool MlOffPresent => File.Exists(Path.Combine(Paths.GameDir(Info), "version.dll.off"));
|
||||
public string? MlInstalledVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var sub in new[] { "net35", "net6" })
|
||||
{
|
||||
var dll = Path.Combine(Paths.GameDir(Info), "MelonLoader", sub, "MelonLoader.dll");
|
||||
if (!File.Exists(dll)) continue;
|
||||
var v = System.Diagnostics.FileVersionInfo.GetVersionInfo(dll).ProductVersion?.Split('+', '-')[0];
|
||||
if (!string.IsNullOrEmpty(v)) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public ModAssignment? MlAssignment => Info.Mods.FirstOrDefault(m => m.ModId == ModDef.MelonLoaderId);
|
||||
|
||||
public string MlText
|
||||
{
|
||||
get
|
||||
{
|
||||
var a = MlAssignment;
|
||||
var installed = MlInstalledVersion is { } iv ? $" · files are v{iv}" : "";
|
||||
if (MlPresent) return (a is { Enabled: true } ? $"MelonLoader enabled · managed {a.Version}" : "MelonLoader enabled · not managed by this app") + installed;
|
||||
if (MlOffPresent) return "MelonLoader disabled · version.dll renamed to version.dll.off";
|
||||
return a != null ? "MelonLoader disabled" : "No MelonLoader";
|
||||
}
|
||||
}
|
||||
|
||||
public string MlToggleText => MlPresent ? "Disable MelonLoader" : "Enable MelonLoader";
|
||||
public IEnumerable<string> MlVersions => Main.MelonLoader.Releases.Select(r => r.Tag);
|
||||
|
||||
public string? MlVersionChoice
|
||||
{
|
||||
get => mlVersionChoice
|
||||
?? (MlAssignment is { } a && a.Version != "latest" ? a.Version : null)
|
||||
?? (MlInstalledVersion is { } v ? Main.MelonLoader.Releases.Select(r => r.Tag).FirstOrDefault(t => t.TrimStart('v') == v) : null);
|
||||
set { if (Set(ref mlVersionChoice, value)) Raise(); }
|
||||
}
|
||||
|
||||
public IEnumerable<ModDef> AddableMods => Main.ModCatalog
|
||||
.Where(m => m.Id != ModDef.MelonLoaderId && Info.Mods.All(a => a.ModId != m.Id));
|
||||
|
||||
public ModDef? AddChoice { get => addChoice; set => Set(ref addChoice, value); }
|
||||
|
||||
void AddMod()
|
||||
{
|
||||
if (AddChoice == null) return;
|
||||
var a = new ModAssignment { ModId = AddChoice.Id };
|
||||
Info.Mods.Add(a);
|
||||
Mods.Add(new AssignmentVM(this, a, AddChoice));
|
||||
AddChoice = null;
|
||||
Raise(nameof(AddableMods));
|
||||
MarkPending();
|
||||
}
|
||||
|
||||
public void RemoveAssignment(AssignmentVM vm)
|
||||
{
|
||||
Info.Mods.Remove(vm.Info);
|
||||
Mods.Remove(vm);
|
||||
Raise(nameof(AddableMods));
|
||||
MarkPending(true);
|
||||
}
|
||||
|
||||
public void MarkPending(bool force = false)
|
||||
{
|
||||
Pending = true;
|
||||
Main.Save();
|
||||
Main.NotifyAssignments();
|
||||
}
|
||||
|
||||
public void ClearPending() => Pending = false;
|
||||
|
||||
public void RebuildHooks()
|
||||
{
|
||||
Hooks.Clear();
|
||||
foreach (var h in Main.HookCatalog) Hooks.Add(new InstallHookVM(this, h));
|
||||
}
|
||||
|
||||
public void Rebuild()
|
||||
{
|
||||
RebuildHooks();
|
||||
Mods.Clear();
|
||||
foreach (var a in Info.Mods.Where(a => a.ModId != ModDef.MelonLoaderId).ToList())
|
||||
{
|
||||
var mod = Main.ModCatalog.FirstOrDefault(m => m.Id == a.ModId);
|
||||
if (mod != null) Mods.Add(new AssignmentVM(this, a, mod));
|
||||
}
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
InstalledBuild = AppManifest.LocalBuildId(Info);
|
||||
InstalledBranch = AppManifest.LocalBranch(Info);
|
||||
CacheText = CacheService.GetState(Info) switch
|
||||
{
|
||||
CacheState.Shared => "Cache: shared (junction to the shared cache)",
|
||||
CacheState.Local => "Cache: stored inside this install, not shared",
|
||||
CacheState.LinkedElsewhere => "Cache: linked to a folder other than the shared cache",
|
||||
_ => "Cache: not created yet",
|
||||
};
|
||||
Raise(nameof(HasGame));
|
||||
RaiseStatus();
|
||||
Raise(nameof(MlText));
|
||||
Raise(nameof(MlToggleText));
|
||||
Raise(nameof(MlVersions));
|
||||
Raise(nameof(MlVersionChoice));
|
||||
Raise(nameof(AddableMods));
|
||||
Raise(nameof(BranchHint));
|
||||
foreach (var m in Mods) m.RefreshVersions();
|
||||
}
|
||||
}
|
||||
|
||||
public class ReleaseRow
|
||||
{
|
||||
public ReleaseRow(ModDef mod, ReleaseInfo release)
|
||||
{
|
||||
Tag = release.Tag;
|
||||
var parts = new List<string> { release.Tag + (release.Prerelease ? " (pre-release)" : "") };
|
||||
if (release.Published != default) parts.Add(release.Published.ToString("yyyy-MM-dd"));
|
||||
if (release.GameVersion.Length > 0) parts.Add("game " + release.GameVersion);
|
||||
if (release.LoaderVersion.Length > 0) parts.Add("MelonLoader " + release.LoaderVersion);
|
||||
if (release.Snapshot) parts.Add("the file from your install");
|
||||
parts.Add(ModStore.IsDownloaded(mod, release.Tag) ? "downloaded" : "not downloaded");
|
||||
Text = string.Join(" · ", parts);
|
||||
}
|
||||
|
||||
public string Tag { get; }
|
||||
public string Text { get; }
|
||||
}
|
||||
|
||||
public class SourceVM : ObservableObject
|
||||
{
|
||||
readonly MainViewModel main;
|
||||
public MainViewModel Main => main;
|
||||
public SourceDef Info { get; }
|
||||
|
||||
public SourceVM(MainViewModel main, SourceDef info)
|
||||
{
|
||||
this.main = main;
|
||||
Info = info;
|
||||
RemoveCommand = new RelayCommand(() => main.RemoveSource(this));
|
||||
}
|
||||
|
||||
public ObservableCollection<ModVM> Mods { get; } = new();
|
||||
string filter = "";
|
||||
public string Filter { get => filter; set { if (Set(ref filter, value)) Raise(nameof(VisibleMods)); } }
|
||||
public IEnumerable<ModVM> VisibleMods => Mods.Where(m => filter.Trim().Length == 0
|
||||
|| m.Name.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Info.Author.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Info.Description.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public void SetMods(IEnumerable<ModVM> mods)
|
||||
{
|
||||
Mods.Clear();
|
||||
foreach (var m in mods) Mods.Add(m);
|
||||
Raise(nameof(VisibleMods));
|
||||
}
|
||||
|
||||
public string Name => Info.Name;
|
||||
public string Detail => Info.Kind == "cvrmg" ? "cvrmg.com" : $"github.com/{Info.Repo}";
|
||||
public bool CanRemove => !Info.Builtin || Info.Kind != "cvrmg";
|
||||
public ICommand RemoveCommand { get; }
|
||||
int count = -1;
|
||||
public int Count { get => count; set { if (Set(ref count, value)) Raise(nameof(CountText)); } }
|
||||
public string CountText => count < 0 ? "loading..." : $"{count} mods";
|
||||
public string Kind => Info.Kind == "cvrmg" ? "CVRMG" : "GitHub releases";
|
||||
public string Link => Info.Kind == "cvrmg" ? "https://cvrmg.com" : $"https://github.com/{Info.Repo}";
|
||||
}
|
||||
|
||||
public class GroupHeader : ObservableObject
|
||||
{
|
||||
readonly Action<GroupHeader> toggle;
|
||||
bool expanded = true;
|
||||
|
||||
public GroupHeader(string title, int count, bool expanded, Action<GroupHeader> toggle)
|
||||
{
|
||||
Title = title;
|
||||
Count = count;
|
||||
this.expanded = expanded;
|
||||
this.toggle = toggle;
|
||||
ToggleCommand = new RelayCommand(() => this.toggle(this));
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public int Count { get; }
|
||||
public bool Expanded => expanded;
|
||||
public string Text => $"{(expanded ? "▾" : "▸")} {Title} ({Count})";
|
||||
public ICommand ToggleCommand { get; }
|
||||
}
|
||||
|
||||
public class ModVM : ObservableObject
|
||||
{
|
||||
public MainViewModel Main { get; }
|
||||
public ModDef Info { get; }
|
||||
public ObservableCollection<ReleaseRow> Releases { get; } = new();
|
||||
ReleaseRow? selected;
|
||||
|
||||
public static string[] Types { get; } = { "Mods", "Plugins", "UserLibs", "Root" };
|
||||
|
||||
public ModVM(MainViewModel main, ModDef info)
|
||||
{
|
||||
Main = main;
|
||||
Info = info;
|
||||
RefreshCommand = new RelayCommand(() => Main.RefreshModAsync(this));
|
||||
DownloadCommand = new RelayCommand(() => Main.DownloadModAsync(this));
|
||||
DeleteDownloadCommand = new RelayCommand(() => Main.DeleteModDownloadAsync(this));
|
||||
RemoveCommand = new RelayCommand(() => Main.RemoveModAsync(this));
|
||||
AddLocalCommand = new RelayCommand(() => Main.AddLocalFilesAsync(this));
|
||||
ManageCommand = new RelayCommand(() => Main.ManagedMod = this);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
static readonly Dictionary<string, string> FileVersions = new();
|
||||
|
||||
string? FileVersionOf(string tag)
|
||||
{
|
||||
var dir = Paths.ModFilesDir(Info, tag);
|
||||
if (!ModStore.IsDownloaded(Info, tag) || !Directory.Exists(dir)) return null;
|
||||
if (FileVersions.TryGetValue(dir, out var cached)) return cached;
|
||||
var dll = Directory.EnumerateFiles(dir, "*.dll", SearchOption.AllDirectories)
|
||||
.OrderBy(f => ModImportService.Norm(Path.GetFileName(f)) == ModImportService.Norm(Info.FileName) ? 0 : 1).FirstOrDefault();
|
||||
return FileVersions[dir] = dll == null ? "" : AssemblyReader.Read(dll).Version;
|
||||
}
|
||||
|
||||
string InstalledVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Assignment is not { Enabled: true } a) return "";
|
||||
var tag = ModStore.ResolveTag(Info, a.Version);
|
||||
var rel = Info.Releases.FirstOrDefault(r => r.Tag == tag);
|
||||
if (rel?.Snapshot == true)
|
||||
{
|
||||
var i = tag.IndexOf(" (", StringComparison.Ordinal);
|
||||
return tag.StartsWith("installed ", StringComparison.Ordinal) ? tag[10..(i > 10 ? i : tag.Length)] : tag;
|
||||
}
|
||||
var fromFile = FileVersionOf(tag);
|
||||
return string.IsNullOrEmpty(fromFile) ? "" : fromFile;
|
||||
}
|
||||
}
|
||||
|
||||
public string InstalledText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Assignment is not { Enabled: true } a) return "—";
|
||||
var v = InstalledVersion;
|
||||
return v.Length > 0 ? v : ModStore.ResolveTag(Info, a.Version) is { Length: > 0 } t ? t : "—";
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseInfo? Newest => Info.Releases.FirstOrDefault(r => !r.Snapshot && !r.Prerelease) ?? Info.Releases.FirstOrDefault(r => !r.Snapshot);
|
||||
public string LatestText => Info.LatestVersion.Length > 0 ? Info.LatestVersion : "—";
|
||||
|
||||
static int CompareVersions(string a, string b)
|
||||
{
|
||||
a = a.Trim().TrimStart('v', 'V');
|
||||
b = b.Trim().TrimStart('v', 'V');
|
||||
if (Version.TryParse(a, out var x) && Version.TryParse(b, out var y))
|
||||
return (x.Major, x.Minor, Math.Max(x.Build, 0), Math.Max(x.Revision, 0)).CompareTo((y.Major, y.Minor, Math.Max(y.Build, 0), Math.Max(y.Revision, 0)));
|
||||
|
||||
var ca = System.Text.RegularExpressions.Regex.Matches(a, @"\d+|\D+");
|
||||
var cb = System.Text.RegularExpressions.Regex.Matches(b, @"\d+|\D+");
|
||||
for (var i = 0; i < Math.Min(ca.Count, cb.Count); i++)
|
||||
{
|
||||
var pa = ca[i].Value;
|
||||
var pb = cb[i].Value;
|
||||
var c = char.IsDigit(pa[0]) && char.IsDigit(pb[0])
|
||||
? System.Numerics.BigInteger.Parse(pa).CompareTo(System.Numerics.BigInteger.Parse(pb))
|
||||
: string.Compare(pa, pb, StringComparison.OrdinalIgnoreCase);
|
||||
if (c != 0) return c;
|
||||
}
|
||||
return ca.Count.CompareTo(cb.Count);
|
||||
}
|
||||
|
||||
public bool UpdateAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Assignment is not { Enabled: true } a) return false;
|
||||
var installed = InstalledVersion;
|
||||
if (installed.Length > 0 && Info.LatestVersion.Length > 0) return CompareVersions(Info.LatestVersion, installed) > 0;
|
||||
if (Newest == null) return false;
|
||||
var tag = ModStore.ResolveTag(Info, a.Version);
|
||||
var rel = Info.Releases.FirstOrDefault(r => r.Tag == tag);
|
||||
return rel is { Snapshot: false } && tag != Newest.Tag;
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText => Assignment is not { Enabled: true } ? "" : UpdateAvailable ? "Update available" : "Installed";
|
||||
public string ShortDescription => Info.Description.Split('\n')[0].Trim();
|
||||
public string AuthorLine => string.Join(" · ", new[] { Info.Author, Info.Source == "cvrmg" ? "CVRMG" : Info.Source == "github" ? Info.Repo : "local" }.Where(x => x.Length > 0));
|
||||
public string CategoryText => Info.Category.Length > 0 ? Info.Category : Info.Source == "local" ? "Local files" : "Uncategorized";
|
||||
|
||||
InstallVM? Current => Main.SelectedInstall;
|
||||
ModAssignment? Assignment => Current?.Info.Mods.FirstOrDefault(a => a.ModId == Info.Id);
|
||||
public bool HasInstall => Current != null;
|
||||
public bool InstallAssigned => Assignment != null;
|
||||
|
||||
public bool InCatalog => Main.IsInCatalog(Info);
|
||||
|
||||
public bool InstallUsed
|
||||
{
|
||||
get => Assignment is { Enabled: true };
|
||||
set
|
||||
{
|
||||
var cur = Current;
|
||||
if (cur == null) return;
|
||||
Main.EnsureInCatalog(this);
|
||||
var a = Assignment;
|
||||
if (value)
|
||||
{
|
||||
if (a == null) cur.Info.Mods.Add(a = new ModAssignment { ModId = Info.Id });
|
||||
a.Enabled = true;
|
||||
}
|
||||
else if (a != null) a.Enabled = false;
|
||||
cur.Rebuild();
|
||||
cur.MarkPending();
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> VersionChoices => new[] { "latest" }.Concat(Info.Releases.Select(r => r.Tag)).Distinct().ToList();
|
||||
|
||||
public string InstallVersion
|
||||
{
|
||||
get => Assignment?.Version ?? "latest";
|
||||
set
|
||||
{
|
||||
if (value == null || Current == null) return;
|
||||
Main.EnsureInCatalog(this);
|
||||
var a = Assignment;
|
||||
if (a == null) Current.Info.Mods.Add(a = new ModAssignment { ModId = Info.Id });
|
||||
if (a.Version == value && a.Enabled) return;
|
||||
a.Version = value;
|
||||
a.Enabled = true;
|
||||
Current!.Rebuild();
|
||||
Current.MarkPending();
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshUsedBy() => Raise(nameof(UsedBy));
|
||||
|
||||
public void NotifyInstall()
|
||||
{
|
||||
Raise(nameof(HasInstall));
|
||||
Raise(nameof(InstallAssigned));
|
||||
Raise(nameof(InstallUsed));
|
||||
Raise(nameof(VersionChoices));
|
||||
Raise(nameof(InstallVersion));
|
||||
Raise(nameof(ToggleInstallText));
|
||||
Raise(nameof(InstalledText));
|
||||
Raise(nameof(LatestText));
|
||||
Raise(nameof(UpdateAvailable));
|
||||
Raise(nameof(StatusText));
|
||||
Raise(nameof(ShortDescription));
|
||||
}
|
||||
|
||||
public ICommand ToggleInstallCommand => new RelayCommand(() => InstallUsed = !InstallUsed);
|
||||
public string ToggleInstallText => InstallUsed ? "Remove from install" : "Add to install";
|
||||
|
||||
public ICommand RefreshCommand { get; }
|
||||
public ICommand DownloadCommand { get; }
|
||||
public ICommand DeleteDownloadCommand { get; }
|
||||
public ICommand RemoveCommand { get; }
|
||||
public ICommand AddLocalCommand { get; }
|
||||
public ICommand ManageCommand { get; }
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => Info.Name;
|
||||
set { if (Info.Name != value) { Info.Name = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
public string Type
|
||||
{
|
||||
get => Info.Type;
|
||||
set { if (value != null && Info.Type != value) { Info.Type = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
public string AssetFilter
|
||||
{
|
||||
get => Info.AssetFilter;
|
||||
set { if (Info.AssetFilter != value) { Info.AssetFilter = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
public bool IsLocal => Info.Source == "local";
|
||||
public bool IsBuiltin => Info.Id == ModDef.MelonLoaderId;
|
||||
public bool CanRemove => !IsBuiltin;
|
||||
public string Source => Info.Source switch
|
||||
{
|
||||
"cvrmg" => $"CVRMG #{Info.CvrmgId}",
|
||||
"local" => "Local files",
|
||||
_ => $"github.com/{Info.Repo}",
|
||||
};
|
||||
|
||||
public IEnumerable<string> SourceOptions => new[] { Info.Repo.Length > 0 && !Info.Local ? "GitHub" : "", Info.CvrmgId > 0 ? "CVRMG" : "" }.Where(x => x.Length > 0);
|
||||
public bool HasSourceChoice => SourceOptions.Count() > 1;
|
||||
|
||||
public string? SelectedSource
|
||||
{
|
||||
get => Info.Source switch { "cvrmg" => "CVRMG", "github" => "GitHub", _ => null };
|
||||
set
|
||||
{
|
||||
if (value == null) return;
|
||||
var s = value == "CVRMG" ? "cvrmg" : "github";
|
||||
if (s != Info.Source) _ = Main.SwitchSourceAsync(this, s);
|
||||
}
|
||||
}
|
||||
|
||||
public string Details
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (Info.Source == "cvrmg" || Info.CvrmgId > 0) parts.Add("on CVRMG");
|
||||
if (Info.Author.Length > 0) parts.Add("by " + Info.Author);
|
||||
if (Info.Category.Length > 0) parts.Add(Info.Category);
|
||||
if (Info.Status.Length > 0 && Info.Status != "approved") parts.Add($"CVRMG status: {Info.Status}");
|
||||
if (Info.SourceLink.Length > 0) parts.Add(Info.SourceLink);
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public string Description => Info.Description.Length > 600 ? Info.Description[..600] + "..." : Info.Description;
|
||||
public bool HasDescription => Info.Description.Length > 0;
|
||||
public ReleaseRow? SelectedRelease { get => selected; set => Set(ref selected, value); }
|
||||
|
||||
public string UsedBy
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = Main.Installs
|
||||
.Select(i => (i, a: i.Info.Mods.FirstOrDefault(m => m.ModId == Info.Id)))
|
||||
.Where(x => x.a != null)
|
||||
.Select(x => $"{x.i.Info.Name} ({x.i.Info.Branch}): {(x.a!.Enabled ? "" : "disabled, ")}{x.a.Version}")
|
||||
.ToList();
|
||||
return lines.Count == 0 ? "Not used by any install" : string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
public void Rebuild()
|
||||
{
|
||||
var keep = SelectedRelease?.Tag;
|
||||
Releases.Clear();
|
||||
foreach (var r in Info.Releases) Releases.Add(new ReleaseRow(Info, r));
|
||||
if (Info.Local && Info.Releases.Count == 0) Releases.Add(new ReleaseRow(Info, new ReleaseInfo { Tag = "local" }));
|
||||
SelectedRelease = Releases.FirstOrDefault(r => r.Tag == keep) ?? Releases.FirstOrDefault();
|
||||
Raise(nameof(UsedBy));
|
||||
Raise(nameof(Name));
|
||||
Raise(nameof(Source));
|
||||
Raise(nameof(SelectedSource));
|
||||
Raise(nameof(Details));
|
||||
Raise(nameof(Description));
|
||||
NotifyInstall();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class InstallHookVM : ObservableObject
|
||||
{
|
||||
readonly InstallVM owner;
|
||||
public HookDef Hook { get; }
|
||||
|
||||
public InstallHookVM(InstallVM owner, HookDef hook)
|
||||
{
|
||||
this.owner = owner;
|
||||
Hook = hook;
|
||||
RunCommand = new RelayCommand(() => owner.Main.RunHookAsync(owner, hook));
|
||||
}
|
||||
|
||||
public string Name => Hook.Name;
|
||||
|
||||
public string When
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (Hook.BeforeUpdate) parts.Add("before update");
|
||||
if (Hook.AfterUpdate) parts.Add("after install / update");
|
||||
if (Hook.BeforeLaunch) parts.Add("before launch");
|
||||
return parts.Count == 0 ? "manual only" : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => owner.Info.HookIds.Contains(Hook.Id);
|
||||
set
|
||||
{
|
||||
if (Enabled == value) return;
|
||||
if (value) owner.Info.HookIds.Add(Hook.Id);
|
||||
else owner.Info.HookIds.Remove(Hook.Id);
|
||||
Raise();
|
||||
owner.Main.Save();
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand RunCommand { get; }
|
||||
}
|
||||
|
||||
public class AccountVM : ObservableObject
|
||||
{
|
||||
public MainViewModel Main { get; }
|
||||
public GameAccount Info { get; }
|
||||
|
||||
public AccountVM(MainViewModel main, GameAccount info)
|
||||
{
|
||||
Main = main;
|
||||
Info = info;
|
||||
RemoveCommand = new RelayCommand(() => Main.RemoveAccountAsync(this));
|
||||
}
|
||||
|
||||
public ICommand RemoveCommand { get; }
|
||||
public string Username => Info.Username;
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => Info.Name;
|
||||
set
|
||||
{
|
||||
var name = value.Trim();
|
||||
if (name.Length == 0 || Info.Name == name) return;
|
||||
Info.Name = name;
|
||||
Raise();
|
||||
Main.AccountsChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class HookVM : ObservableObject
|
||||
{
|
||||
public MainViewModel Main { get; }
|
||||
public HookDef Info { get; }
|
||||
|
||||
public HookVM(MainViewModel main, HookDef info)
|
||||
{
|
||||
Main = main;
|
||||
Info = info;
|
||||
RemoveCommand = new RelayCommand(() => Main.RemoveHookAsync(this));
|
||||
}
|
||||
|
||||
public ICommand RemoveCommand { get; }
|
||||
|
||||
public bool CanRemove => true;
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => Info.Name;
|
||||
set { if (Info.Name != value) { Info.Name = value; Raise(); Main.HooksChanged(); } }
|
||||
}
|
||||
|
||||
public string Command
|
||||
{
|
||||
get => Info.Command;
|
||||
set { if (Info.Command != value) { Info.Command = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
public string Arguments
|
||||
{
|
||||
get => Info.Arguments;
|
||||
set { if (Info.Arguments != value) { Info.Arguments = value; Raise(); Main.Save(); } }
|
||||
}
|
||||
|
||||
public bool BeforeUpdate { get => Info.BeforeUpdate; set { if (Info.BeforeUpdate != value) { Info.BeforeUpdate = value; Raise(); Main.HooksChanged(); } } }
|
||||
public bool AfterUpdate { get => Info.AfterUpdate; set { if (Info.AfterUpdate != value) { Info.AfterUpdate = value; Raise(); Main.HooksChanged(); } } }
|
||||
public bool BeforeLaunch { get => Info.BeforeLaunch; set { if (Info.BeforeLaunch != value) { Info.BeforeLaunch = value; Raise(); Main.HooksChanged(); } } }
|
||||
public bool DefaultOn { get => Info.DefaultOn; set { if (Info.DefaultOn != value) { Info.DefaultOn = value; Raise(); Main.Save(); } } }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace CVRManager.Views;
|
||||
|
||||
public class LogView : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<string?> TextProperty = AvaloniaProperty.Register<LogView, string?>(nameof(Text));
|
||||
|
||||
readonly ScrollViewer scroll;
|
||||
readonly LogSurface surface = new();
|
||||
|
||||
static LogView() => TextProperty.Changed.AddClassHandler<LogView>((c, _) => c.OnTextChanged());
|
||||
|
||||
public LogView()
|
||||
{
|
||||
scroll = new ScrollViewer
|
||||
{
|
||||
Content = surface,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
};
|
||||
scroll.ScrollChanged += (_, _) => surface.UpdateView(scroll.Offset, scroll.Viewport);
|
||||
Content = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#0B0D10")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#232833")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
ClipToBounds = true,
|
||||
Child = scroll,
|
||||
};
|
||||
}
|
||||
|
||||
public string? Text
|
||||
{
|
||||
get => GetValue(TextProperty);
|
||||
set => SetValue(TextProperty, value);
|
||||
}
|
||||
|
||||
void OnTextChanged()
|
||||
{
|
||||
var atEnd = scroll.Extent.Height <= 0 || scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 24;
|
||||
if (!surface.SetText(Text ?? "")) return;
|
||||
if (atEnd) Dispatcher.UIThread.Post(() => scroll.ScrollToEnd(), DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LogSurface : Control
|
||||
{
|
||||
const int MaxLines = 20000;
|
||||
const int KeepLines = 15000;
|
||||
|
||||
static readonly IBrush DefaultBrush = new SolidColorBrush(Color.Parse("#E6E8EC"));
|
||||
static readonly IBrush TimeBrush = new SolidColorBrush(Color.Parse("#6B7585"));
|
||||
static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#7AA2FF"));
|
||||
static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#F0646A"));
|
||||
static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#F5B13D"));
|
||||
static readonly IBrush SelectionBrush = new SolidColorBrush(Color.Parse("#553A5BA8"));
|
||||
|
||||
static readonly Regex TimeRx = new(@"^(\[\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)(\s*)(.*)$", RegexOptions.Compiled);
|
||||
static readonly Regex TagRx = new(@"^(\s*(?:\[[^\]]{1,40}\]|\([^)]{1,100}\)))(.*)$", RegexOptions.Compiled);
|
||||
static readonly Regex StackRx = new(@"^\s+at\s", RegexOptions.Compiled);
|
||||
static readonly Regex FileLineRx = new(@"^\(Filename:", RegexOptions.Compiled);
|
||||
static readonly Regex PipeLevelRx = new(@"\|\s*(?<l>error|exception|fatal|warn|warning|info|debug|trace)\s*\)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
static readonly Regex BracketLevelRx = new(@"\[(?<l>error|fatal|fail|exception|warn|warning)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
static readonly Regex LeadingLevelRx = new(@"^\s*(?<l>error|fatal|unhandled exception|exception|failed|failure|warning|warn)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
static readonly Regex ExceptionRx = new(@"(?:^|\s)(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*Exception:", RegexOptions.Compiled);
|
||||
|
||||
readonly record struct Seg(string Text, IBrush Brush);
|
||||
|
||||
static readonly Regex AnsiRx = new("\u001b\\[([0-9;]*)m", RegexOptions.Compiled);
|
||||
static readonly Regex OtherEscRx = new("\u001b\\[[0-9;?]*[A-Za-ln-z]", RegexOptions.Compiled);
|
||||
static readonly IBrush[] AnsiNormal = new[] { "#8B93A1", "#F0646A", "#4ADE9B", "#F5B13D", "#7AA2FF", "#C792EA", "#5FD3E0", "#E6E8EC" }
|
||||
.Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray();
|
||||
static readonly IBrush[] AnsiBright = new[] { "#A0A8B5", "#FF8087", "#6EF0B5", "#FFD166", "#9AB8FF", "#DDA8FF", "#8FEAF5", "#FFFFFF" }
|
||||
.Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray();
|
||||
|
||||
static IBrush Rgb(int r, int g, int b) => new SolidColorBrush(Color.FromRgb((byte)Math.Clamp(r, 0, 255), (byte)Math.Clamp(g, 0, 255), (byte)Math.Clamp(b, 0, 255)));
|
||||
|
||||
static IBrush? ApplySgr(string codes, IBrush? current)
|
||||
{
|
||||
var parts = codes.Length == 0 ? new[] { "0" } : codes.Split(';');
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(parts[i], out var n)) continue;
|
||||
if (n == 0 || n == 39) current = null;
|
||||
else if (n >= 30 && n <= 37) current = AnsiNormal[n - 30];
|
||||
else if (n >= 90 && n <= 97) current = AnsiBright[n - 90];
|
||||
else if (n == 38 && i + 1 < parts.Length)
|
||||
{
|
||||
if (parts[i + 1] == "2" && i + 4 < parts.Length && int.TryParse(parts[i + 2], out var r) && int.TryParse(parts[i + 3], out var g) && int.TryParse(parts[i + 4], out var b))
|
||||
{
|
||||
current = Rgb(r, g, b);
|
||||
i += 4;
|
||||
}
|
||||
else if (parts[i + 1] == "5" && i + 2 < parts.Length && int.TryParse(parts[i + 2], out var idx))
|
||||
{
|
||||
if (idx < 8) current = AnsiNormal[idx];
|
||||
else if (idx < 16) current = AnsiBright[idx - 8];
|
||||
else if (idx < 232)
|
||||
{
|
||||
var c = idx - 16;
|
||||
int Level(int v) => v == 0 ? 0 : 55 + v * 40;
|
||||
current = Rgb(Level(c / 36), Level(c / 6 % 6), Level(c % 6));
|
||||
}
|
||||
else { var v = 8 + (idx - 232) * 10; current = Rgb(v, v, v); }
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
static Line ParseAnsi(string raw, IBrush? carried)
|
||||
{
|
||||
raw = OtherEscRx.Replace(raw, "");
|
||||
var segs = new List<Seg>(4);
|
||||
var brush = carried;
|
||||
var pos = 0;
|
||||
foreach (Match m in AnsiRx.Matches(raw))
|
||||
{
|
||||
if (m.Index > pos) segs.Add(new Seg(raw[pos..m.Index], brush ?? DefaultBrush));
|
||||
brush = ApplySgr(m.Groups[1].Value, brush);
|
||||
pos = m.Index + m.Length;
|
||||
}
|
||||
if (pos < raw.Length) segs.Add(new Seg(raw[pos..], brush ?? DefaultBrush));
|
||||
return new Line { Text = string.Concat(segs.Select(s => s.Text)), Segs = segs.ToArray(), AnsiEnd = brush };
|
||||
}
|
||||
|
||||
sealed class Line
|
||||
{
|
||||
public IBrush? AnsiEnd;
|
||||
public string Text = "";
|
||||
public Seg[] Segs = Array.Empty<Seg>();
|
||||
public IBrush? Level;
|
||||
}
|
||||
|
||||
readonly List<Line> lines = new();
|
||||
readonly Dictionary<Line, FormattedText[]> formatted = new();
|
||||
readonly Typeface typeface = new(new FontFamily("Cascadia Mono,Consolas,monospace"));
|
||||
readonly double fontSize = 12;
|
||||
readonly Thickness pad = new(12, 8);
|
||||
readonly double charWidth;
|
||||
readonly double lineHeight;
|
||||
|
||||
string shown = "";
|
||||
int maxCols;
|
||||
Vector offset;
|
||||
Size viewport;
|
||||
(int Line, int Col)? anchor;
|
||||
(int Line, int Col)? caret;
|
||||
bool dragging;
|
||||
|
||||
public LogSurface()
|
||||
{
|
||||
var probe = Format("0000000000", DefaultBrush);
|
||||
charWidth = probe.Width / 10;
|
||||
lineHeight = Math.Ceiling(probe.Height) + 1;
|
||||
Focusable = true;
|
||||
Cursor = new Cursor(StandardCursorType.Ibeam);
|
||||
ClipToBounds = true;
|
||||
}
|
||||
|
||||
FormattedText Format(string text, IBrush brush) =>
|
||||
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, fontSize, brush);
|
||||
|
||||
static IBrush? FromWord(string word) => word.ToLowerInvariant() switch
|
||||
{
|
||||
"error" or "fatal" or "fail" or "exception" or "failed" or "failure" or "unhandled exception" => ErrorBrush,
|
||||
"warn" or "warning" => WarnBrush,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static IBrush? LevelOfLine(string line)
|
||||
{
|
||||
var pipe = PipeLevelRx.Match(line);
|
||||
if (pipe.Success) return FromWord(pipe.Groups["l"].Value);
|
||||
|
||||
var head = line.Length > 160 ? line[..160] : line;
|
||||
var bracket = BracketLevelRx.Match(head);
|
||||
if (bracket.Success) return FromWord(bracket.Groups["l"].Value);
|
||||
|
||||
var message = line;
|
||||
var time = TimeRx.Match(message);
|
||||
if (time.Success) message = time.Groups[3].Value;
|
||||
var tag = TagRx.Match(message);
|
||||
while (tag.Success)
|
||||
{
|
||||
message = tag.Groups[2].Value;
|
||||
tag = TagRx.Match(message);
|
||||
}
|
||||
|
||||
var leading = LeadingLevelRx.Match(message);
|
||||
if (leading.Success) return FromWord(leading.Groups["l"].Value);
|
||||
return ExceptionRx.IsMatch(message) ? ErrorBrush : null;
|
||||
}
|
||||
|
||||
static Line Parse(string text, IBrush? inherited)
|
||||
{
|
||||
var line = new Line { Text = text, Level = inherited };
|
||||
var segs = new List<Seg>(4);
|
||||
if (StackRx.IsMatch(text)) segs.Add(new Seg(text, inherited ?? DefaultBrush));
|
||||
else if (FileLineRx.IsMatch(text)) segs.Add(new Seg(text, TimeBrush));
|
||||
else
|
||||
{
|
||||
var level = LevelOfLine(text);
|
||||
line.Level = level;
|
||||
var rest = text;
|
||||
var time = TimeRx.Match(rest);
|
||||
if (time.Success)
|
||||
{
|
||||
segs.Add(new Seg(time.Groups[1].Value, TimeBrush));
|
||||
if (time.Groups[2].Length > 0) segs.Add(new Seg(time.Groups[2].Value, DefaultBrush));
|
||||
rest = time.Groups[3].Value;
|
||||
}
|
||||
var tag = TagRx.Match(rest);
|
||||
if (tag.Success)
|
||||
{
|
||||
segs.Add(new Seg(tag.Groups[1].Value, level ?? TagBrush));
|
||||
rest = tag.Groups[2].Value;
|
||||
}
|
||||
if (rest.Length > 0) segs.Add(new Seg(rest, level ?? DefaultBrush));
|
||||
}
|
||||
line.Segs = segs.ToArray();
|
||||
return line;
|
||||
}
|
||||
|
||||
void AppendLines(string chunk)
|
||||
{
|
||||
var parts = chunk.Split('\n');
|
||||
var count = parts.Length;
|
||||
if (count > 0 && parts[^1].Length == 0) count--;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var text = parts[i].TrimEnd('\r');
|
||||
var carried = lines.Count > 0 ? lines[^1].AnsiEnd : null;
|
||||
var line = carried != null || text.IndexOf('\u001b') >= 0
|
||||
? ParseAnsi(text, carried)
|
||||
: Parse(text, lines.Count > 0 ? lines[^1].Level : null);
|
||||
text = line.Text;
|
||||
lines.Add(line);
|
||||
if (text.Length > maxCols) maxCols = text.Length;
|
||||
}
|
||||
}
|
||||
|
||||
void TrimHead()
|
||||
{
|
||||
if (lines.Count <= MaxLines) return;
|
||||
var drop = lines.Count - KeepLines;
|
||||
foreach (var l in lines.Take(drop)) formatted.Remove(l);
|
||||
lines.RemoveRange(0, drop);
|
||||
maxCols = lines.Count == 0 ? 0 : lines.Max(l => l.Text.Length);
|
||||
anchor = anchor is { } a ? (Math.Max(0, a.Line - drop), a.Line - drop < 0 ? 0 : a.Col) : null;
|
||||
caret = caret is { } c ? (Math.Max(0, c.Line - drop), c.Line - drop < 0 ? 0 : c.Col) : null;
|
||||
}
|
||||
|
||||
public bool SetText(string text)
|
||||
{
|
||||
if (text == shown) return false;
|
||||
|
||||
string? appended = null;
|
||||
var endsWithNewline = shown.Length == 0 || shown[^1] == '\n';
|
||||
if (shown.Length > 0 && text.StartsWith(shown, StringComparison.Ordinal)) appended = text[shown.Length..];
|
||||
else if (shown.Length > 0)
|
||||
{
|
||||
var key = shown.Length > 400 ? shown[^400..] : shown;
|
||||
var at = text.LastIndexOf(key, StringComparison.Ordinal);
|
||||
if (at >= 0) appended = text[(at + key.Length)..];
|
||||
}
|
||||
|
||||
if (appended == null)
|
||||
{
|
||||
lines.Clear();
|
||||
formatted.Clear();
|
||||
maxCols = 0;
|
||||
anchor = caret = null;
|
||||
AppendLines(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
var partial = "";
|
||||
if (!endsWithNewline && lines.Count > 0)
|
||||
{
|
||||
partial = lines[^1].Text;
|
||||
formatted.Remove(lines[^1]);
|
||||
lines.RemoveAt(lines.Count - 1);
|
||||
}
|
||||
AppendLines(partial + appended);
|
||||
}
|
||||
|
||||
TrimHead();
|
||||
shown = text;
|
||||
InvalidateMeasure();
|
||||
InvalidateVisual();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void UpdateView(Vector newOffset, Size newViewport)
|
||||
{
|
||||
var resized = newViewport != viewport;
|
||||
offset = newOffset;
|
||||
viewport = newViewport;
|
||||
if (resized) InvalidateMeasure();
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override Size MeasureOverride(Size availableSize)
|
||||
{
|
||||
var width = pad.Left + pad.Right + maxCols * charWidth;
|
||||
var height = pad.Top + pad.Bottom + lines.Count * lineHeight;
|
||||
return new Size(Math.Max(width, viewport.Width), Math.Max(height, viewport.Height));
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
|
||||
if (lines.Count == 0) return;
|
||||
|
||||
var first = Math.Max(0, (int)((offset.Y - pad.Top) / lineHeight));
|
||||
var last = Math.Min(lines.Count - 1, (int)((offset.Y + viewport.Height - pad.Top) / lineHeight) + 1);
|
||||
var (selStart, selEnd) = Selection();
|
||||
|
||||
if (formatted.Count > 600) formatted.Clear();
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var y = pad.Top + i * lineHeight;
|
||||
|
||||
if (selStart is { } s && selEnd is { } e && i >= s.Line && i <= e.Line)
|
||||
{
|
||||
var c0 = i == s.Line ? s.Col : 0;
|
||||
var c1 = i == e.Line ? e.Col : line.Text.Length + 1;
|
||||
if (c1 > c0) context.FillRectangle(SelectionBrush, new Rect(pad.Left + c0 * charWidth, y, (c1 - c0) * charWidth, lineHeight));
|
||||
}
|
||||
|
||||
if (!formatted.TryGetValue(line, out var fts))
|
||||
formatted[line] = fts = line.Segs.Select(sg => Format(sg.Text, sg.Brush)).ToArray();
|
||||
|
||||
var x = pad.Left;
|
||||
for (var k = 0; k < line.Segs.Length; k++)
|
||||
{
|
||||
var width = line.Segs[k].Text.Length * charWidth;
|
||||
if (x + width >= offset.X && x <= offset.X + viewport.Width)
|
||||
context.DrawText(fts[k], new Point(x, y));
|
||||
x += width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(int Line, int Col) HitTest(Point p)
|
||||
{
|
||||
if (lines.Count == 0) return (0, 0);
|
||||
var line = Math.Clamp((int)Math.Floor((p.Y - pad.Top) / lineHeight), 0, lines.Count - 1);
|
||||
var col = Math.Clamp((int)Math.Round((p.X - pad.Left) / charWidth), 0, lines[line].Text.Length);
|
||||
return (line, col);
|
||||
}
|
||||
|
||||
((int Line, int Col)? Start, (int Line, int Col)? End) Selection()
|
||||
{
|
||||
if (anchor is not { } a || caret is not { } c || a == c) return (null, null);
|
||||
var forward = a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col);
|
||||
return forward ? (a, c) : (c, a);
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
Focus();
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||
var hit = HitTest(e.GetPosition(this));
|
||||
if (e.ClickCount == 2 && lines.Count > 0)
|
||||
{
|
||||
var text = lines[hit.Line].Text;
|
||||
int start = hit.Col, end = hit.Col;
|
||||
while (start > 0 && !char.IsWhiteSpace(text[start - 1])) start--;
|
||||
while (end < text.Length && !char.IsWhiteSpace(text[end])) end++;
|
||||
anchor = (hit.Line, start);
|
||||
caret = (hit.Line, end);
|
||||
}
|
||||
else if (e.ClickCount >= 3 && lines.Count > 0)
|
||||
{
|
||||
anchor = (hit.Line, 0);
|
||||
caret = (hit.Line, lines[hit.Line].Text.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
anchor = hit;
|
||||
caret = hit;
|
||||
dragging = true;
|
||||
}
|
||||
e.Pointer.Capture(this);
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
if (!dragging) return;
|
||||
caret = HitTest(e.GetPosition(this));
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
dragging = false;
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
|
||||
string SelectedText()
|
||||
{
|
||||
var (start, end) = Selection();
|
||||
if (start is not { } s || end is not { } e) return "";
|
||||
var sb = new StringBuilder();
|
||||
for (var i = s.Line; i <= e.Line && i < lines.Count; i++)
|
||||
{
|
||||
var text = lines[i].Text;
|
||||
var c0 = i == s.Line ? Math.Min(s.Col, text.Length) : 0;
|
||||
var c1 = i == e.Line ? Math.Min(e.Col, text.Length) : text.Length;
|
||||
sb.Append(text, c0, Math.Max(0, c1 - c0));
|
||||
if (i < e.Line) sb.Append('\n');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
protected override async void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) return;
|
||||
if (e.Key == Key.A && lines.Count > 0)
|
||||
{
|
||||
anchor = (0, 0);
|
||||
caret = (lines.Count - 1, lines[^1].Text.Length);
|
||||
InvalidateVisual();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.C)
|
||||
{
|
||||
var text = SelectedText();
|
||||
if (text.Length > 0 && TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) await clipboard.SetTextAsync(text);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:CVRManager.ViewModels"
|
||||
xmlns:m="using:CVRManager.Models"
|
||||
xmlns:v="using:CVRManager.Views"
|
||||
x:Class="CVRManager.Views.MainWindow"
|
||||
Icon="/Assets/app.ico" Title="CVR Manager" Width="1240" Height="820" MinWidth="1000" MinHeight="620">
|
||||
|
||||
<Grid>
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource SidebarBg}" BorderBrush="{DynamicResource CardBorder}" BorderThickness="0,0,0,1" Padding="24,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Path Data="{StaticResource CvrMark}" Fill="{DynamicResource AccentBrush}" Stretch="Uniform" Height="17" VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||
<TextBlock Text="CVR Manager" FontSize="16" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,28,0"/>
|
||||
<RadioButton Classes="tab" GroupName="top" Content="Installs" IsChecked="{Binding IsInstallsPage}"/>
|
||||
<RadioButton Classes="tab" GroupName="top" Content="Sources" IsChecked="{Binding IsModsPage}"/>
|
||||
<RadioButton Classes="tab" GroupName="top" Content="Gallery" IsChecked="{Binding IsGalleryPage}"/>
|
||||
<RadioButton Classes="tab" GroupName="top" Content="Settings" IsChecked="{Binding IsSettingsPage}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Panel>
|
||||
<Grid ColumnDefinitions="300,*" Margin="24,20,24,20" IsVisible="{Binding IsInstallsPage}">
|
||||
<DockPanel Margin="0,0,20,0">
|
||||
<StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="0,8,0,0">
|
||||
<Button Content="+ New install" Command="{Binding ShowNewCommand}" Classes="accent" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Button Content="Refresh branches" Command="{Binding RefreshBranchesCommand}" Classes="ghost" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ListBox x:Name="InstallList" Classes="cards" ItemsSource="{Binding Installs}" SelectedItem="{Binding SelectedInstall}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:InstallVM">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="16,12,12,12" ColumnSpacing="8">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="15" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Branch}" Classes="dim" FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding CardStatus}" Classes="dim" FontSize="12" IsVisible="{Binding !UpdateAvailable}"/>
|
||||
<TextBlock Text="{Binding CardStatus}" Foreground="{DynamicResource WarnFg}" FontWeight="SemiBold" FontSize="12" IsVisible="{Binding UpdateAvailable}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Stretch">
|
||||
<StackPanel Spacing="4" VerticalAlignment="Center">
|
||||
<Button Content="▶ Desktop" Command="{Binding LaunchDesktopCommand}" IsEnabled="{Binding HasGame}" Classes="accent" Padding="10,6" FontSize="12" HorizontalAlignment="Stretch" ToolTip.Tip="Launch in desktop mode (again, if it is already running)"/>
|
||||
<Button Content="▶ VR" Command="{Binding LaunchVrCommand}" IsEnabled="{Binding HasGame}" Padding="10,6" FontSize="12" HorizontalAlignment="Stretch" ToolTip.Tip="Launch in VR mode (again, if it is already running)"/>
|
||||
</StackPanel>
|
||||
<Button Content="{Binding StopShortText}" Command="{Binding StopCommand}" IsVisible="{Binding IsRunning}" Classes="stop" Padding="9,6" FontSize="12" VerticalAlignment="Stretch" VerticalContentAlignment="Center" ToolTip.Tip="{Binding StopTip}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<StackPanel IsVisible="{Binding NoInstalls}" HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="10">
|
||||
<TextBlock Text="No installs yet" Classes="h1" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<ContentControl Content="{Binding SelectedInstall}">
|
||||
<ContentControl.ContentTemplate>
|
||||
<DataTemplate DataType="vm:InstallVM">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="12" Margin="0,0,0,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Name}" Classes="h1"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="chip"><TextBlock Text="{Binding Branch}"/></Border>
|
||||
<Border Classes="chip"><TextBlock Text="{Binding BuildText}"/></Border>
|
||||
<Border Classes="chip good" IsVisible="{Binding UpToDate}"><TextBlock Text="Up to date"/></Border>
|
||||
<Border Classes="chip warn" IsVisible="{Binding UpdateAvailable}"><TextBlock Text="{Binding UpdateChipText}"/></Border>
|
||||
<Border Classes="chip" IsVisible="{Binding UpdateUnknown}"><TextBlock Text="Latest build unknown"/></Border>
|
||||
<Border Classes="chip good" IsVisible="{Binding IsRunning}"><TextBlock Text="{Binding RunningText}"/></Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
|
||||
<ComboBox ItemsSource="{Binding Main.AccountChoices}" SelectedItem="{Binding AccountChoice}" IsVisible="{Binding Main.HasAccounts}" MinWidth="150" MinHeight="40" Padding="12,8" ToolTip.Tip="Account">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="m:AccountChoice"><TextBlock Text="{Binding Name}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ComboBox ItemsSource="{Binding LaunchModes}" SelectedItem="{Binding LaunchModeChoice}" MinWidth="130" MinHeight="40" Padding="12,8">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="m:LaunchModeOption"><TextBlock Text="{Binding Label}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Content="▶ Launch" Command="{Binding LaunchCommand}" IsEnabled="{Binding HasGame}" Classes="accent" MinWidth="132" HorizontalContentAlignment="Center" ToolTip.Tip="{Binding LaunchTip}"/>
|
||||
<Button Content="{Binding StopText}" Command="{Binding StopCommand}" IsVisible="{Binding IsRunning}" Classes="stop" HorizontalContentAlignment="Center" ToolTip.Tip="{Binding StopTip}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Update" Command="{Binding UpdateCommand}"/>
|
||||
<Button Content="Verify files" Command="{Binding VerifyCommand}"/>
|
||||
<Button Content="Open folder" Command="{Binding OpenFolderCommand}"/>
|
||||
<Button Content="Duplicate" Command="{Binding DuplicateCommand}"/>
|
||||
<Button Content="Delete install" Command="{Binding DeleteCommand}" Classes="ghost danger"/>
|
||||
</StackPanel>
|
||||
<Border BorderBrush="{DynamicResource CardBorder}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton Classes="tab" GroupName="sub" Content="Overview" IsChecked="{Binding ShowOverview}"/>
|
||||
<RadioButton Classes="tab" GroupName="sub" Content="Mods" IsChecked="{Binding ShowModsTab}"/>
|
||||
<RadioButton Classes="tab" GroupName="sub" Content="Logs" IsChecked="{Binding ShowLogs}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Panel>
|
||||
<ScrollViewer IsVisible="{Binding ShowOverview}">
|
||||
<StackPanel Spacing="14" Margin="0,0,10,16" MaxWidth="900" HorizontalAlignment="Stretch">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Branch and launch" Classes="h2"/>
|
||||
<TextBlock Text="Name" Classes="label"/>
|
||||
<TextBox Text="{Binding Name}"/>
|
||||
<TextBlock Text="Branch" Classes="label"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<ComboBox ItemsSource="{Binding Main.BranchChoices}" SelectedItem="{Binding BranchHint}" HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="m:BranchChoice"><TextBlock Text="{Binding Display}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Grid.Column="1" Content="+ Beta code" Command="{Binding EnterCodeCommand}" VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Launch arguments" Classes="label"/>
|
||||
<Grid ColumnDefinitions="*,260" ColumnSpacing="8">
|
||||
<TextBox Text="{Binding LaunchArgs}" PlaceholderText="Extra arguments"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{x:Static m:LaunchArgOption.All}" SelectedItem="{Binding ArgChoice}" PlaceholderText="Add an argument..." HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="m:LaunchArgOption">
|
||||
<StackPanel ToolTip.Tip="{Binding Description}">
|
||||
<TextBlock Text="{Binding Flag}" FontWeight="Medium"/>
|
||||
<TextBlock Text="{Binding Description}" Classes="dim" FontSize="11" MaxWidth="360" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="MelonLoader (version.dll)" Classes="h2"/>
|
||||
<TextBlock Text="{Binding MlText}" Classes="dim"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox ItemsSource="{Binding MlVersions}" SelectedItem="{Binding MlVersionChoice}" PlaceholderText="Version" MinWidth="150"/>
|
||||
<Button Content="Install this version" Command="{Binding SetMlVersionCommand}"/>
|
||||
<Button Content="{Binding MlToggleText}" Command="{Binding ToggleMlCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Hooks" Classes="h2"/>
|
||||
<ItemsControl ItemsSource="{Binding Hooks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:InstallHookVM">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12" Margin="0,3">
|
||||
<CheckBox IsChecked="{Binding Enabled}" MinWidth="0"/>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="Medium"/>
|
||||
<TextBlock Text="{Binding When}" Classes="dim" FontSize="12"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Content="Run now" Command="{Binding RunCommand}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Shared cache" Classes="h2"/>
|
||||
<TextBlock Text="{Binding CacheText}" Classes="dim"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Link to shared cache" Command="{Binding LinkCacheCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid IsVisible="{Binding ShowModsTab}" RowDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,4">
|
||||
<Grid ColumnDefinitions="*,200" ColumnSpacing="8" Margin="0,0,0,8">
|
||||
<TextBox Text="{Binding Main.ModSearch}" PlaceholderText="Search all available mods"/>
|
||||
<ComboBox Grid.Column="1" ItemsSource="{x:Static vm:MainViewModel.ModFilters}" SelectedItem="{Binding Main.ModFilter}" HorizontalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="34,250,90,90,*,96" Margin="12,0,12,4">
|
||||
<TextBlock Grid.Column="1" Text="Name" Classes="dim" FontSize="12"/>
|
||||
<TextBlock Grid.Column="2" Text="Installed" Classes="dim" FontSize="12"/>
|
||||
<TextBlock Grid.Column="3" Text="Latest" Classes="dim" FontSize="12"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding Main.AvailableStatus}" Classes="dim" FontSize="12"/>
|
||||
</Grid>
|
||||
<ListBox Grid.Row="2" Classes="table" ItemsSource="{Binding Main.ModRows}" SelectedItem="{Binding Main.SelectedRow}" SelectionChanged="OnTableSelection">
|
||||
<ListBox.DataTemplates>
|
||||
<DataTemplate DataType="vm:GroupHeader">
|
||||
<Border Background="{DynamicResource GroupHeaderBg}" Padding="12,9" Margin="0,6,0,0" CornerRadius="6" Cursor="Hand">
|
||||
<TextBlock Text="{Binding Text}" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:ModVM">
|
||||
<Grid ColumnDefinitions="34,250,90,90,*,96" Margin="12,6" ToolTip.Tip="{Binding Description}">
|
||||
<CheckBox IsChecked="{Binding InstallUsed}" IsEnabled="{Binding HasInstall}" MinWidth="0" VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,10,0">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="Medium" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding AuthorLine}" Classes="dim" FontSize="11" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<TextBlock Text="{Binding InstalledText}" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="update" FontSize="11" Foreground="{DynamicResource WarnFg}" IsVisible="{Binding UpdateAvailable}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Text="{Binding LatestText}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,0,8,0"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding ShortDescription}" Classes="dim" VerticalAlignment="Center" TextWrapping="Wrap" MaxHeight="34" TextTrimming="CharacterEllipsis" Margin="0,0,10,0"/>
|
||||
<Button Grid.Column="5" Content="Manage" Command="{Binding ManageCommand}" Padding="10,5" HorizontalAlignment="Right" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,Auto,*,Auto,Auto" ColumnSpacing="8" Margin="0,12,0,0">
|
||||
<StackPanel Grid.ColumnSpan="2" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Scan Steam install" Command="{Binding Main.ScanSteamInstallCommand}" IsVisible="{Binding Main.HasDetected}"/>
|
||||
<Button Content="Scan this install's folder" Command="{Binding ImportModsCommand}"/>
|
||||
<Button Content="Refresh sources" Command="{Binding Main.RefreshAllModsCommand}" Classes="ghost"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Text="{Binding PendingText}" Foreground="{DynamicResource WarnFg}" VerticalAlignment="Center" HorizontalAlignment="Right" Margin="0,0,12,0"/>
|
||||
<Button Grid.Column="3" Content="Add local file(s)..." Command="{Binding Main.AddLocalNewCommand}"/>
|
||||
<Button Grid.Column="4" Content="Install / Update" Command="{Binding ApplyCommand}" Classes="accent"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid IsVisible="{Binding ShowLogs}" RowDefinitions="Auto,*" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8" Spacing="4">
|
||||
<RadioButton Classes="tab" GroupName="logtab" Content="MelonLoader" IsChecked="{Binding ShowMlLog}" Padding="14,8"/>
|
||||
<RadioButton Classes="tab" GroupName="logtab" Content="Unity" IsChecked="{Binding ShowUnityLog}" Padding="14,8"/>
|
||||
<TextBlock Text="Shared by all installs" Classes="dim" FontSize="12" VerticalAlignment="Center" Margin="12,0,0,0" IsVisible="{Binding ShowUnityLog}"/>
|
||||
</StackPanel>
|
||||
<v:LogView Grid.Row="1" Text="{Binding MlLogText}" IsVisible="{Binding ShowMlLog}"/>
|
||||
<v:LogView Grid.Row="1" Text="{Binding UnityLogText}" IsVisible="{Binding ShowUnityLog}"/>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ContentControl.ContentTemplate>
|
||||
</ContentControl>
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="320,*" Margin="24,20,24,20" IsVisible="{Binding IsModsPage}">
|
||||
<DockPanel Margin="0,0,20,0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="Sources" Classes="h1" Margin="0,0,0,14"/>
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" ColumnSpacing="8" Margin="0,8,0,0">
|
||||
<TextBox Text="{Binding AddSourceRepo}" PlaceholderText="owner/repo or URL"/>
|
||||
<Button Grid.Column="1" Content="Add" Command="{Binding AddSourceCommand}" Classes="accent" Padding="16,8"/>
|
||||
</Grid>
|
||||
<ListBox Classes="cards" ItemsSource="{Binding Sources}" SelectedItem="{Binding SelectedSource}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SourceVM">
|
||||
<StackPanel Spacing="2" Margin="16,12">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Detail}" Classes="dim" FontSize="12" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding CountText}" Classes="dim" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
<ContentControl Grid.Column="1" Content="{Binding SelectedSource}">
|
||||
<ContentControl.ContentTemplate>
|
||||
<DataTemplate DataType="vm:SourceVM">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="10" Margin="0,0,0,12">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBlock Text="{Binding Name}" Classes="h1" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Refresh" Command="{Binding Main.RefreshAllModsCommand}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="Remove source" Command="{Binding RemoveCommand}" IsVisible="{Binding CanRemove}" Classes="ghost danger" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="chip"><TextBlock Text="{Binding Kind}"/></Border>
|
||||
<Border Classes="chip"><TextBlock Text="{Binding CountText}"/></Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Link}" Classes="dim"/>
|
||||
<Grid ColumnDefinitions="*,Auto,260" ColumnSpacing="8">
|
||||
<TextBox Text="{Binding Filter}" PlaceholderText="Search this source"/>
|
||||
<TextBlock Grid.Column="1" Text="Add to" VerticalAlignment="Center" Classes="dim"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Main.Installs}" SelectedItem="{Binding Main.SelectedInstall}" HorizontalAlignment="Stretch" PlaceholderText="Pick an install">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:InstallVM"><TextBlock Text="{Binding Name}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="0,12,0,0" IsVisible="{Binding Main.HasSelectedInstall}">
|
||||
<TextBlock Text="{Binding Main.SelectedInstall.PendingText}" Foreground="{DynamicResource WarnFg}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Install / Update" Command="{Binding Main.SelectedInstall.ApplyCommand}" Classes="accent"/>
|
||||
</Grid>
|
||||
<ListBox Classes="plain" ItemsSource="{Binding VisibleMods}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ModVM">
|
||||
<Grid ColumnDefinitions="*,110,110,Auto,Auto" ColumnSpacing="10" Margin="8,6" ToolTip.Tip="{Binding Description}">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="Medium" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding ShortDescription}" Classes="dim" FontSize="11" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="{Binding InstalledText}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding LatestText}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
<Button Grid.Column="3" Content="{Binding ToggleInstallText}" Command="{Binding ToggleInstallCommand}" IsEnabled="{Binding HasInstall}" Padding="10,5" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="4" Content="Manage" Command="{Binding ManageCommand}" Classes="ghost" Padding="10,5" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ContentControl.ContentTemplate>
|
||||
</ContentControl>
|
||||
</Grid>
|
||||
|
||||
<DockPanel Margin="24,20,24,20" IsVisible="{Binding IsGalleryPage}">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Gallery" Classes="h1"/>
|
||||
<TextBlock Text="{Binding Gallery.FolderText}" Classes="dim" FontSize="12"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Refresh" Command="{Binding Gallery.RefreshCommand}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Content="Open folder" Command="{Binding Gallery.OpenFolderCommand}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding Gallery.Status}" Classes="dim" Margin="0,8,0,0"/>
|
||||
<ScrollViewer>
|
||||
<StackPanel>
|
||||
<TextBlock Text="No screenshots yet." Classes="dim" IsVisible="{Binding Gallery.IsEmpty}" Margin="0,20"/>
|
||||
<ItemsControl ItemsSource="{Binding Gallery.Items}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:GalleryItemVM">
|
||||
<Border Classes="card" Width="276" Margin="0,0,12,12" Padding="10">
|
||||
<StackPanel Spacing="8">
|
||||
<Border Background="{DynamicResource AppBg}" CornerRadius="6" ClipToBounds="True" Height="150">
|
||||
<Image Source="{Binding Thumb}" Stretch="UniformToFill"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding DateText}" FontWeight="Medium"/>
|
||||
<TextBlock Text="{Binding Name}" Classes="dim" FontSize="11" TextTrimming="CharacterEllipsis"/>
|
||||
<Grid ColumnDefinitions="*,*,Auto,Auto" ColumnSpacing="6">
|
||||
<Button Content="Copy" Command="{Binding CopyCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Padding="6,5"/>
|
||||
<Button Grid.Column="1" Content="Open" Command="{Binding OpenCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Padding="6,5"/>
|
||||
<Button Grid.Column="2" Content="Show" Command="{Binding ShowCommand}" Classes="ghost" Padding="8,5"/>
|
||||
<Button Grid.Column="3" Content="Delete" Command="{Binding DeleteCommand}" Classes="ghost danger" Padding="8,5"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="Load more" Command="{Binding Gallery.MoreCommand}" IsVisible="{Binding Gallery.HasMore}" HorizontalAlignment="Center" Margin="0,8,0,16"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
<ScrollViewer IsVisible="{Binding IsSettingsPage}">
|
||||
<StackPanel Spacing="14" Margin="24,20,24,20" MaxWidth="780" HorizontalAlignment="Stretch">
|
||||
<TextBlock Text="Settings" Classes="h1"/>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding VersionText}" Classes="h2"/>
|
||||
<TextBlock Text="{Binding UpdateStatus}" Classes="dim" FontSize="12" IsVisible="{Binding UpdateStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Check for updates" Command="{Binding CheckUpdateCommand}"/>
|
||||
<Button Content="{Binding InstallUpdateText}" Command="{Binding InstallUpdateCommand}" IsVisible="{Binding UpdateReady}" Classes="accent"/>
|
||||
</StackPanel>
|
||||
<CheckBox IsChecked="{Binding CheckUpdates}" Content="Check on startup"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Steam account" Classes="h2"/>
|
||||
<TextBlock Text="{Binding SteamAccountText}" Classes="dim" FontSize="12" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Sign in..." Command="{Binding SteamSignInCommand}"/>
|
||||
<Button Content="Sign out" Command="{Binding SteamSignOutCommand}" IsVisible="{Binding SteamSignedIn}" Classes="ghost"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding SteamClientText}" Classes="dim" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Game accounts" Classes="h2"/>
|
||||
<ItemsControl ItemsSource="{Binding Accounts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AccountVM">
|
||||
<Grid ColumnDefinitions="220,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
||||
<TextBox Text="{Binding Name}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Username}" Classes="dim" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
<Button Grid.Column="2" Content="✕" Command="{Binding RemoveCommand}" Classes="ghost" ToolTip.Tip="Remove"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Grid ColumnDefinitions="220,*,Auto,Auto" ColumnSpacing="10">
|
||||
<TextBox Text="{Binding NewAccountUser}" PlaceholderText="Username"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding NewAccountKey}" PlaceholderText="Access key" PasswordChar="•"/>
|
||||
<Button Grid.Column="2" Content="Add" Command="{Binding AddAccountCommand}"/>
|
||||
<Button Grid.Column="3" Content="Import from installs" Command="{Binding ImportAccountsCommand}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Appearance" Classes="h2"/>
|
||||
<TextBlock Text="{Binding ThemeNote}" IsVisible="{Binding ThemeNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" Classes="dim" TextWrapping="Wrap"/>
|
||||
<ComboBox ItemsSource="{Binding ThemeNames}" SelectedItem="{Binding Theme}" MinWidth="260" HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Library location" Classes="h2"/>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Text="{Binding LibraryRoot}" IsEnabled="{Binding CanChangeRoot}"/>
|
||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseLibraryRootCommand}" IsEnabled="{Binding CanChangeRoot}"/>
|
||||
<Button Grid.Column="2" Content="Apply" Command="{Binding ApplyLibraryRootCommand}" IsEnabled="{Binding CanChangeRoot}"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Open library" Command="{Binding OpenLibraryCommand}"/>
|
||||
<Button Content="Open shared cache" Command="{Binding OpenCacheCommand}"/>
|
||||
<Button Content="Open mod store" Command="{Binding OpenModStoreCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Cache" Classes="h2"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Button Content="Deduplicate and relink all caches" Command="{Binding DedupeCommand}"/>
|
||||
<TextBlock Text="{Binding CacheSize}" Classes="dim" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<CheckBox IsChecked="{Binding CacheGuard}" Content="Multi-instance cache guard"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Hooks" Classes="h2"/>
|
||||
<TextBlock Text="{}{game} {install} {branch} {build} work in arguments." TextWrapping="Wrap" Classes="dim"/>
|
||||
<ItemsControl ItemsSource="{Binding Hooks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:HookVM">
|
||||
<Border BorderBrush="{DynamicResource CardBorder}" BorderThickness="1" CornerRadius="8" Padding="14" Margin="0,0,0,10">
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<TextBox Text="{Binding Name}" FontWeight="SemiBold"/>
|
||||
<Button Grid.Column="1" Content="Remove" Command="{Binding RemoveCommand}" Classes="ghost danger"/>
|
||||
</Grid>
|
||||
<WrapPanel>
|
||||
<CheckBox IsChecked="{Binding BeforeUpdate}" Content="Before update" Margin="0,0,18,0"/>
|
||||
<CheckBox IsChecked="{Binding AfterUpdate}" Content="After install / update" Margin="0,0,18,0"/>
|
||||
<CheckBox IsChecked="{Binding BeforeLaunch}" Content="Before launch"/>
|
||||
</WrapPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBox Text="{Binding Command}" PlaceholderText="Script or program"/>
|
||||
<TextBox Text="{Binding Arguments}" PlaceholderText="Arguments, e.g. --target "{game}""/>
|
||||
</StackPanel>
|
||||
<CheckBox IsChecked="{Binding DefaultOn}" Content="Enable on new installs"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="+ Add command hook" Command="{Binding AddHookCommand}" HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Activity log" Classes="h2"/>
|
||||
<v:LogView Text="{Binding LogText}" Height="260"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="GitHub" Classes="h2"/>
|
||||
<TextBox Text="{Binding GitHubToken}" PasswordChar="●" PlaceholderText="Personal access token (optional)"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
|
||||
<Border IsVisible="{Binding ShowNew}" Background="#B0000000">
|
||||
<Border Classes="card" Width="540" VerticalAlignment="Center" HorizontalAlignment="Center" Background="{DynamicResource OverlayBg}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="New install" Classes="h1"/>
|
||||
<TextBlock Text="Name" Classes="label"/>
|
||||
<TextBox Text="{Binding NewName}" PlaceholderText="Name"/>
|
||||
<TextBlock Text="Branch" Classes="label"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<ComboBox ItemsSource="{Binding BranchChoices}" SelectedItem="{Binding NewChoice}" HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate DataType="m:BranchChoice"><TextBlock Text="{Binding Display}"/></DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Grid.Column="1" Content="+ Beta code" Command="{Binding EnterCodeNewCommand}" VerticalAlignment="Stretch"/>
|
||||
</Grid>
|
||||
<CheckBox IsChecked="{Binding NewImport}" Content="Mirror an existing install" Margin="0,8,0,0"/>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding NewImport}">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<ComboBox ItemsSource="{Binding DetectedInstalls}" SelectedItem="{Binding NewDetected}" PlaceholderText="Detected Steam installs" HorizontalAlignment="Stretch"/>
|
||||
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseImportCommand}"/>
|
||||
</Grid>
|
||||
<TextBox Text="{Binding NewImportPath}" PlaceholderText="Folder with ChilloutVR.exe"/>
|
||||
<CheckBox IsChecked="{Binding NewMoveCache}" Content="Copy its cache too"/>
|
||||
<TextBlock Text="The original is not modified." Classes="dim" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelNewCommand}" Classes="ghost"/>
|
||||
<Button Content="Create install" Command="{Binding CreateInstallCommand}" Classes="accent"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding ShowManage}" Background="#B0000000">
|
||||
<Border Classes="card" Width="500" MaxHeight="680" VerticalAlignment="Center" HorizontalAlignment="Center" Background="{DynamicResource OverlayBg}" DataContext="{Binding ManagedMod}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding Name}" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Details}" Classes="dim" FontSize="12" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="Version" Classes="label"/>
|
||||
<ComboBox ItemsSource="{Binding VersionChoices}" SelectedItem="{Binding InstallVersion}" IsEnabled="{Binding HasInstall}" HorizontalAlignment="Stretch"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" IsVisible="{Binding HasSourceChoice}">
|
||||
<TextBlock Text="Source" VerticalAlignment="Center" Classes="dim"/>
|
||||
<ComboBox ItemsSource="{Binding SourceOptions}" SelectedItem="{Binding SelectedSource}" MinHeight="30" Padding="10,4"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="150,*" ColumnSpacing="10">
|
||||
<ComboBox ItemsSource="{x:Static vm:ModVM.Types}" SelectedItem="{Binding Type}" HorizontalAlignment="Stretch"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding AssetFilter}" PlaceholderText="Asset filter (optional)" IsVisible="{Binding !IsLocal}"/>
|
||||
</Grid>
|
||||
<TextBlock Text="Available versions" Classes="label"/>
|
||||
<ListBox Classes="plain" ItemsSource="{Binding Releases}" SelectedItem="{Binding SelectedRelease}" MaxHeight="170">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ReleaseRow"><TextBlock Text="{Binding Text}" FontSize="12"/></DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<TextBlock Text="{Binding UsedBy}" Classes="dim" FontSize="12"/>
|
||||
<WrapPanel>
|
||||
<Button Content="Download selected" Command="{Binding DownloadCommand}" IsVisible="{Binding !IsLocal}" Margin="0,0,6,6"/>
|
||||
<Button Content="Replace local files..." Command="{Binding AddLocalCommand}" IsVisible="{Binding IsLocal}" Margin="0,0,6,6"/>
|
||||
<Button Content="Delete download" Command="{Binding DeleteDownloadCommand}" Margin="0,0,6,6"/>
|
||||
<Button Content="Remove from catalog" Command="{Binding RemoveCommand}" IsEnabled="{Binding InCatalog}" Classes="ghost danger" Margin="0,0,6,6"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
<Button Content="Close" Command="{Binding Main.CloseManageCommand}" Classes="accent" HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding ShowSignIn}" Background="#B0000000">
|
||||
<Border Classes="card" Width="440" VerticalAlignment="Center" HorizontalAlignment="Center" Background="{DynamicResource OverlayBg}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Sign in to Steam" Classes="h1" FontSize="20"/>
|
||||
<TextBlock Text="{Binding SignInPrompt}" Classes="dim" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding SignInUser}" PlaceholderText="Steam username" IsVisible="{Binding SignInNeedsPassword}"/>
|
||||
<TextBox Text="{Binding SignInPassword}" PasswordChar="●" PlaceholderText="Password" IsVisible="{Binding SignInNeedsPassword}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitSignInCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBox Text="{Binding SignInCode}" PlaceholderText="Steam Guard code" IsVisible="{Binding SignInNeedsCode}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitSignInCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Text="The password is not saved." Classes="dim" FontSize="12" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelSignInCommand}" Classes="ghost"/>
|
||||
<Button Content="Continue" Command="{Binding SubmitSignInCommand}" Classes="accent" IsVisible="{Binding SignInCanContinue}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding ShowCodeEntry}" Background="#B0000000">
|
||||
<Border Classes="card" Width="440" VerticalAlignment="Center" HorizontalAlignment="Center" Background="{DynamicResource OverlayBg}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Enter beta code" Classes="h1" FontSize="20"/>
|
||||
<TextBlock Text="{Binding CodeIntro}" Classes="dim" TextWrapping="Wrap"/>
|
||||
<TextBox Text="{Binding CodeText}" PlaceholderText="Beta access code" IsEnabled="{Binding CodeIdle}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SubmitCodeCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Text="{Binding CodeError}" IsVisible="{Binding HasCodeError}" Foreground="{DynamicResource DangerFg}" TextWrapping="Wrap"/>
|
||||
<TextBlock Text="Checking..." IsVisible="{Binding CodeBusy}" Classes="dim"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelCodeCommand}" IsEnabled="{Binding CodeIdle}" Classes="ghost"/>
|
||||
<Button Content="Check code" Command="{Binding SubmitCodeCommand}" IsEnabled="{Binding CodeIdle}" Classes="accent"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding ToastVisible}" Classes="card" Background="{DynamicResource ToastBg}" Padding="0" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="24" MinWidth="340" MaxWidth="480">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Width="4" CornerRadius="10,0,0,10" Background="{DynamicResource AccentBrush}" IsVisible="{Binding !ToastError}"/>
|
||||
<Border Width="4" CornerRadius="10,0,0,10" Background="{DynamicResource DangerFg}" IsVisible="{Binding ToastError}"/>
|
||||
<StackPanel Grid.Column="1" Spacing="8" Margin="14,12">
|
||||
<TextBlock Text="{Binding Status}" TextWrapping="Wrap"/>
|
||||
<ProgressBar Value="{Binding Progress}" Maximum="100" IsIndeterminate="{Binding Indeterminate}" Height="4" IsVisible="{Binding IsBusy}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Button Content="Cancel" Command="{Binding CancelCommand}" Classes="ghost" IsVisible="{Binding IsBusy}"/>
|
||||
<Button Content="✕" Command="{Binding DismissToastCommand}" Classes="ghost" IsVisible="{Binding IsIdle}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,148 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using CVRManager.ViewModels;
|
||||
|
||||
namespace CVRManager.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var vm = new MainViewModel
|
||||
{
|
||||
PickFolder = async () =>
|
||||
{
|
||||
var r = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { AllowMultiple = false });
|
||||
return r.Count > 0 ? r[0].TryGetLocalPath() : null;
|
||||
},
|
||||
PickFiles = async () =>
|
||||
{
|
||||
var r = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
AllowMultiple = true,
|
||||
FileTypeFilter = new[] { new FilePickerFileType("Mod files") { Patterns = new[] { "*.dll", "*.zip" } } },
|
||||
});
|
||||
return (IReadOnlyList<string>)r.Select(f => f.TryGetLocalPath()).Where(p => p != null).Select(p => p!).ToList();
|
||||
},
|
||||
Confirm = ConfirmAsync,
|
||||
CopyImage = async path =>
|
||||
{
|
||||
if (Clipboard == null) return;
|
||||
using var bmp = new Avalonia.Media.Imaging.Bitmap(path);
|
||||
await Clipboard.SetBitmapAsync(bmp);
|
||||
},
|
||||
};
|
||||
DataContext = vm;
|
||||
Opened += (_, _) => WindowIcons.Apply(this);
|
||||
HookInstallDrag(vm);
|
||||
}
|
||||
|
||||
InstallVM? dragItem;
|
||||
Point dragStart;
|
||||
bool dragging;
|
||||
|
||||
void HookInstallDrag(MainViewModel vm)
|
||||
{
|
||||
var list = InstallList;
|
||||
list.AddHandler(PointerPressedEvent, (_, e) =>
|
||||
{
|
||||
if (!e.GetCurrentPoint(list).Properties.IsLeftButtonPressed) return;
|
||||
if ((e.Source as Visual)?.FindAncestorOfType<Button>(true) != null) return;
|
||||
if ((e.Source as StyledElement)?.DataContext is not InstallVM item) return;
|
||||
dragItem = item;
|
||||
dragStart = e.GetPosition(list);
|
||||
dragging = false;
|
||||
}, RoutingStrategies.Bubble, true);
|
||||
|
||||
list.AddHandler(PointerMovedEvent, (_, e) =>
|
||||
{
|
||||
if (dragItem == null) return;
|
||||
var p = e.GetPosition(list);
|
||||
if (!dragging)
|
||||
{
|
||||
if (Math.Abs(p.Y - dragStart.Y) < 6) return;
|
||||
dragging = true;
|
||||
e.Pointer.Capture(list);
|
||||
list.Cursor = new Cursor(StandardCursorType.SizeNorthSouth);
|
||||
}
|
||||
var from = vm.Installs.IndexOf(dragItem);
|
||||
for (var i = 0; i < list.ItemCount; i++)
|
||||
{
|
||||
if (i == from || list.ContainerFromIndex(i) is not Control c || c.TranslatePoint(default, list) is not { } top) continue;
|
||||
var mid = top.Y + c.Bounds.Height / 2;
|
||||
if ((i > from && p.Y > mid && p.Y < top.Y + c.Bounds.Height) || (i < from && p.Y < mid && p.Y > top.Y))
|
||||
{
|
||||
vm.MoveInstall(dragItem, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
MarkDragged(list);
|
||||
}, RoutingStrategies.Bubble, true);
|
||||
|
||||
void End()
|
||||
{
|
||||
if (dragItem == null) return;
|
||||
var moved = dragging;
|
||||
dragItem = null;
|
||||
dragging = false;
|
||||
list.Cursor = null;
|
||||
MarkDragged(list);
|
||||
if (moved) vm.Save();
|
||||
}
|
||||
list.AddHandler(PointerReleasedEvent, (_, e) => { if (dragging) e.Pointer.Capture(null); End(); }, RoutingStrategies.Bubble, true);
|
||||
list.AddHandler(PointerCaptureLostEvent, (_, _) => End(), RoutingStrategies.Bubble, true);
|
||||
}
|
||||
|
||||
void MarkDragged(ListBox list)
|
||||
{
|
||||
for (var i = 0; i < list.ItemCount; i++)
|
||||
if (list.ContainerFromIndex(i) is Control c)
|
||||
c.Opacity = dragging && c.DataContext == dragItem ? 0.6 : 1;
|
||||
}
|
||||
|
||||
void OnTableSelection(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.AddedItems.Count > 0 && e.AddedItems[0] is GroupHeader header)
|
||||
header.ToggleCommand.Execute(null);
|
||||
}
|
||||
|
||||
async Task<bool> ConfirmAsync(string message)
|
||||
{
|
||||
var dlg = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 460,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
};
|
||||
var ok = new Button { Content = "OK", Padding = new Thickness(18, 6) };
|
||||
var cancel = new Button { Content = "Cancel", Padding = new Thickness(18, 6) };
|
||||
ok.Click += (_, _) => dlg.Close(true);
|
||||
cancel.Click += (_, _) => dlg.Close(false);
|
||||
dlg.Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { ok, cancel },
|
||||
},
|
||||
},
|
||||
};
|
||||
return await dlg.ShowDialog<bool>(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace CVRManager.Views;
|
||||
|
||||
public static class WindowIcons
|
||||
{
|
||||
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;
|
||||
|
||||
[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);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern IntPtr SendMessage(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
static extern int GetSystemMetrics(int index);
|
||||
|
||||
public static void Apply(Window window)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
var exe = Environment.ProcessPath;
|
||||
var hwnd = window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||
if (exe == null || hwnd == IntPtr.Zero || !Path.GetFileName(exe).Equals("CVRManager.exe", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
Set(hwnd, exe, GetSystemMetrics(SM_CXSMICON), ICON_SMALL);
|
||||
Set(hwnd, exe, GetSystemMetrics(SM_CXICON), ICON_BIG);
|
||||
}
|
||||
|
||||
static void Set(IntPtr hwnd, string exe, int size, int kind)
|
||||
{
|
||||
var handles = new IntPtr[1];
|
||||
var ids = new uint[1];
|
||||
if (PrivateExtractIcons(exe, 0, size, size, handles, ids, 1, 0) > 0 && handles[0] != IntPtr.Zero)
|
||||
SendMessage(hwnd, WM_SETICON, (IntPtr)kind, handles[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="CVRManager.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,87 @@
|
||||
<#
|
||||
One-off migration: give every install folder the 4-character id suffix again.
|
||||
|
||||
before: <library>\installs\Public Scripting\
|
||||
after: <library>\installs\Public Scripting-9ce8\ (suffix = first 4 characters of the install's Id in config.json)
|
||||
|
||||
Run it after 2026-10-05-flatten-install-layout.ps1 (which produced names without a suffix).
|
||||
With the suffix, two installs may share a name without their folders colliding.
|
||||
|
||||
Dry run by default. Run with pwsh.
|
||||
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 # show the plan
|
||||
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 -Apply
|
||||
|
||||
Close CVRManager and every game from the library first. config.json is backed up next to itself,
|
||||
and progress is saved after every install, so a folder held open by Explorer only blocks that install.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
|
||||
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
|
||||
|
||||
$all = @($cfg.Installs)
|
||||
$plans = @()
|
||||
foreach ($i in $all) {
|
||||
$stem = "$(Get-SafeFolderName $i.Name)-$($i.Id.Substring(0, 4))"
|
||||
$want = $stem; $n = 2
|
||||
while ($true) {
|
||||
$clash = $all | Where-Object { $_ -ne $i -and $_.Folder -ieq $want }
|
||||
$existsOther = ($want -ine $i.Folder) -and (Test-Path (Join-Path $installs $want))
|
||||
if (-not $clash -and -not $existsOther) { break }
|
||||
$want = "$stem-$n"; $n++
|
||||
}
|
||||
if ($want -cne $i.Folder) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $want } }
|
||||
}
|
||||
|
||||
"Library: $lib"
|
||||
if ($plans.Count -eq 0) { 'Nothing to do: every install folder already has its suffix.'; return }
|
||||
"Planned renames:"
|
||||
foreach ($p in $plans) { " - $($p.Install.Name): '$($p.From)' -> '$($p.To)'" + $(if (-not (Test-Path (Join-Path $installs $p.From))) { ' (folder missing on disk: config entry only)' } else { '' }) }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
|
||||
function Save-Config { [IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false))) }
|
||||
$failed = @()
|
||||
foreach ($p in $plans) {
|
||||
$oldDir = Join-Path $installs $p.From
|
||||
$newDir = Join-Path $installs $p.To
|
||||
try {
|
||||
if (Test-Path $oldDir) {
|
||||
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
|
||||
else { [IO.Directory]::Move($oldDir, $newDir) }
|
||||
}
|
||||
$p.Install.Folder = $p.To
|
||||
Save-Config
|
||||
" done: $($p.Install.Name) -> $newDir"
|
||||
}
|
||||
catch [UnauthorizedAccessException], [IO.IOException] {
|
||||
$failed += $p.Install.Name
|
||||
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
|
||||
Write-Warning 'A folder of this install is probably open in Explorer or another program. Close it and run the script again.'
|
||||
}
|
||||
}
|
||||
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was renamed and saved." } else { 'Config updated. Start CVRManager again.' }
|
||||
@@ -0,0 +1,140 @@
|
||||
<#
|
||||
One-off migration: old install layout -> current layout.
|
||||
|
||||
old: <library>\installs\<Name>-<4 hex>\game\ChilloutVR.exe
|
||||
new: <library>\installs\<Name>\ChilloutVR.exe
|
||||
|
||||
Also registers install folders that exist on disk but are missing from config.json
|
||||
(name taken from the folder, branch from the game's appmanifest BetaKey).
|
||||
|
||||
Dry run by default. Nothing is changed unless -Apply is given. Run with pwsh.
|
||||
pwsh -File 2026-10-05-flatten-install-layout.ps1 # show the plan
|
||||
pwsh -File 2026-10-05-flatten-install-layout.ps1 -Apply # do it
|
||||
|
||||
Close CVRManager and every game from the library first. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy; skips the 'CVRManager is running' check
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
if (-not (Test-Path $installs)) { 'No installs folder; nothing to do.'; return }
|
||||
|
||||
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
|
||||
|
||||
$list = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($i in @($cfg.Installs)) { $list.Add($i) }
|
||||
$newEntries = @()
|
||||
|
||||
# orphan folders: on disk, not in config
|
||||
$known = @($list | ForEach-Object { $_.Folder.ToLowerInvariant() })
|
||||
foreach ($dir in Get-ChildItem $installs -Directory) {
|
||||
if ($known -contains $dir.Name.ToLowerInvariant()) { continue }
|
||||
$hasExe = (Test-Path (Join-Path $dir.FullName 'ChilloutVR.exe')) -or (Test-Path (Join-Path $dir.FullName 'game\ChilloutVR.exe'))
|
||||
if (-not $hasExe) { continue }
|
||||
$m = [regex]::Match($dir.Name, '^(.*)-([0-9a-f]{4})$')
|
||||
$name = if ($m.Success) { $m.Groups[1].Value.Replace('-', ' ').Trim() } else { $dir.Name }
|
||||
$branch = 'public'
|
||||
foreach ($acf in @((Join-Path $dir.FullName 'game\steamapps\appmanifest_661130.acf'), (Join-Path $dir.FullName 'steamapps\appmanifest_661130.acf'))) {
|
||||
if (Test-Path $acf) {
|
||||
$b = [regex]::Match((Get-Content $acf -Raw), '"UserConfig"\s*\{[^}]*?"BetaKey"\s*"([^"]*)"')
|
||||
if ($b.Success -and $b.Groups[1].Value) { $branch = $b.Groups[1].Value; break }
|
||||
}
|
||||
}
|
||||
$defaultHooks = @($cfg.Hooks | Where-Object { $_.DefaultOn } | ForEach-Object { $_.Id })
|
||||
$entry = [pscustomobject]@{
|
||||
Id = ([guid]::NewGuid().ToString('N')).Substring(0, 8); Name = $name; Folder = $dir.Name; Branch = $branch
|
||||
BranchPassword = ''; LaunchArgs = ''; LaunchMode = 'desktop'; Mods = @(); HookIds = $defaultHooks; Deployed = [pscustomobject]@{}
|
||||
}
|
||||
$list.Add($entry); $newEntries += $entry
|
||||
}
|
||||
|
||||
function Test-Taken([string]$candidate, $self) {
|
||||
foreach ($o in $list) { if ($o -ne $self -and $o.Folder -ieq $candidate) { return $true } }
|
||||
if ($candidate -ieq $self.Folder) { return $false }
|
||||
return (Test-Path (Join-Path $installs $candidate))
|
||||
}
|
||||
|
||||
$plans = @()
|
||||
foreach ($i in $list) {
|
||||
$legacy = Join-Path (Join-Path $installs $i.Folder) 'game'
|
||||
$needsFlatten = (Test-Path $legacy) -and ((Test-Path (Join-Path $legacy 'ChilloutVR.exe')) -or -not (Test-Path (Join-Path (Join-Path $installs $i.Folder) 'ChilloutVR.exe')))
|
||||
$base = Get-SafeFolderName $i.Name
|
||||
$candidate = $base; $n = 2
|
||||
while (Test-Taken $candidate $i) { $candidate = "$base ($n)"; $n++ }
|
||||
$needsRename = $candidate -cne $i.Folder
|
||||
if ($needsFlatten -or $needsRename) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $candidate; Flatten = $needsFlatten; Rename = $needsRename; Registers = ($newEntries -contains $i) } }
|
||||
}
|
||||
|
||||
"Library: $lib"
|
||||
if ($newEntries.Count -gt 0) { "Install folders on disk that are missing from the config ($($newEntries.Count)):"; $newEntries | ForEach-Object { " + register '$($_.Name)' (folder '$($_.Folder)', branch '$($_.Branch)')" } }
|
||||
if ($plans.Count -eq 0 -and $newEntries.Count -eq 0) { 'Nothing to do: every install already uses the current layout.'; return }
|
||||
"Planned moves:"
|
||||
foreach ($p in $plans) {
|
||||
$what = @(); if ($p.Rename) { $what += "rename folder '$($p.From)' -> '$($p.To)'" }; if ($p.Flatten) { $what += "move the contents of 'game' up one level" }
|
||||
" - $($p.Install.Name): $($what -join '; ')"
|
||||
}
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
|
||||
function Save-Config([object[]]$installsToWrite) {
|
||||
$cfg.Installs = @($installsToWrite)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
}
|
||||
|
||||
# progress is written after every install, so a failure part-way never leaves the config out of step with the disk
|
||||
$registered = @($list | Where-Object { $newEntries -notcontains $_ }) # installs that were already in the config stay there
|
||||
$failed = @()
|
||||
foreach ($p in $plans) {
|
||||
$oldDir = Join-Path $installs $p.From
|
||||
$newDir = Join-Path $installs $p.To
|
||||
try {
|
||||
if ($p.Rename) {
|
||||
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
|
||||
else { [IO.Directory]::Move($oldDir, $newDir) }
|
||||
$p.Install.Folder = $p.To
|
||||
}
|
||||
$legacy = Join-Path $newDir 'game'
|
||||
if ($p.Flatten -and (Test-Path $legacy)) {
|
||||
foreach ($entry in Get-ChildItem $legacy -Force) {
|
||||
$dest = Join-Path $newDir $entry.Name
|
||||
if (Test-Path $dest) { Write-Warning "Skipped '$($entry.Name)' for $($p.Install.Name): already exists at the destination"; continue }
|
||||
if ($entry.PSIsContainer) { [IO.Directory]::Move($entry.FullName, $dest) } else { [IO.File]::Move($entry.FullName, $dest) }
|
||||
}
|
||||
if (-not (Get-ChildItem $legacy -Force)) { Remove-Item $legacy }
|
||||
}
|
||||
if ($newEntries -contains $p.Install) { $registered += $p.Install }
|
||||
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
||||
" done: $($p.Install.Name) -> $newDir"
|
||||
}
|
||||
catch [UnauthorizedAccessException], [IO.IOException] {
|
||||
$failed += $p.Install.Name
|
||||
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
|
||||
Write-Warning "A folder of this install is probably open in Explorer or another program. Close it and run the script again; it continues where it stopped."
|
||||
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was migrated and saved." }
|
||||
else { 'Config updated. Start CVRManager again.' }
|
||||
@@ -0,0 +1,99 @@
|
||||
<#
|
||||
One-off migration: import the beta access codes listed in "<library>\branch codes.txt" (one code per line) into CVRManager.
|
||||
|
||||
Each code is resolved with Steam (anonymous CheckAppBetaPassword, the same call the app's "Enter beta code" box makes), so a
|
||||
code that unlocks several branches is stored once per branch. For every branch it unlocks:
|
||||
- config.json -> Unlocked gets { Name, Code } (an existing entry for that branch, e.g. a name-only one, gets the code filled in)
|
||||
- installs on that branch get BranchPassword set, so Update works without opening the dropdown
|
||||
Codes are never printed; they are shown as a length only.
|
||||
The codes are checked as your signed-in Steam account, so sign in first: CVRManager > Settings > "Sign in (beta codes)".
|
||||
The saved login token is read with the same Windows user that signed in, so run this as that user.
|
||||
|
||||
Needs a built app for the resolver: -BinDir (default bin\v26 next to the project) must contain CVRManager.dll and SteamKit2.dll.
|
||||
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
|
||||
|
||||
Dry run by default (resolves codes, writes nothing).
|
||||
pwsh -File 2026-10-05-import-branch-codes.ps1
|
||||
pwsh -File 2026-10-05-import-branch-codes.ps1 -Apply
|
||||
Close CVRManager first with -Apply. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[string]$CodesFile,
|
||||
[string]$BinDir = (Join-Path $PSScriptRoot '..\..\bin\v26'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
if (-not $CodesFile) { $CodesFile = Join-Path $lib 'branch codes.txt' }
|
||||
if (-not (Test-Path $CodesFile)) { throw "No codes file at $CodesFile" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
|
||||
$BinDir = (Resolve-Path $BinDir).Path
|
||||
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'SteamKit2.dll'))
|
||||
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'CVRManager.dll'))
|
||||
$resolver = [CVRManager.Services.BranchCodeService]
|
||||
$account = $cfg.SteamUser
|
||||
$token = [CVRManager.Services.TokenVault]::Unprotect($cfg.SteamToken)
|
||||
if (-not $token) { throw 'Not signed in to Steam in the app. Open CVRManager > Settings > "Sign in (beta codes)", then run this again.' }
|
||||
"Checking codes as Steam account '$account'."
|
||||
|
||||
$codes = @(Get-Content $CodesFile | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique)
|
||||
"Codes in file: $($codes.Count)"
|
||||
|
||||
$known = @($cfg.Unlocked | Where-Object { $_.Code } | ForEach-Object { $_.Code } | Select-Object -Unique |
|
||||
Where-Object { $c = $_; -not @($cfg.Unlocked | Where-Object { $_.Code -ceq $c -and -not $_.Key }).Count })
|
||||
$resolved = @()
|
||||
$n = 0
|
||||
foreach ($code in $codes) {
|
||||
$n++
|
||||
if ($known -ccontains $code) { " code $n (len $($code.Length)): already stored with its key, skipped"; continue }
|
||||
for ($try = 1; $try -le 6; $try++) {
|
||||
try {
|
||||
$keys = $resolver::ResolveAsync($code, $account, $token, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
|
||||
$names = @($keys.Keys)
|
||||
" code $n (len $($code.Length)): unlocks $($names.Count) branch(es): $($names -join ', ')"
|
||||
foreach ($name in $names) { $resolved += [pscustomobject]@{ Name = $name; Code = $code; Key = $resolver::ProtectKey($keys[$name]) } }
|
||||
break
|
||||
}
|
||||
catch {
|
||||
$msg = $_.Exception.InnerException.Message ?? $_.Exception.Message
|
||||
if ($msg -match 'RateLimitExceeded' -and $try -lt 6) { " code ${n}: Steam rate limit, waiting 60 s (try $try of 5)"; Start-Sleep 60; continue }
|
||||
" code $n (len $($code.Length)): NOT resolved - $msg"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($resolved.Count -eq 0) { 'Nothing resolved; nothing to do.'; return }
|
||||
|
||||
$unlocked = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($u in @($cfg.Unlocked)) { $unlocked.Add($u) }
|
||||
$added = 0; $filled = 0; $same = 0; $installsSet = 0
|
||||
foreach ($r in $resolved) {
|
||||
$existing = $unlocked | Where-Object { $_.Name -eq $r.Name } | Select-Object -First 1
|
||||
if (-not $existing) { $unlocked.Add([pscustomobject]@{ Name = $r.Name; Code = $r.Code; Key = $r.Key }); $added++ }
|
||||
elseif ($existing.Code -ceq $r.Code) { $same++; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
|
||||
else { if ([string]::IsNullOrEmpty($existing.Code)) { $filled++ } else { " note: replacing the stored code for '$($r.Name)'" }; $existing.Code = $r.Code; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
|
||||
foreach ($i in @($cfg.Installs | Where-Object { $_.Branch -eq $r.Name -and $_.BranchPassword -cne $r.Code })) { $i.BranchPassword = $r.Code; $installsSet++ }
|
||||
}
|
||||
"Unlocked entries: $added added, $filled filled in, $same already correct. Installs given a branch password: $installsSet."
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
$cfg.Unlocked = @($unlocked)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
'Config updated. Start CVRManager again.'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<#
|
||||
One-off migration: copy the list of ChilloutVR beta branches you have unlocked in Steam
|
||||
into CVRManager's branch dropdown. Only branch NAMES are copied; no codes (Steam does not keep them in a readable form).
|
||||
|
||||
Reads <Steam>\config\config.vdf -> InstallConfigStore/Software/Valve/Steam/apps/661130, keys "betahash_<branch>".
|
||||
Writes the names to config.json -> Unlocked (skipping names already present).
|
||||
|
||||
Dry run by default. Run with pwsh.
|
||||
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 # show what would be added
|
||||
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 -Apply
|
||||
|
||||
Close CVRManager first (it rewrites config.json). config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[string]$SteamDir,
|
||||
[switch]$Apply,
|
||||
[switch]$SkipAppCheck # only for testing against a throwaway copy
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $SteamDir) { $SteamDir = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath }
|
||||
if (-not $SteamDir) { throw 'Could not find Steam; pass -SteamDir.' }
|
||||
$vdf = Join-Path $SteamDir 'config\config.vdf'
|
||||
if (-not (Test-Path $vdf)) { throw "No $vdf" }
|
||||
|
||||
function Get-Block([string]$text, [int]$from) {
|
||||
$open = $text.IndexOf('{', $from)
|
||||
if ($open -lt 0) { return $null }
|
||||
$depth = 0
|
||||
for ($i = $open; $i -lt $text.Length; $i++) {
|
||||
if ($text[$i] -eq '{') { $depth++ } elseif ($text[$i] -eq '}') { $depth--; if ($depth -eq 0) { return $text.Substring($open, $i - $open + 1) } }
|
||||
}
|
||||
$null
|
||||
}
|
||||
|
||||
$text = Get-Content $vdf -Raw
|
||||
$apps = [regex]::Match($text, '"apps"\s*\{')
|
||||
if (-not $apps.Success) { throw 'No "apps" section in config.vdf' }
|
||||
$appsBlock = Get-Block $text $apps.Index
|
||||
$app = [regex]::Match($appsBlock, '"661130"\s*\{')
|
||||
if (-not $app.Success) { 'ChilloutVR has no entry in Steam''s config; nothing to import.'; return }
|
||||
$appBlock = Get-Block $appsBlock $app.Index
|
||||
$names = @([regex]::Matches($appBlock, '"betahash_([^"]+)"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
||||
"Branches Steam has unlocked for ChilloutVR ($($names.Count)): $($names -join ', ')"
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and -not $SkipAppCheck -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$existing = @($cfg.Unlocked | ForEach-Object { $_.Name })
|
||||
$toAdd = @($names | Where-Object { $existing -inotcontains $_ })
|
||||
"Already in CVRManager: $($existing.Count). To add: $($toAdd.Count)$(if ($toAdd.Count) { ' (' + ($toAdd -join ', ') + ')' })"
|
||||
if ($toAdd.Count -eq 0) { 'Nothing to do.'; return }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to add them.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
$merged = @($cfg.Unlocked) + @($toAdd | ForEach-Object { [pscustomobject]@{ Name = $_; Code = '' } })
|
||||
$cfg.Unlocked = @($merged)
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Added $($toAdd.Count) branch name(s). Start CVRManager again."
|
||||
@@ -0,0 +1,66 @@
|
||||
<#
|
||||
One-off repair: point each install in the REAL config.json at its folder on disk, using the <Name>-<id4> format.
|
||||
|
||||
Background: the earlier layout migrations were run from inside Claude's packaged app, whose %APPDATA% writes land in a
|
||||
private copy of the profile. The folders under %LOCALAPPDATA%\CVRManager\installs were really moved, but the real
|
||||
%APPDATA%\CVRManager\config.json was never updated, so the app showed "Not installed". This script fixes the real file.
|
||||
It must be started OUTSIDE Claude's sandbox (a normal terminal, or Win32_Process.Create), otherwise it edits the private copy.
|
||||
|
||||
Each -Map entry is "<folder currently on disk>=<install id, at least 4 chars>".
|
||||
The folder is renamed to <SafeName>-<first 4 of id> and the install's Folder in config.json is set to the new name.
|
||||
|
||||
Dry run by default.
|
||||
pwsh -File 2026-10-05-repair-real-config-folders.ps1 -Map 'Public Scripting-6e3b=9ce8','Public Scripting copy-2f65=1864'
|
||||
pwsh -File ... -Apply
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$Map,
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-SafeFolderName([string]$name) {
|
||||
$bad = [IO.Path]::GetInvalidFileNameChars()
|
||||
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
||||
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
||||
$clean = $clean.Trim().TrimEnd('.')
|
||||
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
||||
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
||||
}
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$installs = Join-Path $lib 'installs'
|
||||
"Config: $configPath"
|
||||
"Library: $lib"
|
||||
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first.' }
|
||||
if ($Apply -and (Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })) { throw 'Close the game first.' }
|
||||
|
||||
$plans = foreach ($entry in $Map) {
|
||||
$dirName, $idPrefix = $entry.Split('=', 2)
|
||||
$inst = @($cfg.Installs | Where-Object { $_.Id.StartsWith($idPrefix, [StringComparison]::OrdinalIgnoreCase) })
|
||||
if ($inst.Count -ne 1) { throw "Id '$idPrefix' matches $($inst.Count) installs." }
|
||||
$inst = $inst[0]
|
||||
if (-not (Test-Path (Join-Path $installs $dirName))) { throw "No folder $dirName in $installs" }
|
||||
[pscustomobject]@{ Install = $inst; From = $dirName; To = "$(Get-SafeFolderName $inst.Name)-$($inst.Id.Substring(0, 4))" }
|
||||
}
|
||||
foreach ($p in $plans) { " $($p.Install.Name) (id $($p.Install.Id)): disk '$($p.From)' -> '$($p.To)'; config Folder '$($p.Install.Folder)' -> '$($p.To)'" }
|
||||
$targets = $plans | ForEach-Object { $_.To }
|
||||
if (($targets | Select-Object -Unique).Count -ne $targets.Count) { throw 'Two installs would get the same folder.' }
|
||||
foreach ($p in $plans) { if ($p.From -ine $p.To -and (Test-Path (Join-Path $installs $p.To))) { throw "Target folder '$($p.To)' already exists." } }
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
"Config backed up to $backup"
|
||||
foreach ($p in $plans) {
|
||||
if ($p.From -cne $p.To) { [IO.Directory]::Move((Join-Path $installs $p.From), (Join-Path $installs $p.To)) }
|
||||
$p.Install.Folder = $p.To
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
" done: $($p.Install.Name) -> $($p.To)"
|
||||
}
|
||||
'Config updated. Start CVRManager again.'
|
||||
@@ -0,0 +1,59 @@
|
||||
<#
|
||||
One-off migration: the app no longer has a built-in "Menu patch" hook. Your menu patch becomes an ordinary command hook.
|
||||
|
||||
- writes "<library>\hooks\menupatch\apply.ps1": copies the MenuPatch folder (and PatchMenus.bat) from this folder into the game
|
||||
folder, then runs MenuPatch\patch.ps1 there, exiting with its exit code
|
||||
- config.json: the hook with id "menupatch" gets Command = that apply.ps1 and loses its "Kind"
|
||||
|
||||
Your existing master folder (<library>\hooks\menupatch\MenuPatch and PatchMenus.bat) is not touched.
|
||||
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
|
||||
Dry run by default; -Apply writes. Close CVRManager first with -Apply. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
||||
$master = Join-Path $lib 'hooks\menupatch'
|
||||
$apply1 = Join-Path $master 'apply.ps1'
|
||||
|
||||
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
|
||||
if (-not $hook) { 'No hook with id "menupatch" in the config; nothing to migrate.'; return }
|
||||
"Hook '$($hook.Name)': Kind=$($hook.Kind), Command='$($hook.Command)'"
|
||||
"Master folder: $master (MenuPatch present: $(Test-Path (Join-Path $master 'MenuPatch\patch.ps1')))"
|
||||
"apply.ps1 will be written to: $apply1"
|
||||
|
||||
$script = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$game = $env:CVR_GAME_DIR
|
||||
if (-not $game) { throw 'CVR_GAME_DIR is not set; run this from CVRManager.' }
|
||||
$src = Join-Path $PSScriptRoot 'MenuPatch'
|
||||
if (-not (Test-Path (Join-Path $src 'patch.ps1'))) { throw "No MenuPatch\patch.ps1 in $PSScriptRoot" }
|
||||
robocopy $src (Join-Path $game 'MenuPatch') /E /XD backup 'reference-*' /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE)" }
|
||||
$bat = Join-Path $PSScriptRoot 'PatchMenus.bat'
|
||||
if (Test-Path $bat) { Copy-Item $bat (Join-Path $game 'PatchMenus.bat') -Force }
|
||||
& (Join-Path $game 'MenuPatch\patch.ps1')
|
||||
if ($LASTEXITCODE -eq 2) { exit 0 }
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
|
||||
|
||||
New-Item -ItemType Directory $master -Force | Out-Null
|
||||
[IO.File]::WriteAllText($apply1, $script, (New-Object Text.UTF8Encoding($false)))
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
$hook.Command = $apply1
|
||||
$hook.Arguments = ''
|
||||
$hook.PSObject.Properties.Remove('Kind')
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Config backed up to $backup"
|
||||
'Done. Start CVRManager again.'
|
||||
@@ -0,0 +1,33 @@
|
||||
<#
|
||||
One-off cleanup for configs written by builds before 2026-10-13, which added a built-in "Menu patch" hook to every config.
|
||||
Removes the hook with id "menupatch" when it has no command (the leftover), and drops it from every install.
|
||||
A menupatch hook that has a command (converted by 2026-10-13-menu-patch-to-command-hook.ps1) is left alone.
|
||||
Dry run by default; -Apply writes. Close CVRManager first. config.json is backed up next to itself.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
||||
[switch]$Apply
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$configPath = Join-Path $ConfigDir 'config.json'
|
||||
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
||||
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
|
||||
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
||||
|
||||
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
|
||||
if (-not $hook) { 'No menu patch hook in the config; nothing to do.'; return }
|
||||
if ($hook.Command) { "The menu patch hook runs '$($hook.Command)'; it was set up on purpose, leaving it."; return }
|
||||
$users = @($cfg.Installs | Where-Object { $_.HookIds -contains 'menupatch' } | ForEach-Object Name)
|
||||
"Leftover hook '$($hook.Name)' (no command), enabled on: $(if ($users) { $users -join ', ' } else { 'no installs' })"
|
||||
|
||||
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to remove it.'; return }
|
||||
|
||||
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $configPath $backup
|
||||
$cfg.Hooks = @($cfg.Hooks | Where-Object { $_.Id -ne 'menupatch' })
|
||||
foreach ($i in $cfg.Installs) { $i.HookIds = @($i.HookIds | Where-Object { $_ -ne 'menupatch' }) }
|
||||
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
||||
"Config backed up to $backup"
|
||||
'Removed. Start CVRManager again.'
|
||||
@@ -0,0 +1,37 @@
|
||||
<#
|
||||
Builds the app, zips it and publishes a Gitea release that the in-app updater picks up.
|
||||
release.ps1 -Version 1.0.1 -Token <gitea token> builds, tags v1.0.1, creates the release, uploads zip + .sha256
|
||||
release.ps1 -Version 1.0.1 -BuildOnly only writes dist\<version>\CVRManager-win-x64.zip(.sha256)
|
||||
The token needs write:repository.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Version,
|
||||
[string]$Token = $env:GITEA_TOKEN,
|
||||
[string]$GiteaUrl = 'https://git.koneko.cat',
|
||||
[string]$Repo = 'Koneko/CVRManager',
|
||||
[string]$Notes = '',
|
||||
[switch]$BuildOnly
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
$out = Join-Path $root "dist\$Version"
|
||||
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
|
||||
dotnet publish "$root\CVRManager.csproj" -c Release -r win-x64 --self-contained false -p:Version=$Version -o "$out\app" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'publish failed' }
|
||||
$zip = Join-Path $out 'CVRManager-win-x64.zip'
|
||||
Compress-Archive -Path "$out\app\*" -DestinationPath $zip
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content "$zip.sha256" "$hash CVRManager-win-x64.zip" -NoNewline
|
||||
"Built $zip ($([math]::Round((Get-Item $zip).Length / 1MB, 1)) MB) sha256 $hash"
|
||||
if ($BuildOnly) { return }
|
||||
if (-not $Token) { throw 'No token: pass -Token or set GITEA_TOKEN.' }
|
||||
|
||||
$h = @{ Authorization = "token $Token" }
|
||||
$api = "$GiteaUrl/api/v1/repos/$Repo"
|
||||
$body = @{ tag_name = "v$Version"; name = "CVR Manager $Version"; body = $Notes; draft = $false; prerelease = $false } | ConvertTo-Json
|
||||
$rel = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $h -ContentType 'application/json' -Body $body
|
||||
foreach ($f in $zip, "$zip.sha256") {
|
||||
Invoke-RestMethod -Method Post -Uri "$api/releases/$($rel.id)/assets?name=$([IO.Path]::GetFileName($f))" -Headers $h -Form @{ attachment = Get-Item $f } | Out-Null
|
||||
}
|
||||
"Published $($rel.html_url)"
|
||||
Reference in New Issue
Block a user