use std::{
cell::{Cell, RefCell},
collections::HashMap,
future::Future,
rc::Rc,
};
use cranpose_core::{RuntimeHandle, current_runtime_handle};
use cranpose_macros::composable;
use crate::{
content::{ContentFolderRef, ContentHandle, ContentSinkRef},
file_picker::{
FilePickerError, FilePickerOptions, FilePickerRef, RecoveredPick, SaveDocumentRequest,
local_file_picker,
},
preferences::preferences,
};
pub type LauncherResult<T> = Result<T, FilePickerError>;
thread_local! {
static RECOVERED: RefCell<HashMap<String, RecoveredPick>> = RefCell::new(HashMap::new());
static IN_FLIGHT: RefCell<Option<String>> = const { RefCell::new(None) };
static REGISTERED_KEYS: RefCell<HashMap<String, usize>> = RefCell::new(HashMap::new());
}
const IN_FLIGHT_PREFERENCE: &str = "cranpose.launcher.in-flight";
fn begin_request(request_key: &str) {
IN_FLIGHT.with(|slot| *slot.borrow_mut() = Some(request_key.to_string()));
let _ = preferences().set(IN_FLIGHT_PREFERENCE, request_key);
}
fn finish_request(request_key: &str) {
IN_FLIGHT.with(|slot| {
let mut slot = slot.borrow_mut();
if slot.as_deref() == Some(request_key) {
*slot = None;
}
});
if preferences().get(IN_FLIGHT_PREFERENCE).as_deref() == Some(request_key) {
let _ = preferences().remove(IN_FLIGHT_PREFERENCE);
}
}
fn in_flight_request() -> Option<String> {
IN_FLIGHT
.with(|slot| slot.borrow().clone())
.or_else(|| preferences().get(IN_FLIGHT_PREFERENCE))
}
fn drain_recovered(picker: &FilePickerRef) {
while let Some(pick) = picker.take_recovered_pick() {
let Some(key) = in_flight_request() else {
log::warn!("cranpose: a recovered pick arrived with no request in flight; dropping it");
continue;
};
RECOVERED.with(|inbox| inbox.borrow_mut().insert(key, pick));
}
}
fn take_recovered(request_key: &str) -> Option<RecoveredPick> {
let recovered = RECOVERED.with(|inbox| inbox.borrow_mut().remove(request_key));
if recovered.is_some() {
finish_request(request_key);
}
recovered
}
pub fn clear_launcher_state() {
RECOVERED.with(|inbox| inbox.borrow_mut().clear());
IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
REGISTERED_KEYS.with(|keys| keys.borrow_mut().clear());
}
struct LauncherCore {
request_key: String,
picker: FilePickerRef,
runtime: Option<RuntimeHandle>,
in_flight: Cell<bool>,
}
impl LauncherCore {
fn spawn(self: &Rc<Self>, future: impl Future<Output = ()> + 'static) {
let Some(runtime) = self.runtime.clone() else {
log::warn!(
"cranpose: launcher `{}` has no runtime; the chooser was not presented",
self.request_key
);
self.in_flight.set(false);
finish_request(&self.request_key);
return;
};
if runtime.spawn_ui(future).is_none() {
log::warn!(
"cranpose: launcher `{}` outlived its runtime; the chooser was not presented",
self.request_key
);
self.in_flight.set(false);
finish_request(&self.request_key);
}
}
fn begin(self: &Rc<Self>) -> bool {
if self.in_flight.get() {
return false;
}
self.in_flight.set(true);
begin_request(&self.request_key);
true
}
fn end(self: &Rc<Self>) {
self.in_flight.set(false);
finish_request(&self.request_key);
}
}
struct KeyRegistration {
request_key: String,
}
impl KeyRegistration {
fn new(request_key: &str) -> Self {
REGISTERED_KEYS.with(|keys| {
let mut keys = keys.borrow_mut();
let count = keys.entry(request_key.to_string()).or_insert(0);
*count += 1;
debug_assert!(
*count == 1,
"launcher request key `{request_key}` is registered {count} times; \
keys must be unique so a recovered result reaches the right launcher"
);
});
Self {
request_key: request_key.to_string(),
}
}
}
impl Drop for KeyRegistration {
fn drop(&mut self) {
REGISTERED_KEYS.with(|keys| {
let mut keys = keys.borrow_mut();
if let Some(count) = keys.get_mut(&self.request_key) {
*count -= 1;
if *count == 0 {
keys.remove(&self.request_key);
}
}
});
}
}
struct LauncherSlot {
core: Rc<LauncherCore>,
_registration: KeyRegistration,
}
#[track_caller]
fn remember_core(
request_key: &'static str,
deliver: impl FnOnce(RecoveredPick) + 'static,
) -> Rc<LauncherCore> {
let picker = local_file_picker().current();
let slot = cranpose_core::remember({
let picker = picker.clone();
let request_key = request_key.to_string();
move || LauncherSlot {
core: Rc::new(LauncherCore {
request_key: request_key.clone(),
picker,
runtime: current_runtime_handle(),
in_flight: Cell::new(false),
}),
_registration: KeyRegistration::new(&request_key),
}
});
let core = slot.with(|slot| Rc::clone(&slot.core));
drain_recovered(&picker);
if let Some(recovered) = take_recovered(request_key) {
let core = Rc::clone(&core);
cranpose_core::SideEffect(move || {
core.end();
deliver(recovered);
});
}
core
}
#[derive(Clone)]
pub struct OpenFileLauncher {
core: Rc<LauncherCore>,
on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)>,
}
impl OpenFileLauncher {
pub fn launch(&self, options: FilePickerOptions) {
if !self.core.begin() {
return;
}
let core = Rc::clone(&self.core);
let done = Rc::clone(&self.core);
let on_result = Rc::clone(&self.on_result);
let future = core.picker.pick_file(options);
core.spawn(async move {
let result = future.await;
done.end();
on_result(result);
});
}
pub fn is_in_flight(&self) -> bool {
self.core.in_flight.get()
}
}
#[composable]
#[track_caller]
pub fn rememberOpenFileLauncher(
request_key: &'static str,
on_result: impl Fn(LauncherResult<Option<ContentHandle>>) + 'static,
) -> OpenFileLauncher {
let on_result: Rc<dyn Fn(LauncherResult<Option<ContentHandle>>)> = Rc::new(on_result);
let core = remember_core(request_key, {
let on_result = Rc::clone(&on_result);
move |recovered| match recovered {
RecoveredPick::File(content) => on_result(Ok(Some(content))),
RecoveredPick::Files(mut files) => on_result(Ok(files.drain(..).next())),
_ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
}
});
OpenFileLauncher { core, on_result }
}
#[derive(Clone)]
pub struct OpenFilesLauncher {
core: Rc<LauncherCore>,
on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)>,
}
impl OpenFilesLauncher {
pub fn launch(&self, options: FilePickerOptions) {
if !self.core.begin() {
return;
}
let core = Rc::clone(&self.core);
let done = Rc::clone(&self.core);
let on_result = Rc::clone(&self.on_result);
let future = core.picker.pick_files(options);
core.spawn(async move {
let result = future.await;
done.end();
on_result(result);
});
}
pub fn is_in_flight(&self) -> bool {
self.core.in_flight.get()
}
}
#[composable]
#[track_caller]
pub fn rememberOpenFilesLauncher(
request_key: &'static str,
on_result: impl Fn(LauncherResult<Vec<ContentHandle>>) + 'static,
) -> OpenFilesLauncher {
let on_result: Rc<dyn Fn(LauncherResult<Vec<ContentHandle>>)> = Rc::new(on_result);
let core = remember_core(request_key, {
let on_result = Rc::clone(&on_result);
move |recovered| match recovered {
RecoveredPick::Files(files) => on_result(Ok(files)),
RecoveredPick::File(content) => on_result(Ok(vec![content])),
_ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
}
});
OpenFilesLauncher { core, on_result }
}
#[derive(Clone)]
pub struct OpenFolderLauncher {
core: Rc<LauncherCore>,
on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)>,
}
impl OpenFolderLauncher {
pub fn launch(&self, options: FilePickerOptions) {
if !self.core.begin() {
return;
}
let core = Rc::clone(&self.core);
let done = Rc::clone(&self.core);
let on_result = Rc::clone(&self.on_result);
let future = core.picker.pick_folder(options);
core.spawn(async move {
let result = future.await;
done.end();
on_result(result);
});
}
pub fn is_in_flight(&self) -> bool {
self.core.in_flight.get()
}
}
#[composable]
#[track_caller]
pub fn rememberOpenFolderLauncher(
request_key: &'static str,
on_result: impl Fn(LauncherResult<Option<ContentFolderRef>>) + 'static,
) -> OpenFolderLauncher {
let on_result: Rc<dyn Fn(LauncherResult<Option<ContentFolderRef>>)> = Rc::new(on_result);
let core = remember_core(request_key, {
let on_result = Rc::clone(&on_result);
move |recovered| match recovered {
RecoveredPick::Folder(folder) => on_result(Ok(Some(folder))),
_ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
}
});
OpenFolderLauncher { core, on_result }
}
#[derive(Clone)]
pub struct SaveDocumentLauncher {
core: Rc<LauncherCore>,
on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)>,
}
impl SaveDocumentLauncher {
pub fn launch(&self, request: SaveDocumentRequest) {
if !self.core.begin() {
return;
}
let core = Rc::clone(&self.core);
let done = Rc::clone(&self.core);
let on_result = Rc::clone(&self.on_result);
let future = core.picker.save_document(request);
core.spawn(async move {
let result = future.await;
done.end();
on_result(result);
});
}
pub fn is_in_flight(&self) -> bool {
self.core.in_flight.get()
}
}
#[composable]
#[track_caller]
pub fn rememberSaveDocumentLauncher(
request_key: &'static str,
on_result: impl Fn(LauncherResult<Option<ContentSinkRef>>) + 'static,
) -> SaveDocumentLauncher {
let on_result: Rc<dyn Fn(LauncherResult<Option<ContentSinkRef>>)> = Rc::new(on_result);
let core = remember_core(request_key, move |_recovered| {
log::warn!("cranpose: `{request_key}` recovered a pick of another kind");
});
SaveDocumentLauncher { core, on_result }
}
#[derive(Clone)]
pub struct WritableFolderLauncher {
core: Rc<LauncherCore>,
on_result: Rc<dyn Fn(LauncherResult<Option<String>>)>,
}
impl WritableFolderLauncher {
pub fn launch(&self, options: FilePickerOptions) {
if !self.core.begin() {
return;
}
let core = Rc::clone(&self.core);
let done = Rc::clone(&self.core);
let on_result = Rc::clone(&self.on_result);
let future = core.picker.pick_writable_folder(options);
core.spawn(async move {
let result = future.await;
done.end();
on_result(result);
});
}
pub fn is_in_flight(&self) -> bool {
self.core.in_flight.get()
}
}
#[composable]
#[track_caller]
pub fn rememberWritableFolderLauncher(
request_key: &'static str,
on_result: impl Fn(LauncherResult<Option<String>>) + 'static,
) -> WritableFolderLauncher {
let on_result: Rc<dyn Fn(LauncherResult<Option<String>>)> = Rc::new(on_result);
let core = remember_core(request_key, {
let on_result = Rc::clone(&on_result);
move |recovered| match recovered {
RecoveredPick::WritableFolder(handle) => on_result(Ok(Some(handle))),
_ => log::warn!("cranpose: `{request_key}` recovered a pick of another kind"),
}
});
WritableFolderLauncher { core, on_result }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_in_flight_request_outlives_the_process_that_started_it() {
crate::preferences::set_platform_preferences(std::sync::Arc::new(
crate::preferences::MemoryPreferences::new(),
));
begin_request("test.pick");
assert_eq!(in_flight_request().as_deref(), Some("test.pick"));
IN_FLIGHT.with(|slot| *slot.borrow_mut() = None);
assert_eq!(
in_flight_request().as_deref(),
Some("test.pick"),
"a restarted process must still know which request was outstanding"
);
finish_request("test.other");
assert_eq!(
in_flight_request().as_deref(),
Some("test.pick"),
"one launcher resolving must not clear a different launcher's record"
);
finish_request("test.pick");
assert_eq!(
in_flight_request(),
None,
"a resolved request is not still in flight"
);
crate::preferences::clear_platform_preferences();
}
}