diff --git a/README.md b/README.md index 02c8e942d3..3357827d79 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ NETworManager has integrated some **optional** third-party services to enhance f - [api.github.com](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) - Check for application updates. - [ipify.org](https://www.ipify.org/) - Retrieve the public IP address used by the client. - [ip-api.com](https://ip-api.com/docs/legal) - Retrieve network information (e.g., geolocation, ISP, DNS resolver) used by the client. +- [speed.cloudflare.com](https://www.cloudflare.com/privacypolicy/) - Measure download/upload speed, latency and jitter. ## 📝 License diff --git a/Source/GlobalAssemblyInfo.cs b/Source/GlobalAssemblyInfo.cs index 62b4c6e76a..ce0c39e606 100644 --- a/Source/GlobalAssemblyInfo.cs +++ b/Source/GlobalAssemblyInfo.cs @@ -6,5 +6,5 @@ [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] -[assembly: AssemblyVersion("2026.5.17.0")] -[assembly: AssemblyFileVersion("2026.5.17.0")] +[assembly: AssemblyVersion("2026.5.24.0")] +[assembly: AssemblyFileVersion("2026.5.24.0")] diff --git a/Source/NETworkManager.Converters/NullableDoubleToStringConverter.cs b/Source/NETworkManager.Converters/NullableDoubleToStringConverter.cs new file mode 100644 index 0000000000..dd83917813 --- /dev/null +++ b/Source/NETworkManager.Converters/NullableDoubleToStringConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace NETworkManager.Converters; + +/// +/// Converts a nullable to a formatted string, returning "-/-" for null. +/// Pass a ConverterParameter of the form "F0|ms" or "F1|Mbps" to control the numeric format +/// specifier and the unit suffix, separated by '|'. +/// +public sealed class NullableDoubleToStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not double d) + return "-/-"; + + if (parameter is string fmt) + { + var parts = fmt.Split('|'); + var format = parts.Length > 0 ? parts[0] : "G"; + var unit = parts.Length > 1 ? " " + parts[1] : string.Empty; + return d.ToString(format, culture) + unit; + } + + return d.ToString(culture); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new NotImplementedException(); +} \ No newline at end of file diff --git a/Source/NETworkManager.Documentation/ExternalServicesManager.cs b/Source/NETworkManager.Documentation/ExternalServicesManager.cs index 7f9a50987e..6970f872bf 100644 --- a/Source/NETworkManager.Documentation/ExternalServicesManager.cs +++ b/Source/NETworkManager.Documentation/ExternalServicesManager.cs @@ -16,6 +16,8 @@ public static class ExternalServicesManager new ExternalServicesInfo("ip-api.com", "https://ip-api.com/", Strings.ExternalService_ip_api_Description), new ExternalServicesInfo("ipify.org", "https://www.ipify.org/", - Strings.ExternalService_ipify_Description) + Strings.ExternalService_ipify_Description), + new ExternalServicesInfo("speed.cloudflare.com", "https://speed.cloudflare.com/", + Strings.ExternalService_speed_cloudflare_Description) }; } \ No newline at end of file diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs index d657fbeaf7..0ff7f753ea 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs +++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -4284,6 +4283,15 @@ public static string ExternalService_ipify_Description { } } + /// + /// Looks up a localized string similar to Speed test service used to measure download speed, upload speed, latency, and jitter.. + /// + public static string ExternalService_speed_cloudflare_Description { + get { + return ResourceManager.GetString("ExternalService_speed_cloudflare_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to External services. /// @@ -4311,6 +4319,15 @@ public static string FailedToLoadHostsFileMessage { } } + /// + /// Looks up a localized string similar to Fetching metadata.... + /// + public static string FetchingMetadataDots { + get { + return ResourceManager.GetString("FetchingMetadataDots", resourceCulture); + } + } + /// /// Looks up a localized string similar to Field cannot be empty!. /// @@ -5843,6 +5860,15 @@ public static string ISP { } } + /// + /// Looks up a localized string similar to Jitter. + /// + public static string Jitter { + get { + return ResourceManager.GetString("Jitter", resourceCulture); + } + } + /// /// Looks up a localized string similar to Keyboard. /// @@ -5915,6 +5941,15 @@ public static string LastUsableIPAddress { } } + /// + /// Looks up a localized string similar to Latency. + /// + public static string Latency { + get { + return ResourceManager.GetString("Latency", resourceCulture); + } + } + /// /// Looks up a localized string similar to Latitude. /// @@ -6554,6 +6589,33 @@ public static string MeasuredTime { } } + /// + /// Looks up a localized string similar to Measuring download speed.... + /// + public static string MeasuringDownloadSpeedDots { + get { + return ResourceManager.GetString("MeasuringDownloadSpeedDots", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Measuring latency.... + /// + public static string MeasuringLatencyDots { + get { + return ResourceManager.GetString("MeasuringLatencyDots", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Measuring upload speed.... + /// + public static string MeasuringUploadSpeedDots { + get { + return ResourceManager.GetString("MeasuringUploadSpeedDots", resourceCulture); + } + } + /// /// Looks up a localized string similar to Megabits. /// @@ -9936,6 +9998,15 @@ public static string RunCommandDotsWithHotKey { } } + /// + /// Looks up a localized string similar to Run speed test. + /// + public static string RunSpeedTest { + get { + return ResourceManager.GetString("RunSpeedTest", resourceCulture); + } + } + /// /// Looks up a localized string similar to Save. /// @@ -10849,6 +10920,25 @@ public static string Speed { } } + /// + /// Looks up a localized string similar to Speed Test. + /// + public static string SpeedTest { + get { + return ResourceManager.GetString("SpeedTest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Measure download and upload speeds, latency, and jitter with speed.cloudflare.com. + ///Cloudflare may log your IP address and network information. See Cloudflare's privacy policy for details.. + /// + public static string SpeedTestDisclaimerMessage { + get { + return ResourceManager.GetString("SpeedTestDisclaimerMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to SplashScreen. /// @@ -11011,6 +11101,15 @@ public static string Steel { } } + /// + /// Looks up a localized string similar to Stop. + /// + public static string Stop { + get { + return ResourceManager.GetString("Stop", resourceCulture); + } + } + /// /// Looks up a localized string similar to Subnet. /// diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx index 8a2a680b75..9e99aee06d 100644 --- a/Source/NETworkManager.Localization/Resources/Strings.resx +++ b/Source/NETworkManager.Localization/Resources/Strings.resx @@ -4306,4 +4306,38 @@ You can copy your profile files from “{0}” to “{1}” to migrate your exis Import profiles... + + Speed Test + + + Run speed test + + + Fetching metadata... + + + Measuring latency... + + + Measuring download speed... + + + Measuring upload speed... + + + Measure download and upload speeds, latency, and jitter with speed.cloudflare.com. +Cloudflare may log your IP address and network information. See Cloudflare's privacy policy for details. + + + Latency + + + Jitter + + + Speed test service used to measure download speed, upload speed, latency, and jitter. + + + Stop + \ No newline at end of file diff --git a/Source/NETworkManager.Models/Cloudflare/SpeedTestMetaInfo.cs b/Source/NETworkManager.Models/Cloudflare/SpeedTestMetaInfo.cs new file mode 100644 index 0000000000..36faa18d8a --- /dev/null +++ b/Source/NETworkManager.Models/Cloudflare/SpeedTestMetaInfo.cs @@ -0,0 +1,73 @@ +using Newtonsoft.Json; + +namespace NETworkManager.Models.Cloudflare; + +/// +/// Cloudflare PoP (Point of Presence) information returned by the +/// speed.cloudflare.com/meta endpoint. +/// +public class SpeedTestMetaColo +{ + /// + /// IATA airport code of the PoP (e.g. "FRA"). + /// + [JsonProperty("iata")] + public string Iata { get; set; } + + /// + /// City of the PoP (e.g. "Frankfurt-am-Main"). + /// + [JsonProperty("city")] + public string City { get; set; } + + /// + /// ISO 3166-1 alpha-2 country code of the PoP (e.g. "DE"). + /// + [JsonProperty("cca2")] + public string Cca2 { get; set; } +} + +/// +/// Deserialized response of the speed.cloudflare.com/meta endpoint. +/// Provides client and Cloudflare PoP metadata used to enrich the speed +/// test result. Requires the Origin: https://speed.cloudflare.com +/// header on the request, otherwise an empty object is returned. +/// +public class SpeedTestMetaInfo +{ + /// + /// Public IP address of the requesting client as seen by Cloudflare. + /// + [JsonProperty("clientIp")] + public string ClientIp { get; set; } + + /// + /// Autonomous System Number of the client's ISP. + /// + [JsonProperty("asn")] + public int Asn { get; set; } + + /// + /// Human-readable ISP name (e.g. "innogy TelNet"). + /// + [JsonProperty("asOrganization")] + public string AsOrganization { get; set; } + + /// + /// ISO 3166-1 alpha-2 country code of the client (e.g. "DE"). + /// + [JsonProperty("country")] + public string Country { get; set; } + + /// + /// City of the client (e.g. "Bochum"). + /// + [JsonProperty("city")] + public string City { get; set; } + + /// + /// Cloudflare PoP (Point of Presence) details. + /// + [JsonProperty("colo")] + public SpeedTestMetaColo Colo { get; set; } +} \ No newline at end of file diff --git a/Source/NETworkManager.Models/Cloudflare/SpeedTestProgress.cs b/Source/NETworkManager.Models/Cloudflare/SpeedTestProgress.cs new file mode 100644 index 0000000000..b7e7585f11 --- /dev/null +++ b/Source/NETworkManager.Models/Cloudflare/SpeedTestProgress.cs @@ -0,0 +1,46 @@ +namespace NETworkManager.Models.Cloudflare; + +/// +/// Phase of a Cloudflare speed test run, reported by +/// via . +/// +public enum SpeedTestPhase +{ + FetchingMetadata, + MeasuringLatency, + MeasuringDownload, + MeasuringUpload +} + +/// +/// Progress event passed by to update the UI +/// with the currently running measurement phase and the latest live estimate +/// of each metric. Values are null until the first sample for that +/// metric has been collected. +/// +/// Current measurement phase. +/// Live download throughput estimate (Mbps). +/// Live upload throughput estimate (Mbps). +/// Live latency estimate (50th percentile of probes). +/// Live jitter estimate (average consecutive delta). +/// +/// Set when this emission marks a freshly completed download sample (one HTTP +/// request finished). Mid-stream live updates leave this null. +/// +/// +/// Set when this emission marks a freshly completed upload sample. +/// +/// +/// Cloudflare /meta response, emitted once after metadata is fetched +/// so ISP / location / server details can be displayed before the bandwidth +/// measurements complete. +/// +public record SpeedTestProgress( + SpeedTestPhase Phase, + double? DownloadMbps = null, + double? UploadMbps = null, + double? LatencyMs = null, + double? JitterMs = null, + double? NewDownloadSampleMbps = null, + double? NewUploadSampleMbps = null, + SpeedTestMetaInfo Meta = null); diff --git a/Source/NETworkManager.Models/Cloudflare/SpeedTestResult.cs b/Source/NETworkManager.Models/Cloudflare/SpeedTestResult.cs new file mode 100644 index 0000000000..e584eba9e4 --- /dev/null +++ b/Source/NETworkManager.Models/Cloudflare/SpeedTestResult.cs @@ -0,0 +1,71 @@ +namespace NETworkManager.Models.Cloudflare; + +/// +/// Final result of a Cloudflare speed test run. +/// +public class SpeedTestResult +{ + /// + /// Download throughput in megabits per second (Mbps). null when no + /// samples were collected (e.g. test cancelled before any download). + /// + public double? DownloadMbps { get; set; } + + /// + /// Upload throughput in megabits per second (Mbps). null when no + /// samples were collected. + /// + public double? UploadMbps { get; set; } + + /// + /// Unloaded latency in milliseconds (50th percentile of latency probes). + /// null when no probes were collected. + /// + public double? LatencyMs { get; set; } + + /// + /// Average consecutive delta between latency samples, in milliseconds. + /// null when fewer than two probes were collected. + /// + public double? JitterMs { get; set; } + + /// + /// ISP name (Cloudflare meta asOrganization). + /// + public string Isp { get; set; } + + /// + /// City of the client (Cloudflare meta city). + /// + public string ClientCity { get; set; } + + /// + /// ISO 3166-1 alpha-2 country code of the client (Cloudflare meta country). + /// + public string ClientCountry { get; set; } + + /// + /// City of the Cloudflare PoP serving the test, e.g. "Frankfurt-am-Main". + /// + public string ServerCity { get; set; } + + /// + /// ISO 3166-1 alpha-2 country code of the Cloudflare PoP, e.g. "DE". + /// + public string ServerCountry { get; set; } + + /// + /// IATA code of the Cloudflare PoP, e.g. "FRA". + /// + public string ServerIata { get; set; } + + /// + /// Indicates that the speed test run failed. + /// + public bool HasError { get; set; } + + /// + /// Error message when is true. + /// + public string ErrorMessage { get; set; } +} diff --git a/Source/NETworkManager.Models/Cloudflare/SpeedTestService.cs b/Source/NETworkManager.Models/Cloudflare/SpeedTestService.cs new file mode 100644 index 0000000000..1c5b097be3 --- /dev/null +++ b/Source/NETworkManager.Models/Cloudflare/SpeedTestService.cs @@ -0,0 +1,438 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace NETworkManager.Models.Cloudflare; + +/// +/// Runs a network speed test against speed.cloudflare.com, modeled +/// after the official cloudflare/speedtest +/// JavaScript library. The full network logic is reimplemented in C#; no +/// telemetry is sent to aim.cloudflare.com. +/// +public class SpeedTestService +{ + private const string BaseUrl = "https://speed.cloudflare.com"; + private const string Origin = "https://speed.cloudflare.com"; + + private const double DefaultEstimatedServerTimeMs = 10.0; + private const double BandwidthFinishRequestDurationMs = 1000.0; + private const double BandwidthMinRequestDurationMs = 10.0; + private const double EstimatedHeaderFraction = 1.005; + + /// + /// Minimum gap between mid-stream live throughput emissions, in ms. + /// + private const double LiveProgressIntervalMs = 250.0; + + private static readonly Regex ServerTimingRegex = new(@"(?:^|;)\s*dur=([0-9.]+)", RegexOptions.Compiled); + + private enum StepDirection { Download, Upload } + + /// + /// Interleaved download/upload sequence from defaultConfig.js of the + /// official cloudflare/speedtest library. The initial 100 KB download + /// (bypassMinDuration) is executed separately between the two latency phases. + /// + private static readonly (StepDirection Direction, int Bytes, int Count)[] Steps = + { + (StepDirection.Download, 100_000, 9), + (StepDirection.Download, 1_000_000, 8), + (StepDirection.Upload, 100_000, 8), + (StepDirection.Upload, 1_000_000, 6), + (StepDirection.Download, 10_000_000, 6), + (StepDirection.Upload, 10_000_000, 4), + (StepDirection.Download, 25_000_000, 4), + (StepDirection.Upload, 25_000_000, 4), + (StepDirection.Download, 100_000_000, 3), + (StepDirection.Upload, 50_000_000, 3), + (StepDirection.Download, 250_000_000, 2), + }; + + private const int LatencyInitialProbes = 1; + private const int LatencyMeasurementProbes = 20; + + private static readonly HttpClient _client = CreateClient(); + + private static HttpClient CreateClient() + { + var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + client.DefaultRequestHeaders.Add("Origin", Origin); + client.DefaultRequestHeaders.UserAgent.ParseAdd("NETworkManager"); + return client; + } + + /// + /// Runs a full speed test sequence (metadata, latency, download, upload). + /// Live estimates for each metric are reported via + /// as samples accumulate. The returned + /// contains the final aggregated values. + /// + public async Task RunAsync(IProgress progress, CancellationToken cancellationToken) + { + var pings = new List(); + var downloadBps = new List(); + var uploadBps = new List(); + + // 1. Metadata + Emit(progress, SpeedTestPhase.FetchingMetadata, pings, downloadBps, uploadBps, null, null); + var meta = await FetchMetaAsync(cancellationToken).ConfigureAwait(false); + progress?.Report(new SpeedTestProgress(SpeedTestPhase.FetchingMetadata, Meta: meta)); + + // 2. Initial latency probe (server-time estimation) + Emit(progress, SpeedTestPhase.MeasuringLatency, pings, downloadBps, uploadBps, null, null); + for (var i = 0; i < LatencyInitialProbes; i++) + { + await MeasureLatencyAsync(pings, cancellationToken).ConfigureAwait(false); + Emit(progress, SpeedTestPhase.MeasuringLatency, pings, downloadBps, uploadBps, null, null); + } + + // 3. Initial download estimate (100 KB × 1 — bypassMinDuration, between the two latency phases) + Emit(progress, SpeedTestPhase.MeasuringDownload, pings, downloadBps, uploadBps, null, null); + { + var (bps, durationMs) = await MeasureDownloadAsync(100_000, cancellationToken, + liveBps => Emit(progress, SpeedTestPhase.MeasuringDownload, + pings, downloadBps, uploadBps, liveBps, null)).ConfigureAwait(false); + if (durationMs >= BandwidthMinRequestDurationMs) + { + downloadBps.Add(bps); + Emit(progress, SpeedTestPhase.MeasuringDownload, + pings, downloadBps, uploadBps, null, null, + newDownloadSampleBps: bps); + } + } + + // 4. Proper unloaded latency measurement (20 probes) + Emit(progress, SpeedTestPhase.MeasuringLatency, pings, downloadBps, uploadBps, null, null); + for (var i = 0; i < LatencyMeasurementProbes; i++) + { + await MeasureLatencyAsync(pings, cancellationToken).ConfigureAwait(false); + Emit(progress, SpeedTestPhase.MeasuringLatency, pings, downloadBps, uploadBps, null, null); + } + + // 5. Interleaved download / upload (official cloudflare/speedtest sequence) + var downloadStopped = false; + var uploadStopped = false; + var currentPhase = SpeedTestPhase.MeasuringDownload; + Emit(progress, currentPhase, pings, downloadBps, uploadBps, null, null); + + foreach (var step in Steps) + { + if (step.Direction == StepDirection.Download && downloadStopped) continue; + if (step.Direction == StepDirection.Upload && uploadStopped) continue; + + var stepPhase = step.Direction == StepDirection.Download + ? SpeedTestPhase.MeasuringDownload + : SpeedTestPhase.MeasuringUpload; + if (stepPhase != currentPhase) + { + currentPhase = stepPhase; + Emit(progress, currentPhase, pings, downloadBps, uploadBps, null, null); + } + + for (var i = 0; i < step.Count; i++) + { + if (step.Direction == StepDirection.Download) + { + + var phaseSnapshot = currentPhase; + var (bps, durationMs) = await MeasureDownloadAsync(step.Bytes, cancellationToken, + liveBps => Emit(progress, phaseSnapshot, + pings, downloadBps, uploadBps, liveBps, null)).ConfigureAwait(false); + if (durationMs >= BandwidthMinRequestDurationMs) + { + downloadBps.Add(bps); + Emit(progress, currentPhase, + pings, downloadBps, uploadBps, null, null, + newDownloadSampleBps: bps); + } + if (durationMs >= BandwidthFinishRequestDurationMs) + { + downloadStopped = true; + break; + } + } + else + { + var (bps, durationMs) = await MeasureUploadAsync(step.Bytes, cancellationToken) + .ConfigureAwait(false); + if (durationMs >= BandwidthMinRequestDurationMs) + { + uploadBps.Add(bps); + Emit(progress, currentPhase, + pings, downloadBps, uploadBps, null, null, + newUploadSampleBps: bps); + } + if (durationMs >= BandwidthFinishRequestDurationMs) + { + uploadStopped = true; + break; + } + } + } + } + + // 6. Result + return new SpeedTestResult + { + DownloadMbps = downloadBps.Count > 0 ? Percentile(downloadBps, 0.9) / 1_000_000.0 : null, + UploadMbps = uploadBps.Count > 0 ? Percentile(uploadBps, 0.9) / 1_000_000.0 : null, + LatencyMs = pings.Count > 0 ? Percentile(pings, 0.5) : null, + JitterMs = pings.Count >= 2 ? Jitter(pings) : null, + Isp = meta?.AsOrganization, + ClientCity = meta?.City, + ClientCountry = meta?.Country, + ServerCity = meta?.Colo?.City, + ServerCountry = meta?.Colo?.Cca2, + ServerIata = meta?.Colo?.Iata + }; + } + + /// + /// Builds and emits a using current + /// sample lists. and + /// let mid-stream callers + /// report an instantaneous estimate even before the first completed + /// sample is in the list. + /// + private static void Emit(IProgress progress, SpeedTestPhase phase, + List pings, List downloadBps, List uploadBps, + double? liveDownloadBpsOverride, double? liveUploadBpsOverride, + double? newDownloadSampleBps = null, double? newUploadSampleBps = null) + { + if (progress == null) + return; + + // P90 of completed samples ⊔ current live instantaneous — consistent with + // the final result formula; live override applies only before the first sample. + double? downloadMbps = null; + if (downloadBps.Count > 0 || liveDownloadBpsOverride.HasValue) + { + var p90Bps = downloadBps.Count > 0 ? Percentile(downloadBps, 0.9) : 0.0; + if (liveDownloadBpsOverride.HasValue && liveDownloadBpsOverride.Value > p90Bps) + p90Bps = liveDownloadBpsOverride.Value; + downloadMbps = p90Bps / 1_000_000.0; + } + + double? uploadMbps = null; + if (uploadBps.Count > 0 || liveUploadBpsOverride.HasValue) + { + var p90Bps = uploadBps.Count > 0 ? Percentile(uploadBps, 0.9) : 0.0; + if (liveUploadBpsOverride.HasValue && liveUploadBpsOverride.Value > p90Bps) + p90Bps = liveUploadBpsOverride.Value; + uploadMbps = p90Bps / 1_000_000.0; + } + + double? latencyMs = pings.Count > 0 ? Percentile(pings, 0.5) : null; + double? jitterMs = pings.Count >= 2 ? Jitter(pings) : null; + + var newDownloadSampleMbps = newDownloadSampleBps / 1_000_000.0; + var newUploadSampleMbps = newUploadSampleBps / 1_000_000.0; + + progress.Report(new SpeedTestProgress(phase, downloadMbps, uploadMbps, latencyMs, jitterMs, + newDownloadSampleMbps, newUploadSampleMbps)); + } + + #region Measurement primitives + + private async Task FetchMetaAsync(CancellationToken cancellationToken) + { + using var response = await _client.GetAsync($"{BaseUrl}/meta", cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonConvert.DeserializeObject(json); + } + + private async Task MeasureLatencyAsync(List pings, CancellationToken cancellationToken) + { + var sw = Stopwatch.StartNew(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/__down?bytes=0"); + using var response = await _client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + sw.Stop(); + var ttfb = sw.Elapsed.TotalMilliseconds; + response.EnsureSuccessStatusCode(); + await response.Content.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false); + + var serverTime = ParseServerTiming(response) ?? DefaultEstimatedServerTimeMs; + var ping = ttfb - serverTime; + if (ping < 0) ping = 0; + pings.Add(ping); + } + + private async Task<(double Bps, double DurationMs)> MeasureDownloadAsync(int bytes, + CancellationToken cancellationToken, Action onLiveBps) + { + var sw1 = Stopwatch.StartNew(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/__down?bytes={bytes}"); + using var response = await _client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + sw1.Stop(); + var ttfb = sw1.Elapsed.TotalMilliseconds; + response.EnsureSuccessStatusCode(); + var serverTime = ParseServerTiming(response) ?? DefaultEstimatedServerTimeMs; + var ping = ttfb - serverTime; + if (ping < 0) ping = 0; + + var sw2 = Stopwatch.StartNew(); + long bodyBytes; + double lastProgressMs = 0; + long lastProgressBytes = 0; + await using (var stream = await response.Content + .ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = await stream.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false)) > 0) + { + total += read; + var elapsedMs = sw2.Elapsed.TotalMilliseconds; + if (onLiveBps != null && elapsedMs - lastProgressMs >= LiveProgressIntervalMs + && elapsedMs > 0) + { + // Instantaneous throughput over the last interval (delta-based) + var deltaBytes = total - lastProgressBytes; + var deltaMs = elapsedMs - lastProgressMs; + var liveBps = 8.0 * deltaBytes / (deltaMs / 1000.0); + onLiveBps(liveBps); + lastProgressMs = elapsedMs; + lastProgressBytes = total; + } + } + bodyBytes = total; + } + sw2.Stop(); + var bodyMs = sw2.Elapsed.TotalMilliseconds; + var contentLength = response.Content.Headers.ContentLength ?? bodyBytes; + + var downloadDurationMs = ping + bodyMs; + if (downloadDurationMs <= 0) + return (0.0, downloadDurationMs); + + var bps = 8.0 * contentLength / (downloadDurationMs / 1000.0); + return (bps, downloadDurationMs); + } + + private async Task<(double Bps, double DurationMs)> MeasureUploadAsync(int bytes, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/__up"); + using var content = new ZeroStreamContent(bytes); + request.Content = content; + + var sw = Stopwatch.StartNew(); + using var response = await _client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + sw.Stop(); + var ttfb = sw.Elapsed.TotalMilliseconds; + response.EnsureSuccessStatusCode(); + await response.Content.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false); + + var uploadDurationMs = ttfb; + if (uploadDurationMs <= 0) + return (0.0, uploadDurationMs); + + var bps = 8.0 * bytes * EstimatedHeaderFraction / (uploadDurationMs / 1000.0); + return (bps, uploadDurationMs); + } + + #endregion + + #region Helpers + + private static double? ParseServerTiming(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("server-timing", out var values)) + return null; + foreach (var value in values) + { + var match = ServerTimingRegex.Match(value); + if (match.Success && double.TryParse(match.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var dur)) + return dur; + } + return null; + } + + private static double Percentile(List values, double p) + { + if (values == null || values.Count == 0) + return 0.0; + var sorted = values.OrderBy(v => v).ToList(); + if (sorted.Count == 1) + return sorted[0]; + var rank = p * (sorted.Count - 1); + var lower = (int)Math.Floor(rank); + var upper = (int)Math.Ceiling(rank); + if (lower == upper) + return sorted[lower]; + var weight = rank - lower; + return sorted[lower] * (1 - weight) + sorted[upper] * weight; + } + + private static double Jitter(List pings) + { + if (pings == null || pings.Count < 2) + return 0.0; + double sum = 0; + for (var i = 1; i < pings.Count; i++) + sum += Math.Abs(pings[i] - pings[i - 1]); + return sum / (pings.Count - 1); + } + + #endregion + + /// + /// Streams zero bytes as HTTP content without allocating + /// a full-sized buffer. A single small shared chunk is reused across all writes. + /// + private sealed class ZeroStreamContent : HttpContent + { + private readonly int _length; + private static readonly byte[] _chunk = new byte[81920]; + + internal ZeroStreamContent(int length) + { + _length = length; + Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + Headers.ContentLength = length; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + SerializeToStreamAsync(stream, context, CancellationToken.None); + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, + CancellationToken cancellationToken) + { + var remaining = _length; + while (remaining > 0) + { + var toWrite = Math.Min(_chunk.Length, remaining); + await stream.WriteAsync(_chunk.AsMemory(0, toWrite), cancellationToken).ConfigureAwait(false); + remaining -= toWrite; + } + } + + protected override bool TryComputeLength(out long length) + { + length = _length; + return true; + } + } +} diff --git a/Source/NETworkManager/NETworkManager.csproj b/Source/NETworkManager/NETworkManager.csproj index eb835a62da..817363ce92 100644 --- a/Source/NETworkManager/NETworkManager.csproj +++ b/Source/NETworkManager/NETworkManager.csproj @@ -61,6 +61,7 @@ + diff --git a/Source/NETworkManager/ViewModels/SpeedTestWidgetViewModel.cs b/Source/NETworkManager/ViewModels/SpeedTestWidgetViewModel.cs new file mode 100644 index 0000000000..7fa4dce898 --- /dev/null +++ b/Source/NETworkManager/ViewModels/SpeedTestWidgetViewModel.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.ObjectModel; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using LiveChartsCore; +using LiveChartsCore.Drawing; +using LiveChartsCore.Kernel.Sketches; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using log4net; +using NETworkManager.Localization.Resources; +using NETworkManager.Models.Cloudflare; +using NETworkManager.Utilities; +using SkiaSharp; + +namespace NETworkManager.ViewModels; + +/// +/// View model for the Cloudflare speed test widget. Exposes live values for +/// the metric tiles plus per-sample history for the download/upload sparkline +/// charts (LiveCharts2). +/// +public class SpeedTestWidgetViewModel : ViewModelBase +{ + private static readonly ILog Log = LogManager.GetLogger(typeof(SpeedTestWidgetViewModel)); + + private readonly SpeedTestService _service = new(); + private CancellationTokenSource _cts; + + public bool IsRunning + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// + /// True until the user accepts the privacy disclaimer for the current + /// VM lifetime. Not persisted — the disclaimer is shown on every app + /// start by design. + /// + public bool ShowDisclaimer + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = true; + + public SpeedTestResult Result + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasResult)); + } + } + + public bool HasResult => Result != null; + + public string StatusMessage + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + public double? CurrentDownloadMbps + { + get; + private set + { + if (Nullable.Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } + + public double? CurrentUploadMbps + { + get; + private set + { + if (Nullable.Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } + + public double? CurrentLatencyMs + { + get; + private set + { + if (Nullable.Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } + + public double? CurrentJitterMs + { + get; + private set + { + if (Nullable.Equals(value, field)) + return; + + field = value; + OnPropertyChanged(); + } + } + + /// Download samples (Mbps), one per completed HTTP request. + private ObservableCollection DownloadSamples { get; } = []; + + /// Upload samples (Mbps), one per completed HTTP request. + private ObservableCollection UploadSamples { get; } = []; + + /// LiveCharts2 series for the download sparkline. + public ISeries[] DownloadSeries { get; } + + /// LiveCharts2 series for the upload sparkline. + public ISeries[] UploadSeries { get; } + + /// Hidden X-axes for the download sparkline (anchored at 0 so the first sample sits at the left edge). + public ICartesianAxis[] DownloadXAxes { get; } = [new Axis { IsVisible = false, MinLimit = 0, MinStep = 1 }]; + + /// Hidden Y-axes for the download sparkline. + public ICartesianAxis[] DownloadYAxes { get; } = [new Axis { IsVisible = false, MinLimit = 0 }]; + + /// Hidden X-axes for the upload sparkline. + public ICartesianAxis[] UploadXAxes { get; } = [new Axis { IsVisible = false, MinLimit = 0, MinStep = 1 }]; + + /// Hidden Y-axes for the upload sparkline. + public ICartesianAxis[] UploadYAxes { get; } = [new Axis { IsVisible = false, MinLimit = 0 }]; + + private ICommand _runCommand; + public ICommand RunCommand => _runCommand ??= new RelayCommand(_ => RunAction()); + + private ICommand _acceptDisclaimerCommand; + public ICommand AcceptDisclaimerCommand => _acceptDisclaimerCommand ??= new RelayCommand(_ => AcceptDisclaimerAction()); + + public SpeedTestWidgetViewModel() + { + var downloadColor = SKColor.Parse("#1ba1e2"); + DownloadSeries = + [ + new LineSeries + { + Values = DownloadSamples, + GeometrySize = 3, + LineSmoothness = 0.3, + DataPadding = new LvcPoint(0, 0), + Stroke = new SolidColorPaint(downloadColor) { StrokeThickness = 1.5f }, + Fill = new SolidColorPaint(downloadColor.WithAlpha(0x33)), + GeometryStroke = new SolidColorPaint(downloadColor) { StrokeThickness = 1.5f }, + GeometryFill = new SolidColorPaint(downloadColor), + YToolTipLabelFormatter = point => $"{point.Model:F1} Mbps" + } + ]; + + var uploadColor = SKColor.Parse("#7fba00"); + UploadSeries = + [ + new LineSeries + { + Values = UploadSamples, + GeometrySize = 3, + LineSmoothness = 0.3, + DataPadding = new LvcPoint(0, 0), + Stroke = new SolidColorPaint(uploadColor) { StrokeThickness = 1.5f }, + Fill = new SolidColorPaint(uploadColor.WithAlpha(0x33)), + GeometryStroke = new SolidColorPaint(uploadColor) { StrokeThickness = 1.5f }, + GeometryFill = new SolidColorPaint(uploadColor), + YToolTipLabelFormatter = point => $"{point.Model:F1} Mbps" + } + ]; + } + + private void RunAction() + { + if (IsRunning) + { + _cts?.Cancel(); + return; + } + + if (ShowDisclaimer) + return; + + _ = RunAsync(); + } + + private void AcceptDisclaimerAction() + { + ShowDisclaimer = false; + _ = RunAsync(); + } + + private async Task RunAsync() + { + if (IsRunning) + return; + + IsRunning = true; + Result = null; + CurrentDownloadMbps = null; + CurrentUploadMbps = null; + CurrentLatencyMs = null; + CurrentJitterMs = null; + DownloadSamples.Clear(); + UploadSamples.Clear(); + StatusMessage = Strings.FetchingMetadataDots; + + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + + var progress = new Progress(p => + { + StatusMessage = p.Phase switch + { + SpeedTestPhase.FetchingMetadata => Strings.FetchingMetadataDots, + SpeedTestPhase.MeasuringLatency => Strings.MeasuringLatencyDots, + SpeedTestPhase.MeasuringDownload => Strings.MeasuringDownloadSpeedDots, + SpeedTestPhase.MeasuringUpload => Strings.MeasuringUploadSpeedDots, + _ => string.Empty + }; + + if (p.DownloadMbps.HasValue) + CurrentDownloadMbps = p.DownloadMbps; + if (p.UploadMbps.HasValue) + CurrentUploadMbps = p.UploadMbps; + if (p.LatencyMs.HasValue) + CurrentLatencyMs = p.LatencyMs; + if (p.JitterMs.HasValue) + CurrentJitterMs = p.JitterMs; + + if (p.NewDownloadSampleMbps.HasValue) + { + // Seed the first point twice so LiveCharts2 renders the sparkline + // anchored at x=0 rather than starting mid-chart on the first sample. + if (DownloadSamples.Count == 0) + DownloadSamples.Add(p.NewDownloadSampleMbps.Value); + DownloadSamples.Add(p.NewDownloadSampleMbps.Value); + } + if (p.NewUploadSampleMbps.HasValue) + { + // Same workaround as above for the upload sparkline. + if (UploadSamples.Count == 0) + UploadSamples.Add(p.NewUploadSampleMbps.Value); + UploadSamples.Add(p.NewUploadSampleMbps.Value); + } + + if (p.Meta != null) + { + // Populate Result with metadata-only fields so the UI can + // show ISP / location / server while measurements are still running. + Result = new SpeedTestResult + { + Isp = p.Meta.AsOrganization, + ClientCity = p.Meta.City, + ClientCountry = p.Meta.Country, + ServerCity = p.Meta.Colo?.City, + ServerCountry = p.Meta.Colo?.Cca2, + ServerIata = p.Meta.Colo?.Iata + }; + } + }); + + try + { + var result = await _service.RunAsync(progress, _cts.Token); + Result = result; + CurrentDownloadMbps = result.DownloadMbps; + CurrentUploadMbps = result.UploadMbps; + CurrentLatencyMs = result.LatencyMs; + CurrentJitterMs = result.JitterMs; + } + catch (OperationCanceledException) + { + // Partial results remain visible; everything resets at the start of the next run. + } + catch (Exception ex) + { + Log.Error("Speed test failed.", ex); + Result = new SpeedTestResult + { + HasError = true, + ErrorMessage = ex.Message + }; + } + finally + { + IsRunning = false; + } + } +} diff --git a/Source/NETworkManager/Views/DashboardView.xaml b/Source/NETworkManager/Views/DashboardView.xaml index 4cf0b44586..b4d0f0a276 100644 --- a/Source/NETworkManager/Views/DashboardView.xaml +++ b/Source/NETworkManager/Views/DashboardView.xaml @@ -23,13 +23,17 @@ + + - + - + diff --git a/Source/NETworkManager/Views/DashboardView.xaml.cs b/Source/NETworkManager/Views/DashboardView.xaml.cs index db73e924b1..e36726f392 100644 --- a/Source/NETworkManager/Views/DashboardView.xaml.cs +++ b/Source/NETworkManager/Views/DashboardView.xaml.cs @@ -9,6 +9,7 @@ public partial class DashboardView private readonly NetworkConnectionWidgetView _networkConnectionWidgetView = new(); private readonly IPApiIPGeolocationWidgetView _ipApiIPGeolocationWidgetView = new(); private readonly IPApiDNSResolverWidgetView _ipApiDNSResolverWidgetView = new(); + private readonly SpeedTestWidgetView _speedTestWidgetView = new(); public DashboardView() @@ -20,6 +21,7 @@ public DashboardView() ContentControlNetworkConnection.Content = _networkConnectionWidgetView; ContentControlIPApiIPGeolocation.Content = _ipApiIPGeolocationWidgetView; ContentControlIPApiDNSResolver.Content = _ipApiDNSResolverWidgetView; + ContentControlSpeedTest.Content = _speedTestWidgetView; // Check all widgets Check(); diff --git a/Source/NETworkManager/Views/IPApiDNSResolverWidgetView.xaml b/Source/NETworkManager/Views/IPApiDNSResolverWidgetView.xaml index 07a8e688a3..7c832ac7e3 100644 --- a/Source/NETworkManager/Views/IPApiDNSResolverWidgetView.xaml +++ b/Source/NETworkManager/Views/IPApiDNSResolverWidgetView.xaml @@ -34,8 +34,7 @@ - - + diff --git a/Source/NETworkManager/Views/IPApiIPGeolocationWidgetView.xaml b/Source/NETworkManager/Views/IPApiIPGeolocationWidgetView.xaml index 45b4d7dcca..86173f75d7 100644 --- a/Source/NETworkManager/Views/IPApiIPGeolocationWidgetView.xaml +++ b/Source/NETworkManager/Views/IPApiIPGeolocationWidgetView.xaml @@ -38,7 +38,6 @@ - @@ -71,7 +70,7 @@ - + diff --git a/Source/NETworkManager/Views/NetworkConnectionWidgetView.xaml b/Source/NETworkManager/Views/NetworkConnectionWidgetView.xaml index 6414ee6ca2..0838b5e8a3 100644 --- a/Source/NETworkManager/Views/NetworkConnectionWidgetView.xaml +++ b/Source/NETworkManager/Views/NetworkConnectionWidgetView.xaml @@ -20,16 +20,14 @@ - - - - - + + + - + - - + @@ -117,7 +113,7 @@ - + @@ -157,7 +153,7 @@ - + @@ -168,8 +164,7 @@ - - + @@ -181,7 +176,7 @@ - @@ -219,7 +214,7 @@ - - diff --git a/Source/NETworkManager/Views/SpeedTestWidgetView.xaml b/Source/NETworkManager/Views/SpeedTestWidgetView.xaml new file mode 100644 index 0000000000..250a2313d5 --- /dev/null +++ b/Source/NETworkManager/Views/SpeedTestWidgetView.xaml @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +