using System.Collections.ObjectModel;
using System.Globalization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.Localization;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Signals;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// Health filter offered by the proxy list.
public enum ProxyHealthFilter
{
/// No filtering.
All,
/// Only proxies whose last check succeeded.
Alive,
/// Only proxies whose last check failed.
Dead,
/// Only proxies that were never checked.
Unchecked,
}
/// Manages the proxy pool: refresh from the feed, probe, and edit the custom list.
public partial class ProxiesViewModel : PageViewModel, IDisposable
{
///
/// Rows rendered at once. A free feed carries a few thousand proxies, and a virtualising list
/// still pays to build every row view model, so the view is capped and says so.
///
private const int MaxDisplayedRows = 500;
private readonly IProxyPool _pool;
private readonly IMutableProxySource _customSource;
private readonly ILogger _logger;
private readonly ISequencer _mainThread;
private readonly Dictionary _rows = new(StringComparer.Ordinal);
private readonly Signal _poolChanged = new();
/// Free-text filter over address and country.
[Reactive]
public partial string SearchText { get; set; }
/// Protocol filter; means no filtering.
[Reactive]
public partial LocalizedOption ProtocolFilter { get; set; }
/// Health filter.
[Reactive]
public partial LocalizedOption HealthFilter { get; set; }
/// Text box contents for adding custom proxies.
[Reactive]
public partial string NewProxies { get; set; }
/// Currently selected row.
[Reactive]
public partial ProxyRowViewModel? SelectedProxy { get; set; }
/// Outcome of the last action; when idle.
[Reactive]
public partial string? StatusMessage { get; set; }
/// Progress of the running sweep, 0..1.
[Reactive]
public partial double SweepProgress { get; set; }
/// Whether a sweep is running.
[Reactive]
public partial bool IsSweeping { get; set; }
/// How many rows the filter matched before the display cap.
[Reactive]
public partial int MatchedCount { get; set; }
/// Total entries in the pool.
[Reactive]
public partial int TotalCount { get; set; }
/// How many entries are currently alive.
[Reactive]
public partial int AliveCount { get; set; }
/// Creates the page.
/// The pool being managed.
/// The user's editable list.
/// The shared startup load, joined so the page can report its outcome.
/// Diagnostics.
/// Scheduler for UI-affine updates; tests pass an immediate one.
public ProxiesViewModel(
IProxyPool pool,
IMutableProxySource customSource,
IProxyPoolLoader loader,
ILogger logger,
ISequencer? mainThread = null
)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
_customSource = customSource ?? throw new ArgumentNullException(nameof(customSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
ArgumentNullException.ThrowIfNull(loader);
SearchText = string.Empty;
NewProxies = string.Empty;
ProtocolFilter = ProtocolFilters[0];
HealthFilter = HealthFilters[0];
var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping);
RefreshCommand = ReactiveCommand.CreateFromTask(RefreshAsync, idle, _mainThread);
SweepCommand = ReactiveCommand.CreateFromTask(SweepAsync, idle, _mainThread);
AddCustomCommand = ReactiveCommand.CreateFromTask(AddCustomAsync, idle, _mainThread);
ClearCustomCommand = ReactiveCommand.CreateFromTask(ClearCustomAsync, idle, _mainThread);
RemoveSelectedCommand = ReactiveCommand.CreateFromTask(
RemoveSelectedAsync,
this.WhenAnyValue(x => x.SelectedProxy).Select(static row => row is { IsCustom: true }),
_mainThread
);
// The pool raises Changed on every single lease outcome; rebuilding the view that often
// would make a running parse unusable, so coalesce into one refresh per burst.
_poolChanged
.Throttle(TimeSpan.FromMilliseconds(250), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
_pool.Changed += OnPoolChanged;
this.WhenAnyValue(x => x.SearchText, x => x.ProtocolFilter, x => x.HealthFilter, (_, _, _) => RxVoid.Default)
.Throttle(TimeSpan.FromMilliseconds(150), _mainThread)
.ObserveOn(_mainThread)
.Subscribe(_ => Rebuild());
foreach (var command in new[] { RefreshCommand, SweepCommand, AddCustomCommand, ClearCustomCommand })
{
command.ThrownExceptions.Subscribe(OnCommandFailed);
}
RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed);
Rebuild();
// The pool is normally already loading by the time this page is built, but joining the
// same operation means the page reports the outcome whether it is opened during the load
// or long after it. Not awaited — a constructor cannot be, and the task never throws.
_ = ReportInitialLoadAsync(loader);
}
///
public override string TitleKey => "Page.Proxies";
///
public override string IconKey => "IconShield";
/// Rows currently shown, already filtered and capped.
public ObservableCollection Proxies { get; } = [];
/// Protocol filter options.
public IReadOnlyList> ProtocolFilters { get; } =
LocalizedOption.For(
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5
);
/// Health filter options.
public IReadOnlyList> HealthFilters { get; } =
LocalizedOption.ForAll();
/// Reloads every source into the pool.
public ReactiveCommand RefreshCommand { get; }
/// Probes the whole pool.
public ReactiveCommand SweepCommand { get; }
/// Adds the addresses typed into .
public ReactiveCommand AddCustomCommand { get; }
/// Removes the selected custom proxy.
public ReactiveCommand RemoveSelectedCommand { get; }
/// Empties the custom list.
public ReactiveCommand ClearCustomCommand { get; }
private async Task ReportInitialLoadAsync(IProxyPoolLoader loader)
{
var result = await loader.EnsureLoadedAsync().ConfigureAwait(false);
var message = Localizer.Instance.Format(
"Proxies.Status.Ready",
Localizer.Instance.Plural("Proxies.Count.Proxies", result.Total),
result.Live
);
// Anything the user has done since — an add, a check — is more interesting than the
// startup count, so do not overwrite it.
OnUi(() => StatusMessage ??= message);
}
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = Localizer.Instance.Format(
"Proxies.Status.PoolHolds",
Localizer.Instance.Plural("Proxies.Count.Proxies", count)
);
OnUi(() => StatusMessage = message);
}
private async Task SweepAsync(CancellationToken cancellationToken)
{
OnUi(() =>
{
IsSweeping = true;
SweepProgress = 0d;
StatusMessage = null;
});
try
{
var progress = new Progress(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count;
var message = Localizer.Instance.Format("Proxies.Status.Answered", Format(alive), Format(total));
OnUi(() => StatusMessage = message);
}
catch (OperationCanceledException)
{
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.CheckCancelled"]);
}
finally
{
OnUi(() =>
{
IsSweeping = false;
SweepProgress = 0d;
});
}
}
private async Task AddCustomAsync(CancellationToken cancellationToken)
{
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
var loc = Localizer.Instance;
if (parsed.Count == 0 && rejected.Count == 0)
{
OnUi(() => StatusMessage = loc["Proxies.Status.Nothing"]);
return;
}
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = loc.Format("Proxies.Status.Added", Format(added), Format(parsed.Count));
if (rejected.Count > 0)
{
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
var sample = string.Join(", ", rejected.Take(3));
if (rejected.Count > 3)
{
sample += " " + loc.Format("Proxies.Status.RejectedMore", Format(rejected.Count - 3));
}
message += " " + loc.Format("Proxies.Status.Rejected", sample);
}
OnUi(() =>
{
StatusMessage = message;
if (added > 0)
{
NewProxies = string.Empty;
}
});
}
private async Task RemoveSelectedAsync(CancellationToken cancellationToken)
{
if (SelectedProxy is not { IsCustom: true } row)
{
return;
}
await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Removed", row.Address));
}
private async Task ClearCustomAsync(CancellationToken cancellationToken)
{
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.Cleared"]);
}
///
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// Health and source captions live on the rows, so they need the nudge individually.
foreach (var row in _rows.Values)
{
row.Refresh();
}
}
///
///
/// Detaching from the pool matters: the pool is a singleton and would otherwise keep this
/// page — and every row in it — alive for the process lifetime.
///
public void Dispose()
{
_pool.Changed -= OnPoolChanged;
_poolChanged.Dispose();
GC.SuppressFinalize(this);
}
private void OnPoolChanged(object? sender, EventArgs e) => _poolChanged.OnNext(RxVoid.Default);
private void Rebuild()
{
var entries = _pool.Entries;
// Reconcile rows rather than recreating them: a row rebuilt on every pool event would
// drop the user's selection mid-sweep.
var seen = new HashSet(StringComparer.Ordinal);
foreach (var entry in entries)
{
var key = entry.Endpoint.Key;
seen.Add(key);
if (_rows.TryGetValue(key, out var existing))
{
existing.Refresh();
}
else
{
_rows[key] = new ProxyRowViewModel(entry);
}
}
foreach (var stale in _rows.Keys.Where(key => !seen.Contains(key)).ToArray())
{
_rows.Remove(stale);
}
var matched = _rows.Values.Where(PassesFilters).OrderBy(row => row.Address, StringComparer.Ordinal).ToArray();
Proxies.Clear();
foreach (var row in matched.Take(MaxDisplayedRows))
{
Proxies.Add(row);
}
MatchedCount = matched.Length;
TotalCount = entries.Count;
AliveCount = entries.Count(entry => entry.Health == ProxyHealthState.Alive);
}
private bool PassesFilters(ProxyRowViewModel row)
{
if (
ProtocolFilter.Value != ProxyProtocolFilter.All
&& !ProtocolFilter.Value.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
)
{
return false;
}
var healthOk = HealthFilter.Value switch
{
ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive,
ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead,
ProxyHealthFilter.Unchecked => row.Entry.Health == ProxyHealthState.Unknown,
_ => true,
};
return healthOk && row.Matches(SearchText);
}
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Proxy action failed");
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Failed", exception.Message));
}
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
private void OnUi(Action action) => _mainThread.Schedule(action);
}