1use std::{
29 cell::{Cell, RefCell},
30 collections::HashMap,
31 future::Future,
32 rc::Rc,
33};
34
35use cranpose_core::{RuntimeHandle, current_runtime_handle};
36use cranpose_macros::composable;
37
38use crate::{
39 content::{ContentFolderRef, ContentHandle, ContentSinkRef},
40 file_picker::{
41 FilePickerError, FilePickerOptions, FilePickerRef, RecoveredPick, SaveDocumentRequest,
42 local_file_picker,
43 },
44 preferences::preferences,
45};
46
47pub type LauncherResult<T> = Result<T, FilePickerError>;
52
53thread_local! {
54 static RECOVERED: RefCell<HashMap<String, RecoveredPick>> = RefCell::new(HashMap::new());
55 static IN_FLIGHT: RefCell<Option<String>> = const { RefCell::new(None) };
56 static REGISTERED_KEYS: RefCell<HashMap<String, usize>> = RefCell::new(HashMap::new());
57}
58
59const IN_FLIGHT_PREFERENCE: &str = "cranpose.launcher.in-flight";
60
61fn begin_request(request_key: &str) {
62 IN_FLIGHT.with(|slot| *slot.borrow_mut() = Some(request_key.to_string()));
63 let _ = preferences().set(IN_FLIGHT_PREFERENCE, request_key);
64}
65
66fn finish_request(request_key: &str) {
67 IN_FLIGHT.with(|slot| {
68 let mut slot = slot.borrow_mut();
69 if slot.as_deref() == Some(request_key) {
70 *slot = None;
71 }
72 });
73 if preferences().get(IN_FLIGHT_PREFERENCE).as_deref() == Some(request_key) {
74 let _ = preferences().remove(IN_FLIGHT_PREFERENCE);
75 }
76}
77
78fn in_flight_request() -> Option<String> {
79 IN_FLIGHT
80 .with(|slot| slot.borrow().clone())
81 .or_else(|| preferences().get(IN_FLIGHT_PREFERENCE))
82}
83
84fn drain_recovered(picker: &FilePickerRef) {
85 while let Some(pick) = picker.take_recovered_pick() {
86 let Some(key) = in_flight_request() else {
87 log::warn!("cranpose: a recovered pick arrived with no request in flight; dropping it");
88 continue;
89 };
90 RECOVERED.with(|inbox| inbox.borrow_mut().insert(key, pick));
91 }
92}
93
94fn take_recovered(request_key: &str) -> Option<RecoveredPick> {
95 let recovered = RECOVERED.with(|inbox| inbox.borrow_mut().remove(request_key));
96 if recovered.is_some() {
97 finish_request(request_key);
98 }
99 recovered
100}
101
102pub fn clear_launcher_state() {
105 RECOVERED.with(|inbox| inbox.borrow_mut().clear());
106 IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
107 REGISTERED_KEYS.with(|keys| keys.borrow_mut().clear());
108}
109
110struct LauncherCore {
111 request_key: String,
112 picker: FilePickerRef,
113 runtime: Option<RuntimeHandle>,
114 in_flight: Cell<bool>,
115}
116
117impl LauncherCore {
118 fn spawn(self: &Rc<Self>, future: impl Future<Output = ()> + 'static) {
119 let Some(runtime) = self.runtime.clone() else {
120 log::warn!(
121 "cranpose: launcher `{}` has no runtime; the chooser was not presented",
122 self.request_key
123 );
124 self.in_flight.set(false);
125 finish_request(&self.request_key);
126 return;
127 };
128 if runtime.spawn_ui(future).is_none() {
129 log::warn!(
130 "cranpose: launcher `{}` outlived its runtime; the chooser was not presented",
131 self.request_key
132 );
133 self.in_flight.set(false);
134 finish_request(&self.request_key);
135 }
136 }
137
138 fn begin(self: &Rc<Self>) -> bool {
139 if self.in_flight.get() {
140 return false;
141 }
142 self.in_flight.set(true);
143 begin_request(&self.request_key);
144 true
145 }
146
147 fn end(self: &Rc<Self>) {
148 self.in_flight.set(false);
149 finish_request(&self.request_key);
150 }
151}
152
153struct KeyRegistration {
154 request_key: String,
155}
156
157impl KeyRegistration {
158 fn new(request_key: &str) -> Self {
159 REGISTERED_KEYS.with(|keys| {
160 let mut keys = keys.borrow_mut();
161 let count = keys.entry(request_key.to_string()).or_insert(0);
162 *count += 1;
163 debug_assert!(
164 *count == 1,
165 "launcher request key `{request_key}` is registered {count} times; \
166 keys must be unique so a recovered result reaches the right launcher"
167 );
168 });
169 Self {
170 request_key: request_key.to_string(),
171 }
172 }
173}
174
175impl Drop for KeyRegistration {
176 fn drop(&mut self) {
177 REGISTERED_KEYS.with(|keys| {
178 let mut keys = keys.borrow_mut();
179 if let Some(count) = keys.get_mut(&self.request_key) {
180 *count -= 1;
181 if *count == 0 {
182 keys.remove(&self.request_key);
183 }
184 }
185 });
186 }
187}
188
189struct LauncherSlot {
190 core: Rc<LauncherCore>,
191 _registration: KeyRegistration,
192}
193
194#[track_caller]
195fn remember_core(
196 request_key: &'static str,
197 deliver: impl FnOnce(RecoveredPick) + 'static,
198) -> Rc<LauncherCore> {
199 let picker = local_file_picker().current();
200 let slot = cranpose_core::remember({
201 let picker = picker.clone();
202 let request_key = request_key.to_string();
203 move || LauncherSlot {
204 core: Rc::new(LauncherCore {
205 request_key: request_key.clone(),
206 picker,
207 runtime: current_runtime_handle(),
208 in_flight: Cell::new(false),
209 }),
210 _registration: KeyRegistration::new(&request_key),
211 }
212 });
213 let core = slot.with(|slot| Rc::clone(&slot.core));
214
215 drain_recovered(&picker);
216 if let Some(recovered) = take_recovered(request_key) {
217 let core = Rc::clone(&core);
218 cranpose_core::SideEffect(move || {
219 core.end();
220 deliver(recovered);
221 });
222 }
223 core
224}
225
226#[derive(Clone)]
228pub struct OpenFileLauncher {
229 core: Rc<LauncherCore>,
230 on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)>,
231}
232
233impl OpenFileLauncher {
234 pub fn launch(&self, options: FilePickerOptions) {
236 if !self.core.begin() {
237 return;
238 }
239 let core = Rc::clone(&self.core);
240 let done = Rc::clone(&self.core);
241 let on_result = Rc::clone(&self.on_result);
242 let future = core.picker.pick_file(options);
243 core.spawn(async move {
244 let result = future.await;
245 done.end();
246 on_result(result);
247 });
248 }
249
250 pub fn is_in_flight(&self) -> bool {
252 self.core.in_flight.get()
253 }
254}
255
256#[allow(non_snake_case)]
258#[composable]
259#[track_caller]
260pub fn rememberOpenFileLauncher(
261 request_key: &'static str,
262 on_result: impl Fn(LauncherResult<Option<ContentHandle>>) + 'static,
263) -> OpenFileLauncher {
264 let on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)> = Rc::new(on_result);
265 let core = remember_core(request_key, {
266 let on_result = Rc::clone(&on_result);
267 move |recovered| match recovered {
268 RecoveredPick::File(content) => on_result(Ok(Some(content))),
269 RecoveredPick::Files(mut files) => on_result(Ok(files.drain(..).next())),
270 _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
271 }
272 });
273 OpenFileLauncher { core, on_result }
274}
275
276#[derive(Clone)]
278pub struct OpenFilesLauncher {
279 core: Rc<LauncherCore>,
280 on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)>,
281}
282
283impl OpenFilesLauncher {
284 pub fn launch(&self, options: FilePickerOptions) {
286 if !self.core.begin() {
287 return;
288 }
289 let core = Rc::clone(&self.core);
290 let done = Rc::clone(&self.core);
291 let on_result = Rc::clone(&self.on_result);
292 let future = core.picker.pick_files(options);
293 core.spawn(async move {
294 let result = future.await;
295 done.end();
296 on_result(result);
297 });
298 }
299
300 pub fn is_in_flight(&self) -> bool {
302 self.core.in_flight.get()
303 }
304}
305
306#[allow(non_snake_case)]
308#[composable]
309#[track_caller]
310pub fn rememberOpenFilesLauncher(
311 request_key: &'static str,
312 on_result: impl Fn(LauncherResult<Vec<ContentHandle>>) + 'static,
313) -> OpenFilesLauncher {
314 let on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)> = Rc::new(on_result);
315 let core = remember_core(request_key, {
316 let on_result = Rc::clone(&on_result);
317 move |recovered| match recovered {
318 RecoveredPick::Files(files) => on_result(Ok(files)),
319 RecoveredPick::File(content) => on_result(Ok(vec![content])),
320 _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
321 }
322 });
323 OpenFilesLauncher { core, on_result }
324}
325
326#[derive(Clone)]
328pub struct OpenFolderLauncher {
329 core: Rc<LauncherCore>,
330 on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)>,
331}
332
333impl OpenFolderLauncher {
334 pub fn launch(&self, options: FilePickerOptions) {
336 if !self.core.begin() {
337 return;
338 }
339 let core = Rc::clone(&self.core);
340 let done = Rc::clone(&self.core);
341 let on_result = Rc::clone(&self.on_result);
342 let future = core.picker.pick_folder(options);
343 core.spawn(async move {
344 let result = future.await;
345 done.end();
346 on_result(result);
347 });
348 }
349
350 pub fn is_in_flight(&self) -> bool {
352 self.core.in_flight.get()
353 }
354}
355
356#[allow(non_snake_case)]
358#[composable]
359#[track_caller]
360pub fn rememberOpenFolderLauncher(
361 request_key: &'static str,
362 on_result: impl Fn(LauncherResult<Option<ContentFolderRef>>) + 'static,
363) -> OpenFolderLauncher {
364 let on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)> = Rc::new(on_result);
365 let core = remember_core(request_key, {
366 let on_result = Rc::clone(&on_result);
367 move |recovered| match recovered {
368 RecoveredPick::Folder(folder) => on_result(Ok(Some(folder))),
369 _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
370 }
371 });
372 OpenFolderLauncher { core, on_result }
373}
374
375#[derive(Clone)]
377pub struct SaveDocumentLauncher {
378 core: Rc<LauncherCore>,
379 on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)>,
380}
381
382impl SaveDocumentLauncher {
383 pub fn launch(&self, request: SaveDocumentRequest) {
385 if !self.core.begin() {
386 return;
387 }
388 let core = Rc::clone(&self.core);
389 let done = Rc::clone(&self.core);
390 let on_result = Rc::clone(&self.on_result);
391 let future = core.picker.save_document(request);
392 core.spawn(async move {
393 let result = future.await;
394 done.end();
395 on_result(result);
396 });
397 }
398
399 pub fn is_in_flight(&self) -> bool {
401 self.core.in_flight.get()
402 }
403}
404
405#[allow(non_snake_case)]
407#[composable]
408#[track_caller]
409pub fn rememberSaveDocumentLauncher(
410 request_key: &'static str,
411 on_result: impl Fn(LauncherResult<Option<ContentSinkRef>>) + 'static,
412) -> SaveDocumentLauncher {
413 let on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)> = Rc::new(on_result);
414 let core = remember_core(request_key, move |_recovered| {
415 log::warn!("cranpose: `{request_key}` recovered a pick of another kind");
416 });
417 SaveDocumentLauncher { core, on_result }
418}
419
420#[derive(Clone)]
424pub struct WritableFolderLauncher {
425 core: Rc<LauncherCore>,
426 on_result: Rc<dyn Fn(LauncherResult<Option<String>>)>,
427}
428
429impl WritableFolderLauncher {
430 pub fn launch(&self, options: FilePickerOptions) {
432 if !self.core.begin() {
433 return;
434 }
435 let core = Rc::clone(&self.core);
436 let done = Rc::clone(&self.core);
437 let on_result = Rc::clone(&self.on_result);
438 let future = core.picker.pick_writable_folder(options);
439 core.spawn(async move {
440 let result = future.await;
441 done.end();
442 on_result(result);
443 });
444 }
445
446 pub fn is_in_flight(&self) -> bool {
448 self.core.in_flight.get()
449 }
450}
451
452#[allow(non_snake_case)]
454#[composable]
455#[track_caller]
456pub fn rememberWritableFolderLauncher(
457 request_key: &'static str,
458 on_result: impl Fn(LauncherResult<Option<String>>) + 'static,
459) -> WritableFolderLauncher {
460 let on_result: Rc<dyn Fn(LauncherResult<Option<String>>)> = Rc::new(on_result);
461 let core = remember_core(request_key, {
462 let on_result = Rc::clone(&on_result);
463 move |recovered| match recovered {
464 RecoveredPick::WritableFolder(handle) => on_result(Ok(Some(handle))),
465 _ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
466 }
467 });
468 WritableFolderLauncher { core, on_result }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn an_in_flight_request_outlives_the_process_that_started_it() {
477 crate::preferences::set_platform_preferences(std::sync::Arc::new(
478 crate::preferences::MemoryPreferences::new(),
479 ));
480
481 begin_request("test.pick");
482 assert_eq!(in_flight_request().as_deref(), Some("test.pick"));
483
484 IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
485 assert_eq!(
486 in_flight_request().as_deref(),
487 Some("test.pick"),
488 "a restarted process must still know which request was outstanding"
489 );
490
491 finish_request("test.other");
492 assert_eq!(
493 in_flight_request().as_deref(),
494 Some("test.pick"),
495 "one launcher resolving must not clear a different launcher's record"
496 );
497
498 finish_request("test.pick");
499 assert_eq!(
500 in_flight_request(),
501 None,
502 "a resolved request is not still in flight"
503 );
504
505 crate::preferences::clear_platform_preferences();
506 }
507}