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);
}
}