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 @@
-
-
-
-
-
+
+
+
-
+
@@ -40,8 +38,7 @@
-
-
+
@@ -53,7 +50,7 @@
-
+
@@ -93,7 +90,7 @@
-
+
@@ -104,8 +101,7 @@
-
-
+
@@ -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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/Views/SpeedTestWidgetView.xaml.cs b/Source/NETworkManager/Views/SpeedTestWidgetView.xaml.cs
new file mode 100644
index 0000000000..eae7ef5fe2
--- /dev/null
+++ b/Source/NETworkManager/Views/SpeedTestWidgetView.xaml.cs
@@ -0,0 +1,14 @@
+using NETworkManager.ViewModels;
+
+namespace NETworkManager.Views;
+
+public partial class SpeedTestWidgetView
+{
+ private readonly SpeedTestWidgetViewModel _viewModel = new();
+
+ public SpeedTestWidgetView()
+ {
+ InitializeComponent();
+ DataContext = _viewModel;
+ }
+}
diff --git a/Source/NETworkManager/Views/WiFiView.xaml b/Source/NETworkManager/Views/WiFiView.xaml
index 14353753b8..12aa93c6de 100644
--- a/Source/NETworkManager/Views/WiFiView.xaml
+++ b/Source/NETworkManager/Views/WiFiView.xaml
@@ -840,8 +840,7 @@
-