platform: add native dialog lifecycle services

This commit is contained in:
skylartaylor authored and skylar taylor-barrick committed 2026-10-22 00:46:41 +02:00
1 parent ce89625521
commit ff19b46588
13 files changed
+860

No files matched your search

Generated
+7
View File
@@ -686,6 +686,13 @@ dependencies = [
"wasm-bindgen-test",
]
[[package]]
name = "craft-platform"
version = "0.1.0"
dependencies = [
"egui",
]
[[package]]
name = "craft-print"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs"
[workspace.dependencies]
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
craft-platform = { path = "crates/platform" }
craft-ai-core = { path = "crates/ai-core" }
craft-pdf = { path = "crates/pdf", version = "=0.1.0" }
krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
+7
View File
@@ -106,3 +106,10 @@ Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Cl
Their licences and per-file hashes are retained in vendor/hayro-interpret/assets,
licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets;
applications retain their own document-font selection and licensing policy.
Platform dialog lifecycle provenance
The dialog outcomes and lifecycle adapt preserved local platform candidate
9b135779a1e7136438a217c18afe1693ea23f1ab.
Copyright (c) 2026 ArtCraft Team and the craft-libs contributors.
All adapted source is licensed MIT OR Apache-2.0.
+2
View File
@@ -25,6 +25,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
## Libraries
- [`craft-platform`](crates/platform/README.md): optional native dialog lifecycle; application policy stays in the apps.
- [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md).
- [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "craft-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Small platform lifecycle services for the Craft suite"
[lints]
workspace = true
[features]
default = []
dialogs = []
egui = ["dep:egui"]
[dependencies]
egui = { workspace = true, optional = true }
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the craft-libs contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6
View File
@@ -0,0 +1,6 @@
Platform dialog lifecycle provenance
The dialog outcomes and lifecycle adapt preserved local platform candidate
9b135779a1e7136438a217c18afe1693ea23f1ab.
Copyright (c) 2026 ArtCraft Team and the craft-libs contributors.
All adapted source is licensed MIT OR Apache-2.0.
+42
View File
@@ -0,0 +1,42 @@
# craft-platform
Small platform lifecycle services for Craft applications. Application state, settings, palettes,
document policy and native command routing remain in their current owners. Defaults add no
third-party dependencies. Enable only the services a host uses.
| Feature | Shared responsibility |
| --- | --- |
| default | Typed dialog outcomes; no backend or toolkit. |
| `dialogs` | Reserve waiting workers before eager UI-thread native picker creation; serial requests, failure delivery and completion wakeups. The backend factory belongs to the app. |
| `egui` | Combine with `dialogs` for the deferred-close guard. |
## Native dialogs
`dialogs::wait_prepared(create, deliver)` reserves the worker first. Call it on the native UI thread.
`create` must call `pick_file`, `save_file`, or the equivalent backend method **before returning its
future**. Putting that call inside an async block delays native sheet creation until the worker
polls it. Worker reservation failure never opens the sheet. No live picker has a timeout.
`NativeDialogs<Request, Answer>` provides an optional serial queue. Pump it on the event-loop
thread before consuming answers and after code that can request a picker. Replies become visible
only after the pump releases transport ownership, so a continuation can enqueue the next picker.
Dropping a stale reply does not release the actual picker. A host-specific preflight is optional;
it runs off-thread and must be bounded. Unknown capability permits a picker. Proven absence may
request an application-owned fallback. Custom save-format sheets and portal choices remain intact.
`Outcome::Selected`, explicit `Cancelled`, `Failed` and `Indeterminate` have different evidence.
An rfd `None` is `Indeterminate`; it cannot prove user cancellation rather than backend failure.
Disconnected workers are failures. Apps own localization, document identity/revision checks, filters,
actual save receipts, chained continuations and the decision to close.
Retain the actual parent window through queued requests, preflight, the native picker AND any
unconsumed application continuation. `DeferredClose::update` cancels close during pending work and
returns a request to retry the application's normal close policy afterward. It never grants
permission to discard work. Run those guards before `craft_quit::on_close_request` in `App::logic`.
## Integration
Enable only the services a host uses. Applications retain palettes, settings, document identity,
close policy, filters, names, MIME types and native command routing. Existing craft-settings,
craft-atomic, craft-print, craft-docpath and craft-quit remain canonical.
+78
View File
@@ -0,0 +1,78 @@
//! Dialog results with evidence: an empty rfd answer does not establish user cancellation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Capability {
Available,
Unavailable(String),
Unknown(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ErrorKind {
Unavailable,
Worker,
Backend,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DialogError {
pub kind: ErrorKind,
pub message: String,
}
impl DialogError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
}
}
impl std::fmt::Display for DialogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for DialogError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome<T> {
Selected(T),
/// Explicit app/backend cancellation; never inferred from rfd's empty answer.
Cancelled,
Failed(DialogError),
/// No selection; rfd cannot say whether the user cancelled or its backend failed.
Indeterminate,
}
impl<T> Outcome<T> {
pub fn from_rfd(value: Option<T>) -> Self {
value.map_or(Self::Indeterminate, Self::Selected)
}
pub fn map<U>(self, map: impl FnOnce(T) -> U) -> Outcome<U> {
match self {
Self::Selected(value) => Outcome::Selected(map(value)),
Self::Cancelled => Outcome::Cancelled,
Self::Failed(error) => Outcome::Failed(error),
Self::Indeterminate => Outcome::Indeterminate,
}
}
pub fn failed(message: impl Into<String>) -> Self {
Self::Failed(DialogError::new(ErrorKind::Backend, message))
}
}
impl<T> Outcome<Vec<T>> {
pub fn from_rfd_files(value: Option<Vec<T>>) -> Self {
Self::from_rfd(value.filter(|paths| !paths.is_empty()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_and_empty_rfd_answers_never_establish_cancellation() {
assert_eq!(Outcome::<String>::from_rfd(None), Outcome::Indeterminate);
assert_eq!(Outcome::<Vec<String>>::from_rfd_files(Some(Vec::new())), Outcome::Indeterminate);
assert_eq!(Outcome::from_rfd_files(Some(vec!["a"])), Outcome::Selected(vec!["a"]));
for _capability in [Capability::Available, Capability::Unavailable("missing".into()), Capability::Unknown("timeout".into())] {
assert_eq!(Outcome::<String>::from_rfd(None), Outcome::Indeterminate);
}
}
#[test]
fn mapping_preserves_explicit_cancellation_and_failure() {
assert_eq!(Outcome::<u8>::Cancelled.map(u32::from), Outcome::Cancelled);
assert_eq!(Outcome::<u8>::failed("worker stopped").map(u32::from), Outcome::failed("worker stopped"));
assert_eq!(Outcome::Selected(7_u8).map(u32::from), Outcome::Selected(7));
}
}
+247
View File
@@ -0,0 +1,247 @@
//! UI-thread picker creation with reserved waiting workers. No backend or application policy.
//!
//! `create` must call the native picker method eagerly on the event-loop thread, returning its
//! already-created future. Hosts retain the parent through transport AND their continuation.
//! A missing backend answer is indeterminate; only explicit backend evidence proves cancellation.
use crate::dialog_outcome::{DialogError, ErrorKind, Outcome};
use std::{
future::Future,
pin::Pin,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, Sender, SyncSender, TryRecvError},
},
thread::JoinHandle,
};
pub type DialogFuture<T> = Pin<Box<dyn Future<Output = Outcome<T>> + Send>>;
/// Wait for a future without polling an idle native dialog.
pub fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut context = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
std::thread::park();
}
}
// Notification callbacks must not unwind a reserved worker after the UI observes readiness.
fn notify(wake: &dyn Fn()) {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(wake));
}
fn worker_error(message: impl Into<String>) -> DialogError {
DialogError::new(ErrorKind::Worker, message)
}
fn guarded<T>(f: impl FnOnce() -> Outcome<T>) -> Outcome<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Outcome::Failed(worker_error("The file-dialog backend failed")))
}
/// Reserve the worker before opening a native sheet. A failed reservation never calls `create`.
/// No timeout is imposed on a live picker. Backend constructor/poll panics become explicit failures.
pub fn wait_prepared<T: Send + 'static, F: Future<Output = Outcome<T>> + Send + 'static>(
create: impl FnOnce() -> F,
deliver: impl FnOnce(Outcome<T>) + Send + 'static,
) -> std::io::Result<JoinHandle<()>> {
let (send, receive) = mpsc::channel::<F>();
let worker = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
deliver(guarded(|| match receive.recv() {
Ok(future) => block_on(future),
Err(_) => Outcome::Failed(worker_error("The file-dialog factory failed")),
}));
})?;
if let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)) {
send.send(future).map_err(|_| std::io::Error::other("The reserved file-dialog worker stopped"))?;
}
Ok(worker)
}
type Job<R, T> = (R, Sender<Outcome<T>>);
/// Clonable request endpoint. At most one queued, preparing or live request is accepted.
pub struct DialogClient<R, T> {
sender: SyncSender<Job<R, T>>,
busy: Arc<AtomicBool>,
wake: Arc<dyn Fn() + Send + Sync>,
}
impl<R, T> Clone for DialogClient<R, T> {
fn clone(&self) -> Self {
Self { sender: self.sender.clone(), busy: self.busy.clone(), wake: self.wake.clone() }
}
}
impl<R, T> DialogClient<R, T> {
pub fn request(&self, request: R) -> Receiver<Outcome<T>> {
let (answer, receive) = mpsc::channel();
if self.busy.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
let _ = answer.send(Outcome::Failed(worker_error("A file dialog is already pending")));
} else if let Err(error) = self.sender.try_send((request, answer)) {
self.busy.store(false, Ordering::SeqCst);
let (_, answer) = match error {
mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job) => job,
};
let _ = answer.send(Outcome::Failed(worker_error("The file-dialog host is unavailable")));
}
notify(self.wake.as_ref());
receive
}
}
struct Preparing<R, T> {
request: R,
ready: Receiver<()>,
send: Sender<DialogFuture<T>>,
completion: Completion<T>,
}
struct Completion<T> {
done: Receiver<Outcome<T>>,
answer: Sender<Outcome<T>>,
}
impl<T> Completion<T> {
fn poll(&self) -> Option<Outcome<T>> {
match self.done.try_recv() {
Ok(outcome) => Some(outcome),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Outcome::Failed(worker_error("The file-dialog worker stopped without an answer"))),
}
}
}
enum State<R, T> {
Preparing(Preparing<R, T>),
Waiting(Completion<T>),
}
/// Event-loop-owned transport. Dropping a reply does not release a live picker or its parent.
/// `pump` must run before consuming replies, and after app code that may enqueue a request.
/// The host must retain this service/window while `pending()` or its own continuation is pending.
pub struct NativeDialogs<R, T> {
queue: Receiver<Job<R, T>>,
busy: Arc<AtomicBool>,
state: Option<State<R, T>>,
wake: Arc<dyn Fn() + Send + Sync>,
check: Arc<dyn Fn() -> Result<(), DialogError> + Send + Sync>,
}
impl<R, T: Send + 'static> NativeDialogs<R, T> {
pub fn new(wake: impl Fn() + Send + Sync + 'static) -> (DialogClient<R, T>, Self) {
Self::with_preflight(wake, || Ok(()))
}
/// Optional host-owned advisory check, run on the reserved worker before any picker exists.
/// Hosts must bound this check. Unknown capability must permit the actual picker; return an
/// Unavailable error only when absence is proven. This service performs no filesystem probes.
pub fn with_preflight(
wake: impl Fn() + Send + Sync + 'static,
check: impl Fn() -> Result<(), DialogError> + Send + Sync + 'static,
) -> (DialogClient<R, T>, Self) {
let (sender, queue) = mpsc::sync_channel(1);
let busy = Arc::new(AtomicBool::new(false));
let wake: Arc<dyn Fn() + Send + Sync> = Arc::new(wake);
(DialogClient { sender, busy: busy.clone(), wake: wake.clone() }, Self { queue, busy, state: None, wake, check: Arc::new(check) })
}
pub fn pending(&self) -> bool {
self.busy.load(Ordering::SeqCst)
}
pub fn pump(&mut self, mut create: impl FnMut(R) -> DialogFuture<T>) {
if let Some(state) = self.state.take() {
match state {
State::Waiting(completion) => {
if let Some(outcome) = completion.poll() {
// Only the UI pump publishes replies, after releasing the transport.
// A continuation can therefore immediately request its next picker.
self.busy.store(false, Ordering::SeqCst);
let _ = completion.answer.send(outcome);
} else {
self.state = Some(State::Waiting(completion));
}
}
State::Preparing(preparing) => {
if let Some(outcome) = preparing.completion.poll() {
self.busy.store(false, Ordering::SeqCst);
let _ = preparing.completion.answer.send(outcome);
} else if preparing.ready.try_recv().is_ok() {
let future = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| create(preparing.request)))
.unwrap_or_else(|_| Box::pin(async { Outcome::Failed(worker_error("The file-dialog factory failed")) }));
let _ = preparing.send.send(future);
self.state = Some(State::Waiting(preparing.completion));
} else {
self.state = Some(State::Preparing(preparing));
}
}
}
}
if self.state.is_none()
&& let Ok((request, answer)) = self.queue.try_recv()
{
let (ready_send, ready) = mpsc::channel();
let (send, receive) = mpsc::channel::<DialogFuture<T>>();
let (done_send, done) = mpsc::channel();
let check = self.check.clone();
let wake = self.wake.clone();
let started = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
let output = guarded(|| {
if let Err(error) = check() {
return Outcome::Failed(error);
}
if ready_send.send(()).is_err() {
return Outcome::Failed(worker_error("The file-dialog host stopped"));
}
notify(wake.as_ref());
match receive.recv() {
Ok(future) => block_on(future),
Err(_) => Outcome::Failed(worker_error("The file-dialog host stopped")),
}
});
let _ = done_send.send(output);
notify(wake.as_ref());
});
match started {
Ok(_) => self.state = Some(State::Preparing(Preparing { request, ready, send, completion: Completion { done, answer } })),
Err(error) => {
self.busy.store(false, Ordering::SeqCst);
let _ = answer.send(Outcome::Failed(worker_error(error.to_string())));
notify(self.wake.as_ref());
}
}
}
}
}
/// Holds close intent while a picker/continuation owns the parent. A retry never authorizes
/// discarding documents: the caller must run its ordinary unsaved-work policy again.
#[cfg(feature = "egui")]
#[derive(Default, Debug)]
pub struct DeferredClose {
held: bool,
}
#[cfg(feature = "egui")]
impl DeferredClose {
pub fn update(&mut self, ctx: &egui::Context, pending: bool) -> bool {
let outgoing = ctx.viewport(|v| {
v.commands
.iter()
.rev()
.find_map(|c| match c {
egui::ViewportCommand::Close => Some(true),
egui::ViewportCommand::CancelClose => Some(false),
_ => None,
})
.unwrap_or(false)
});
if pending {
let requested = outgoing || ctx.input(|i| i.viewport().close_requested());
self.held |= requested;
// Every viewport command requests a repaint. An idle native picker must sleep.
if requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
false
} else if std::mem::take(&mut self.held) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.request_repaint();
true
} else {
false
}
}
}
+5
View File
@@ -0,0 +1,5 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts.
pub mod dialog_outcome;
#[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))]
pub mod dialogs;
+248
View File
@@ -0,0 +1,248 @@
#![cfg(all(feature = "dialogs", not(target_arch = "wasm32")))]
use craft_platform::{
dialog_outcome::{DialogError, ErrorKind, Outcome},
dialogs::{NativeDialogs, wait_prepared},
};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
mpsc,
};
use std::time::{Duration, Instant};
fn pump_until<R, T: Send + 'static>(
service: &mut NativeDialogs<R, T>,
mut create: impl FnMut(R) -> craft_platform::dialogs::DialogFuture<T>,
until: impl Fn() -> bool,
) {
let deadline = Instant::now() + Duration::from_secs(3);
while !until() {
assert!(Instant::now() < deadline, "worker did not settle");
service.pump(&mut create);
std::thread::yield_now();
}
}
#[test]
fn factory_is_eager_on_caller_and_completion_on_worker() -> Result<(), Box<dyn std::error::Error>> {
let caller = std::thread::current().id();
let (tx, rx) = mpsc::channel();
let worker = wait_prepared(
|| {
assert_eq!(caller, std::thread::current().id());
async { Outcome::Selected(42) }
},
move |outcome| {
let _ = tx.send((outcome, std::thread::current().id()));
},
)?;
let (outcome, thread) = rx.recv_timeout(Duration::from_secs(3))?;
assert_eq!(outcome, Outcome::Selected(42));
assert_ne!(caller, thread);
assert!(worker.join().is_ok());
Ok(())
}
#[test]
fn constructor_and_poll_failures_are_not_cancellation() -> Result<(), Box<dyn std::error::Error>> {
for construct in [false, true] {
let (tx, rx) = mpsc::channel();
let worker = wait_prepared(
|| {
if construct {
std::panic::resume_unwind(Box::new("constructor"));
}
async {
std::panic::resume_unwind(Box::new("poll"));
#[allow(unreachable_code)]
Outcome::<u8>::Cancelled
}
},
move |outcome| {
let _ = tx.send(outcome);
},
)?;
assert!(matches!(rx.recv_timeout(Duration::from_secs(3))?, Outcome::Failed(DialogError { kind: ErrorKind::Worker, .. })));
assert!(worker.join().is_ok());
}
Ok(())
}
#[test]
fn queued_and_dropped_replies_keep_live_picker_busy() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::new(|| {});
let reply = c.request(());
assert!(service.pending());
drop(reply);
let (release, wait) = mpsc::channel();
let mut wait = Some(wait);
let created = Arc::new(AtomicUsize::new(0));
let count = created.clone();
pump_until(
&mut service,
move |()| {
count.fetch_add(1, Ordering::SeqCst);
let wait = wait.take();
Box::pin(async move {
let _ = wait.map(|w| w.recv());
Outcome::Selected(1)
})
},
|| created.load(Ordering::SeqCst) == 1,
);
assert!(service.pending());
assert!(matches!(c.request(()).recv_timeout(Duration::from_secs(3))?, Outcome::Failed(_)));
release.send(())?;
let deadline = Instant::now() + Duration::from_secs(3);
while service.pending() {
assert!(Instant::now() < deadline);
service.pump(|()| Box::pin(async { Outcome::Indeterminate }));
std::thread::yield_now();
}
Ok(())
}
#[test]
fn proven_absence_skips_native_factory() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::with_preflight(|| {}, || Err(DialogError::new(ErrorKind::Unavailable, "test backend missing")));
let reply = c.request(());
let called = Arc::new(AtomicUsize::new(0));
let count = called.clone();
let mut result = None;
let deadline = Instant::now() + Duration::from_secs(3);
while result.is_none() {
assert!(Instant::now() < deadline);
service.pump(|()| {
count.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Outcome::Selected(1) })
});
result = reply.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(called.load(Ordering::SeqCst), 0);
assert!(matches!(result, Some(Outcome::Failed(DialogError { kind: ErrorKind::Unavailable, .. }))));
Ok(())
}
#[test]
fn missing_backend_answer_is_ambiguous() {
assert_eq!(Outcome::<u8>::from_rfd(None), Outcome::Indeterminate);
}
#[cfg(feature = "egui")]
#[test]
fn held_close_retries_policy_without_granting_discard() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
let mut output = ctx.run_ui(input, |ui| assert!(!close.update(ui.ctx(), true)));
assert!(output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::CancelClose));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false)));
assert!(!output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Close));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}
#[test]
fn notification_panic_cannot_kill_reserved_picker_worker() -> Result<(), Box<dyn std::error::Error>> {
let (c, mut service) = NativeDialogs::<(), u8>::new(|| std::panic::resume_unwind(Box::new("wake failure")));
let reply = c.request(());
let mut result = None;
let deadline = Instant::now() + Duration::from_secs(3);
while result.is_none() {
assert!(Instant::now() < deadline);
service.pump(|()| Box::pin(async { Outcome::Selected(7) }));
result = reply.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(result, Some(Outcome::Selected(7)));
Ok(())
}
#[test]
fn completion_between_pumps_cannot_reject_a_chained_request() -> Result<(), Box<dyn std::error::Error>> {
let wakes = Arc::new(AtomicUsize::new(0));
let count = wakes.clone();
let (client, mut service) = NativeDialogs::<u8, u8>::new(move || {
count.fetch_add(1, Ordering::SeqCst);
});
let first = client.request(1);
let (release, wait) = mpsc::channel();
let mut wait = Some(wait);
let created = Arc::new(AtomicUsize::new(0));
let count = created.clone();
pump_until(
&mut service,
move |value| {
count.fetch_add(1, Ordering::SeqCst);
let wait = wait.take();
Box::pin(async move {
let _ = wait.map(|wait| wait.recv());
Outcome::Selected(value)
})
},
|| created.load(Ordering::SeqCst) == 1,
);
// Finish after the host's pump, before its app continuation polls the reply.
release.send(())?;
let deadline = Instant::now() + Duration::from_secs(3);
while wakes.load(Ordering::SeqCst) < 3 {
assert!(Instant::now() < deadline, "worker did not publish completion");
std::thread::yield_now();
}
assert!(matches!(first.try_recv(), Err(mpsc::TryRecvError::Empty)));
assert!(service.pending(), "an unpublished completion still retains the parent");
service.pump(|value| Box::pin(async move { Outcome::Selected(value) }));
assert_eq!(first.try_recv()?, Outcome::Selected(1));
assert!(!service.pending());
// The app can chain the next dialog immediately upon observing the first answer.
let second = client.request(2);
assert!(matches!(second.try_recv(), Err(mpsc::TryRecvError::Empty)));
let mut result = None;
while result.is_none() {
assert!(Instant::now() < deadline, "chained picker did not settle");
service.pump(|value| Box::pin(async move { Outcome::Selected(value) }));
result = second.try_recv().ok();
std::thread::yield_now();
}
assert_eq!(result, Some(Outcome::Selected(2)));
assert!(!service.pending());
Ok(())
}
#[cfg(feature = "egui")]
#[test]
fn pending_dialog_without_close_intent_does_not_schedule_viewport_work() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
// Let egui settle its own initial font/style passes before testing the idle host.
for _ in 0..4 {
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
}
for _ in 0..3 {
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(!close.update(ui.ctx(), true));
});
output.textures_delta.clear();
let viewport = output.viewport_output.get(&egui::ViewportId::ROOT).unwrap();
assert!(viewport.commands.is_empty(), "an idle picker must not enqueue commands: {:?}", viewport.commands);
assert_ne!(viewport.repaint_delay, std::time::Duration::ZERO, "an idle picker must not request another frame");
}
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}
#[cfg(feature = "egui")]
#[test]
fn programmatic_close_is_held_until_the_pending_dialog_settles() {
let ctx = egui::Context::default();
let mut close = craft_platform::dialogs::DeferredClose::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
assert!(!close.update(ui.ctx(), true));
});
output.textures_delta.clear();
let commands = &output.viewport_output.get(&egui::ViewportId::ROOT).unwrap().commands;
let last_close = commands.iter().rev().find(|command| matches!(command, egui::ViewportCommand::Close | egui::ViewportCommand::CancelClose));
assert_eq!(last_close, Some(&egui::ViewportCommand::CancelClose));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false)));
output.textures_delta.clear();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false)));
output.textures_delta.clear();
}