Files
av-parser/src/AvParser.UI/ViewModels/SettingsViewModel.cs
T
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00

249 lines
10 KiB
C#

using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage;
using AvParser.UI.Localization;
using AvParser.UI.Responsive;
using AvParser.UI.Services;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.SourceGenerators;
using Serilog.Core;
namespace AvParser.UI.ViewModels;
/// <summary>Theme, logging level and where the app keeps its files.</summary>
public partial class SettingsViewModel : PageViewModel
{
private readonly ISettingsService _settings;
private readonly IThemeService _theme;
private readonly LoggingLevelSwitch _levelSwitch;
private readonly IProxyPool _proxyPool;
private readonly ILocalizationService _localization;
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
[Reactive]
public partial LocalizedOption<AppTheme> SelectedTheme { get; set; }
/// <summary>Selected UI language. Applied immediately.</summary>
[Reactive]
public partial LocalizedOption<AppLanguage> SelectedLanguage { get; set; }
/// <summary>Selected Serilog level name. Takes effect immediately.</summary>
[Reactive]
public partial string SelectedLogLevel { get; set; }
/// <summary>How the pool picks the next proxy.</summary>
[Reactive]
public partial LocalizedOption<ProxyRotation> SelectedRotation { get; set; }
/// <summary>When proxy liveness is verified.</summary>
[Reactive]
public partial LocalizedOption<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>How many working proxies the startup warm-up aims for.</summary>
[Reactive]
public partial int ProxyMinimumLive { get; set; }
/// <summary>Whether network parsers may run with no proxy available.</summary>
[Reactive]
public partial bool AllowDirectConnection { get; set; }
/// <summary>Creates the page.</summary>
public SettingsViewModel(
ISettingsService settings,
IThemeService theme,
IAppPaths paths,
LoggingLevelSwitch levelSwitch,
IProxyPool proxyPool,
ILocalizationService localization,
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));
_localization = localization ?? throw new ArgumentNullException(nameof(localization));
ArgumentNullException.ThrowIfNull(paths);
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
SettingsFile = paths.SettingsFile;
LogDirectory = paths.LogDirectory;
SelectedTheme = Option(Themes, theme.Current);
SelectedLanguage = Option(Languages, localization.Current);
SelectedLogLevel = settings.Current.MinimumLogLevel;
var current = settings.Current;
SelectedRotation = Option(Rotations, current.ProxyRotation);
SelectedHealthCheck = Option(HealthChecks, current.ProxyHealthCheck);
UseProxyFeed = current.ProxyUseFeed;
ProxyProbeUrl = current.ProxyProbeUrl;
ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds;
ProxyProbeConcurrency = current.ProxyProbeConcurrency;
ProxyMinimumLive = current.ProxyMinimumLive;
AllowDirectConnection = current.AllowDirectConnection;
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLanguage)
.ObserveOn(scheduler)
.Subscribe(option => _localization.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLogLevel)
.Where(static level => !string.IsNullOrEmpty(level))
.DistinctUntilChanged()
.Subscribe(ApplyLogLevel);
// Keep the picker honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = Option(Themes, 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,
x => x.ProxyMinimumLive,
x => x.AllowDirectConnection,
(_, _, _, _, _, _, _, _) => 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 />
public override string TitleKey => "Page.Settings";
/// <inheritdoc />
public override string IconKey => "IconSettings";
/// <summary>Theme options offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<AppTheme>> Themes { get; } = LocalizedOption<AppTheme>.ForAll();
/// <summary>Language options offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<AppLanguage>> Languages { get; } = LocalizedOption<AppLanguage>.ForAll();
/// <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<LocalizedOption<ProxyRotation>> Rotations { get; } = LocalizedOption<ProxyRotation>.ForAll();
/// <summary>Liveness policies offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<ProxyHealthCheck>> HealthChecks { get; } =
LocalizedOption<ProxyHealthCheck>.ForAll();
/// <summary>Explains the selected rotation in one line.</summary>
public string RotationDescription => Localizer.Instance[$"Settings.Rotation.{SelectedRotation.Value}"];
/// <summary>Explains the selected health-check policy in one line.</summary>
public string HealthCheckDescription => Localizer.Instance[$"Settings.HealthCheck.{SelectedHealthCheck.Value}"];
/// <summary>Describes the compact breakpoint band.</summary>
public string CompactHint => Localizer.Instance.Format("Settings.Breakpoint.CompactHint", MediumBreakpoint);
/// <summary>Describes the medium breakpoint band.</summary>
public string MediumHint =>
Localizer.Instance.Format("Settings.Breakpoint.MediumHint", MediumBreakpoint, ExpandedBreakpoint);
/// <summary>Describes the expanded breakpoint band.</summary>
public string ExpandedHint => Localizer.Instance.Format("Settings.Breakpoint.ExpandedHint", ExpandedBreakpoint);
/// <summary>Full path of the settings file.</summary>
public string SettingsFile { get; }
/// <summary>Directory holding rolling log files.</summary>
public string LogDirectory { get; }
/// <summary>Width in pixels at which the shell switches from compact to the icon rail.</summary>
public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth;
/// <summary>Width in pixels at which the shell switches to the full sidebar.</summary>
public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth;
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
foreach (
var name in new[]
{
nameof(RotationDescription),
nameof(HealthCheckDescription),
nameof(CompactHint),
nameof(MediumHint),
nameof(ExpandedHint),
}
)
{
this.RaisePropertyChanged(name);
}
}
private static LocalizedOption<TValue> Option<TValue>(IReadOnlyList<LocalizedOption<TValue>> options, TValue value)
where TValue : struct, Enum => options.First(option => option.Value.Equals(value));
private void ApplyLogLevel(string level)
{
_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.Value,
ProxyHealthCheck = SelectedHealthCheck.Value,
ProxyUseFeed = UseProxyFeed,
ProxyProbeUrl = ProxyProbeUrl,
ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds,
ProxyProbeConcurrency = ProxyProbeConcurrency,
ProxyMinimumLive = ProxyMinimumLive,
AllowDirectConnection = AllowDirectConnection,
};
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());
}
}