49fb3ca253
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1640 lines
67 KiB
C#
1640 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);
|
|
_ = 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<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}";
|
|
}
|
|
}
|