mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 14:32:14 +00:00
platform: add native dialog lifecycle services
This commit is contained in:
1 parent
ce89625521
commit
ff19b46588
13 files changed
+860
No files matched your search
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user