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