Skip to main content

netrunner_gui/
app.rs

1//! Application state and update logic for the GPUI desktop app.
2
3use futures::StreamExt;
4use gpui::{Context, SharedString};
5use netrunner_core::{HistoryStorage, Settings, SpeedTestResult, TestConfig, TestEvent};
6
7use crate::engine::spawn_speed_test;
8
9/// Which transfer the live chart is currently tracking.
10#[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
35/// Maximum number of samples kept for each live chart.
36pub const MAX_SAMPLES: usize = 120;
37
38/// Test servers cycled through by the settings panel.
39pub const SERVER_PRESETS: &[&str] = &[
40    "https://speed.cloudflare.com",
41    "https://httpbin.org",
42    "https://www.google.com",
43    "https://fast.com",
44];
45
46/// The root application entity.
47pub 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    /// Recent past runs, newest first (read from the shared redb history).
69    pub history: Vec<SpeedTestResult>,
70}
71
72impl SpeedApp {
73    /// Build a fresh, side-effect-free state (no disk access — see [`Self::boot`]).
74    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    /// Load persisted settings (JSON) and history (redb) from disk.
98    ///
99    /// Kept separate from [`Self::new`] so unit tests stay pure and don't touch
100    /// the user's real config/history files.
101    pub fn boot(&mut self) {
102        self.settings = Settings::load();
103        self.config = self.settings.to_config();
104        self.reload_history();
105    }
106
107    /// Re-read recent runs from the shared history database.
108    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    /// Persist the most recent result to the shared history and refresh the list.
117    ///
118    /// Uses a single store handle for the write **and** the follow-up read, and
119    /// surfaces failures on the status line instead of swallowing them. Saving
120    /// the same result twice is harmless (identical timestamp key overwrites).
121    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    /// Toggle the "run a test on launch" preference and persist settings.
141    pub fn toggle_auto_run(&mut self) {
142        self.settings.auto_run = !self.settings.auto_run;
143        let _ = self.settings.save();
144    }
145
146    /// Clear all saved history (and the on-screen list).
147    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    /// Rebuild the active [`TestConfig`] from settings and persist to JSON.
155    fn apply_and_save_settings(&mut self) {
156        self.config = self.settings.to_config();
157        let _ = self.settings.save();
158    }
159
160    /// Cycle the test server through the built-in presets.
161    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    /// Adjust the test payload size (MB), clamped to a sane range.
172    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    /// Adjust the per-test timeout (seconds), clamped to a sane range.
179    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    /// Cycle the output detail level.
186    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    /// Kick off a full speed test and stream its progress into this entity.
198    pub fn start(&mut self, cx: &mut Context<Self>) {
199        if self.running {
200            return;
201        }
202
203        // Reset state for a fresh run.
204        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                            // Save this run to the shared history (redb) and
230                            // refresh the on-screen list.
231                            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                // Safety net: guarantee the finished run is saved even if the
246                // Completed event didn't persist it (idempotent — same key).
247                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    /// Apply a single progress event to the state.
259    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        // Oldest samples were dropped, newest retained.
374        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        // Point the config dir at a throwaway HOME so we don't touch real data.
421        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}