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    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    /// Toggle the "run a test on launch" preference and persist settings.
127    pub fn toggle_auto_run(&mut self) {
128        self.settings.auto_run = !self.settings.auto_run;
129        let _ = self.settings.save();
130    }
131
132    /// Clear all saved history (and the on-screen list).
133    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    /// Rebuild the active [`TestConfig`] from settings and persist to JSON.
141    fn apply_and_save_settings(&mut self) {
142        self.config = self.settings.to_config();
143        let _ = self.settings.save();
144    }
145
146    /// Cycle the test server through the built-in presets.
147    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    /// Adjust the test payload size (MB), clamped to a sane range.
158    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    /// Adjust the per-test timeout (seconds), clamped to a sane range.
165    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    /// Cycle the output detail level.
172    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    /// Kick off a full speed test and stream its progress into this entity.
184    pub fn start(&mut self, cx: &mut Context<Self>) {
185        if self.running {
186            return;
187        }
188
189        // Reset state for a fresh run.
190        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                            // Save this run to the shared history (redb) and
216                            // refresh the on-screen list.
217                            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    /// Apply a single progress event to the state.
240    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        // Oldest samples were dropped, newest retained.
355        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}