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>? PickFolder { get; set; } public Func>>? PickFiles { get; set; } public Func>? Confirm { get; set; } public Func? 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 Installs { get; } = new(); public ObservableCollection Mods { get; } = new(); public ObservableCollection Hooks { get; } = new(); public ObservableCollection Accounts { get; } = new(); public ObservableCollection 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 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 Branches { get; } = new(); public ObservableCollection BranchChoices { get; } = new(); public ObservableCollection DetectedInstalls { get; } = new(); public bool HasDetected => DetectedInstalls.Count > 0; public IEnumerable 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? 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 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 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 ModRows { get; } = new(); public List AvailableMods { get; private set; } = new(); readonly Dictionary 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 { 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 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(); var byEntry = new Dictionary(); var claimed = new HashSet(); 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 { 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 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 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 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); _ = Task.Run(() => { try { Directory.Delete(Path.Combine(Paths.Root, "update"), true); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } }); 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 { 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> 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 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 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 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 FindGameProcesses(InstallInfo info) { var exe = Path.GetFullPath(Paths.GameExe(info)); var found = new List(); 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 renameTimers = new(); readonly HashSet 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 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 { 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 { 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 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}"; } }