Skip to main content

flashkraft_gui/core/
state.rs

1//! Application State Module
2//!
3//! NOTE: `hotplug_stream` is defined at the bottom of this file as a free
4//! function so it can be passed as a `fn() -> impl Stream` pointer to
5//! `Subscription::run`.
6//!
7//! ## Hotplug
8//!
9//! [`FlashKraft::subscription`] includes a persistent USB hotplug watch
10//! powered by `watch_usb_events()` (inotify on Linux, FSEvents on macOS,
11//! ReadDirectoryChangesW on Windows — no elevated privileges required).
12//! When any block device is connected or disconnected the subscription emits
13//! [`Message::UsbHotplugDetected`], which `update.rs` handles by re-running
14//! drive detection — identical to the user pressing Refresh, but automatic.
15//!
16//! This module contains the main application state (FlashKraft struct)
17//! which represents the complete state of the application at any point in time.
18//!
19//! Following The Elm Architecture, this module also implements the core
20//! application methods: update, view, and subscription.
21
22use crate::core::flash_runner::FlashProgress;
23use crate::core::storage::Storage;
24use crate::core::{update, Message};
25use crate::domain::{DriveInfo, ImageInfo};
26use crate::ui;
27use crate::ui::components::animated_progress::AnimatedProgress;
28use flashkraft_core::commands::watch_usb_events;
29use futures::StreamExt as _;
30use iced::Color;
31use iced::{stream, Element, Subscription, Task, Theme};
32use std::sync::{atomic::AtomicBool, Arc};
33
34/// The main application state
35///
36/// This struct represents the complete state of the FlashKraft application.
37/// All state is managed immutably and changes only through the `update` function.
38#[derive(Debug)]
39pub struct FlashKraft {
40    /// Currently selected image file
41    pub selected_image: Option<ImageInfo>,
42
43    /// Currently selected target drive
44    pub selected_target: Option<DriveInfo>,
45
46    /// List of available drives detected on the system
47    pub available_drives: Vec<DriveInfo>,
48
49    /// Current flash progress (0.0 to 1.0), None if not flashing
50    pub flash_progress: Option<f32>,
51
52    /// Bytes written during flash operation
53    pub flash_bytes_written: u64,
54
55    /// Current transfer speed in MB/s
56    pub flash_speed_mb_s: f32,
57
58    /// Current pipeline stage label (e.g. "Writing image to device…", "Verifying written data…")
59    pub flash_stage: String,
60
61    /// Verification overall progress (0.0–1.0 across both image-hash and device read-back passes).
62    /// `None` when verification has not started yet.
63    pub verify_progress: Option<f32>,
64
65    /// Current verification read speed in MB/s (0.0 when not verifying).
66    pub verify_speed_mb_s: f32,
67
68    /// Which verification pass is active: `"image"` or `"device"`. Empty when not verifying.
69    pub verify_phase: &'static str,
70
71    /// Error message if an error occurred
72    pub error_message: Option<String>,
73
74    /// Whether the device selection view is currently open
75    pub device_selection_open: bool,
76
77    /// Whether a flash operation is currently active (for subscription).
78    /// Remains true through the entire pipeline including verification —
79    /// only set to false when `FlashCompleted` arrives.
80    pub flashing_active: bool,
81
82    /// Set to true when `FlashCompleted(Ok(()))` arrives.
83    /// Distinct from `flashing_active` so the view can show the complete
84    /// screen without killing the subscription prematurely.
85    pub flash_complete: bool,
86
87    /// Cancellation token for flash operation
88    pub flash_cancel_token: Arc<AtomicBool>,
89
90    /// Monotonically increasing counter incremented on every new flash attempt.
91    /// Included in the subscription ID hash so that flashing the same image to
92    /// the same device a second time always creates a fresh subscription instead
93    /// of reusing the completed (pending) one from the previous run.
94    pub flash_run_id: u64,
95
96    /// Currently selected theme
97    pub theme: Theme,
98
99    /// Storage for persistent preferences
100    pub storage: Option<Storage>,
101
102    /// Animated progress bar for flash operations
103    pub animated_progress: AnimatedProgress,
104
105    /// Separate green animated progress bar shown during verification.
106    pub verify_animated_progress: AnimatedProgress,
107
108    /// Animation time for progress line glow effects (0.0 to infinity)
109    pub animation_time: f32,
110}
111
112impl FlashKraft {
113    /// Create a new FlashKraft instance with default values
114    pub fn new() -> Self {
115        // Try to initialize storage and load saved theme
116        let storage = Storage::new().ok();
117        let theme = storage
118            .as_ref()
119            .and_then(|s| s.load_theme())
120            .unwrap_or_else(|| {
121                crate::core::storage::custom_theme_from_core(
122                    "Default",
123                    &flashkraft_core::theme_by_index(0),
124                )
125            });
126
127        // Initialize animated progress with theme
128        let mut animated_progress = AnimatedProgress::new();
129        animated_progress.set_theme(theme.clone());
130
131        // Verification bar is always green regardless of theme.
132        let verify_animated_progress =
133            AnimatedProgress::new_with_color(Color::from_rgb(0.18, 0.78, 0.45));
134
135        Self {
136            selected_image: None,
137            selected_target: None,
138            available_drives: Vec::new(),
139            flash_progress: None,
140            flash_bytes_written: 0,
141            flash_speed_mb_s: 0.0,
142            flash_stage: String::new(),
143            verify_progress: None,
144            verify_speed_mb_s: 0.0,
145            verify_phase: "",
146            error_message: None,
147            device_selection_open: false,
148            flashing_active: false,
149            flash_complete: false,
150            flash_cancel_token: Arc::new(AtomicBool::new(false)),
151            flash_run_id: 0,
152            theme,
153            storage,
154            animated_progress,
155            verify_animated_progress,
156            animation_time: 0.0,
157        }
158    }
159
160    /// Check if the application is ready to flash
161    ///
162    /// Returns true if both an image and target are selected
163    pub fn is_ready_to_flash(&self) -> bool {
164        self.selected_image.is_some() && self.selected_target.is_some()
165    }
166
167    /// Check if a flash operation is currently in progress
168    pub fn is_flashing(&self) -> bool {
169        self.flash_progress.is_some()
170    }
171
172    /// Check if the flash operation is complete (pipeline finished successfully).
173    pub fn is_flash_complete(&self) -> bool {
174        self.flash_complete
175    }
176
177    /// Check if there is an error
178    pub fn has_error(&self) -> bool {
179        self.error_message.is_some()
180    }
181
182    /// Reset the application state
183    pub fn reset(&mut self) {
184        self.selected_image = None;
185        self.selected_target = None;
186        self.flash_progress = None;
187        self.flash_bytes_written = 0;
188        self.flash_speed_mb_s = 0.0;
189        self.flash_stage = String::new();
190        self.verify_progress = None;
191        self.verify_speed_mb_s = 0.0;
192        self.verify_phase = "";
193        self.error_message = None;
194        self.device_selection_open = false;
195        self.flashing_active = false;
196        self.flash_complete = false;
197        self.flash_cancel_token = Arc::new(AtomicBool::new(false));
198        // Do NOT reset flash_run_id — it must keep incrementing across resets
199        // so that a re-flash after Reset always gets a fresh subscription.
200    }
201
202    /// Cancel current selections
203    pub fn cancel_selections(&mut self) {
204        self.reset();
205    }
206
207    /// Prepare state for a new flash attempt.
208    pub fn begin_flash_state(&mut self) {
209        self.flash_cancel_token = Arc::new(AtomicBool::new(false));
210        self.flash_run_id = self.flash_run_id.wrapping_add(1);
211        self.flash_progress = Some(0.0);
212        self.error_message = None;
213        self.flashing_active = true;
214        self.flash_complete = false;
215    }
216}
217
218impl Default for FlashKraft {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224// ============================================================================
225// Elm Architecture Implementation
226// ============================================================================
227
228impl FlashKraft {
229    /// Update the application state based on a message
230    ///
231    /// This is the core of The Elm Architecture. All state changes
232    /// flow through this function.
233    ///
234    /// # Arguments
235    ///
236    /// * `message` - The message to process
237    ///
238    /// # Returns
239    ///
240    /// A Task that may trigger async operations, or Task::none()
241    pub fn update(&mut self, message: Message) -> Task<Message> {
242        // Optionally log messages for debugging (exclude AnimationTick to avoid spam)
243        if !matches!(message, Message::AnimationTick) {
244            #[cfg(debug_assertions)]
245            println!("[DEBUG] Message: {:?}", message);
246        }
247
248        // Delegate to the update function
249        update::update(self, message)
250    }
251
252    /// Render the user interface
253    ///
254    /// This is a pure function that describes what the UI should look
255    /// like based on the current state.
256    ///
257    /// # Returns
258    ///
259    /// An Element describing the UI to render
260    pub fn view(&self) -> Element<'_, Message> {
261        // Delegate to the view function
262        ui::view(self)
263    }
264
265    /// Subscribe to long-running operations
266    ///
267    /// This enables streaming progress updates from the flash operation,
268    /// animation ticks for the progress bar, and automatic USB hotplug
269    /// detection (so the drive list updates without the user pressing Refresh).
270    ///
271    /// # Returns
272    ///
273    /// A Subscription that emits messages for ongoing operations
274    pub fn subscription(&self) -> Subscription<Message> {
275        let mut subscriptions = Vec::new();
276
277        // ── USB hotplug — always active ───────────────────────────────────────
278        //
279        // watch_usb_events() watches /sys/block via inotify on Linux, /dev via
280        // FSEvents on macOS, and a directory via ReadDirectoryChangesW on
281        // Windows.  No elevated privileges are required for any of these
282        // watches.  When the watch cannot be created (path missing or sandboxed)
283        // we simply produce no events rather than crashing.
284        let hotplug_sub = Subscription::run(hotplug_stream);
285        subscriptions.push(hotplug_sub);
286
287        // ── Flash progress ────────────────────────────────────────────────────
288        if self.flashing_active {
289            if let (Some(image), Some(target)) = (&self.selected_image, &self.selected_target) {
290                let flash_sub = crate::core::flash_runner::flash_progress(
291                    image.path.clone(),
292                    target.device_path.clone().into(),
293                    self.flash_cancel_token.clone(),
294                    self.flash_run_id,
295                )
296                .map(|progress| match progress {
297                    FlashProgress::Progress {
298                        progress,
299                        bytes_written,
300                        speed_mb_s,
301                    } => Message::FlashProgressUpdate(progress, bytes_written, speed_mb_s),
302                    FlashProgress::VerifyProgress {
303                        phase,
304                        overall,
305                        bytes_read,
306                        total_bytes,
307                        speed_mb_s,
308                    } => Message::VerifyProgressUpdate(
309                        overall,
310                        phase,
311                        bytes_read,
312                        total_bytes,
313                        speed_mb_s,
314                    ),
315                    FlashProgress::Message(msg) => Message::Status(msg),
316                    FlashProgress::Completed => Message::FlashCompleted(Ok(())),
317                    FlashProgress::Failed(err) => Message::FlashCompleted(Err(err)),
318                });
319                subscriptions.push(flash_sub);
320            }
321        }
322
323        // Animation tick — always active for progress-line glow and spinner effects
324        subscriptions.push(iced::window::frames().map(|_| Message::AnimationTick));
325
326        Subscription::batch(subscriptions)
327    }
328}
329
330/// Free function that creates the USB hotplug event stream.
331///
332/// Defined as a named `fn` (not a closure) so it can be passed directly to
333/// [`Subscription::run`], which requires a `fn() -> S` pointer.
334fn hotplug_stream() -> impl futures::Stream<Item = Message> {
335    stream::channel(4, async |mut output| {
336        use futures::SinkExt as _;
337        match watch_usb_events() {
338            Ok(mut events) => {
339                while let Some(_event) = events.next().await {
340                    let _ = output.send(Message::UsbHotplugDetected).await;
341                }
342            }
343            Err(e) => {
344                eprintln!("[hotplug] watch_usb_events failed: {e}");
345                // Park the future so the subscription stays alive but
346                // silent — avoids Iced restarting it in a tight loop.
347                std::future::pending::<()>().await;
348            }
349        }
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::path::PathBuf;
357
358    #[test]
359    fn test_new_state() {
360        let state = FlashKraft::new();
361        assert!(state.selected_image.is_none());
362        assert!(state.selected_target.is_none());
363        assert!(state.available_drives.is_empty());
364        assert!(!state.is_ready_to_flash());
365        assert!(!state.device_selection_open);
366    }
367
368    #[test]
369    fn test_is_ready_to_flash() {
370        let mut state = FlashKraft::new();
371        assert!(!state.is_ready_to_flash());
372
373        state.selected_image = Some(ImageInfo {
374            path: PathBuf::from("/tmp/test.img"),
375            name: "test.img".to_string(),
376            size_mb: 100.0,
377        });
378        assert!(!state.is_ready_to_flash());
379
380        state.selected_target = Some(DriveInfo::new(
381            "USB".to_string(),
382            "/media/usb".to_string(),
383            32.0,
384            "/dev/sdb".to_string(),
385        ));
386        assert!(state.is_ready_to_flash());
387    }
388
389    #[test]
390    fn test_is_flashing() {
391        let mut state = FlashKraft::new();
392        assert!(!state.is_flashing());
393
394        state.flash_progress = Some(0.5);
395        assert!(state.is_flashing());
396    }
397
398    #[test]
399    fn test_reset() {
400        let mut state = FlashKraft::new();
401        state.selected_image = Some(ImageInfo {
402            path: PathBuf::from("/tmp/test.img"),
403            name: "test.img".to_string(),
404            size_mb: 100.0,
405        });
406        state.flash_progress = Some(0.5);
407        state.error_message = Some("Error".to_string());
408        state.device_selection_open = true;
409
410        state.reset();
411
412        assert!(state.selected_image.is_none());
413        assert!(state.flash_progress.is_none());
414        assert!(state.error_message.is_none());
415        assert!(!state.device_selection_open);
416    }
417}