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::{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/// The root application entity.
39pub struct SpeedApp {
40    pub config: TestConfig,
41    pub status: SharedString,
42    pub phase: Phase,
43    pub running: bool,
44
45    pub download_samples: Vec<f32>,
46    pub upload_samples: Vec<f32>,
47
48    pub download_mbps: f32,
49    pub upload_mbps: f32,
50    pub peak_download: f32,
51    pub peak_upload: f32,
52    pub ping_ms: f32,
53
54    pub location: Option<SharedString>,
55    pub isp: Option<SharedString>,
56    pub server: Option<SharedString>,
57    pub result: Option<SpeedTestResult>,
58}
59
60impl SpeedApp {
61    pub fn new() -> Self {
62        Self {
63            config: TestConfig::default(),
64            status: "Ready to jack in.".into(),
65            phase: Phase::Idle,
66            running: false,
67            download_samples: Vec::new(),
68            upload_samples: Vec::new(),
69            download_mbps: 0.0,
70            upload_mbps: 0.0,
71            peak_download: 0.0,
72            peak_upload: 0.0,
73            ping_ms: 0.0,
74            location: None,
75            isp: None,
76            server: None,
77            result: None,
78        }
79    }
80
81    /// Kick off a full speed test and stream its progress into this entity.
82    pub fn start(&mut self, cx: &mut Context<Self>) {
83        if self.running {
84            return;
85        }
86
87        // Reset state for a fresh run.
88        self.running = true;
89        self.phase = Phase::Locating;
90        self.status = "Initialising neural interface…".into();
91        self.download_samples.clear();
92        self.upload_samples.clear();
93        self.download_mbps = 0.0;
94        self.upload_mbps = 0.0;
95        self.peak_download = 0.0;
96        self.peak_upload = 0.0;
97        self.ping_ms = 0.0;
98        self.location = None;
99        self.isp = None;
100        self.server = None;
101        self.result = None;
102
103        let rx = spawn_speed_test(self.config.clone());
104
105        cx.spawn(async move |this, cx| {
106            let mut rx = rx;
107            while let Some(event) = rx.next().await {
108                let keep_going = this
109                    .update(cx, |app, cx| {
110                        app.apply(event);
111                        cx.notify();
112                    })
113                    .is_ok();
114                if !keep_going {
115                    break;
116                }
117            }
118            let _ = this.update(cx, |app, cx| {
119                app.running = false;
120                if app.phase != Phase::Done {
121                    app.phase = Phase::Done;
122                }
123                cx.notify();
124            });
125        })
126        .detach();
127
128        cx.notify();
129    }
130
131    /// Apply a single progress event to the state.
132    pub fn apply(&mut self, event: TestEvent) {
133        match event {
134            TestEvent::Status(msg) => self.status = msg.into(),
135            TestEvent::LocationDetected {
136                city, country, isp, ..
137            } => {
138                self.location = Some(format!("{city}, {country}").into());
139                self.isp = isp.map(Into::into);
140                self.phase = Phase::Locating;
141            }
142            TestEvent::ServerPoolBuilt { .. } | TestEvent::NearbyServersFound { .. } => {
143                self.phase = Phase::Servers;
144            }
145            TestEvent::ServersSelected { .. } => self.phase = Phase::Servers,
146            TestEvent::PrimarySelected { name, location, .. } => {
147                self.server = Some(format!("{name} · {location}").into());
148                self.phase = Phase::Servers;
149            }
150            TestEvent::PhaseStarted(phase) => {
151                use netrunner_core::Phase as CorePhase;
152                self.phase = match phase {
153                    CorePhase::Locating => Phase::Locating,
154                    CorePhase::BuildingServers | CorePhase::SelectingServers => Phase::Servers,
155                    CorePhase::Latency => Phase::Latency,
156                    CorePhase::Download => Phase::Download,
157                    CorePhase::Upload => Phase::Upload,
158                    CorePhase::Jitter => self.phase,
159                };
160                self.status = phase.title().to_string().into();
161            }
162            TestEvent::DownloadSample {
163                mbps, peak_mbps, ..
164            } => {
165                self.download_mbps = mbps as f32;
166                self.peak_download = peak_mbps as f32;
167                push_sample(&mut self.download_samples, mbps as f32);
168            }
169            TestEvent::UploadSample {
170                mbps, peak_mbps, ..
171            } => {
172                self.upload_mbps = mbps as f32;
173                self.peak_upload = peak_mbps as f32;
174                push_sample(&mut self.upload_samples, mbps as f32);
175            }
176            TestEvent::DownloadComplete { mbps } => self.download_mbps = mbps as f32,
177            TestEvent::UploadComplete { mbps } => self.upload_mbps = mbps as f32,
178            TestEvent::LatencyProgress { avg_ms } | TestEvent::LatencyComplete { avg_ms } => {
179                self.ping_ms = avg_ms as f32;
180            }
181            TestEvent::Completed(result) => {
182                self.download_mbps = result.download_mbps as f32;
183                self.upload_mbps = result.upload_mbps as f32;
184                self.ping_ms = result.ping_ms as f32;
185                self.phase = Phase::Done;
186                self.status = "Test complete.".into();
187                self.result = Some(*result);
188            }
189            TestEvent::JitterComplete { .. } | TestEvent::DiagnosticsComplete(_) => {}
190        }
191    }
192}
193
194impl Default for SpeedApp {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200fn push_sample(buf: &mut Vec<f32>, value: f32) {
201    buf.push(value);
202    if buf.len() > MAX_SAMPLES {
203        let overflow = buf.len() - MAX_SAMPLES;
204        buf.drain(0..overflow);
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use netrunner_core::Phase as CorePhase;
212
213    #[test]
214    fn apply_download_sample_tracks_current_and_peak() {
215        let mut app = SpeedApp::new();
216        app.apply(TestEvent::PhaseStarted(CorePhase::Download));
217        assert_eq!(app.phase, Phase::Download);
218
219        app.apply(TestEvent::DownloadSample {
220            mbps: 120.0,
221            peak_mbps: 120.0,
222            elapsed_secs: 1.0,
223        });
224        app.apply(TestEvent::DownloadSample {
225            mbps: 80.0,
226            peak_mbps: 120.0,
227            elapsed_secs: 2.0,
228        });
229
230        assert_eq!(app.download_samples.len(), 2);
231        assert_eq!(app.download_mbps, 80.0);
232        assert_eq!(app.peak_download, 120.0);
233    }
234
235    #[test]
236    fn sample_buffer_is_capped() {
237        let mut app = SpeedApp::new();
238        for i in 0..(MAX_SAMPLES + 50) {
239            app.apply(TestEvent::UploadSample {
240                mbps: i as f64,
241                peak_mbps: i as f64,
242                elapsed_secs: 0.0,
243            });
244        }
245        assert_eq!(app.upload_samples.len(), MAX_SAMPLES);
246        // Oldest samples were dropped, newest retained.
247        assert_eq!(
248            *app.upload_samples.last().unwrap(),
249            (MAX_SAMPLES + 49) as f32
250        );
251    }
252
253    #[test]
254    fn completed_populates_result_and_metrics() {
255        let mut app = SpeedApp::new();
256        let result = netrunner_core::SpeedTestResult {
257            download_mbps: 250.0,
258            upload_mbps: 40.0,
259            ping_ms: 12.0,
260            ..Default::default()
261        };
262
263        app.apply(TestEvent::Completed(Box::new(result)));
264
265        assert_eq!(app.phase, Phase::Done);
266        assert!(app.result.is_some());
267        assert_eq!(app.download_mbps, 250.0);
268        assert_eq!(app.upload_mbps, 40.0);
269        assert_eq!(app.ping_ms, 12.0);
270    }
271
272    #[test]
273    fn location_event_is_formatted() {
274        let mut app = SpeedApp::new();
275        app.apply(TestEvent::LocationDetected {
276            city: "Berlin".into(),
277            country: "Germany".into(),
278            isp: Some("Example ISP".into()),
279            source: "ipapi.co".into(),
280        });
281        assert_eq!(
282            app.location.as_ref().map(|s| s.to_string()),
283            Some("Berlin, Germany".to_string())
284        );
285        assert_eq!(
286            app.isp.as_ref().map(|s| s.to_string()),
287            Some("Example ISP".to_string())
288        );
289    }
290}