9bb4f85802
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
935 lines
35 KiB
C#
935 lines
35 KiB
C#
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(); } } }
|
||
}
|