Files
CVRManager/ViewModels/MainViewModel.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

1639 lines
67 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Text;
using System.Windows.Input;
using Avalonia.Threading;
using CVRManager.Models;
using CVRManager.Services;
namespace CVRManager.ViewModels;
public class MainViewModel : ObservableObject
{
readonly AppConfig cfg;
readonly GitHubService github;
readonly ModStore store;
readonly CvrmgService cvrmg = new();
readonly ModImportService importer;
readonly StringBuilder log = new();
CancellationTokenSource? cts;
bool busy;
string status = "Ready";
double progress;
bool indeterminate;
InstallVM? selectedInstall;
ModVM? selectedMod;
bool showNew;
string newName = "";
BranchChoice? newChoice;
string? newImportPath;
bool newMoveCache = true;
bool newDownload = true;
string addModRepo = "";
string cacheSize = "";
public Func<Task<string?>>? PickFolder { get; set; }
public Func<Task<IReadOnlyList<string>>>? PickFiles { get; set; }
public Func<string, Task<bool>>? Confirm { get; set; }
public Func<string, Task>? CopyImage { get; set; }
public GalleryViewModel Gallery { get; }
public void ReportStatus(string text) => Status = text;
public MainViewModel()
{
cfg = ConfigStore.Load();
ThemeService.Apply(cfg.Theme);
github = new GitHubService(() => cfg.GitHubToken);
store = new ModStore(github);
importer = new ModImportService(github, cvrmg);
Gallery = new GalleryViewModel(this);
foreach (var m in cfg.Mods) Mods.Add(new ModVM(this, m));
foreach (var h in cfg.Hooks) Hooks.Add(new HookVM(this, h));
foreach (var a in cfg.Accounts) Accounts.Add(new AccountVM(this, a));
RebuildAccountChoices();
foreach (var src in cfg.Sources) Sources.Add(new SourceVM(this, src));
AvailableMods = Mods.ToList();
selectedSource = Sources.FirstOrDefault();
Mods.CollectionChanged += (_, _) => RegroupMods();
RegroupMods();
foreach (var i in cfg.Installs) Installs.Add(new InstallVM(this, i));
RebuildChoices();
newChoice = BranchChoices.FirstOrDefault(c => c.Name == "public");
Installs.CollectionChanged += (_, _) => { Raise(nameof(NoInstalls)); Raise(nameof(ShowDetail)); };
selectedInstall = Installs.FirstOrDefault();
selectedMod = Mods.FirstOrDefault(m => m.Info.Id != ModDef.MelonLoaderId);
CreateInstallCommand = new RelayCommand(CreateInstallAsync);
ShowNewCommand = new RelayCommand(() => { ShowNew = true; NewImport = false; DetectExisting(); });
CancelNewCommand = new RelayCommand(() => ShowNew = false);
RefreshBranchesCommand = new RelayCommand(RefreshBranchesAsync);
CancelCommand = new RelayCommand(() => cts?.Cancel());
AddModCommand = new RelayCommand(AddModAsync);
AddLocalNewCommand = new RelayCommand(AddLocalFilesNewAsync);
RefreshAllModsCommand = new RelayCommand(RefreshAllModsAsync);
DedupeCommand = new RelayCommand(DedupeAllAsync);
ApplyLibraryRootCommand = new RelayCommand(ApplyLibraryRoot);
BrowseLibraryRootCommand = new RelayCommand(BrowseLibraryRootAsync);
BrowseImportCommand = new RelayCommand(BrowseImportAsync);
OpenLibraryCommand = new RelayCommand(() => Explorer(Paths.Root));
OpenCacheCommand = new RelayCommand(() => Explorer(Paths.SharedCache));
OpenModStoreCommand = new RelayCommand(() => Explorer(Paths.ModStore));
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
timer.Tick += (_, _) => { if (IsInstallsPage) SelectedInstall?.RefreshLogs(); };
timer.Start();
Log($"Library: {Paths.Root}");
_ = StartupAsync();
}
public ObservableCollection<InstallVM> Installs { get; } = new();
public ObservableCollection<ModVM> Mods { get; } = new();
public ObservableCollection<HookVM> Hooks { get; } = new();
public ObservableCollection<AccountVM> Accounts { get; } = new();
public ObservableCollection<AccountChoice> AccountChoices { get; } = new();
public bool HasAccounts => Accounts.Count > 0;
string newAccountUser = "";
string newAccountKey = "";
public string NewAccountUser { get => newAccountUser; set => Set(ref newAccountUser, value); }
public string NewAccountKey { get => newAccountKey; set => Set(ref newAccountKey, value); }
public void RebuildAccountChoices()
{
AccountChoices.Clear();
AccountChoices.Add(new AccountChoice("", "Game login"));
foreach (var a in cfg.Accounts) AccountChoices.Add(new AccountChoice(a.Id, a.Name));
Raise(nameof(HasAccounts));
foreach (var i in Installs) i.RaiseAccount();
}
public void AccountsChanged()
{
Save();
RebuildAccountChoices();
}
GameAccount AddAccount(string name, string username, string userId, string key)
{
var a = new GameAccount { Name = name, Username = username, UserId = userId, AccessKey = TokenVault.Protect(key) };
cfg.Accounts.Add(a);
Accounts.Add(new AccountVM(this, a));
return a;
}
public ICommand AddAccountCommand => new RelayCommand(() =>
{
var user = NewAccountUser.Trim();
var key = NewAccountKey.Trim();
if (user.Length == 0 || key.Length == 0) { Status = "Enter a username and an access key."; return; }
AddAccount(user, user, "", key);
NewAccountUser = NewAccountKey = "";
AccountsChanged();
});
public ICommand ImportAccountsCommand => new RelayCommand(async () =>
{
var dirs = cfg.Installs.Select(Paths.GameDir).ToList();
var found = await Task.Run(() => AccountService.Scan(dirs).ToList());
var known = cfg.Accounts.Select(a => (a.Username, TokenVault.Unprotect(a.AccessKey))).ToHashSet();
var added = 0;
foreach (var f in found)
{
if (!known.Add((f.Username, f.AccessKey))) continue;
AddAccount(f.Name, f.Username, f.UserId, f.AccessKey);
added++;
}
Status = added == 0 ? "No new accounts found." : $"Imported {added} account{(added == 1 ? "" : "s")}.";
if (added > 0) AccountsChanged();
});
public async Task RemoveAccountAsync(AccountVM vm)
{
if (Confirm != null && !await Confirm($"Remove the account '{vm.Info.Name}'?")) return;
cfg.Accounts.Remove(vm.Info);
Accounts.Remove(vm);
foreach (var i in cfg.Installs.Where(i => i.AccountId == vm.Info.Id)) i.AccountId = "";
AccountsChanged();
}
public IEnumerable<HookDef> HookCatalog => cfg.Hooks;
string? hookWarning;
public void HooksChanged()
{
Save();
foreach (var i in Installs) i.RebuildHooks();
}
public ICommand AddHookCommand => new RelayCommand(() =>
{
var def = new HookDef { Name = "New hook" };
cfg.Hooks.Add(def);
Hooks.Add(new HookVM(this, def));
foreach (var i in Installs) if (def.DefaultOn) i.Info.HookIds.Add(def.Id);
HooksChanged();
});
public async Task RemoveHookAsync(HookVM vm)
{
if (!vm.CanRemove) return;
if (Confirm != null && !await Confirm($"Remove the hook '{vm.Info.Name}'?")) return;
cfg.Hooks.Remove(vm.Info);
Hooks.Remove(vm);
foreach (var i in Installs) i.Info.HookIds.Remove(vm.Info.Id);
HooksChanged();
}
public Task RunHookAsync(InstallVM vm, HookDef hook) => Run($"Running {hook.Name} on {vm.Info.Name}", async ct =>
{
if (!await HookService.RunAsync(hook, vm.Info, vm.InstalledBuild, Log, ct))
throw new InvalidOperationException($"{hook.Name} reported problems; see the activity log in Settings.");
});
async Task RunHooksAsync(InstallVM vm, string evt, CancellationToken ct)
{
var failed = await HookService.RunEventAsync(cfg.Hooks, vm.Info, evt, AppManifest.LocalBuildId(vm.Info), Log, ct);
if (failed.Count > 0) hookWarning = $"hook problems: {string.Join(", ", failed)} (see the activity log in Settings)";
}
public ObservableCollection<BranchInfo> Branches { get; } = new();
public ObservableCollection<BranchChoice> BranchChoices { get; } = new();
public ObservableCollection<string> DetectedInstalls { get; } = new();
public bool HasDetected => DetectedInstalls.Count > 0;
public IEnumerable<ModDef> ModCatalog => cfg.Mods;
public ModDef MelonLoader => cfg.Mods.First(m => m.Id == ModDef.MelonLoaderId);
public ICommand CreateInstallCommand { get; }
public ICommand ShowNewCommand { get; }
public ICommand CancelNewCommand { get; }
public ICommand RefreshBranchesCommand { get; }
public ICommand CancelCommand { get; }
public ICommand AddModCommand { get; }
public ICommand AddLocalNewCommand { get; }
public ICommand RefreshAllModsCommand { get; }
public ICommand DedupeCommand { get; }
public ICommand ApplyLibraryRootCommand { get; }
public ICommand BrowseLibraryRootCommand { get; }
public ICommand BrowseImportCommand { get; }
public ICommand OpenLibraryCommand { get; }
public ICommand OpenCacheCommand { get; }
public ICommand OpenModStoreCommand { get; }
string page = "installs";
public bool IsInstallsPage { get => page == "installs"; set { if (value) SetPage("installs"); } }
public bool IsModsPage { get => page == "mods"; set { if (value) SetPage("mods"); } }
public bool IsSettingsPage { get => page == "settings"; set { if (value) SetPage("settings"); } }
public bool IsGalleryPage
{
get => page == "gallery";
set
{
if (!value) return;
SetPage("gallery");
if (Gallery.Items.Count == 0) _ = Gallery.RefreshAsync();
}
}
void SetPage(string p)
{
if (page == p) return;
page = p;
Raise(nameof(IsInstallsPage));
Raise(nameof(IsModsPage));
Raise(nameof(IsSettingsPage));
Raise(nameof(IsGalleryPage));
}
public bool NoInstalls => Installs.Count == 0;
public bool IsBusy { get => busy; private set { if (Set(ref busy, value)) Raise(nameof(IsIdle)); } }
public bool IsIdle => !busy;
public string Status
{
get => status;
private set
{
status = value;
Raise();
ShowToast(value);
}
}
bool toastVisible;
bool toastError;
int toastToken;
public bool ToastVisible { get => toastVisible; private set => Set(ref toastVisible, value); }
public bool ToastError { get => toastError; private set => Set(ref toastError, value); }
void ShowToast(string text, bool error = false)
{
if (text == "Ready") return;
ToastError = error;
ToastVisible = true;
var token = ++toastToken;
if (error) return;
_ = Task.Run(async () =>
{
await Task.Delay(5000);
Dispatcher.UIThread.Post(() => { if (token == toastToken && !IsBusy) ToastVisible = false; });
});
}
public ICommand DismissToastCommand => new RelayCommand(() => ToastVisible = false);
bool showCode, codeBusy;
string codeText = "", codeError = "";
InstallVM? codeTarget;
public bool ShowCodeEntry { get => showCode; private set => Set(ref showCode, value); }
public string CodeText { get => codeText; set => Set(ref codeText, value); }
public string CodeError { get => codeError; private set { if (Set(ref codeError, value)) Raise(nameof(HasCodeError)); } }
public bool HasCodeError => codeError.Length > 0;
public bool CodeBusy { get => codeBusy; private set { if (Set(ref codeBusy, value)) Raise(nameof(CodeIdle)); } }
public bool CodeIdle => !codeBusy;
public string CodeIntro => codeTarget != null
? $"Unlocked branches are added and {codeTarget.Info.Name} switches to the first."
: "Unlocked branches are added and the first is selected.";
public void BeginCodeEntry(InstallVM? install = null)
{
codeTarget = install;
CodeText = "";
CodeError = "";
Raise(nameof(CodeIntro));
ShowCodeEntry = true;
}
public ICommand CancelCodeCommand => new RelayCommand(() => { if (!CodeBusy) ShowCodeEntry = false; });
public ICommand EnterCodeNewCommand => new RelayCommand(() => BeginCodeEntry());
public ICommand SubmitCodeCommand => new RelayCommand(async () =>
{
var code = CodeText.Trim();
if (CodeBusy) return;
if (code.Length == 0) { CodeError = "Type the access code first."; return; }
if (!SteamSignedIn) { CodeError = "Sign in to Steam in Settings first; Steam only checks codes for a signed-in account."; return; }
CodeError = "";
CodeBusy = true;
try
{
var names = await CheckCodeAsync(code);
ShowCodeEntry = false;
var pick = BranchChoices.FirstOrDefault(c => c.Name == names[0]);
if (pick == null) return;
if (codeTarget != null) codeTarget.BranchHint = pick;
else NewChoice = pick;
Status = names.Count == 1 ? $"Unlocked branch {names[0]}." : $"Unlocked {names.Count} branches; selected {names[0]}.";
}
catch (Exception e)
{
CodeError = e.Message;
Log($"Access code check failed: {e.Message}");
}
finally { CodeBusy = false; }
});
bool showSignIn, signInNeedsCode;
string signInPassword = "", signInCode = "", signInPrompt = "";
TaskCompletionSource<string?>? signInReply;
public bool ShowSignIn { get => showSignIn; private set => Set(ref showSignIn, value); }
bool signInWaiting;
public bool SignInNeedsCode { get => signInNeedsCode; private set { if (Set(ref signInNeedsCode, value)) Raise(nameof(SignInNeedsPassword)); } }
public bool SignInWaiting { get => signInWaiting; private set { if (Set(ref signInWaiting, value)) { Raise(nameof(SignInNeedsPassword)); Raise(nameof(SignInCanContinue)); } } }
public bool SignInNeedsPassword => !signInNeedsCode && !signInWaiting;
public bool SignInCanContinue => !signInWaiting;
public string SignInUser { get => cfg.SteamUser; set { if (cfg.SteamUser != value) { cfg.SteamUser = value.Trim(); Raise(); Save(); } } }
public string SignInPassword { get => signInPassword; set => Set(ref signInPassword, value); }
public string SignInCode { get => signInCode; set => Set(ref signInCode, value); }
public string SignInPrompt { get => signInPrompt; private set => Set(ref signInPrompt, value); }
public bool SteamSignedIn => !string.IsNullOrEmpty(cfg.SteamToken);
public string SteamAccountText => SteamSignedIn ? $"Signed in as {cfg.SteamUser}." : "Not signed in.";
public ICommand SteamSignInCommand => new RelayCommand(async () =>
{
SignInPassword = "";
SignInCode = "";
SignInNeedsCode = false;
SignInWaiting = false;
SignInPrompt = "Steam sign-in";
ShowSignIn = true;
var pass = await WaitSignInAsync();
if (pass == null) { ShowSignIn = false; return; }
SignInPrompt = "Signing in...";
await Run("Steam sign-in", async ct =>
{
async Task<string> Ask(string prompt)
{
SignInCode = "";
SignInPrompt = prompt;
SignInNeedsCode = true;
ShowSignIn = true;
return await WaitSignInAsync() ?? throw new OperationCanceledException();
}
try
{
var token = await SteamLink.SignInAsync(cfg.SteamUser, pass,
() => Dispatcher.UIThread.Post(() =>
{
SignInPrompt = "Approve on your Steam mobile app.";
SignInWaiting = true;
ShowSignIn = true;
}),
wrong => Ask(wrong ? "That Steam Guard code was wrong. Enter the code from your Steam mobile app" : "Enter the Steam Guard code from your Steam mobile app"),
(mail, wrong) => Ask($"{(wrong ? "That code was wrong. " : "")}Enter the Steam Guard code emailed to {mail}"),
ct);
cfg.SteamToken = TokenVault.Protect(token);
Save();
Log($"Signed in to Steam as {cfg.SteamUser}.");
}
finally
{
ShowSignIn = false;
SignInNeedsCode = false;
SignInWaiting = false;
Raise(nameof(SteamSignedIn));
Raise(nameof(SteamAccountText));
}
});
SignInPassword = "";
});
Task<string?> WaitSignInAsync()
{
signInReply = new(TaskCreationOptions.RunContinuationsAsynchronously);
return signInReply.Task;
}
public ICommand SubmitSignInCommand => new RelayCommand(() =>
{
if (!SignInNeedsCode && !SignInWaiting && (string.IsNullOrWhiteSpace(cfg.SteamUser) || SignInPassword.Length == 0))
{
SignInPrompt = "Enter both your Steam username and password.";
return;
}
var reply = SignInNeedsCode ? SignInCode.Trim() : SignInPassword;
SignInPassword = "";
signInReply?.TrySetResult(reply);
});
public ICommand CancelSignInCommand => new RelayCommand(() => signInReply?.TrySetResult(null));
public ICommand SteamSignOutCommand => new RelayCommand(() =>
{
cfg.SteamToken = "";
Save();
Raise(nameof(SteamSignedIn));
Raise(nameof(SteamAccountText));
});
bool newImport;
bool NewImportDone;
public bool NewImport { get => newImport; set => Set(ref newImport, value); }
public double Progress { get => progress; private set => Set(ref progress, value); }
public bool Indeterminate { get => indeterminate; private set => Set(ref indeterminate, value); }
public string LogText { get { lock (logLock) return log.ToString(); } }
readonly object logLock = new();
bool logRaisePending;
public InstallVM? SelectedInstall
{
get => selectedInstall;
set { if (Set(ref selectedInstall, value)) { Raise(nameof(HasSelectedInstall)); ShowNew = false; NotifyAssignments(); } }
}
public bool HasSelectedInstall => selectedInstall != null;
public void RefreshModUsage()
{
foreach (var m in AvailableMods) m.RefreshUsedBy();
}
ModVM? managedMod;
public ModVM? ManagedMod { get => managedMod; set { if (Set(ref managedMod, value)) Raise(nameof(ShowManage)); } }
public bool ShowManage => managedMod != null;
public ICommand CloseManageCommand => new RelayCommand(() => ManagedMod = null);
public void NotifyAssignments()
{
foreach (var m in AvailableMods.Concat(Mods).Distinct().ToList()) m.NotifyInstall();
if (modFilter != "All mods") RegroupMods();
}
public ObservableCollection<object> ModRows { get; } = new();
public List<ModVM> AvailableMods { get; private set; } = new();
readonly Dictionary<string, ModVM> transientMods = new();
string availableStatus = "Loading mods from your sources...";
public string AvailableStatus { get => availableStatus; private set => Set(ref availableStatus, value); }
public bool IsInCatalog(ModDef def) => cfg.Mods.Contains(def);
public void EnsureInCatalog(ModVM vm)
{
if (cfg.Mods.Contains(vm.Info)) return;
cfg.Mods.Add(vm.Info);
Mods.Add(vm);
Save();
}
static bool Matches(ModDef d, AvailableEntry e)
{
if (d.CvrmgId > 0 && e.Cvrmg != null && e.Cvrmg.Id == d.CvrmgId) return true;
if (d.Source == "github" && d.AssetFilter.Length > 0 && e.Keys.Contains(ModImportService.Norm(d.AssetFilter.Replace(".dll", "", StringComparison.OrdinalIgnoreCase)))) return true;
if (d.FileName.Length > 0 && e.Keys.Contains(ModImportService.Norm(Path.GetFileNameWithoutExtension(d.FileName)))) return true;
return e.Keys.Contains(ModImportService.Norm(d.Name));
}
ModDef BuildDef(AvailableEntry e)
{
var def = new ModDef { Name = e.Name, Type = e.Cvrmg?.ModType ?? "Mods" };
if (e.Repos.FirstOrDefault() is { } o)
{
def.Source = "github";
def.Repo = o.Repo;
def.AssetFilter = o.Asset;
def.FileName = o.Asset;
def.SourceLink = $"https://github.com/{o.Repo}";
def.Releases = o.Releases;
}
else if (e.Cvrmg != null)
{
def.Source = "cvrmg";
def.FileName = Paths.Safe(e.Cvrmg.Name) + ".dll";
def.Releases = new List<ReleaseInfo> { CvrmgService.ToRelease(e.Cvrmg, def.FileName) };
}
if (e.Cvrmg != null) CvrmgService.ApplyMeta(def, e.Cvrmg);
if (def.Category.Length == 0) def.Category = "Not on CVRMG";
return def;
}
public async Task RefreshAvailableAsync(bool force, CancellationToken ct = default)
{
AvailableStatus = "Loading mods from your sources...";
List<AvailableEntry> entries;
try
{
entries = await AvailableModsService.BuildAsync(cvrmg, importer, cfg.Sources, force, Log, ct);
}
catch (Exception e) when (e is not OperationCanceledException)
{
AvailableStatus = "Could not load sources: " + e.Message;
entries = new();
}
var vms = new List<ModVM>();
var byEntry = new Dictionary<AvailableEntry, ModVM>();
var claimed = new HashSet<ModDef>();
foreach (var e in entries)
{
var existing = cfg.Mods.FirstOrDefault(d => d.Id != ModDef.MelonLoaderId && !claimed.Contains(d) && Matches(d, e));
if (existing != null)
{
claimed.Add(existing);
if (e.Cvrmg != null)
{
if (existing.CvrmgId == 0) existing.CvrmgId = e.Cvrmg.Id;
if (existing.Category.Length == 0 || existing.Category == "Not on CVRMG") existing.Category = e.Cvrmg.Category;
if (existing.Description.Length == 0) existing.Description = e.Cvrmg.Description;
if (existing.Author.Length == 0) existing.Author = e.Cvrmg.Author;
existing.Status = e.Cvrmg.StatusText;
existing.LatestVersion = e.Cvrmg.ModVersion;
}
if (existing.Source == "github" && e.Repos.FirstOrDefault(o => o.Repo.Equals(existing.Repo, StringComparison.OrdinalIgnoreCase)) is { } offer)
existing.SetReleases(ModImportService.FilterReleases(offer.Releases, existing.AssetFilter));
else if (existing.Source == "cvrmg" && e.Cvrmg != null)
existing.SetReleases(new List<ReleaseInfo> { CvrmgService.ToRelease(e.Cvrmg, existing.FileName.Length > 0 ? existing.FileName : Paths.Safe(e.Cvrmg.Name) + ".dll") });
var vm = Mods.FirstOrDefault(v => v.Info == existing);
if (vm != null) { vms.Add(vm); byEntry[e] = vm; }
continue;
}
if (e.Cvrmg is { ApprovalStatus: 3 }) continue;
if (!transientMods.TryGetValue(e.Key, out var t)) transientMods[e.Key] = t = new ModVM(this, BuildDef(e));
else
{
var fresh = BuildDef(e);
t.Info.Releases = fresh.Releases;
t.Info.Description = fresh.Description;
t.Info.LatestVersion = fresh.LatestVersion;
t.Info.Category = fresh.Category;
t.Info.Status = fresh.Status;
}
t.Rebuild();
vms.Add(t);
byEntry[e] = t;
}
foreach (var vm in Mods.Where(v => v.Info.Id != ModDef.MelonLoaderId && !claimed.Contains(v.Info))) vms.Add(vm);
foreach (var src in Sources)
{
var own = src.Info.Kind == "cvrmg"
? entries.Where(e => e.Cvrmg != null)
: entries.Where(e => e.Repos.Any(o => o.Repo.Equals(src.Info.Repo, StringComparison.OrdinalIgnoreCase)));
src.SetMods(own.Select(e => byEntry.GetValueOrDefault(e)).Where(v => v != null).Select(v => v!).Distinct()
.OrderBy(v => v.Info.Name, StringComparer.OrdinalIgnoreCase));
src.Count = src.Mods.Count;
}
AvailableMods = vms;
foreach (var vm in Mods) vm.Rebuild();
Save();
AvailableStatus = $"{vms.Count} mods from {Sources.Count} sources";
RegroupMods();
}
readonly HashSet<string> collapsedMods = new();
bool regroupQueued;
string modSearch = "";
string modFilter = "All mods";
public static string[] ModFilters { get; } = { "All mods", "Installed", "Updates available" };
public string ModSearch { get => modSearch; set { if (Set(ref modSearch, value)) RegroupMods(); } }
public string ModFilter { get => modFilter; set { if (value != null && Set(ref modFilter, value)) RegroupMods(); } }
public void RegroupMods()
{
if (regroupQueued) return;
regroupQueued = true;
Dispatcher.UIThread.Post(() =>
{
regroupQueued = false;
var keep = selectedMod;
var q = modSearch.Trim();
ModRows.Clear();
var visible = AvailableMods.Where(m => m.Info.Id != ModDef.MelonLoaderId)
.Where(m => q.Length == 0 || m.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || m.Info.Author.Contains(q, StringComparison.OrdinalIgnoreCase) || m.CategoryText.Contains(q, StringComparison.OrdinalIgnoreCase) || m.Info.Description.Contains(q, StringComparison.OrdinalIgnoreCase))
.Where(m => modFilter switch { "Installed" => m.InstallUsed, "Updates available" => m.UpdateAvailable, _ => true })
.GroupBy(m => m.CategoryText)
.OrderBy(g => g.Key is "Uncategorized" or "Local files" ? 1 : 0)
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase);
foreach (var g in visible)
{
ModRows.Add(new GroupHeader(g.Key, g.Count(), !collapsedMods.Contains(g.Key), h => { if (!collapsedMods.Remove(h.Title)) collapsedMods.Add(h.Title); RegroupMods(); }));
if (collapsedMods.Contains(g.Key)) continue;
foreach (var m in g.OrderBy(m => m.Info.Name, StringComparer.OrdinalIgnoreCase)) ModRows.Add(m);
}
if (keep != null && ModRows.Contains(keep)) selectedMod = keep;
Raise(nameof(SelectedRow));
});
}
public object? SelectedRow
{
get => selectedMod;
set { if (value is ModVM m) SelectedMod = m; }
}
public ModVM? SelectedMod
{
get => selectedMod;
set { if (Set(ref selectedMod, value)) { Raise(nameof(HasSelectedMod)); Raise(nameof(SelectedRow)); selectedMod?.Rebuild(); } }
}
public bool HasSelectedMod => selectedMod != null;
public bool ShowNew { get => showNew; set { if (Set(ref showNew, value)) Raise(nameof(ShowDetail)); } }
public bool ShowDetail => Installs.Count > 0;
public string NewName { get => newName; set => Set(ref newName, value); }
public BranchChoice? NewChoice
{
get => newChoice;
set
{
if (value != null) Set(ref newChoice, value);
}
}
public string? NewImportPath { get => newImportPath; set => Set(ref newImportPath, value); }
public bool NewMoveCache { get => newMoveCache; set => Set(ref newMoveCache, value); }
public bool NewDownload { get => newDownload; set => Set(ref newDownload, value); }
public string? NewDetected
{
get => NewImportPath;
set
{
NewImportPath = value;
if (value == null) return;
var found = SteamIntegration.FindExistingInstalls().FirstOrDefault(x => x.GameDir == value);
if (found.Manifest == null) return;
try
{
var state = Vdf.ParseDocument(File.ReadAllText(found.Manifest)).Dict("AppState");
var branch = state?.Dict("UserConfig")?.Str("BetaKey");
if (!string.IsNullOrEmpty(branch)) NewChoice = BranchChoices.FirstOrDefault(c => c.Name == branch) ?? AddChoice(new BranchChoice(branch, ""));
}
catch { }
Raise();
}
}
public string AddModRepo { get => addModRepo; set => Set(ref addModRepo, value); }
public string CacheSize { get => cacheSize; private set => Set(ref cacheSize, value); }
public string LibraryRoot
{
get => cfg.LibraryRoot;
set { if (cfg.LibraryRoot != value) { cfg.LibraryRoot = value; Raise(); } }
}
public bool CanChangeRoot => cfg.Installs.Count == 0;
public string SteamUser
{
get => cfg.SteamUser;
set { if (cfg.SteamUser != value) { cfg.SteamUser = value; Raise(); Raise(nameof(SignInUser)); Save(); } }
}
public bool CheckUpdates
{
get => cfg.CheckUpdates;
set { if (cfg.CheckUpdates != value) { cfg.CheckUpdates = value; Raise(); Save(); } }
}
UpdateInfo? update;
string updateStatus = "";
public string VersionText => $"CVR Manager {UpdateService.Current.ToString(3)}";
public string UpdateStatus { get => updateStatus; private set => Set(ref updateStatus, value); }
public bool UpdateReady => update != null;
public string InstallUpdateText => update == null ? "" : $"Install {update.Tag}";
public ICommand CheckUpdateCommand => new RelayCommand(() => CheckForUpdateAsync(true));
public ICommand InstallUpdateCommand => new RelayCommand(InstallUpdateAsync);
async Task CheckForUpdateAsync(bool manual)
{
if (manual) UpdateStatus = "Checking...";
try
{
update = await UpdateService.CheckAsync(CancellationToken.None);
UpdateStatus = update != null ? $"{update.Tag} is available." : manual ? "Up to date." : "";
if (update != null && !manual) ShowToast($"CVR Manager {update.Tag} is available. Install it in Settings.");
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException)
{
update = null;
UpdateStatus = manual ? $"Could not check: {e.Message}" : "";
}
Raise(nameof(UpdateReady));
Raise(nameof(InstallUpdateText));
}
Task InstallUpdateAsync()
{
var info = update;
if (info == null) return Task.CompletedTask;
if (!UpdateService.CanWriteAppDir())
{
UpdateStatus = "The app folder is read-only; move CVR Manager somewhere you can write to.";
return Task.CompletedTask;
}
return Run($"Updating to {info.Tag}", async ct =>
{
var staged = await UpdateService.DownloadAsync(info, SetProgress, ct);
Save();
UpdateService.ApplyAndRestart(staged);
Dispatcher.UIThread.Post(() => (Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.Shutdown());
});
}
public bool CacheGuard
{
get => cfg.CacheGuard;
set { if (cfg.CacheGuard != value) { cfg.CacheGuard = value; Raise(); Save(); } }
}
public IEnumerable<string> ThemeNames => ThemeService.Names;
public string ThemeNote { get; private set; } = "";
public string Theme
{
get => cfg.Theme;
set
{
if (value == null || cfg.Theme == value) return;
cfg.Theme = value;
var ok = ThemeService.Apply(value);
ThemeNote = ok ? "" : "No saved in-game colours; using ChilloutVR.";
Raise();
Raise(nameof(ThemeNote));
Save();
}
}
public string GitHubToken
{
get => cfg.GitHubToken;
set { if (cfg.GitHubToken != value) { cfg.GitHubToken = value; Raise(); Save(); } }
}
public string SteamClientText => SteamIntegration.IsRunning
? "Steam is running."
: "Steam is started silently on launch.";
public void Save() => ConfigStore.Save(cfg);
public void Log(string line)
{
lock (logLock)
{
log.AppendLine(line);
if (log.Length > 60000) log.Remove(0, log.Length - 40000);
if (logRaisePending) return;
logRaisePending = true;
}
Dispatcher.UIThread.Post(async () =>
{
await Task.Delay(150);
lock (logLock) logRaisePending = false;
Raise(nameof(LogText));
});
}
void SetProgress(double p) => Dispatcher.UIThread.Post(() => { Indeterminate = false; Progress = p; });
async Task Run(string title, Func<CancellationToken, Task> work)
{
if (IsBusy) { ToastVisible = true; return; }
IsBusy = true;
Status = title;
Indeterminate = true;
cts = new CancellationTokenSource();
try
{
hookWarning = null;
await work(cts.Token);
if (hookWarning != null)
{
status = $"{title}: done, but {hookWarning}";
Raise(nameof(Status));
ShowToast(status, true);
}
else Status = $"{title}: done";
}
catch (OperationCanceledException)
{
Status = $"{title}: cancelled";
Log(Status);
}
catch (Exception e)
{
Log($"ERROR: {e.Message}");
status = $"{title}: failed - {e.Message}";
Raise(nameof(Status));
ShowToast(status, true);
}
finally
{
Indeterminate = false;
Progress = 0;
IsBusy = false;
cts = null;
Save();
foreach (var i in Installs) i.Refresh();
foreach (var m in Mods) m.Rebuild();
Raise(nameof(CanChangeRoot));
}
}
async Task StartupAsync()
{
DetectExisting();
AdoptRunningGames();
_ = RefreshAvailableAsync(false);
var cached = ConfigStore.LoadBranches();
if (cached.Count > 0)
{
foreach (var b in cached) Branches.Add(b);
RebuildChoices();
}
_ = RefreshBranchesQuietAsync(CancellationToken.None);
if (cfg.CheckUpdates) _ = CheckForUpdateAsync(false);
if (!IsBusy) await RefreshModCoreAsync(MelonLoader, quiet: true);
_ = Task.Run(() =>
{
var size = FileOps.FormatSize(CacheService.SizeOf(Paths.SharedCache));
Dispatcher.UIThread.Post(() => CacheSize = $"Shared cache size: {size}");
});
}
BranchChoice AddChoice(BranchChoice c)
{
BranchChoices.Add(c);
return c;
}
public void RebuildChoices()
{
var names = new List<BranchChoice> { new("public", "") };
names.AddRange(Branches.Where(b => !b.PasswordRequired && b.Name != "public").Select(b => new BranchChoice(b.Name, "")));
names.AddRange(cfg.Unlocked.Select(u => new BranchChoice(u.Name, u.Code)));
foreach (var i in cfg.Installs)
if (names.All(n => n.Name != i.Branch)) names.Add(new BranchChoice(i.Branch, i.BranchPassword));
var keepNew = NewChoice?.Name;
BranchChoices.Clear();
foreach (var n in names.DistinctBy(n => n.Name)) BranchChoices.Add(n);
newChoice = BranchChoices.FirstOrDefault(c => c.Name == keepNew) ?? BranchChoices.First();
Raise(nameof(NewChoice));
foreach (var i in Installs) i.Refresh();
}
async Task<List<string>> CheckCodeAsync(string code)
{
var keys = await BranchCodeService.ResolveAsync(code, cfg.SteamUser, SteamToken, CancellationToken.None);
if (keys.Count == 0) throw new InvalidOperationException("That code does not unlock any ChilloutVR branch.");
foreach (var (n, key) in keys)
{
cfg.Unlocked.RemoveAll(u => u.Name == n);
cfg.Unlocked.Add(new UnlockedBranch { Name = n, Code = code, Key = BranchCodeService.ProtectKey(key) });
}
Save();
RebuildChoices();
Log($"Access code unlocks: {string.Join(", ", keys.Keys)}");
_ = RefreshBranchesQuietAsync(CancellationToken.None);
return keys.Keys.ToList();
}
void DetectExisting()
{
DetectedInstalls.Clear();
foreach (var (game, _) in SteamIntegration.FindExistingInstalls()) DetectedInstalls.Add(game);
Raise(nameof(HasDetected));
}
public Task RefreshBranchesAsync() => Run("Refreshing branches", async ct =>
{
var list = await SteamDownloader.GetBranchesAsync(cfg.SteamUser, SteamToken, PrivateBranches(), StoreBranchKey, Log, ct);
Branches.Clear();
foreach (var b in list) Branches.Add(b);
ConfigStore.SaveBranches(list);
RebuildChoices();
Log($"Branches: {string.Join(", ", list.Select(b => b.Name + (b.PasswordRequired ? " (password)" : "")))}");
foreach (var i in Installs) i.Refresh();
});
string SteamToken => TokenVault.Unprotect(cfg.SteamToken);
void StoreBranchKey(string branch, byte[] key)
{
var entry = cfg.Unlocked.FirstOrDefault(u => u.Name == branch);
if (entry == null) return;
entry.Key = BranchCodeService.ProtectKey(key);
Dispatcher.UIThread.Post(Save);
}
List<SteamDownloader.PrivateBranch> PrivateBranches() =>
cfg.Unlocked.Where(u => u.Code.Length > 0).Select(u => new SteamDownloader.PrivateBranch(u.Name, u.Code, BranchCodeService.UnprotectKey(u.Key))).ToList();
Task InstallFilesAsync(InstallInfo info, bool validate, CancellationToken ct)
{
var key = BranchCodeService.UnprotectKey(cfg.Unlocked.FirstOrDefault(u => u.Name == info.Branch)?.Key ?? "");
return SteamDownloader.InstallAsync(Paths.GameDir(info), info.Branch, info.BranchPassword, key, StoreBranchKey,
cfg.SteamUser, SteamToken, validate, Log, SetProgress, ct);
}
async Task CreateInstallAsync()
{
var name = NewName.Trim();
if (name.Length == 0) { Status = "Give the install a name."; return; }
if (cfg.Installs.Any(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) { Status = "An install with that name exists."; return; }
var info = new InstallInfo
{
Name = name,
Branch = NewChoice?.Name ?? "public",
BranchPassword = NewChoice?.Code ?? "",
};
info.Folder = InstallFolders.UniqueFolder(cfg.Installs, info, name);
info.HookIds = cfg.Hooks.Where(h => h.DefaultOn).Select(h => h.Id).ToList();
var import = NewImport ? NewImportPath : null;
if (NewImport && string.IsNullOrWhiteSpace(import)) { Status = "Choose the folder to import."; return; }
var moveCache = NewMoveCache;
var download = !NewImport;
NewImportDone = NewImport;
InstallVM? vm = null;
await Run($"Creating {name}", async ct =>
{
Directory.CreateDirectory(Paths.GameDir(info));
cfg.Installs.Add(info);
vm = new InstallVM(this, info);
Installs.Add(vm);
SelectedInstall = vm;
ShowNew = false;
Raise(nameof(CanChangeRoot));
if (!string.IsNullOrEmpty(import))
{
Log($"Mirroring {import} into this install. The original is only read, never changed...");
var cacheSrc = Path.Combine(import, "ChilloutVR_Data", "Cache");
await Task.Run(() => FileOps.CopyTree(import, Paths.GameDir(info),
p => string.Equals(p, cacheSrc, StringComparison.OrdinalIgnoreCase), Log, ct), ct);
var found = SteamIntegration.FindExistingInstalls().FirstOrDefault(x => x.GameDir == import);
if (found.Manifest != null)
{
Directory.CreateDirectory(Path.GetDirectoryName(Paths.Manifest(info))!);
File.Copy(found.Manifest, Paths.Manifest(info), true);
}
if (moveCache && Directory.Exists(cacheSrc))
{
Log("Copying the source cache into the shared cache (the original is left as it is)...");
await Task.Run(() => CacheService.CopyInto(cacheSrc, Paths.SharedCache, Log, ct), ct);
}
}
if (download) await InstallFilesAsync(info, true, ct);
if (Directory.Exists(Path.GetDirectoryName(Paths.CacheDir(info))!) || File.Exists(Paths.GameExe(info)))
await CacheService.LinkAsync(info, Log, ct);
if (NewImportDone) await ImportModsCoreAsync(info, ct);
if (File.Exists(Paths.GameExe(info))) await RunHooksAsync(vm, HookDef.AfterUpdateEvent, ct);
});
vm?.Refresh();
NewName = "";
NewImportPath = null;
}
public Task UpdateInstallAsync(InstallVM vm, bool validate) => Run($"Updating {vm.Info.Name}", async ct =>
{
await RunHooksAsync(vm, HookDef.BeforeUpdateEvent, ct);
await InstallFilesAsync(vm.Info, validate, ct);
await CacheService.LinkAsync(vm.Info, Log, ct);
if (vm.Info.Mods.Count > 0) await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct);
vm.ClearPending();
await RunHooksAsync(vm, HookDef.AfterUpdateEvent, ct);
await RefreshBranchesQuietAsync(ct);
});
async Task RefreshBranchesQuietAsync(CancellationToken ct)
{
try
{
var list = await SteamDownloader.GetBranchesAsync(cfg.SteamUser, SteamToken, PrivateBranches(), StoreBranchKey, Log, ct);
Branches.Clear();
foreach (var b in list) Branches.Add(b);
ConfigStore.SaveBranches(list);
RebuildChoices();
}
catch (OperationCanceledException) { throw; }
catch (Exception e) { Log($"Branch refresh failed: {e.Message}"); }
}
public Task LinkCacheAsync(InstallVM vm) => Run("Linking cache", async ct =>
{
await CacheService.LinkAsync(vm.Info, Log, ct);
});
Task DedupeAllAsync() => Run("Deduplicating caches", async ct =>
{
foreach (var i in Installs.Where(i => CacheService.GetState(i.Info) != CacheState.Missing).ToList())
{
Log($"{i.Info.Name}: {CacheService.GetState(i.Info)}");
await CacheService.LinkAsync(i.Info, Log, ct);
}
var size = await Task.Run(() => CacheService.SizeOf(Paths.SharedCache), ct);
CacheSize = $"Shared cache size: {FileOps.FormatSize(size)}";
});
public void OpenFolder(InstallVM vm)
{
var path = Directory.Exists(Paths.GameDir(vm.Info)) ? Paths.GameDir(vm.Info) : Paths.InstallDir(vm.Info);
Directory.CreateDirectory(path);
Explorer(path);
}
static void Explorer(string path)
{
Directory.CreateDirectory(path);
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true });
}
public void MoveInstall(InstallVM vm, int to)
{
var from = Installs.IndexOf(vm);
if (from < 0 || to < 0 || to >= Installs.Count || from == to) return;
Installs.Move(from, to);
cfg.Installs.RemoveAt(from);
cfg.Installs.Insert(to, vm.Info);
if (selectedInstall == vm) Raise(nameof(SelectedInstall));
else SelectedInstall = vm;
}
public Task DuplicateInstallAsync(InstallVM src) => Run($"Duplicating {src.Info.Name}", async ct =>
{
var srcGame = Paths.GameDir(src.Info);
if (!Directory.Exists(srcGame) || !File.Exists(Paths.GameExe(src.Info)))
throw new InvalidOperationException("There is nothing to duplicate yet: this install has no game files.");
if (src.IsRunning || FindGameProcesses(src.Info).Count > 0)
throw new InvalidOperationException("Stop the game before duplicating its install.");
var name = $"{src.Info.Name} copy";
for (var n = 2; cfg.Installs.Any(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); n++)
name = $"{src.Info.Name} copy {n}";
var info = new InstallInfo
{
Name = name,
Branch = src.Info.Branch,
BranchPassword = src.Info.BranchPassword,
LaunchArgs = src.Info.LaunchArgs,
LaunchMode = src.Info.LaunchMode,
AccountId = src.Info.AccountId,
HookIds = src.Info.HookIds.ToList(),
Mods = src.Info.Mods.Select(a => new ModAssignment { ModId = a.ModId, Version = a.Version, Enabled = a.Enabled }).ToList(),
Deployed = src.Info.Deployed.ToDictionary(kv => kv.Key, kv => kv.Value.ToList()),
};
info.Folder = InstallFolders.UniqueFolder(cfg.Installs, info, name);
var skip = new[]
{
Path.GetFullPath(Paths.CacheDir(src.Info)),
Path.GetFullPath(Path.Combine(srcGame, "MelonLoader", "Logs")),
Path.GetFullPath(Path.Combine(srcGame, "MelonLoader", "Latest.log")),
};
Log($"Copying {src.Info.Name} to {name} (cache and logs are not copied; the cache is shared)...");
try
{
await Task.Run(() => FileOps.CopyTree(srcGame, Paths.GameDir(info),
p => skip.Contains(Path.GetFullPath(p), StringComparer.OrdinalIgnoreCase), Log, ct), ct);
await CacheService.LinkAsync(info, Log, ct);
}
catch
{
await Task.Run(() => FileOps.DeleteDirectorySafe(Paths.InstallDir(info)));
throw;
}
cfg.Installs.Add(info);
var vm = new InstallVM(this, info);
Installs.Add(vm);
SelectedInstall = vm;
Raise(nameof(CanChangeRoot));
Log($"Created {name}.");
});
public async Task DeleteInstallAsync(InstallVM vm)
{
if (Confirm != null && !await Confirm($"Delete install '{vm.Info.Name}' and all its game files?\nThe shared cache and downloaded mods are kept.")) return;
await Run($"Deleting {vm.Info.Name}", async ct =>
{
await Task.Run(() =>
{
CacheService.RemoveLinkIfJunction(Paths.CacheDir(vm.Info));
FileOps.DeleteDirectorySafe(Paths.InstallDir(vm.Info));
}, ct);
cfg.Installs.Remove(vm.Info);
Installs.Remove(vm);
SelectedInstall = Installs.FirstOrDefault();
});
}
readonly HashSet<InstallVM> launching = new();
public async Task LaunchAsync(InstallVM vm, string? modeKey = null)
{
var exe = Paths.GameExe(vm.Info);
if (!File.Exists(exe)) { Status = "Nothing to launch: update this install first."; return; }
if (!launching.Add(vm)) return;
try { await LaunchCoreAsync(vm, exe, modeKey); }
finally { launching.Remove(vm); }
}
async Task LaunchCoreAsync(InstallVM vm, string exe, string? modeKey)
{
Status = $"Preparing {vm.Info.Name}...";
if (vm.Pending) await ApplyModsAsync(vm);
var hookFailures = await HookService.RunEventAsync(cfg.Hooks, vm.Info, HookDef.BeforeLaunchEvent, vm.InstalledBuild, Log, CancellationToken.None);
if (hookFailures.Count > 0) Log($"Launching anyway; hook problems: {string.Join(", ", hookFailures)}");
var steamOk = await SteamIntegration.EnsureRunningAsync(Log);
if (!steamOk) Log("WARNING: the Steam client is not running, so Steam will not count this session.");
var gameDir = Paths.GameDir(vm.Info);
await Task.Run(() =>
{
try { MultiInstance.Prepare(gameDir, Log); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException) { Log($"Could not prepare the install for several instances: {e.Message}"); }
var appIdFile = Path.Combine(gameDir, "steam_appid.txt");
if (!File.Exists(appIdFile)) File.WriteAllText(appIdFile, Paths.AppId.ToString());
try
{
if (cfg.CacheGuard) CacheGuardService.Deploy(gameDir, Log);
else CacheGuardService.Remove(gameDir, Log);
}
catch (Exception e) { Log($"Cache guard step failed: {e.Message}"); }
});
var psi = new ProcessStartInfo(exe)
{
WorkingDirectory = Paths.GameDir(vm.Info),
UseShellExecute = false,
};
psi.Environment["SteamAppId"] = Paths.AppId.ToString();
psi.Environment["SteamGameId"] = Paths.AppId.ToString();
var mode = LaunchModeOption.Find(modeKey ?? vm.Info.LaunchMode);
var extra = SplitArgs(mode.Arguments).Concat(SplitArgs(vm.Info.LaunchArgs)).ToList();
var account = cfg.Accounts.FirstOrDefault(a => a.Id == vm.Info.AccountId);
if (account != null)
{
try { extra.Add("--profile=" + await Task.Run(() => AccountService.WriteProfile(gameDir, account))); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidOperationException) { Log($"ERROR: {e.Message}"); Status = e.Message; return; }
}
foreach (var arg in extra) psi.ArgumentList.Add(arg);
Process proc;
try { proc = await Task.Run(() => Process.Start(psi)!); }
catch (Exception e) { Log($"ERROR: could not start the game: {e.Message}"); return; }
var instanceNo = vm.InstanceCount + 1;
Status = instanceNo > 1 ? $"Launched another instance of {vm.Info.Name} ({instanceNo} running)" : $"Launched {vm.Info.Name}";
Log($"Launched {vm.Info.Name}, launch mode: {mode.Label}{(account != null ? $", account: {account.Name}" : "")} (pid {proc.Id}, instance {instanceNo}).");
_ = Task.Run(() => GameWindow.ApplyIconWhenReadyAsync(proc, exe));
Watch(vm, proc, checkSteam: true);
}
readonly HashSet<int> trackedPids = new();
void Watch(InstallVM vm, Process proc, bool checkSteam)
{
var pid = proc.Id;
trackedPids.Add(pid);
vm.InstanceStarted();
var wasRunning = SteamIntegration.IsAppRunning;
_ = Task.Run(async () =>
{
if (checkSteam)
{
for (var i = 0; i < 40 && !proc.HasExited && !SteamIntegration.IsAppRunning; i++) await Task.Delay(1000);
if (proc.HasExited) { }
else if (SteamIntegration.IsAppRunning)
Log(wasRunning ? "Steam already showed ChilloutVR as running before this launch." : "Steam registered the session as ChilloutVR; playtime is being counted.");
else
Log("Steam has not registered the game as running; playtime may not be counted.");
}
await proc.WaitForExitAsync();
Dispatcher.UIThread.Post(() =>
{
trackedPids.Remove(pid);
vm.InstanceEnded();
Log($"{vm.Info.Name} exited (pid {pid}).");
if (vm.InstanceCount == 0 && renamePending.Remove(vm)) TryRenameFolder(vm);
vm.Refresh();
});
});
}
static List<Process> FindGameProcesses(InstallInfo info)
{
var exe = Path.GetFullPath(Paths.GameExe(info));
var found = new List<Process>();
foreach (var p in Process.GetProcessesByName("ChilloutVR"))
{
try
{
if (string.Equals(p.MainModule?.FileName, exe, StringComparison.OrdinalIgnoreCase)) { found.Add(p); continue; }
}
catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException) { }
p.Dispose();
}
return found;
}
readonly Dictionary<InstallVM, CancellationTokenSource> renameTimers = new();
readonly HashSet<InstallVM> renamePending = new();
bool TryRenameFolder(InstallVM vm)
{
var info = vm.Info;
var desired = InstallFolders.UniqueFolder(cfg.Installs, info, info.Name);
if (desired.Equals(info.Folder, StringComparison.Ordinal)) return true;
if (vm.IsRunning || FindGameProcesses(info).Count > 0)
{
renamePending.Add(vm);
return false;
}
var before = info.Folder;
try
{
InstallFolders.Rename(info, desired);
info.Folder = desired;
Save();
vm.Refresh();
Log($"Renamed the install folder '{before}' to '{desired}'.");
return true;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
Log($"Could not rename the folder of '{info.Name}' ({e.Message}).");
return false;
}
}
public void ScheduleFolderRename(InstallVM vm)
{
if (renameTimers.TryGetValue(vm, out var previous)) previous.Cancel();
var cts = renameTimers[vm] = new CancellationTokenSource();
var token = cts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(1200, token);
while (IsBusy) await Task.Delay(1000, token);
}
catch (OperationCanceledException) { return; }
Dispatcher.UIThread.Post(() =>
{
if (token.IsCancellationRequested) return;
renameTimers.Remove(vm);
if (!TryRenameFolder(vm) && vm.IsRunning)
Status = $"The folder will be renamed after {vm.Info.Name} is closed.";
});
});
}
void AdoptRunningGames()
{
foreach (var vm in Installs)
{
foreach (var p in FindGameProcesses(vm.Info))
{
if (trackedPids.Contains(p.Id)) { p.Dispose(); continue; }
Log($"{vm.Info.Name} is already running (pid {p.Id}); adopted it.");
var adopted = p;
_ = Task.Run(() => GameWindow.ApplyIconWhenReadyAsync(adopted, Paths.GameExe(vm.Info)));
Watch(vm, p, checkSteam: false);
}
}
}
public async Task StopAsync(InstallVM vm)
{
var targets = FindGameProcesses(vm.Info);
if (targets.Count == 0)
{
vm.ResetInstances();
Status = $"{vm.Info.Name} is not running.";
return;
}
Log($"Stopping {vm.Info.Name} ({targets.Count} instance{(targets.Count == 1 ? "" : "s")})...");
Status = $"Stopping {vm.Info.Name}...";
foreach (var p in targets)
{
try { p.CloseMainWindow(); } catch (InvalidOperationException) { }
}
var deadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < deadline && targets.Any(p => !p.HasExited)) await Task.Delay(250);
foreach (var p in targets.Where(p => !p.HasExited))
{
Log($"{vm.Info.Name} did not close in time; ending the process.");
try { p.Kill(true); } catch (Exception e) when (e is InvalidOperationException or System.ComponentModel.Win32Exception) { }
}
foreach (var p in targets) p.Dispose();
}
static IEnumerable<string> SplitArgs(string s)
{
var sb = new StringBuilder();
var quoted = false;
foreach (var c in s)
{
if (c == '"') quoted = !quoted;
else if (char.IsWhiteSpace(c) && !quoted)
{
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
}
else sb.Append(c);
}
if (sb.Length > 0) yield return sb.ToString();
}
public Task ApplyModsAsync(InstallVM vm) => Run($"Applying mods to {vm.Info.Name}", async ct =>
{
await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct);
vm.ClearPending();
});
public Task ToggleMelonLoaderAsync(InstallVM vm)
{
var game = Paths.GameDir(vm.Info);
var dll = Path.Combine(game, "version.dll");
var off = dll + ".off";
var a = vm.MlAssignment;
if (vm.MlPresent)
{
if (a != null) { a.Enabled = false; return ApplyModsAsync(vm); }
return Run("Disabling MelonLoader", _ => { File.Move(dll, off, true); return Task.CompletedTask; });
}
if (a != null) { a.Enabled = true; return ApplyModsAsync(vm); }
if (vm.MlOffPresent) return Run("Enabling MelonLoader", _ => { File.Move(off, dll, true); return Task.CompletedTask; });
return SetMelonLoaderVersionAsync(vm);
}
public Task SetMelonLoaderVersionAsync(InstallVM vm)
{
var tag = vm.MlVersionChoice;
if (tag == null) { Status = "Pick a MelonLoader version first (the app never guesses one)."; return Task.CompletedTask; }
var a = vm.MlAssignment;
if (a == null) vm.Info.Mods.Add(a = new ModAssignment { ModId = ModDef.MelonLoaderId });
a.Version = tag;
a.Enabled = true;
return Run("Installing MelonLoader", async ct =>
{
if (MelonLoader.Releases.Count == 0) await RefreshModCoreAsync(MelonLoader, true, ct);
await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct);
vm.ClearPending();
});
}
async Task RefreshModCoreAsync(ModDef mod, bool quiet = false, CancellationToken ct = default)
{
if (mod.Source == "local" || mod.Local) return;
try
{
if (mod.Source == "cvrmg")
{
var all = await cvrmg.GetAllAsync(ct);
var m = all.FirstOrDefault(x => x.Id == mod.CvrmgId) ?? throw new InvalidOperationException("no longer listed on CVRMG");
CvrmgService.ApplyMeta(mod, m);
mod.SetReleases(new List<ReleaseInfo> { CvrmgService.ToRelease(m, mod.FileName.Length > 0 ? mod.FileName : Paths.Safe(m.Name) + ".dll") });
}
else
{
var fresh = await github.GetReleasesAsync(mod.Repo, ct);
var filtered = ModImportService.FilterReleases(fresh, mod.AssetFilter).ToList();
if (mod.Id == ModDef.MelonLoaderId)
{
try
{
if (await github.GetMelonLoaderNightlyAsync(ct) is { } nightly) filtered.Insert(0, nightly);
}
catch (Exception e) when (e is not OperationCanceledException) { Log($"MelonLoader nightly: could not look up ({e.Message})"); }
}
mod.SetReleases(filtered);
}
if (!quiet) Log($"{mod.Name}: {mod.Releases.Count} versions, latest {mod.LatestTag}");
}
catch (Exception e) when (quiet && e is not OperationCanceledException)
{
Log($"{mod.Name}: could not fetch versions ({e.Message})");
}
Dispatcher.UIThread.Post(() =>
{
Mods.FirstOrDefault(m => m.Info == mod)?.Rebuild();
foreach (var i in Installs) i.Refresh();
RegroupMods();
Save();
});
}
public async Task SwitchSourceAsync(ModVM vm, string source)
{
var def = vm.Info;
if (def.Source == source) return;
var previous = def.Source;
def.Source = source;
def.Releases = def.Releases.Where(r => r.Snapshot).ToList();
try
{
if (source == "cvrmg")
{
var m = (await cvrmg.GetAllAsync(CancellationToken.None)).FirstOrDefault(x => x.Id == def.CvrmgId)
?? throw new InvalidOperationException("this mod is not listed on CVRMG");
if (def.FileName.Length == 0) def.FileName = Paths.Safe(def.Name) + ".dll";
CvrmgService.ApplyMeta(def, m);
def.SetReleases(new List<ReleaseInfo> { CvrmgService.ToRelease(m, def.FileName) });
}
else
{
var releases = await importer.ReleasesAsync(def.Repo, Log, CancellationToken.None);
def.SetReleases(ModImportService.FilterReleases(releases, def.AssetFilter));
}
}
catch (Exception e)
{
Log($"{def.Name}: could not switch to {source} ({e.Message})");
def.Source = previous;
}
foreach (var inst in Installs)
if (inst.Info.Mods.FirstOrDefault(a => a.ModId == def.Id) is { } a && a.Version != "latest" && def.Releases.All(r => r.Tag != a.Version))
a.Version = "latest";
vm.Rebuild();
if (cfg.Mods.Contains(def)) Save();
}
public Task RefreshModAsync(ModVM vm) => Run($"Refreshing {vm.Info.Name}", ct => RefreshModCoreAsync(vm.Info, ct: ct));
Task RefreshAllModsAsync() => Run("Refreshing mod sources", ct => RefreshAvailableAsync(true, ct));
async Task AddModAsync()
{
var repo = GitHubService.ParseRepo(AddModRepo);
if (repo == null) { Status = "Enter a GitHub repo as owner/repo or a github.com URL."; return; }
if (cfg.Mods.Any(m => m.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase))) { Status = "That repo is already in the catalog."; return; }
var def = new ModDef { Name = repo.Split('/')[1], Repo = repo };
await Run($"Adding {repo}", async ct =>
{
await RefreshModCoreAsync(def, ct: ct);
cfg.Mods.Add(def);
var vm = new ModVM(this, def);
Mods.Add(vm);
SelectedMod = vm;
AddModRepo = "";
});
}
void SyncModList()
{
foreach (var def in cfg.Mods.Where(d => Mods.All(v => v.Info != d)).ToList()) Mods.Add(new ModVM(this, def));
foreach (var m in Mods) m.Rebuild();
_ = RefreshAvailableAsync(false);
}
async Task ImportModsCoreAsync(InstallInfo info, CancellationToken ct)
{
var found = await importer.IdentifyAsync(Paths.GameDir(info), cfg.Sources, Log, ct);
var added = await Task.Run(() => importer.Apply(cfg, info, found, Log), ct);
int Count(string k) => found.Count(f => f.Source == k);
Log($"Imported {added} mods: {Count("github")} from GitHub repos, {Count("cvrmg")} from CVRMG, {Count("local")} kept as local snapshots.");
SyncModList();
}
public Task ImportInstallModsAsync(InstallVM vm) => Run($"Importing mods from {vm.Info.Name}", async ct =>
{
await ImportModsCoreAsync(vm.Info, ct);
vm.Rebuild();
vm.ClearPending();
});
public ObservableCollection<SourceVM> Sources { get; } = new();
string addSourceRepo = "";
public string AddSourceRepo { get => addSourceRepo; set => Set(ref addSourceRepo, value); }
public ICommand ScanSteamInstallCommand => new RelayCommand(() => Run("Scanning your Steam install's mods", async ct =>
{
var dir = DetectedInstalls.FirstOrDefault() ?? throw new InvalidOperationException("No Steam install of ChilloutVR was found.");
var found = await importer.IdentifyAsync(dir, cfg.Sources, Log, ct);
var added = await Task.Run(() => importer.Apply(cfg, null, found, Log, dir), ct);
int Count(string k) => found.Count(f => f.Source == k);
Log($"Catalog updated from {dir}: {added} mods ({Count("github")} from GitHub repos, {Count("cvrmg")} from CVRMG, {Count("local")} local snapshots).");
SyncModList();
}));
public ICommand AddSourceCommand => new RelayCommand(() =>
{
var repo = GitHubService.ParseRepo(AddSourceRepo);
if (repo == null) { Status = "Enter a GitHub repo as owner/repo or a github.com URL."; return; }
if (cfg.Sources.Any(s => s.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase))) { Status = "That source is already listed."; return; }
var def = new SourceDef { Name = repo.Split('/')[1], Kind = "github", Repo = repo };
cfg.Sources.Add(def);
var sv = new SourceVM(this, def);
Sources.Add(sv);
SelectedSource = sv;
AddSourceRepo = "";
Save();
_ = RefreshAvailableAsync(false);
});
public void RemoveSource(SourceVM vm)
{
if (!vm.CanRemove) return;
cfg.Sources.Remove(vm.Info);
Sources.Remove(vm);
if (SelectedSource == vm) SelectedSource = Sources.FirstOrDefault();
Save();
_ = RefreshAvailableAsync(false);
}
SourceVM? selectedSource;
public SourceVM? SelectedSource { get => selectedSource; set { if (Set(ref selectedSource, value)) Raise(nameof(HasSelectedSource)); } }
public bool HasSelectedSource => selectedSource != null;
public async Task AddLocalFilesAsync(ModVM? vm = null)
{
if (PickFiles == null) return;
var files = await PickFiles();
if (files.Count == 0) return;
await Run("Importing local mod", ct =>
{
var def = vm?.Info;
if (def == null)
{
def = new ModDef { Name = Path.GetFileNameWithoutExtension(files[0]), Local = true };
def.Releases.Add(new ReleaseInfo { Tag = "local", Published = DateTime.Now });
cfg.Mods.Add(def);
var nv = new ModVM(this, def);
Mods.Add(nv);
SelectedMod = nv;
}
store.ImportLocal(def, files);
return Task.CompletedTask;
});
}
public Task AddLocalFilesNewAsync() => AddLocalFilesAsync();
public Task DownloadModAsync(ModVM vm) => Run($"Downloading {vm.Info.Name}", async ct =>
{
var tag = vm.SelectedRelease?.Tag ?? vm.Info.LatestTag;
if (tag == null) throw new InvalidOperationException("No version selected; refresh versions first.");
await store.EnsureDownloadedAsync(vm.Info, tag, Log, ct);
});
public async Task DeleteModDownloadAsync(ModVM vm)
{
var tag = vm.SelectedRelease?.Tag;
if (tag == null) return;
await Run($"Deleting {vm.Info.Name} {tag}", _ => { ModStore.DeleteVersion(vm.Info, tag); return Task.CompletedTask; });
}
public async Task RemoveModAsync(ModVM vm)
{
if (vm.IsBuiltin) return;
var users = Installs.Where(i => i.Info.Mods.Any(a => a.ModId == vm.Info.Id)).ToList();
if (Confirm != null && !await Confirm(users.Count == 0
? $"Remove {vm.Info.Name} from the catalog and delete its downloads?"
: $"Remove {vm.Info.Name}? It is assigned to: {string.Join(", ", users.Select(u => u.Info.Name))}. Its files stay in those installs until you apply mods there.")) return;
await Run($"Removing {vm.Info.Name}", ct =>
{
foreach (var u in users)
{
u.Info.Mods.RemoveAll(a => a.ModId == vm.Info.Id);
u.Rebuild();
u.MarkPending();
}
cfg.Mods.Remove(vm.Info);
Mods.Remove(vm);
SelectedMod = Mods.FirstOrDefault();
_ = RefreshAvailableAsync(false);
var dir = Path.Combine(Paths.ModStore, vm.Info.Id);
if (Directory.Exists(dir)) Directory.Delete(dir, true);
return Task.CompletedTask;
});
}
async Task BrowseLibraryRootAsync()
{
if (PickFolder == null) return;
var p = await PickFolder();
if (p != null) LibraryRoot = p;
}
async Task BrowseImportAsync()
{
if (PickFolder == null) return;
var p = await PickFolder();
if (p != null) NewImportPath = p;
}
void ApplyLibraryRoot()
{
if (!CanChangeRoot) { Status = "Delete all installs before changing the library location."; return; }
Paths.Root = cfg.LibraryRoot;
Save();
Directory.CreateDirectory(Paths.Root);
Status = $"Library location set to {Paths.Root}";
}
}