1use futures::StreamExt;
4use gpui::{Context, SharedString};
5use netrunner_core::{HistoryStorage, Settings, SpeedTestResult, TestConfig, TestEvent};
6
7use crate::engine::spawn_speed_test;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Phase {
12 Idle,
13 Locating,
14 Servers,
15 Latency,
16 Download,
17 Upload,
18 Done,
19}
20
21impl Phase {
22 pub fn label(self) -> &'static str {
23 match self {
24 Phase::Idle => "Ready",
25 Phase::Locating => "Detecting location…",
26 Phase::Servers => "Selecting servers…",
27 Phase::Latency => "Measuring latency…",
28 Phase::Download => "Testing download…",
29 Phase::Upload => "Testing upload…",
30 Phase::Done => "Complete",
31 }
32 }
33}
34
35pub const MAX_SAMPLES: usize = 120;
37
38pub const SERVER_PRESETS: &[&str] = &[
40 "https://speed.cloudflare.com",
41 "https://httpbin.org",
42 "https://www.google.com",
43 "https://fast.com",
44];
45
46pub struct SpeedApp {
48 pub config: TestConfig,
49 pub settings: Settings,
50 pub status: SharedString,
51 pub phase: Phase,
52 pub running: bool,
53
54 pub download_samples: Vec<f32>,
55 pub upload_samples: Vec<f32>,
56
57 pub download_mbps: f32,
58 pub upload_mbps: f32,
59 pub peak_download: f32,
60 pub peak_upload: f32,
61 pub ping_ms: f32,
62
63 pub location: Option<SharedString>,
64 pub isp: Option<SharedString>,
65 pub server: Option<SharedString>,
66 pub result: Option<SpeedTestResult>,
67
68 pub history: Vec<SpeedTestResult>,
70}
71
72impl SpeedApp {
73 pub fn new() -> Self {
75 let settings = Settings::default();
76 Self {
77 config: settings.to_config(),
78 settings,
79 status: "Ready to jack in.".into(),
80 phase: Phase::Idle,
81 running: false,
82 download_samples: Vec::new(),
83 upload_samples: Vec::new(),
84 download_mbps: 0.0,
85 upload_mbps: 0.0,
86 peak_download: 0.0,
87 peak_upload: 0.0,
88 ping_ms: 0.0,
89 location: None,
90 isp: None,
91 server: None,
92 result: None,
93 history: Vec::new(),
94 }
95 }
96
97 pub fn boot(&mut self) {
102 self.settings = Settings::load();
103 self.config = self.settings.to_config();
104 self.reload_history();
105 }
106
107 pub fn reload_history(&mut self) {
109 if let Ok(store) = HistoryStorage::new() {
110 if let Ok(results) = store.get_recent_results(self.settings.max_history) {
111 self.history = results;
112 }
113 }
114 }
115
116 fn persist_last_result(&mut self) {
118 if let Some(result) = self.result.clone() {
119 if let Ok(store) = HistoryStorage::new() {
120 let _ = store.save_result(&result);
121 }
122 self.reload_history();
123 }
124 }
125
126 pub fn toggle_auto_run(&mut self) {
128 self.settings.auto_run = !self.settings.auto_run;
129 let _ = self.settings.save();
130 }
131
132 pub fn clear_history(&mut self) {
134 if let Ok(store) = HistoryStorage::new() {
135 let _ = store.clear_history();
136 }
137 self.history.clear();
138 }
139
140 fn apply_and_save_settings(&mut self) {
142 self.config = self.settings.to_config();
143 let _ = self.settings.save();
144 }
145
146 pub fn cycle_server(&mut self) {
148 let idx = SERVER_PRESETS
149 .iter()
150 .position(|s| *s == self.settings.server_url)
151 .map(|i| (i + 1) % SERVER_PRESETS.len())
152 .unwrap_or(0);
153 self.settings.server_url = SERVER_PRESETS[idx].to_string();
154 self.apply_and_save_settings();
155 }
156
157 pub fn adjust_size(&mut self, delta: i64) {
159 let v = (self.settings.test_size_mb as i64 + delta).clamp(1, 1000);
160 self.settings.test_size_mb = v as u64;
161 self.apply_and_save_settings();
162 }
163
164 pub fn adjust_timeout(&mut self, delta: i64) {
166 let v = (self.settings.timeout_seconds as i64 + delta).clamp(5, 120);
167 self.settings.timeout_seconds = v as u64;
168 self.apply_and_save_settings();
169 }
170
171 pub fn cycle_detail(&mut self) {
173 use netrunner_core::DetailLevel::*;
174 self.settings.detail_level = match self.settings.detail_level {
175 Basic => Standard,
176 Standard => Detailed,
177 Detailed => Debug,
178 Debug => Basic,
179 };
180 self.apply_and_save_settings();
181 }
182
183 pub fn start(&mut self, cx: &mut Context<Self>) {
185 if self.running {
186 return;
187 }
188
189 self.running = true;
191 self.phase = Phase::Locating;
192 self.status = "Initialising neural interface…".into();
193 self.download_samples.clear();
194 self.upload_samples.clear();
195 self.download_mbps = 0.0;
196 self.upload_mbps = 0.0;
197 self.peak_download = 0.0;
198 self.peak_upload = 0.0;
199 self.ping_ms = 0.0;
200 self.location = None;
201 self.isp = None;
202 self.server = None;
203 self.result = None;
204
205 let rx = spawn_speed_test(self.config.clone());
206
207 cx.spawn(async move |this, cx| {
208 let mut rx = rx;
209 while let Some(event) = rx.next().await {
210 let keep_going = this
211 .update(cx, |app, cx| {
212 let completed = matches!(&event, TestEvent::Completed(_));
213 app.apply(event);
214 if completed {
215 app.persist_last_result();
218 }
219 cx.notify();
220 })
221 .is_ok();
222 if !keep_going {
223 break;
224 }
225 }
226 let _ = this.update(cx, |app, cx| {
227 app.running = false;
228 if app.phase != Phase::Done {
229 app.phase = Phase::Done;
230 }
231 cx.notify();
232 });
233 })
234 .detach();
235
236 cx.notify();
237 }
238
239 pub fn apply(&mut self, event: TestEvent) {
241 match event {
242 TestEvent::Status(msg) => self.status = msg.into(),
243 TestEvent::LocationDetected {
244 city, country, isp, ..
245 } => {
246 self.location = Some(format!("{city}, {country}").into());
247 self.isp = isp.map(Into::into);
248 self.phase = Phase::Locating;
249 }
250 TestEvent::ServerPoolBuilt { .. } | TestEvent::NearbyServersFound { .. } => {
251 self.phase = Phase::Servers;
252 }
253 TestEvent::ServersSelected { .. } => self.phase = Phase::Servers,
254 TestEvent::PrimarySelected { name, location, .. } => {
255 self.server = Some(format!("{name} · {location}").into());
256 self.phase = Phase::Servers;
257 }
258 TestEvent::PhaseStarted(phase) => {
259 use netrunner_core::Phase as CorePhase;
260 self.phase = match phase {
261 CorePhase::Locating => Phase::Locating,
262 CorePhase::BuildingServers | CorePhase::SelectingServers => Phase::Servers,
263 CorePhase::Latency => Phase::Latency,
264 CorePhase::Download => Phase::Download,
265 CorePhase::Upload => Phase::Upload,
266 CorePhase::Jitter => self.phase,
267 };
268 self.status = phase.title().to_string().into();
269 }
270 TestEvent::DownloadSample {
271 mbps, peak_mbps, ..
272 } => {
273 self.download_mbps = mbps as f32;
274 self.peak_download = peak_mbps as f32;
275 push_sample(&mut self.download_samples, mbps as f32);
276 }
277 TestEvent::UploadSample {
278 mbps, peak_mbps, ..
279 } => {
280 self.upload_mbps = mbps as f32;
281 self.peak_upload = peak_mbps as f32;
282 push_sample(&mut self.upload_samples, mbps as f32);
283 }
284 TestEvent::DownloadComplete { mbps } => self.download_mbps = mbps as f32,
285 TestEvent::UploadComplete { mbps } => self.upload_mbps = mbps as f32,
286 TestEvent::LatencyProgress { avg_ms } | TestEvent::LatencyComplete { avg_ms } => {
287 self.ping_ms = avg_ms as f32;
288 }
289 TestEvent::Completed(result) => {
290 self.download_mbps = result.download_mbps as f32;
291 self.upload_mbps = result.upload_mbps as f32;
292 self.ping_ms = result.ping_ms as f32;
293 self.phase = Phase::Done;
294 self.status = "Test complete.".into();
295 self.result = Some(*result);
296 }
297 TestEvent::JitterComplete { .. } | TestEvent::DiagnosticsComplete(_) => {}
298 }
299 }
300}
301
302impl Default for SpeedApp {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308fn push_sample(buf: &mut Vec<f32>, value: f32) {
309 buf.push(value);
310 if buf.len() > MAX_SAMPLES {
311 let overflow = buf.len() - MAX_SAMPLES;
312 buf.drain(0..overflow);
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use netrunner_core::Phase as CorePhase;
320
321 #[test]
322 fn apply_download_sample_tracks_current_and_peak() {
323 let mut app = SpeedApp::new();
324 app.apply(TestEvent::PhaseStarted(CorePhase::Download));
325 assert_eq!(app.phase, Phase::Download);
326
327 app.apply(TestEvent::DownloadSample {
328 mbps: 120.0,
329 peak_mbps: 120.0,
330 elapsed_secs: 1.0,
331 });
332 app.apply(TestEvent::DownloadSample {
333 mbps: 80.0,
334 peak_mbps: 120.0,
335 elapsed_secs: 2.0,
336 });
337
338 assert_eq!(app.download_samples.len(), 2);
339 assert_eq!(app.download_mbps, 80.0);
340 assert_eq!(app.peak_download, 120.0);
341 }
342
343 #[test]
344 fn sample_buffer_is_capped() {
345 let mut app = SpeedApp::new();
346 for i in 0..(MAX_SAMPLES + 50) {
347 app.apply(TestEvent::UploadSample {
348 mbps: i as f64,
349 peak_mbps: i as f64,
350 elapsed_secs: 0.0,
351 });
352 }
353 assert_eq!(app.upload_samples.len(), MAX_SAMPLES);
354 assert_eq!(
356 *app.upload_samples.last().unwrap(),
357 (MAX_SAMPLES + 49) as f32
358 );
359 }
360
361 #[test]
362 fn completed_populates_result_and_metrics() {
363 let mut app = SpeedApp::new();
364 let result = netrunner_core::SpeedTestResult {
365 download_mbps: 250.0,
366 upload_mbps: 40.0,
367 ping_ms: 12.0,
368 ..Default::default()
369 };
370
371 app.apply(TestEvent::Completed(Box::new(result)));
372
373 assert_eq!(app.phase, Phase::Done);
374 assert!(app.result.is_some());
375 assert_eq!(app.download_mbps, 250.0);
376 assert_eq!(app.upload_mbps, 40.0);
377 assert_eq!(app.ping_ms, 12.0);
378 }
379
380 #[test]
381 fn location_event_is_formatted() {
382 let mut app = SpeedApp::new();
383 app.apply(TestEvent::LocationDetected {
384 city: "Berlin".into(),
385 country: "Germany".into(),
386 isp: Some("Example ISP".into()),
387 source: "ipapi.co".into(),
388 });
389 assert_eq!(
390 app.location.as_ref().map(|s| s.to_string()),
391 Some("Berlin, Germany".to_string())
392 );
393 assert_eq!(
394 app.isp.as_ref().map(|s| s.to_string()),
395 Some("Example ISP".to_string())
396 );
397 }
398}