CVR Manager 1.0.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Exterrata
2026-10-25 02:46:35 -04:00
commit 9bb4f85802
59 changed files with 7913 additions and 0 deletions
+934
View File
@@ -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(); } } }
}