using System.Collections.Concurrent; using AcDream.Plugin.Abstractions; namespace OpenAC.MosswartMassacre.Metas; /// /// Meta and nav sync from the club's repository: the /mm metasrefresh, /// metascheck, metaspull and metaspullfile verbs and, on a /// host with a window, the Metas window. Nothing here reaches the backend. /// /// /// Network work runs on the thread pool; every result, chat line and window /// update is queued and applied on the next tick. The file list is not tied /// to a character, so it survives a relog. /// internal sealed class MetaSyncFeature : IMmFeature { /// The panel's markup file, beside the plugin assembly. public const string MarkupFile = "mosswartmassacre-metas.xml"; private readonly MmContext _context; private readonly MetaSyncManager _manager; private readonly ConcurrentQueue _onTick = new(); private readonly CancellationTokenSource _shutdown = new(); private readonly Action _tick; private readonly IDisposable? _panel; private Task _lastOperation = Task.CompletedTask; private bool _disposed; public MetaSyncFeature(MmContext context) : this(context, context.Host.VtankProfiles, handler: null) { } /// Builds the feature on a given profile storage and HTTP handler; tests pass fakes. internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler) { _context = context; _manager = new MetaSyncManager( profiles, handler, () => RepositoryUrl, () => _context.Clock.UtcNow, message => _onTick.Enqueue(() => _context.Chat.Write(message))); _tick = _ => Pump(); context.Host.Events.Tick += _tick; context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list"); context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote"); context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files"); context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path"); if (!context.Host.HasUi) return; Panel = new MetasViewModel(); Panel.ShowPath(_manager.StorageRoot); Panel.ShowFiles([]); Panel.ButtonPressed += OnButton; Panel.SearchRequested += ShowFiles; string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? "."; _panel = context.Host.Ui.RegisterPanel( new PluginPanelDescriptor("metas", "Metas") { IconText = "ME", // Shown while the binding's IsVisible is set; the host's own // shown state starts true so that flag alone opens it. StartVisible = true, ShowInSidePanel = true, }, Path.Combine(directory, MarkupFile), Panel); } /// /// The window's binding object; set its IsVisible to open the /// window. Null on a host without a window. /// public MetasViewModel? Panel { get; } internal MetaSyncManager Manager => _manager; /// The operation started last; tests await it before pumping the tick. internal Task LastOperation => _lastOperation; /// The repository setting, or the original's repository when it is empty. private string RepositoryUrl { get { string? url = _context.Settings.Current.MetasRepositoryUrl; return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url; } } public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot); public void OnLogoff() { } public void Dispose() { if (_disposed) return; _disposed = true; _context.Host.Events.Tick -= _tick; _shutdown.Cancel(); if (Panel is not null) { Panel.ButtonPressed -= OnButton; Panel.SearchRequested -= ShowFiles; } _panel?.Dispose(); _onTick.Clear(); // Operations run one after another, so the manager goes once the // last one, cancelled above, has finished. _lastOperation.ContinueWith( _ => { _manager.Dispose(); _shutdown.Dispose(); }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); } /// Applies finished work on the tick thread. Internal so tests can pump without a host tick. internal void Pump() { while (_onTick.TryDequeue(out Action? action)) { try { action(); } catch (Exception ex) { _context.Host.Log.Error("A metas update failed.", ex); } } } private void RunRefresh(Action? done = null, Action? failed = null) => Start( token => _manager.RefreshCatalogAsync(token), count => { ShowFiles(); if (done is null) _context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files"); else done(count); }, ex => { if (failed is null) _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); else failed(ex); }); private void RunCheck( Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null, Action? failed = null) => Start( token => _manager.CheckStatusesAsync(token), summary => { ShowFiles(); if (done is null) { _context.Chat.Write( $"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}"); } else { done(summary); } }, ex => { if (failed is null) _context.Chat.Write($"[Metas] Check failed: {ex.Message}"); else failed(ex); }); private void RunPullOutdated( Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null, Action? failed = null) => Start( token => _manager.DownloadOutdatedAsync(token), result => { ShowFiles(); if (done is null) { _context.Chat.Write( $"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}"); } else { done(result); } }, ex => { if (failed is null) _context.Chat.Write($"[Metas] Pull failed: {ex.Message}"); else failed(ex); }); private void HandlePullFile(string[] args) { if (args.Length < 2) { _context.Chat.Write("Usage: /mm metaspullfile "); return; } string relativePath = string.Join(" ", args.Skip(1)); Start( token => _manager.DownloadFileAsync(relativePath, token), result => { ShowFiles(); _context.Chat.Write($"[Metas] {relativePath}: {result.Message}"); }, ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}")); } private void OnButton(string button) { MetasViewModel? panel = Panel; if (panel is null) return; switch (button) { case nameof(MetasViewModel.RefreshList): panel.ShowPath(_manager.StorageRoot); panel.StatusText = "Status: refreshing remote file list..."; RunRefresh( count => panel.StatusText = $"Status: loaded {count} remote files", ex => { panel.StatusText = "Status: refresh failed"; _context.Chat.Write($"[Metas] Refresh failed: {ex.Message}"); }); break; case nameof(MetasViewModel.CheckUpdates): panel.ShowPath(_manager.StorageRoot); panel.StatusText = "Status: checking remote updates..."; RunCheck( summary => panel.StatusText = $"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors", ex => { panel.StatusText = "Status: update check failed"; _context.Chat.Write($"[Metas] Update check failed: {ex.Message}"); }); break; case nameof(MetasViewModel.DownloadOutdated): panel.StatusText = "Status: downloading outdated files..."; RunPullOutdated( result => panel.StatusText = $"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}", ex => { panel.StatusText = "Status: pull outdated failed"; _context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}"); }); break; case nameof(MetasViewModel.DownloadSelected): string selection = panel.SelectedRelativePath; if (string.IsNullOrWhiteSpace(selection)) { _context.Chat.Write("[Metas] Select a file first"); return; } panel.StatusText = $"Status: downloading {selection}..."; Start( token => _manager.DownloadFileAsync(selection, token), result => { ShowFiles(); panel.StatusText = $"Status: {result.Message}"; }, ex => { panel.StatusText = "Status: download failed"; _context.Chat.Write($"[Metas] Download failed: {ex.Message}"); }); break; } } private void ShowFiles() { if (Panel is null) return; Panel.ShowFiles(_manager.GetCachedFiles()); Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc); } /// Runs on the thread pool and applies its outcome on the next tick. private void Start(Func> work, Action done, Action failed) { if (_disposed) return; CancellationToken token = _shutdown.Token; Task previous = _lastOperation; // Each operation starts when the one before it has finished, the // order the player asked for them in. _lastOperation = Task.Run(async () => { await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); try { T result = await work(token).ConfigureAwait(false); _onTick.Enqueue(() => done(result)); } catch (OperationCanceledException) when (token.IsCancellationRequested) { // Shutting down: nobody is left to tell. } catch (Exception ex) { _onTick.Enqueue(() => failed(ex)); } }, CancellationToken.None); } }