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}