Skip to main content

holodeck_simctl_tui/
app.rs

1use std::future::Future;
2use std::time::Duration;
3
4use chrono::Local;
5use holodeck_core::{SimctlError, default_media_path};
6use holodeck_services::AppDependencies;
7use ratatui::DefaultTerminal;
8use ratatui::crossterm::event::{self, Event};
9use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
10use uuid::Uuid;
11
12use crate::input::map_key_event;
13use crate::state::{self, AppEvent, AppState, SideEffect};
14use crate::theme::Theme;
15use crate::view;
16
17pub struct HolodeckApp {
18    dependencies: AppDependencies,
19}
20
21impl HolodeckApp {
22    pub fn new(dependencies: AppDependencies) -> Self {
23        Self { dependencies }
24    }
25
26    pub fn live() -> Self {
27        Self::new(AppDependencies::live())
28    }
29
30    pub async fn run(&self) -> std::io::Result<()> {
31        let mut terminal = ratatui::init();
32        let result = self.run_loop(&mut terminal).await;
33        ratatui::restore();
34        if self.dependencies.recording_service.is_recording().await {
35            let _ = self.dependencies.recording_service.stop().await;
36        }
37        result
38    }
39
40    async fn run_loop(&self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
41        let (tx, mut rx) = unbounded_channel::<AppEvent>();
42
43        let input_tx = tx.clone();
44        std::thread::spawn(move || input_loop(input_tx));
45
46        let poll_interval = Duration::from_secs_f64(self.dependencies.configuration.poll_interval_seconds.max(0.1));
47        let poll_tx = tx.clone();
48        let poll_task = tokio::spawn(async move {
49            let mut ticker = tokio::time::interval(poll_interval);
50            ticker.tick().await; // first tick fires immediately; the initial Refresh below already covers it
51            loop {
52                ticker.tick().await;
53                if poll_tx.send(AppEvent::PollTick).is_err() {
54                    break;
55                }
56            }
57        });
58
59        let size = terminal.size()?;
60        let mut state = AppState { rows: i64::from(size.height), cols: i64::from(size.width), ..AppState::default() };
61        // Resolved once at launch, like the Swift TUI's other config reads
62        // (`pollIntervalSeconds`) — a theme change requires a restart.
63        let theme = Theme::from_name(self.dependencies.configuration.theme);
64
65        self.dispatch(SideEffect::Refresh, tx.clone());
66        self.dispatch(SideEffect::LoadUrlHistory, tx.clone());
67
68        terminal.draw(|frame| view::render(frame, &state, &theme))?;
69        let mut last_rendered = state.clone();
70
71        while let Some(event) = rx.recv().await {
72            let output = state::reduce(&state, event);
73            state = output.state;
74            for effect in output.effects {
75                self.dispatch(effect, tx.clone());
76            }
77            if state != last_rendered {
78                terminal.draw(|frame| view::render(frame, &state, &theme))?;
79                last_rendered = state.clone();
80            }
81            if state.is_quitting {
82                break;
83            }
84        }
85
86        poll_task.abort();
87        Ok(())
88    }
89
90    fn dispatch(&self, effect: SideEffect, tx: UnboundedSender<AppEvent>) {
91        let deps = &self.dependencies;
92        match effect {
93            SideEffect::Boot(id) => spawn_per_simulator(tx, id, {
94                let service = deps.simulator_service.clone();
95                move |id| async move { service.boot(id).await }
96            }),
97            SideEffect::Shutdown(id) => spawn_per_simulator(tx, id, {
98                let service = deps.simulator_service.clone();
99                move |id| async move { service.shutdown(id).await }
100            }),
101            SideEffect::EraseSimulator(id) => spawn_per_simulator(tx, id, {
102                let service = deps.simulator_service.clone();
103                move |id| async move { service.erase(id).await }
104            }),
105            SideEffect::DeleteSimulator(id) => spawn_per_simulator(tx, id, {
106                let service = deps.simulator_service.clone();
107                move |id| async move { service.delete(id).await }
108            }),
109
110            SideEffect::Refresh => {
111                let service = deps.simulator_service.clone();
112                spawn(tx, async move { service.list(false).await }, AppEvent::Refreshed, AppEvent::RefreshFailed);
113            }
114
115            SideEffect::StartRecording(id) => {
116                let recording_service = deps.recording_service.clone();
117                let output = default_media_path::record(&deps.configuration.resolved_screenshots_directory(), Local::now());
118                let codec = deps.configuration.video_codec;
119                let output_for_event = output.clone();
120                tokio::spawn(async move {
121                    match recording_service.start(id, &output, codec).await {
122                        Ok(()) => {
123                            let _ = tx.send(AppEvent::RecordingStarted(id, output_for_event));
124                        }
125                        Err(err) => {
126                            let _ = tx.send(AppEvent::RecordingFailed(err.to_string()));
127                        }
128                    }
129                });
130            }
131
132            SideEffect::StopRecording => {
133                let recording_service = deps.recording_service.clone();
134                tokio::spawn(async move {
135                    let path = recording_service.stop().await;
136                    let _ = tx.send(AppEvent::RecordingStopped(path));
137                });
138            }
139
140            SideEffect::CaptureScreenshot(id) => {
141                let screenshot_service = deps.screenshot_service.clone();
142                let screenshot_type = deps.configuration.screenshot_type;
143                let output = default_media_path::screenshot(
144                    &deps.configuration.resolved_screenshots_directory(),
145                    screenshot_type,
146                    Local::now(),
147                );
148                let output_for_event = output.clone();
149                tokio::spawn(async move {
150                    match screenshot_service.capture(id, &output, screenshot_type).await {
151                        Ok(()) => {
152                            let _ = tx.send(AppEvent::ScreenshotSaved(output_for_event));
153                        }
154                        Err(err) => {
155                            let _ = tx.send(AppEvent::ScreenshotFailed(err.to_string()));
156                        }
157                    }
158                });
159            }
160
161            SideEffect::SetAppearance(id, appearance) => {
162                let client = deps.simctl_client.clone();
163                tokio::spawn(async move {
164                    match client.set_appearance(id, appearance).await {
165                        Ok(()) => {
166                            let _ = tx.send(AppEvent::AppearanceChanged(id, appearance));
167                        }
168                        Err(err) => {
169                            let _ = tx.send(AppEvent::AppearanceFailed(err.to_string()));
170                        }
171                    }
172                });
173            }
174
175            SideEffect::LoadTargets => {
176                let service = deps.simulator_service.clone();
177                spawn(
178                    tx,
179                    async move { service.available_targets().await },
180                    |targets| AppEvent::TargetsLoaded { device_types: targets.device_types, runtimes: targets.runtimes },
181                    AppEvent::TargetsFailed,
182                );
183            }
184
185            SideEffect::CreateSimulator { name, device_type, runtime } => {
186                let service = deps.simulator_service.clone();
187                let name_for_event = name.clone();
188                tokio::spawn(async move {
189                    match service.create(&name, &device_type, &runtime).await {
190                        Ok(id) => {
191                            let _ = tx.send(AppEvent::SimulatorCreated(id, name_for_event));
192                        }
193                        Err(err) => {
194                            let _ = tx.send(AppEvent::SimulatorCreateFailed(err.to_string()));
195                        }
196                    }
197                });
198            }
199
200            SideEffect::FocusSimulator(id) => {
201                let service = deps.simulator_service.clone();
202                tokio::spawn(async move {
203                    let _ = service.focus(id).await;
204                });
205            }
206
207            SideEffect::LoadInstalledApps(id) => {
208                let service = deps.simulator_service.clone();
209                spawn(tx, async move { service.list_apps(id).await }, AppEvent::AppsLoaded, AppEvent::AppsLoadFailed);
210            }
211
212            SideEffect::ApplyPrivacy { udid, action, permission, bundle_id } => {
213                let client = deps.simctl_client.clone();
214                let bundle_id_for_event = bundle_id.clone();
215                tokio::spawn(async move {
216                    match client.privacy(udid, action, permission, Some(&bundle_id)).await {
217                        Ok(()) => {
218                            let _ = tx.send(AppEvent::PrivacyApplied { bundle_id: bundle_id_for_event });
219                        }
220                        Err(err) => {
221                            let _ = tx.send(AppEvent::PrivacyApplyFailed(err.to_string()));
222                        }
223                    }
224                });
225            }
226
227            SideEffect::LoadUrlHistory => {
228                let store = deps.url_history_store.clone();
229                tokio::spawn(async move {
230                    let _ = tx.send(AppEvent::UrlHistoryLoaded(store.load()));
231                });
232            }
233
234            SideEffect::OpenUrl { udid, url } => {
235                let client = deps.simctl_client.clone();
236                let store = deps.url_history_store.clone();
237                tokio::spawn(async move {
238                    match client.open_url(udid, &url).await {
239                        Ok(()) => {
240                            let history = store.record(&url).unwrap_or_else(|_| store.load());
241                            let _ = tx.send(AppEvent::UrlOpened { url, history });
242                        }
243                        Err(err) => {
244                            let _ = tx.send(AppEvent::UrlOpenFailed(err.to_string()));
245                        }
246                    }
247                });
248            }
249
250            SideEffect::LaunchApp { udid, bundle_id, language } => {
251                let client = deps.simctl_client.clone();
252                let bundle_id_for_event = bundle_id.clone();
253                tokio::spawn(async move {
254                    match client.launch_app(udid, &bundle_id, language.as_deref()).await {
255                        Ok(()) => {
256                            let _ = tx.send(AppEvent::AppLaunched { bundle_id: bundle_id_for_event });
257                        }
258                        Err(err) => {
259                            let _ = tx.send(AppEvent::AppLaunchFailed(err.to_string()));
260                        }
261                    }
262                });
263            }
264        }
265    }
266}
267
268/// One generic spawner replacing the ~18 near-identical `AppSpawn` helper
269/// functions in the Swift original.
270fn spawn<Fut, T>(
271    tx: UnboundedSender<AppEvent>,
272    work: Fut,
273    on_ok: impl FnOnce(T) -> AppEvent + Send + 'static,
274    on_err: impl FnOnce(String) -> AppEvent + Send + 'static,
275) where
276    Fut: Future<Output = Result<T, SimctlError>> + Send + 'static,
277    T: Send + 'static,
278{
279    tokio::spawn(async move {
280        match work.await {
281            Ok(value) => {
282                let _ = tx.send(on_ok(value));
283            }
284            Err(err) => {
285                let _ = tx.send(on_err(err.to_string()));
286            }
287        }
288    });
289}
290
291fn spawn_per_simulator<F, Fut>(tx: UnboundedSender<AppEvent>, id: Uuid, work: F)
292where
293    F: FnOnce(Uuid) -> Fut + Send + 'static,
294    Fut: Future<Output = Result<(), SimctlError>> + Send + 'static,
295{
296    tokio::spawn(async move {
297        match work(id).await {
298            Ok(()) => {
299                let _ = tx.send(AppEvent::OperationCompleted(id));
300            }
301            Err(err) => {
302                let _ = tx.send(AppEvent::OperationFailed(id, err.to_string()));
303            }
304        }
305    });
306}
307
308/// Blocking input loop, run on a plain OS thread rather than a tokio task
309/// since `crossterm::event::{poll, read}` are blocking calls. Sending on an
310/// `UnboundedSender` from outside the tokio runtime is fine — it's a plain
311/// non-blocking queue push.
312fn input_loop(tx: UnboundedSender<AppEvent>) {
313    loop {
314        let ready = match event::poll(Duration::from_millis(100)) {
315            Ok(ready) => ready,
316            Err(_) => return,
317        };
318        if !ready {
319            continue;
320        }
321        let Ok(ev) = event::read() else { return };
322        let mapped = match ev {
323            Event::Key(key_event) => map_key_event(key_event).map(AppEvent::Key),
324            Event::Resize(cols, rows) => Some(AppEvent::Resized { rows: i64::from(rows), cols: i64::from(cols) }),
325            _ => None,
326        };
327        if let Some(event) = mapped
328            && tx.send(event).is_err()
329        {
330            return;
331        }
332    }
333}