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) {
122 let Some(result) = self.result.clone() else {
123 return;
124 };
125 match HistoryStorage::new() {
126 Ok(store) => {
127 if let Err(e) = store.save_result(&result) {
128 self.status = format!("Failed to save history: {e}").into();
129 }
130 if let Ok(results) = store.get_recent_results(self.settings.max_history) {
131 self.history = results;
132 }
133 }
134 Err(e) => {
135 self.status = format!("Failed to open history: {e}").into();
136 }
137 }
138 }
139
140 pub fn toggle_auto_run(&mut self) {
142 self.settings.auto_run = !self.settings.auto_run;
143 let _ = self.settings.save();
144 }
145
146 pub fn clear_history(&mut self) {
148 if let Ok(store) = HistoryStorage::new() {
149 let _ = store.clear_history();
150 }
151 self.history.clear();
152 }
153
154 fn apply_and_save_settings(&mut self) {
156 self.config = self.settings.to_config();
157 let _ = self.settings.save();
158 }
159
160 pub fn cycle_server(&mut self) {
162 let idx = SERVER_PRESETS
163 .iter()
164 .position(|s| *s == self.settings.server_url)
165 .map(|i| (i + 1) % SERVER_PRESETS.len())
166 .unwrap_or(0);
167 self.settings.server_url = SERVER_PRESETS[idx].to_string();
168 self.apply_and_save_settings();
169 }
170
171 pub fn adjust_size(&mut self, delta: i64) {
173 let v = (self.settings.test_size_mb as i64 + delta).clamp(1, 1000);
174 self.settings.test_size_mb = v as u64;
175 self.apply_and_save_settings();
176 }
177
178 pub fn adjust_timeout(&mut self, delta: i64) {
180 let v = (self.settings.timeout_seconds as i64 + delta).clamp(5, 120);
181 self.settings.timeout_seconds = v as u64;
182 self.apply_and_save_settings();
183 }
184
185 pub fn cycle_detail(&mut self) {
187 use netrunner_core::DetailLevel::*;
188 self.settings.detail_level = match self.settings.detail_level {
189 Basic => Standard,
190 Standard => Detailed,
191 Detailed => Debug,
192 Debug => Basic,
193 };
194 self.apply_and_save_settings();
195 }
196
197 pub fn start(&mut self, cx: &mut Context<Self>) {
199 if self.running {
200 return;
201 }
202
203 self.running = true;
205 self.phase = Phase::Locating;
206 self.status = "Initialising neural interface…".into();
207 self.download_samples.clear();
208 self.upload_samples.clear();
209 self.download_mbps = 0.0;
210 self.upload_mbps = 0.0;
211 self.peak_download = 0.0;
212 self.peak_upload = 0.0;
213 self.ping_ms = 0.0;
214 self.location = None;
215 self.isp = None;
216 self.server = None;
217 self.result = None;
218
219 let rx = spawn_speed_test(self.config.clone());
220
221 cx.spawn(async move |this, cx| {
222 let mut rx = rx;
223 while let Some(event) = rx.next().await {
224 let keep_going = this
225 .update(cx, |app, cx| {
226 let completed = matches!(&event, TestEvent::Completed(_));
227 app.apply(event);
228 if completed {
229 app.persist_last_result();
232 }
233 cx.notify();
234 })
235 .is_ok();
236 if !keep_going {
237 break;
238 }
239 }
240 let _ = this.update(cx, |app, cx| {
241 app.running = false;
242 if app.phase != Phase::Done {
243 app.phase = Phase::Done;
244 }
245 if app.result.is_some() {
248 app.persist_last_result();
249 }
250 cx.notify();
251 });
252 })
253 .detach();
254
255 cx.notify();
256 }
257
258 pub fn apply(&mut self, event: TestEvent) {
260 match event {
261 TestEvent::Status(msg) => self.status = msg.into(),
262 TestEvent::LocationDetected {
263 city, country, isp, ..
264 } => {
265 self.location = Some(format!("{city}, {country}").into());
266 self.isp = isp.map(Into::into);
267 self.phase = Phase::Locating;
268 }
269 TestEvent::ServerPoolBuilt { .. } | TestEvent::NearbyServersFound { .. } => {
270 self.phase = Phase::Servers;
271 }
272 TestEvent::ServersSelected { .. } => self.phase = Phase::Servers,
273 TestEvent::PrimarySelected { name, location, .. } => {
274 self.server = Some(format!("{name} · {location}").into());
275 self.phase = Phase::Servers;
276 }
277 TestEvent::PhaseStarted(phase) => {
278 use netrunner_core::Phase as CorePhase;
279 self.phase = match phase {
280 CorePhase::Locating => Phase::Locating,
281 CorePhase::BuildingServers | CorePhase::SelectingServers => Phase::Servers,
282 CorePhase::Latency => Phase::Latency,
283 CorePhase::Download => Phase::Download,
284 CorePhase::Upload => Phase::Upload,
285 CorePhase::Jitter => self.phase,
286 };
287 self.status = phase.title().to_string().into();
288 }
289 TestEvent::DownloadSample {
290 mbps, peak_mbps, ..
291 } => {
292 self.download_mbps = mbps as f32;
293 self.peak_download = peak_mbps as f32;
294 push_sample(&mut self.download_samples, mbps as f32);
295 }
296 TestEvent::UploadSample {
297 mbps, peak_mbps, ..
298 } => {
299 self.upload_mbps = mbps as f32;
300 self.peak_upload = peak_mbps as f32;
301 push_sample(&mut self.upload_samples, mbps as f32);
302 }
303 TestEvent::DownloadComplete { mbps } => self.download_mbps = mbps as f32,
304 TestEvent::UploadComplete { mbps } => self.upload_mbps = mbps as f32,
305 TestEvent::LatencyProgress { avg_ms } | TestEvent::LatencyComplete { avg_ms } => {
306 self.ping_ms = avg_ms as f32;
307 }
308 TestEvent::Completed(result) => {
309 self.download_mbps = result.download_mbps as f32;
310 self.upload_mbps = result.upload_mbps as f32;
311 self.ping_ms = result.ping_ms as f32;
312 self.phase = Phase::Done;
313 self.status = "Test complete.".into();
314 self.result = Some(*result);
315 }
316 TestEvent::JitterComplete { .. } | TestEvent::DiagnosticsComplete(_) => {}
317 }
318 }
319}
320
321impl Default for SpeedApp {
322 fn default() -> Self {
323 Self::new()
324 }
325}
326
327fn push_sample(buf: &mut Vec<f32>, value: f32) {
328 buf.push(value);
329 if buf.len() > MAX_SAMPLES {
330 let overflow = buf.len() - MAX_SAMPLES;
331 buf.drain(0..overflow);
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use netrunner_core::Phase as CorePhase;
339
340 #[test]
341 fn apply_download_sample_tracks_current_and_peak() {
342 let mut app = SpeedApp::new();
343 app.apply(TestEvent::PhaseStarted(CorePhase::Download));
344 assert_eq!(app.phase, Phase::Download);
345
346 app.apply(TestEvent::DownloadSample {
347 mbps: 120.0,
348 peak_mbps: 120.0,
349 elapsed_secs: 1.0,
350 });
351 app.apply(TestEvent::DownloadSample {
352 mbps: 80.0,
353 peak_mbps: 120.0,
354 elapsed_secs: 2.0,
355 });
356
357 assert_eq!(app.download_samples.len(), 2);
358 assert_eq!(app.download_mbps, 80.0);
359 assert_eq!(app.peak_download, 120.0);
360 }
361
362 #[test]
363 fn sample_buffer_is_capped() {
364 let mut app = SpeedApp::new();
365 for i in 0..(MAX_SAMPLES + 50) {
366 app.apply(TestEvent::UploadSample {
367 mbps: i as f64,
368 peak_mbps: i as f64,
369 elapsed_secs: 0.0,
370 });
371 }
372 assert_eq!(app.upload_samples.len(), MAX_SAMPLES);
373 assert_eq!(
375 *app.upload_samples.last().unwrap(),
376 (MAX_SAMPLES + 49) as f32
377 );
378 }
379
380 #[test]
381 fn completed_populates_result_and_metrics() {
382 let mut app = SpeedApp::new();
383 let result = netrunner_core::SpeedTestResult {
384 download_mbps: 250.0,
385 upload_mbps: 40.0,
386 ping_ms: 12.0,
387 ..Default::default()
388 };
389
390 app.apply(TestEvent::Completed(Box::new(result)));
391
392 assert_eq!(app.phase, Phase::Done);
393 assert!(app.result.is_some());
394 assert_eq!(app.download_mbps, 250.0);
395 assert_eq!(app.upload_mbps, 40.0);
396 assert_eq!(app.ping_ms, 12.0);
397 }
398
399 #[test]
400 fn location_event_is_formatted() {
401 let mut app = SpeedApp::new();
402 app.apply(TestEvent::LocationDetected {
403 city: "Berlin".into(),
404 country: "Germany".into(),
405 isp: Some("Example ISP".into()),
406 source: "ipapi.co".into(),
407 });
408 assert_eq!(
409 app.location.as_ref().map(|s| s.to_string()),
410 Some("Berlin, Germany".to_string())
411 );
412 assert_eq!(
413 app.isp.as_ref().map(|s| s.to_string()),
414 Some("Example ISP".to_string())
415 );
416 }
417
418 #[test]
419 fn completed_run_is_persisted_and_reloaded() {
420 let tmp = std::env::temp_dir().join(format!("nr_gui_persist_{}", std::process::id()));
422 let _ = std::fs::remove_dir_all(&tmp);
423 std::fs::create_dir_all(&tmp).unwrap();
424 std::env::set_var("HOME", &tmp);
425 std::env::set_var("XDG_CONFIG_HOME", tmp.join(".config"));
426
427 let mut app = SpeedApp::new();
428 app.reload_history();
429 assert_eq!(app.history.len(), 0, "fresh HOME should have empty history");
430
431 app.result = Some(netrunner_core::SpeedTestResult {
432 download_mbps: 100.0,
433 upload_mbps: 20.0,
434 ping_ms: 5.0,
435 ..Default::default()
436 });
437 app.persist_last_result();
438
439 assert_eq!(app.history.len(), 1, "run should be saved and reloaded");
440 assert_eq!(app.history[0].download_mbps, 100.0);
441
442 let _ = std::fs::remove_dir_all(&tmp);
443 }
444}