Add a proxy pool with rotation, liveness checks and a management page

The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 17:22:30 +03:00
co-authored by Claude Opus 5
parent aeafe0af36
commit 9bf2ea5532
48 changed files with 4422 additions and 5 deletions
@@ -1,4 +1,5 @@
using AvParser.Core.Parsing;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Storage;
using AvParser.UI.Navigation;
@@ -37,13 +38,20 @@ public static class UiServiceCollectionExtensions
sp.GetRequiredService<ISettingsService>(),
sp.GetRequiredService<IThemeService>(),
sp.GetRequiredService<IAppPaths>(),
sp.GetRequiredService<LoggingLevelSwitch>()
sp.GetRequiredService<LoggingLevelSwitch>(),
sp.GetRequiredService<IProxyPool>()
));
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
sp.GetRequiredService<IProxyPool>(),
sp.GetRequiredService<IMutableProxySource>(),
sp.GetRequiredService<ILogger<ProxiesViewModel>>()
));
services.AddSingleton<AboutViewModel>();
// Order here is the order of the navigation rail; the first entry is the landing page.
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>());
+17
View File
@@ -71,6 +71,23 @@
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
</Style>
<!-- Status chips. Bound from data with Classes.ok / Classes.bad. -->
<Style Selector="Border.chip.ok">
<Setter Property="Background" Value="{DynamicResource AppSuccessSoftBrush}" />
</Style>
<Style Selector="Border.chip.ok TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AppSuccessBrush}" />
</Style>
<Style Selector="Border.chip.bad">
<Setter Property="Background" Value="{DynamicResource AppDangerSoftBrush}" />
</Style>
<Style Selector="Border.chip.bad TextBlock">
<Setter Property="Foreground" Value="{DynamicResource AppDangerBrush}" />
</Style>
<!-- Icon glyph. Paths inherit the surrounding foreground so they follow the theme. -->
<Style Selector="PathIcon.glyph">
<Setter Property="Width" Value="{DynamicResource IconSize}" />
+10
View File
@@ -39,6 +39,16 @@
<StreamGeometry x:Key="IconAlert">M12 2 1 21h22L12 2zm1 14.2h-2v-2h2v2zm0-3.8h-2V8.6h2v3.8z</StreamGeometry>
<StreamGeometry x:Key="IconShield">
M12 2 4 5.2v6.1c0 4.6 3.4 8.9 8 10.7 4.6-1.8 8-6.1 8-10.7V5.2L12 2zm0 2.2 6 2.4v4.7c0 3.6-2.5 7-6 8.5-3.5-1.5-6-4.9-6-8.5V6.6l6-2.4z
</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z</StreamGeometry>
<StreamGeometry x:Key="IconPlus">M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z</StreamGeometry>
<StreamGeometry x:Key="IconTrash">M9 3h6l1 1h4v2H4V4h4l1-1zM6 7h12l-1 13H7L6 7z</StreamGeometry>
<StreamGeometry x:Key="IconSparkle">
M12 2.5 13.9 9l6.6 1.9-6.6 1.9L12 19.4l-1.9-6.6L3.5 11 10.1 9 12 2.5z
</StreamGeometry>
+2
View File
@@ -21,6 +21,7 @@
<SolidColorBrush x:Key="AppDangerBrush" Color="#DC2626" />
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#FDECEC" />
<SolidColorBrush x:Key="AppSuccessBrush" Color="#15803D" />
<SolidColorBrush x:Key="AppSuccessSoftBrush" Color="#E4F4EA" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
@@ -36,6 +37,7 @@
<SolidColorBrush x:Key="AppDangerBrush" Color="#F87171" />
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#33191B" />
<SolidColorBrush x:Key="AppSuccessBrush" Color="#4ADE80" />
<SolidColorBrush x:Key="AppSuccessSoftBrush" Color="#16281C" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
@@ -0,0 +1,363 @@
using System.Collections.ObjectModel;
using System.Globalization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Signals;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// <summary>Health filter offered by the proxy list.</summary>
public enum ProxyHealthFilter
{
/// <summary>No filtering.</summary>
All,
/// <summary>Only proxies whose last check succeeded.</summary>
Alive,
/// <summary>Only proxies whose last check failed.</summary>
Dead,
/// <summary>Only proxies that were never checked.</summary>
Unchecked,
}
/// <summary>Manages the proxy pool: refresh from the feed, probe, and edit the custom list.</summary>
public partial class ProxiesViewModel : PageViewModel, IDisposable
{
/// <summary>
/// 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.
/// </summary>
private const int MaxDisplayedRows = 500;
private readonly IProxyPool _pool;
private readonly IMutableProxySource _customSource;
private readonly ILogger<ProxiesViewModel> _logger;
private readonly ISequencer _mainThread;
private readonly Dictionary<string, ProxyRowViewModel> _rows = new(StringComparer.Ordinal);
private readonly Signal<RxVoid> _poolChanged = new();
/// <summary>Free-text filter over address and country.</summary>
[Reactive]
public partial string SearchText { get; set; }
/// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary>
[Reactive]
public partial ProxyProtocolFilter ProtocolFilter { get; set; }
/// <summary>Health filter.</summary>
[Reactive]
public partial ProxyHealthFilter HealthFilter { get; set; }
/// <summary>Text box contents for adding custom proxies.</summary>
[Reactive]
public partial string NewProxies { get; set; }
/// <summary>Currently selected row.</summary>
[Reactive]
public partial ProxyRowViewModel? SelectedProxy { get; set; }
/// <summary>Outcome of the last action; <see langword="null"/> when idle.</summary>
[Reactive]
public partial string? StatusMessage { get; set; }
/// <summary>Progress of the running sweep, 0..1.</summary>
[Reactive]
public partial double SweepProgress { get; set; }
/// <summary>Whether a sweep is running.</summary>
[Reactive]
public partial bool IsSweeping { get; set; }
/// <summary>How many rows the filter matched before the display cap.</summary>
[Reactive]
public partial int MatchedCount { get; set; }
/// <summary>Total entries in the pool.</summary>
[Reactive]
public partial int TotalCount { get; set; }
/// <summary>How many entries are currently alive.</summary>
[Reactive]
public partial int AliveCount { get; set; }
/// <summary>Creates the page.</summary>
/// <param name="pool">The pool being managed.</param>
/// <param name="customSource">The user's editable list.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
public ProxiesViewModel(
IProxyPool pool,
IMutableProxySource customSource,
ILogger<ProxiesViewModel> 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;
SearchText = string.Empty;
NewProxies = string.Empty;
ProtocolFilter = ProxyProtocolFilter.All;
HealthFilter = ProxyHealthFilter.All;
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();
}
/// <inheritdoc />
public override string Title => "Proxies";
/// <inheritdoc />
public override string IconKey => "IconShield";
/// <summary>Rows currently shown, already filtered and capped.</summary>
public ObservableCollection<ProxyRowViewModel> Proxies { get; } = [];
/// <summary>Protocol filter options.</summary>
public IReadOnlyList<ProxyProtocolFilter> ProtocolFilters { get; } =
[
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5,
];
/// <summary>Health filter options.</summary>
public IReadOnlyList<ProxyHealthFilter> HealthFilters { get; } =
[ProxyHealthFilter.All, ProxyHealthFilter.Alive, ProxyHealthFilter.Dead, ProxyHealthFilter.Unchecked];
/// <summary>Reloads every source into the pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
/// <summary>Probes the whole pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> SweepCommand { get; }
/// <summary>Adds the addresses typed into <see cref="NewProxies"/>.</summary>
public ReactiveCommand<RxVoid, RxVoid> AddCustomCommand { get; }
/// <summary>Removes the selected custom proxy.</summary>
public ReactiveCommand<RxVoid, RxVoid> RemoveSelectedCommand { get; }
/// <summary>Empties the custom list.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCustomCommand { get; }
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Pool holds {Format(count)} prox{(count == 1 ? "y" : "ies")}.");
}
private async Task SweepAsync(CancellationToken cancellationToken)
{
OnUi(() =>
{
IsSweeping = true;
SweepProgress = 0d;
StatusMessage = null;
});
try
{
var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count;
OnUi(() => StatusMessage = $"{Format(alive)} of {Format(total)} answered.");
}
catch (OperationCanceledException)
{
OnUi(() => StatusMessage = "Check cancelled.");
}
finally
{
OnUi(() =>
{
IsSweeping = false;
SweepProgress = 0d;
});
}
}
private async Task AddCustomAsync(CancellationToken cancellationToken)
{
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
if (parsed.Count == 0 && rejected.Count == 0)
{
OnUi(() => StatusMessage = "Nothing to add.");
return;
}
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = $"Added {Format(added)} of {Format(parsed.Count)}.";
if (rejected.Count > 0)
{
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
message += $" Could not parse: {string.Join(", ", rejected.Take(3))}";
if (rejected.Count > 3)
{
message += $" and {Format(rejected.Count - 3)} more";
}
}
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 = $"Removed {row.Address}.");
}
private async Task ClearCustomAsync(CancellationToken cancellationToken)
{
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = "Custom list cleared.");
}
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
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<string>(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 != ProxyProtocolFilter.All
&& !ProtocolFilter.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
)
{
return false;
}
var healthOk = HealthFilter 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 = $"Failed: {exception.Message}");
}
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
private void OnUi(Action action) => _mainThread.Schedule(action);
}
@@ -0,0 +1,88 @@
using System.Globalization;
using AvParser.Core.Proxies;
using ReactiveUI;
namespace AvParser.UI.ViewModels;
/// <summary>One row of the proxy list.</summary>
/// <remarks>
/// A thin view over <see cref="ProxyEntry"/> rather than a copy of it: the pool mutates entries
/// on every request, and mirroring their fields would mean reconciling two sources of truth.
/// The pool has no change notification per entry, so the page calls <see cref="Refresh"/> after
/// a pool-level change instead.
/// </remarks>
public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject
{
/// <summary>The pool entry behind this row.</summary>
public ProxyEntry Entry { get; } = entry ?? throw new ArgumentNullException(nameof(entry));
/// <summary>Stable key, used to reconcile rows against the pool.</summary>
public string Key => Entry.Endpoint.Key;
/// <summary>Address as <c>scheme://host:port</c>.</summary>
public string Address => Entry.Endpoint.ToString();
/// <summary>Protocol name for the list.</summary>
public string Protocol => Entry.Endpoint.Protocol.ToString().ToUpperInvariant();
/// <summary>Country code, or an em dash when unknown.</summary>
public string Country => Entry.Endpoint.Country ?? "—";
/// <summary>Anonymity level.</summary>
public string Anonymity => Entry.Endpoint.Anonymity.ToString();
/// <summary>Whether the proxy came from the user's own list.</summary>
public bool IsCustom => Entry.Source == ProxySourceKind.Custom;
/// <summary>Source label.</summary>
public string Source => IsCustom ? "custom" : "feed";
/// <summary>Last known health, as a word.</summary>
public string HealthText =>
Entry.Health switch
{
ProxyHealthState.Alive => "alive",
ProxyHealthState.Dead => "dead",
_ => "unchecked",
};
/// <summary>Whether the last check succeeded. Drives the row's accent.</summary>
public bool IsAlive => Entry.Health == ProxyHealthState.Alive;
/// <summary>Whether the last check failed.</summary>
public bool IsDead => Entry.Health == ProxyHealthState.Dead;
/// <summary>Latency of the last successful check, or an em dash.</summary>
public string LatencyText =>
Entry.Latency is { } latency
? $"{latency.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture)} ms"
: "—";
/// <summary>Successes and failures observed so far.</summary>
public string ScoreText =>
$"{Entry.SuccessCount.ToString(CultureInfo.CurrentCulture)}/{(Entry.SuccessCount + Entry.FailureCount).ToString(CultureInfo.CurrentCulture)}";
/// <summary>Reason recorded with the last failure.</summary>
public string? LastError => Entry.LastError;
/// <summary>Whether the entry is sidelined right now.</summary>
public bool IsQuarantined => Entry.QuarantinedUntilUtc is { } until && until > DateTimeOffset.UtcNow;
/// <summary>Re-reads everything that the pool can change behind our back.</summary>
public void Refresh()
{
this.RaisePropertyChanged(nameof(HealthText));
this.RaisePropertyChanged(nameof(IsAlive));
this.RaisePropertyChanged(nameof(IsDead));
this.RaisePropertyChanged(nameof(LatencyText));
this.RaisePropertyChanged(nameof(ScoreText));
this.RaisePropertyChanged(nameof(LastError));
this.RaisePropertyChanged(nameof(IsQuarantined));
}
/// <summary>Whether the row matches a free-text query.</summary>
public bool Matches(string? query) =>
string.IsNullOrWhiteSpace(query)
|| Address.Contains(query, StringComparison.OrdinalIgnoreCase)
|| Country.Contains(query, StringComparison.OrdinalIgnoreCase);
}
@@ -1,3 +1,4 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage;
@@ -17,6 +18,7 @@ public partial class SettingsViewModel : PageViewModel
private readonly ISettingsService _settings;
private readonly IThemeService _theme;
private readonly LoggingLevelSwitch _levelSwitch;
private readonly IProxyPool _proxyPool;
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
[Reactive]
@@ -26,18 +28,44 @@ public partial class SettingsViewModel : PageViewModel
[Reactive]
public partial string SelectedLogLevel { get; set; }
/// <summary>How the pool picks the next proxy.</summary>
[Reactive]
public partial ProxyRotation SelectedRotation { get; set; }
/// <summary>When proxy liveness is verified.</summary>
[Reactive]
public partial ProxyHealthCheck SelectedHealthCheck { get; set; }
/// <summary>Whether the remote proxy feed is consulted.</summary>
[Reactive]
public partial bool UseProxyFeed { get; set; }
/// <summary>URL fetched to decide whether a proxy works.</summary>
[Reactive]
public partial string ProxyProbeUrl { get; set; }
/// <summary>Per-proxy probe timeout, in seconds.</summary>
[Reactive]
public partial int ProxyProbeTimeoutSeconds { get; set; }
/// <summary>How many probes run at once during a sweep.</summary>
[Reactive]
public partial int ProxyProbeConcurrency { get; set; }
/// <summary>Creates the page.</summary>
public SettingsViewModel(
ISettingsService settings,
IThemeService theme,
IAppPaths paths,
LoggingLevelSwitch levelSwitch,
IProxyPool proxyPool,
ISequencer? mainThread = null
)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_theme = theme ?? throw new ArgumentNullException(nameof(theme));
_levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch));
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
ArgumentNullException.ThrowIfNull(paths);
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
@@ -48,6 +76,14 @@ public partial class SettingsViewModel : PageViewModel
SelectedTheme = theme.Current;
SelectedLogLevel = settings.Current.MinimumLogLevel;
var current = settings.Current;
SelectedRotation = current.ProxyRotation;
SelectedHealthCheck = current.ProxyHealthCheck;
UseProxyFeed = current.ProxyUseFeed;
ProxyProbeUrl = current.ProxyProbeUrl;
ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds;
ProxyProbeConcurrency = current.ProxyProbeConcurrency;
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply);
this.WhenAnyValue(x => x.SelectedLogLevel)
@@ -57,6 +93,27 @@ public partial class SettingsViewModel : PageViewModel
// Keep the radio group honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value);
// Every proxy knob funnels through one handler: they all end up rebuilding the same
// ProxyOptions, and applying them one at a time would reconfigure the pool six times.
this.WhenAnyValue(
x => x.SelectedRotation,
x => x.SelectedHealthCheck,
x => x.UseProxyFeed,
x => x.ProxyProbeUrl,
x => x.ProxyProbeTimeoutSeconds,
x => x.ProxyProbeConcurrency,
(_, _, _, _, _, _) => RxVoid.Default
)
.Throttle(TimeSpan.FromMilliseconds(200), scheduler)
.ObserveOn(scheduler)
.Subscribe(_ => ApplyProxySettings());
this.WhenAnyValue(x => x.SelectedRotation)
.Subscribe(_ => this.RaisePropertyChanged(nameof(RotationDescription)));
this.WhenAnyValue(x => x.SelectedHealthCheck)
.Subscribe(_ => this.RaisePropertyChanged(nameof(HealthCheckDescription)));
}
/// <inheritdoc />
@@ -71,6 +128,29 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>Serilog level names, most to least verbose.</summary>
public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels;
/// <summary>Rotation strategies offered by the picker.</summary>
public IReadOnlyList<ProxyRotation> Rotations { get; } =
[ProxyRotation.Sticky, ProxyRotation.RoundRobin, ProxyRotation.WeightedRandom];
/// <summary>Liveness policies offered by the picker.</summary>
public IReadOnlyList<ProxyHealthCheck> HealthChecks { get; } = [ProxyHealthCheck.Pool, ProxyHealthCheck.Lazy];
/// <summary>Explains the selected rotation in one line.</summary>
public string RotationDescription =>
SelectedRotation switch
{
ProxyRotation.Sticky => "One proxy per session, replaced only when it fails. Keeps site sessions intact.",
ProxyRotation.RoundRobin =>
"A different proxy on every request. Spreads rate limits, but breaks session cookies.",
_ => "Random, weighted by feed score and how often the proxy has actually worked here.",
};
/// <summary>Explains the selected health-check policy in one line.</summary>
public string HealthCheckDescription =>
SelectedHealthCheck == ProxyHealthCheck.Pool
? "Probe the whole pool up front, in parallel. One sweep, then no per-request delay."
: "Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip.";
/// <summary>Full path of the settings file.</summary>
public string SettingsFile { get; }
@@ -88,4 +168,28 @@ public partial class SettingsViewModel : PageViewModel
_levelSwitch.MinimumLevel = AppLogging.ParseLevel(level);
_settings.Update(current => current with { MinimumLogLevel = level });
}
private void ApplyProxySettings()
{
AppSettings? applied = null;
_settings.Update(current =>
{
applied = current with
{
ProxyRotation = SelectedRotation,
ProxyHealthCheck = SelectedHealthCheck,
ProxyUseFeed = UseProxyFeed,
ProxyProbeUrl = ProxyProbeUrl,
ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds,
ProxyProbeConcurrency = ProxyProbeConcurrency,
};
return applied;
});
// Update() short-circuits a no-op change, so fall back to the stored value: the pool must
// still be configured on the very first pass, when nothing has changed yet.
_proxyPool.Configure((applied ?? _settings.Current).ToProxyOptions());
}
}
+1
View File
@@ -99,6 +99,7 @@
<TextBox
Text="{Binding InputText}"
AcceptsReturn="True"
VerticalContentAlignment="Top"
AcceptsTab="True"
TextWrapping="NoWrap"
PlaceholderText="Paste text here, or press Sample"
+221
View File
@@ -0,0 +1,221 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
xmlns:conv="clr-namespace:AvParser.UI.Converters"
x:Class="AvParser.UI.Views.ProxiesView"
x:DataType="vm:ProxiesViewModel"
>
<Grid RowDefinitions="Auto,Auto,*">
<!-- ===== Toolbar ===== -->
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
<StackPanel Spacing="12">
<WrapPanel Orientation="Horizontal">
<StackPanel Spacing="4" Margin="0,0,16,8">
<TextBlock Classes="caption" Text="POOL" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding RefreshCommand}" ToolTip.Tip="Reload every source">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" />
<TextBlock Text="Refresh" />
</StackPanel>
</Button>
<Button Command="{Binding SweepCommand}" ToolTip.Tip="Probe every proxy in the pool">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconShield}" />
<TextBlock Text="Check all" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="220">
<TextBlock Classes="caption" Text="SEARCH" />
<TextBox Text="{Binding SearchText}" PlaceholderText="address or country" />
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="PROTOCOL" />
<ComboBox
ItemsSource="{Binding ProtocolFilters}"
SelectedItem="{Binding ProtocolFilter}"
HorizontalAlignment="Stretch"
/>
</StackPanel>
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="130">
<TextBlock Classes="caption" Text="STATUS" />
<ComboBox
ItemsSource="{Binding HealthFilters}"
SelectedItem="{Binding HealthFilter}"
HorizontalAlignment="Stretch"
/>
</StackPanel>
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="chip accent">
<TextBlock Classes="mono caption">
<Run Text="{Binding AliveCount}" />
<Run Text="alive" />
</TextBlock>
</Border>
<Border Classes="chip">
<TextBlock Classes="mono caption">
<Run Text="{Binding TotalCount}" />
<Run Text="total" />
</TextBlock>
</Border>
<Border Classes="chip">
<TextBlock Classes="mono caption">
<Run Text="{Binding MatchedCount}" />
<Run Text="matched" />
</TextBlock>
</Border>
</StackPanel>
</StackPanel>
</Border>
<!-- ===== Progress and status ===== -->
<StackPanel Grid.Row="1" Spacing="8" Margin="0,0,0,12">
<ProgressBar
Minimum="0"
Maximum="1"
Value="{Binding SweepProgress}"
IsVisible="{Binding IsSweeping}"
Height="4"
/>
<TextBlock
Classes="muted"
Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
/>
</StackPanel>
<!-- ===== List and custom entry ===== -->
<Grid Grid.Row="2" ColumnDefinitions="2*,8,*">
<Border Grid.Column="0" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<TextBlock Classes="caption" Text="PROXIES" />
</Border>
<ListBox
ItemsSource="{Binding Proxies}"
SelectedItem="{Binding SelectedProxy}"
Background="Transparent"
BorderThickness="0"
SelectionMode="Single"
>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ProxyRowViewModel">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<Border
Grid.Column="0"
Classes="chip"
Classes.ok="{Binding IsAlive}"
Classes.bad="{Binding IsDead}"
VerticalAlignment="Center"
MinWidth="70"
>
<TextBlock Classes="mono caption" Text="{Binding HealthText}" HorizontalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Address}" />
<TextBlock
Classes="caption"
Text="{Binding LastError}"
IsVisible="{Binding LastError, Converter={x:Static ObjectConverters.IsNotNull}}"
Foreground="{DynamicResource AppDangerBrush}"
/>
</StackPanel>
<TextBlock
Grid.Column="2"
Classes="caption"
Text="{Binding Country}"
VerticalAlignment="Center"
MinWidth="30"
/>
<TextBlock
Grid.Column="3"
Classes="mono caption"
Text="{Binding LatencyText}"
VerticalAlignment="Center"
MinWidth="60"
TextAlignment="Right"
/>
<Border Grid.Column="4" Classes="chip" VerticalAlignment="Center">
<TextBlock Classes="caption" Text="{Binding Source}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="Transparent" />
<Border Grid.Column="2" Classes="card" Padding="0">
<DockPanel LastChildFill="True">
<Border
DockPanel.Dock="Top"
Padding="16,12"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource AppBorderBrush}"
>
<TextBlock Classes="caption" Text="CUSTOM PROXIES" />
</Border>
<StackPanel DockPanel.Dock="Bottom" Spacing="8" Margin="16,12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="primary" Command="{Binding AddCustomCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Classes="glyph" Data="{DynamicResource IconPlus}" />
<TextBlock Text="Add" />
</StackPanel>
</Button>
<Button
Classes="destructive"
Command="{Binding RemoveSelectedCommand}"
ToolTip.Tip="Remove the selected custom proxy"
>
<TextBlock Text="Remove" />
</Button>
<Button Classes="icon" Command="{Binding ClearCustomCommand}" ToolTip.Tip="Clear the custom list">
<PathIcon Classes="glyph" Data="{DynamicResource IconTrash}" />
</Button>
</StackPanel>
<TextBlock
Classes="muted"
Text="One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported."
/>
</StackPanel>
<TextBox
Text="{Binding NewProxies}"
AcceptsReturn="True"
VerticalContentAlignment="Top"
TextWrapping="NoWrap"
PlaceholderText="socks5://10.0.0.1:1080&#x0a;user:pass@10.0.0.2:8080"
BorderThickness="0"
Background="Transparent"
Margin="4,0"
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="{DynamicResource FontSizeBody}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
/>
</DockPanel>
</Border>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,13 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace AvParser.UI.Views;
/// <summary>Proxy pool management: feed refresh, probing and the custom list.</summary>
public partial class ProxiesView : UserControl
{
/// <summary>Creates the view.</summary>
public ProxiesView() => InitializeComponent();
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
+60
View File
@@ -51,6 +51,66 @@
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="16">
<TextBlock Classes="subtitle" Text="Proxies" />
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="ROTATION" />
<ComboBox
ItemsSource="{Binding Rotations}"
SelectedItem="{Binding SelectedRotation}"
HorizontalAlignment="Stretch"
/>
<TextBlock Classes="muted" Text="{Binding RotationDescription}" />
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="LIVENESS CHECK" />
<ComboBox
ItemsSource="{Binding HealthChecks}"
SelectedItem="{Binding SelectedHealthCheck}"
HorizontalAlignment="Stretch"
/>
<TextBlock Classes="muted" Text="{Binding HealthCheckDescription}" />
</StackPanel>
<CheckBox IsChecked="{Binding UseProxyFeed}" Content="Use the public proxifly feed" />
<StackPanel Spacing="6">
<TextBlock Classes="caption" Text="PROBE URL" />
<TextBox Text="{Binding ProxyProbeUrl}" />
<TextBlock
Classes="muted"
Text="Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones."
/>
</StackPanel>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="6">
<TextBlock Classes="caption" Text="PROBE TIMEOUT (SEC)" />
<NumericUpDown
Value="{Binding ProxyProbeTimeoutSeconds}"
Minimum="1"
Maximum="120"
Increment="1"
FormatString="0"
/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock Classes="caption" Text="PARALLEL PROBES" />
<NumericUpDown
Value="{Binding ProxyProbeConcurrency}"
Minimum="1"
Maximum="512"
Increment="8"
FormatString="0"
/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Classes="subtitle" Text="Layout breakpoints" />