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; 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 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
268fn 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
308fn 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}