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