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