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