Files
CVRManager/ViewModels/ItemViewModels.cs
T
Exterrata 9bb4f85802 CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:46:35 -04:00

935 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); } } }
}