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 Versions => new[] { "latest" }.Concat(Mod.Releases.Select(r => r.Tag)).Concat(Mod.Local ? new[] { "local" } : Array.Empty()).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 Mods { get; } = new(); public ObservableCollection 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 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 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 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 { 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 Mods { get; } = new(); string filter = ""; public string Filter { get => filter; set { if (Set(ref filter, value)) Raise(nameof(VisibleMods)); } } public IEnumerable 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 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 toggle; bool expanded = true; public GroupHeader(string title, int count, bool expanded, Action 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 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 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 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 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(); 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(); 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(); } } } }