mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
Desktop: prepare large PDFs off the UI thread (#1493)
* #307: split document open into prepare and commit * #307: move desktop open preparation off the frame thread
This commit is contained in:
+84
-29
@@ -2609,6 +2609,34 @@ pub struct Session {
|
||||
date_language: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
#[derive(Clone)]
|
||||
pub struct OpenContext {
|
||||
now: Option<i64>,
|
||||
js_off: bool,
|
||||
trust: Arc<TrustStore>,
|
||||
}
|
||||
|
||||
/// A fully prepared document that has not yet been registered in a session.
|
||||
pub struct PreparedDocument {
|
||||
document: Document,
|
||||
}
|
||||
|
||||
impl OpenContext {
|
||||
/// Prepare a document without mutating the session that supplied this context.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let name = name.into();
|
||||
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
|
||||
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
|
||||
fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
|
||||
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
|
||||
@@ -2901,13 +2929,45 @@ impl Session {
|
||||
self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
let name = name.into();
|
||||
guard(|| self.open_unguarded(name, path, bytes, password))
|
||||
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
|
||||
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
|
||||
pub fn open_context(&self) -> OpenContext {
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() }
|
||||
}
|
||||
|
||||
fn open_unguarded(&mut self, name: String, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
/// Prepare a document without adding it to this session.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
self.open_context().prepare_open(name, path, bytes, password)
|
||||
}
|
||||
|
||||
/// Register a prepared document. This is the only open step that mutates the session.
|
||||
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
|
||||
self.next_id += 1;
|
||||
let id = DocId(self.next_id);
|
||||
let mut document = prepared.document;
|
||||
document.id = id;
|
||||
self.docs.push(document);
|
||||
id
|
||||
}
|
||||
|
||||
/// Synchronous convenience API used by the CLI and automation.
|
||||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
let prepared = self.prepare_open(name, path, bytes, password)?;
|
||||
Ok(self.commit_open(prepared))
|
||||
}
|
||||
|
||||
fn prepare_open_unguarded(
|
||||
context: &OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
|
||||
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
|
||||
// files, so give it the user password that owner authentication recovers.
|
||||
@@ -2926,7 +2986,7 @@ impl Session {
|
||||
// A dynamic XFA form is a shell around an XML template; lay the template out into real
|
||||
// pages and fields so the rest of the engine works on it. The original bytes stay: the
|
||||
// laid-out form is one appended revision.
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// Write `work` as one more revision and reopen it: what the document then is.
|
||||
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
|
||||
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
@@ -2995,7 +3055,7 @@ impl Session {
|
||||
_ => None,
|
||||
};
|
||||
let (bytes, info, cos) = match (&xfa, cos) {
|
||||
(Some(_), Ok(Ok(cos))) if !self.js_off => match xfa_template.clone() {
|
||||
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
|
||||
Some(tpl) => {
|
||||
let mut work = cos.clone();
|
||||
let pages = info.pages.len();
|
||||
@@ -3038,24 +3098,22 @@ impl Session {
|
||||
};
|
||||
// XFA notes survive the document being re-read after edits.
|
||||
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
|
||||
let id = self.push_document(name, path, bytes, info, cos, render_password, password, xfa)?;
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
// A script that ran away on open turns the form's scripts off for this document.
|
||||
d.xfa_template = if ran_away { None } else { xfa_template };
|
||||
d.js_output.append(script_output);
|
||||
if ran_away {
|
||||
d.xfa_scripts_off = true;
|
||||
d.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
note_warnings(&mut d.xfa_warnings, &xfa_warnings);
|
||||
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
|
||||
// A script that ran away on open turns the form's scripts off for this document.
|
||||
document.xfa_template = if ran_away { None } else { xfa_template };
|
||||
document.js_output.append(script_output);
|
||||
if ran_away {
|
||||
document.xfa_scripts_off = true;
|
||||
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
Ok(id)
|
||||
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
|
||||
Ok(PreparedDocument { document })
|
||||
}
|
||||
|
||||
/// The last step of opening: build the document record and register it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn push_document(
|
||||
&mut self,
|
||||
fn build_document(
|
||||
trust: &Arc<TrustStore>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
@@ -3064,7 +3122,7 @@ impl Session {
|
||||
render_password: Option<String>,
|
||||
password: Option<&str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
) -> Result<DocId, OpenError> {
|
||||
) -> Document {
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let (mut editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => {
|
||||
@@ -3114,11 +3172,9 @@ impl Session {
|
||||
.unwrap_or_default();
|
||||
let links = editor.as_ref().map(|e| pdfcraft_annot::links::list(&e.cos)).unwrap_or_default();
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust, &sig_cache)).unwrap_or_default();
|
||||
self.next_id += 1;
|
||||
let id = DocId(self.next_id);
|
||||
self.docs.push(Document {
|
||||
id,
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
Document {
|
||||
id: DocId(0),
|
||||
name,
|
||||
path,
|
||||
bytes,
|
||||
@@ -3135,7 +3191,7 @@ impl Session {
|
||||
added,
|
||||
links,
|
||||
signatures,
|
||||
trust: self.trust.clone(),
|
||||
trust: trust.clone(),
|
||||
sig_cache,
|
||||
editor,
|
||||
config,
|
||||
@@ -3146,8 +3202,7 @@ impl Session {
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> {
|
||||
|
||||
@@ -36,6 +36,20 @@ fn session_with(n: usize) -> (Session, DocId) {
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_open_does_not_mutate_the_session_until_commit() {
|
||||
fn assert_send<T: Send>() {}
|
||||
assert_send::<crate::PreparedDocument>();
|
||||
|
||||
let mut session = Session::new();
|
||||
let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares");
|
||||
assert!(session.get(DocId(1)).is_none());
|
||||
|
||||
let id = session.commit_open(prepared);
|
||||
assert_eq!(id, DocId(1));
|
||||
assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
|
||||
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
//! for this session without changing the saved preference. `ui.state.default_mode` reports it.
|
||||
//! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference`
|
||||
//! reports the choice and `ui.state.theme` reports the resolved light/dark colours.
|
||||
//! - `ui.open {path}`: open a file.
|
||||
//! - `ui.open {path}`: accept a file open; native opens report `pending: true` until they
|
||||
//! complete and are listed in `ui.state.pending_opens`.
|
||||
//! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -642,9 +643,15 @@ impl Host for crate::PdfCraftApp {
|
||||
Some(json!({ "tab": i, "doc": v.id.0, "name": d.name, "path": d.path, "pages": d.info.pages.len(), "dirty": d.dirty, "active": self.active == Some(i) }))
|
||||
})
|
||||
.collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let pending_opens: Vec<Value> =
|
||||
self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let pending_opens: Vec<Value> = Vec::new();
|
||||
json!({
|
||||
"docking": self.docking,
|
||||
"documents": docs,
|
||||
"pending_opens": pending_opens,
|
||||
"active": active.map(|v| json!({
|
||||
"doc": v.id.0,
|
||||
"page": v.current + 1,
|
||||
@@ -743,6 +750,8 @@ impl Host for crate::PdfCraftApp {
|
||||
self.open_path(path);
|
||||
if self.views.len() > before || self.password_prompt.is_some() {
|
||||
Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() }))
|
||||
} else if self.pending_opens.iter().any(|pending| pending.path.as_deref() == Some(path)) {
|
||||
Ok(json!({ "accepted": path, "pending": true }))
|
||||
} else {
|
||||
Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}")))
|
||||
}
|
||||
|
||||
+281
-33
@@ -117,6 +117,8 @@ pub(crate) fn date_text_for_pdf(session: &Session) -> Result<String, String> {
|
||||
/// The longest author name kept (Preferences ▸ Identity, restored settings).
|
||||
pub(crate) const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod open_worker;
|
||||
pub mod portable;
|
||||
mod protect;
|
||||
mod recovery;
|
||||
@@ -130,6 +132,9 @@ pub mod updates;
|
||||
mod wheel_pager;
|
||||
mod widgets;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod open_worker_tests;
|
||||
|
||||
use pdfcraft_engine::{DocId, Session};
|
||||
|
||||
pub use canvas::{DocView, RasterMemory};
|
||||
@@ -392,6 +397,8 @@ pub struct PasswordPrompt {
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp: Option<file_watch::Stamp>,
|
||||
pub input: String,
|
||||
pub error: Option<String>,
|
||||
/// A late startup file must stay in the background even after it is unlocked.
|
||||
@@ -406,6 +413,32 @@ pub struct PasswordPrompt {
|
||||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct PendingOpen {
|
||||
token: u64,
|
||||
receiver: std::sync::mpsc::Receiver<open_worker::ResultMessage>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
stamp: Option<file_watch::Stamp>,
|
||||
activate: bool,
|
||||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
recovered: Option<RecoveryMeta>,
|
||||
then: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct OpenRequest {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
stamp: Option<file_watch::Stamp>,
|
||||
input: open_worker::Input,
|
||||
password: Option<String>,
|
||||
activate: bool,
|
||||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
recovered: Option<RecoveryMeta>,
|
||||
then: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Asks for the permissions password of an open document whose security restricts it, which
|
||||
/// lifts the restrictions (#995). Acrobat asks for it when such a document's security is changed.
|
||||
pub struct UnlockPrompt {
|
||||
@@ -524,6 +557,15 @@ pub struct PdfCraftApp {
|
||||
/// Open documents' files, watched for changes on disk.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) watch: file_watch::FileWatch,
|
||||
/// One native worker prepares opened documents without owning app or session state.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
open_worker: Option<open_worker::OpenWorker>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending_opens: Vec<PendingOpen>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
next_open_token: u64,
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: Option<std::sync::Arc<open_worker::PrepareGate>>,
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
pub pinned: folders_ui::PinnedFolders,
|
||||
pub toast: Option<(String, f64)>,
|
||||
@@ -844,6 +886,14 @@ impl PdfCraftApp {
|
||||
reload_changed_files: true,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
open_worker: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending_opens: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
next_open_token: 0,
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
toast: None,
|
||||
integrated_titlebar: false,
|
||||
@@ -1032,6 +1082,119 @@ impl PdfCraftApp {
|
||||
self.try_open(name, path, std::sync::Arc::new(bytes), None, activate)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn schedule_open(&mut self, request: OpenRequest) -> Result<u64, String> {
|
||||
if self.open_worker.is_none() {
|
||||
self.open_worker = Some(
|
||||
open_worker::OpenWorker::new(
|
||||
#[cfg(test)]
|
||||
self.open_prepare_gate.clone(),
|
||||
)
|
||||
.map_err(|e| format!("couldn't start the document-open worker: {e}"))?,
|
||||
);
|
||||
}
|
||||
let token = self.next_open_token.wrapping_add(1);
|
||||
let worker = self.open_worker.as_ref().ok_or("the document-open worker is unavailable")?;
|
||||
if let Some(ctx) = &self.ctx {
|
||||
worker.set_context(ctx);
|
||||
}
|
||||
let receiver =
|
||||
worker.submit(token, self.session.open_context(), request.name.clone(), request.path.clone(), request.password, request.input)?;
|
||||
self.next_open_token = token;
|
||||
self.pending_opens.push(PendingOpen {
|
||||
token,
|
||||
receiver,
|
||||
name: request.name,
|
||||
path: request.path,
|
||||
stamp: request.stamp,
|
||||
activate: request.activate,
|
||||
after_open: request.after_open,
|
||||
recovered: request.recovered,
|
||||
then: request.then,
|
||||
});
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn poll_pending_opens(&mut self) {
|
||||
let mut ready = Vec::new();
|
||||
let mut waiting = Vec::new();
|
||||
let pending_opens = std::mem::take(&mut self.pending_opens);
|
||||
for pending in pending_opens {
|
||||
match pending.receiver.try_recv() {
|
||||
Ok(result) if result.token == pending.token => ready.push((pending, result.kind)),
|
||||
Ok(_) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => waiting.push(pending),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.notify_fmt("Couldn't open {name}: the background worker stopped", &[("name", &pending.name)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending_opens = waiting;
|
||||
for (pending, kind) in ready {
|
||||
self.finish_pending_open(pending, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_pending_open(&mut self, pending: PendingOpen, kind: open_worker::ResultKind) {
|
||||
match kind {
|
||||
open_worker::ResultKind::ReadFailed(reason) => {
|
||||
self.notify_fmt("Couldn't read {name}: {e}", &[("name", &pending.name), ("e", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::RemoteFailed(reason) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::RecoveryFailed(reason) => {
|
||||
self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &pending.name), ("e", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::Failed(reason) => {
|
||||
self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::Conversion(bytes) => {
|
||||
if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]);
|
||||
}
|
||||
}
|
||||
open_worker::ResultKind::Prepared { bytes, result } => match *result {
|
||||
Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) {
|
||||
Ok(id) => {
|
||||
if pending.path.is_some() {
|
||||
self.watch.known_as(id, pending.stamp);
|
||||
}
|
||||
if let Some(destination) = pending.after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some(meta) = &pending.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
if let Some(command) = pending.then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
|
||||
},
|
||||
Err(error @ (pdfcraft_render::OpenError::NeedsPassword | pdfcraft_render::OpenError::WrongPassword)) => {
|
||||
let password_error =
|
||||
matches!(error, pdfcraft_render::OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
self.password_prompt = Some(PasswordPrompt {
|
||||
name: pending.name,
|
||||
path: pending.path,
|
||||
bytes,
|
||||
stamp: pending.stamp,
|
||||
input: String::new(),
|
||||
error: password_error,
|
||||
activate: pending.activate,
|
||||
recovered: pending.recovered,
|
||||
then: pending.then,
|
||||
after_open: pending.after_open,
|
||||
});
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn try_open(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -1041,15 +1204,18 @@ impl PdfCraftApp {
|
||||
activate: bool,
|
||||
) -> Result<(), String> {
|
||||
use pdfcraft_render::OpenError;
|
||||
let size = bytes.len();
|
||||
let id = match self.session.open(name, path.clone(), bytes.clone(), password) {
|
||||
Ok(id) => id,
|
||||
let prepared = match self.session.prepare_open(name, path.clone(), bytes.clone(), password) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => {
|
||||
let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let stamp = path.as_deref().and_then(file_watch::Stamp::at);
|
||||
self.password_prompt = Some(PasswordPrompt {
|
||||
name: name.to_string(),
|
||||
path,
|
||||
bytes,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
stamp,
|
||||
input: String::new(),
|
||||
error,
|
||||
activate,
|
||||
@@ -1061,6 +1227,18 @@ impl PdfCraftApp {
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ())
|
||||
}
|
||||
|
||||
fn finish_open(
|
||||
&mut self,
|
||||
name: &str,
|
||||
path: Option<String>,
|
||||
size: usize,
|
||||
prepared: pdfcraft_engine::PreparedDocument,
|
||||
activate: bool,
|
||||
) -> Result<DocId, String> {
|
||||
let id = self.session.commit_open(prepared);
|
||||
self.password_prompt = None;
|
||||
// The reading-colours preference is app-wide: a newly opened document starts with it.
|
||||
// Idempotent: the engine returns `false` when the document has the setting already.
|
||||
@@ -1115,7 +1293,7 @@ impl PdfCraftApp {
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Ok(())
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1128,8 +1306,23 @@ impl PdfCraftApp {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into());
|
||||
match f.bytes() {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.open_bytes(&name, None, bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
if self.ctx.is_some() && !self.run_inline {
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: None,
|
||||
stamp: None,
|
||||
input: open_worker::Input::Bytes(std::sync::Arc::new(bytes)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
} else if let Err(error) = self.open_bytes(&name, None, bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
|
||||
@@ -1260,11 +1453,43 @@ impl PdfCraftApp {
|
||||
}
|
||||
return;
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.ctx.is_some() && !self.run_inline {
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: p.name.clone(),
|
||||
path: p.path.clone(),
|
||||
stamp: p.stamp,
|
||||
input: open_worker::Input::Bytes(p.bytes.clone()),
|
||||
password: Some(pw),
|
||||
activate: p.activate,
|
||||
after_open: p.after_open.clone(),
|
||||
recovered: p.recovered.clone(),
|
||||
then: p.then,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
let mut prompt = p;
|
||||
prompt.error = Some(error);
|
||||
self.password_prompt = Some(prompt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp);
|
||||
let after_open = p.after_open.clone();
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
// A recovered encrypted document is open once its prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = path_for_watch.as_deref()
|
||||
&& let Some(id) = self
|
||||
.views
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|view| self.session.get(view.id).and_then(|doc| (doc.path.as_deref() == Some(path)).then_some(view.id)))
|
||||
{
|
||||
self.watch.known_as(id, stamp_for_watch);
|
||||
}
|
||||
if let Some(destination) = after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
@@ -1338,24 +1563,20 @@ impl PdfCraftApp {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone());
|
||||
let stamp = file_watch::Stamp::at(&path);
|
||||
let tabs = self.views.len();
|
||||
self.startup_superseded = true;
|
||||
let bytes = match pdfcraft_engine::read_remote_document(std::path::Path::new(&path)) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.try_open(&name, Some(path), std::sync::Arc::new(bytes), None, true) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
} else if let Some(prompt) = self.password_prompt.as_mut() {
|
||||
prompt.after_open = Some(destination);
|
||||
} else if self.views.len() > tabs {
|
||||
if let Some(view) = self.views.last() {
|
||||
self.watch.known_as(view.id, stamp);
|
||||
}
|
||||
self.apply_remote_destination(destination);
|
||||
let result = self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: Some(path.clone()),
|
||||
stamp,
|
||||
input: open_worker::Input::RemotePath(std::path::PathBuf::from(path)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: Some(destination),
|
||||
recovered: None,
|
||||
then: None,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &error)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,18 +1607,38 @@ impl PdfCraftApp {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
|
||||
let stamp = file_watch::Stamp::at(path);
|
||||
let tabs = self.views.len();
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
} else if self.views.len() > tabs
|
||||
&& let Some(doc) = self.views.last().map(|v| v.id)
|
||||
{
|
||||
self.watch.known_as(doc, stamp);
|
||||
// Before the first frame there is no interaction to keep responsive; this also preserves
|
||||
// deterministic inline startup and test flows. Once live, reading and preparation use
|
||||
// the bounded worker and commit on the UI thread.
|
||||
if self.ctx.is_none() || self.run_inline {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let before = self.views.len();
|
||||
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
} else if self.views.len() > before
|
||||
&& let Some(doc) = self.views.last().map(|view| view.id)
|
||||
{
|
||||
self.watch.known_as(doc, stamp);
|
||||
}
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]),
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]),
|
||||
return;
|
||||
}
|
||||
self.startup_superseded = true;
|
||||
if let Err(error) = self.schedule_open(OpenRequest {
|
||||
name: name.clone(),
|
||||
path: Some(path.to_string()),
|
||||
stamp,
|
||||
input: open_worker::Input::Path(std::path::PathBuf::from(path)),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
}) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2395,6 +2636,13 @@ impl eframe::App for PdfCraftApp {
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
i18n::set_current(lang);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(worker) = &self.open_worker {
|
||||
worker.set_context(ctx);
|
||||
}
|
||||
self.poll_pending_opens();
|
||||
}
|
||||
// Chinese wants its own faces before the Japanese ones (one baseline per line).
|
||||
let cjk = theme::CjkPreference::for_language(lang.code());
|
||||
if !self.styled {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Native document reads and preparation run away from the frame thread.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Condvar, Mutex, PoisonError};
|
||||
|
||||
use crate::RecoveryStore;
|
||||
use pdfcraft_engine::{OpenContext, PreparedDocument, SourceKind};
|
||||
use pdfcraft_render::OpenError;
|
||||
|
||||
const MAX_QUEUED_OPENS: usize = 16;
|
||||
|
||||
pub(crate) enum Input {
|
||||
Path(PathBuf),
|
||||
RemotePath(PathBuf),
|
||||
Recovery(RecoveryStore, String),
|
||||
Bytes(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
pub(crate) enum ResultKind {
|
||||
Conversion(Arc<Vec<u8>>),
|
||||
Prepared { bytes: Arc<Vec<u8>>, result: Box<Result<PreparedDocument, OpenError>> },
|
||||
ReadFailed(String),
|
||||
RemoteFailed(String),
|
||||
RecoveryFailed(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub(crate) struct ResultMessage {
|
||||
pub token: u64,
|
||||
pub kind: ResultKind,
|
||||
}
|
||||
|
||||
struct Job {
|
||||
token: u64,
|
||||
context: OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
password: Option<String>,
|
||||
input: Input,
|
||||
reply: Sender<ResultMessage>,
|
||||
#[cfg(test)]
|
||||
gate: Option<Arc<PrepareGate>>,
|
||||
}
|
||||
|
||||
struct Queue {
|
||||
jobs: VecDeque<Job>,
|
||||
shutdown: bool,
|
||||
repaint: Option<egui::Context>,
|
||||
}
|
||||
|
||||
pub(crate) struct OpenWorker {
|
||||
queue: Arc<(Mutex<Queue>, Condvar)>,
|
||||
#[cfg(test)]
|
||||
gate: Option<Arc<PrepareGate>>,
|
||||
}
|
||||
|
||||
impl OpenWorker {
|
||||
pub fn new(#[cfg(test)] gate: Option<Arc<PrepareGate>>) -> std::io::Result<Self> {
|
||||
let queue = Arc::new((Mutex::new(Queue { jobs: VecDeque::new(), shutdown: false, repaint: None }), Condvar::new()));
|
||||
let worker_queue = queue.clone();
|
||||
std::thread::Builder::new().name("pdfcraft-open".into()).spawn(move || run(worker_queue))?;
|
||||
Ok(Self {
|
||||
queue,
|
||||
#[cfg(test)]
|
||||
gate,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_context(&self, ctx: &egui::Context) {
|
||||
let (lock, _) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
queue.repaint = Some(ctx.clone());
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
token: u64,
|
||||
context: OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
password: Option<String>,
|
||||
input: Input,
|
||||
) -> Result<Receiver<ResultMessage>, String> {
|
||||
let (reply, receiver) = mpsc::channel();
|
||||
let job = Job {
|
||||
token,
|
||||
context,
|
||||
name,
|
||||
path,
|
||||
password,
|
||||
input,
|
||||
reply,
|
||||
#[cfg(test)]
|
||||
gate: self.gate.clone(),
|
||||
};
|
||||
let (lock, ready) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if queue.shutdown {
|
||||
return Err("the document-open worker has stopped".into());
|
||||
}
|
||||
if queue.jobs.len() >= MAX_QUEUED_OPENS {
|
||||
return Err("too many documents are waiting to open".into());
|
||||
}
|
||||
queue.jobs.push_back(job);
|
||||
ready.notify_one();
|
||||
Ok(receiver)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenWorker {
|
||||
fn drop(&mut self) {
|
||||
let (lock, ready) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
queue.shutdown = true;
|
||||
queue.jobs.clear();
|
||||
queue.repaint = None;
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn run(queue: Arc<(Mutex<Queue>, Condvar)>) {
|
||||
loop {
|
||||
let job = {
|
||||
let (lock, ready) = &*queue;
|
||||
let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while state.jobs.is_empty() && !state.shutdown {
|
||||
state = ready.wait(state).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
if state.shutdown {
|
||||
return;
|
||||
}
|
||||
state.jobs.pop_front()
|
||||
};
|
||||
let Some(job) = job else { continue };
|
||||
#[cfg(test)]
|
||||
if let Some(gate) = &job.gate {
|
||||
gate.pause_before_prepare();
|
||||
}
|
||||
let token = job.token;
|
||||
let reply = job.reply.clone();
|
||||
#[cfg(test)]
|
||||
let gate = job.gate.clone();
|
||||
let kind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepare(job)))
|
||||
.unwrap_or_else(|_| ResultKind::Failed("opening failed unexpectedly in the background worker".into()));
|
||||
let _ = reply.send(ResultMessage { token, kind });
|
||||
let repaint = {
|
||||
let (lock, _) = &*queue;
|
||||
lock.lock().unwrap_or_else(PoisonError::into_inner).repaint.clone()
|
||||
};
|
||||
if let Some(ctx) = repaint {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(gate) = &gate {
|
||||
let _ = gate.finished.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(job: Job) -> ResultKind {
|
||||
let bytes = match &job.input {
|
||||
Input::Path(path) => match std::fs::read(path) {
|
||||
Ok(bytes) => Arc::new(bytes),
|
||||
Err(error) => return ResultKind::ReadFailed(error.to_string()),
|
||||
},
|
||||
Input::RemotePath(path) => match pdfcraft_engine::read_remote_document(path) {
|
||||
Ok(bytes) => Arc::new(bytes),
|
||||
Err(error) => return ResultKind::RemoteFailed(error.to_string()),
|
||||
},
|
||||
Input::Recovery(store, key) => match store.read(key) {
|
||||
Ok(bytes) => Arc::new(bytes),
|
||||
Err(error) => return ResultKind::RecoveryFailed(error.to_string()),
|
||||
},
|
||||
Input::Bytes(bytes) => bytes.clone(),
|
||||
};
|
||||
if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) {
|
||||
return ResultKind::Conversion(bytes);
|
||||
}
|
||||
let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref());
|
||||
ResultKind::Prepared { bytes, result: Box::new(result) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct PrepareGate {
|
||||
pub started: Sender<()>,
|
||||
release: Mutex<Receiver<()>>,
|
||||
pub finished: Sender<()>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl PrepareGate {
|
||||
pub fn pause_before_prepare(&self) {
|
||||
let _ = self.started.send(());
|
||||
if let Ok(release) = self.release.lock() {
|
||||
let _ = release.recv();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn prepare_gate() -> (Arc<PrepareGate>, Receiver<()>, Sender<()>, Receiver<()>) {
|
||||
let (started, started_rx) = mpsc::channel();
|
||||
let (release_tx, release) = mpsc::channel();
|
||||
let (finished, finished_rx) = mpsc::channel();
|
||||
(Arc::new(PrepareGate { started, release: Mutex::new(release), finished }), started_rx, release_tx, finished_rx)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{PdfCraftApp, control::ControlClient, open_worker};
|
||||
|
||||
fn fixture(pages: usize) -> Vec<u8> {
|
||||
let mut objects = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()];
|
||||
let kids = (0..pages).map(|page| format!("{} 0 R", 4 + 2 * page)).collect::<Vec<_>>().join(" ");
|
||||
objects.push(format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 300] >>"));
|
||||
objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
for page in 0..pages {
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * page));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", page + 1);
|
||||
objects.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
}
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) {
|
||||
let slot: Arc<Mutex<Option<ControlClient>>> = Arc::default();
|
||||
let saved = slot.clone();
|
||||
let mut harness = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(move |creation| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").expect("language preference");
|
||||
*saved.lock().expect("control-client slot") = Some(app.attach_control(&creation.egui_ctx));
|
||||
app.open_bytes("existing.pdf", None, fixture(3)).expect("existing document");
|
||||
app
|
||||
});
|
||||
harness.run_steps(3);
|
||||
(harness, slot.lock().expect("control-client slot").take().expect("control client"))
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
fn request(harness: &mut Harness<'static, PdfCraftApp>, client: &ControlClient, method: &str, params: Value) -> Value {
|
||||
let reply = client.send(method, params);
|
||||
for _ in 0..5 {
|
||||
harness.step();
|
||||
if let Ok(result) = reply.try_recv() {
|
||||
return result.expect("control request succeeded");
|
||||
}
|
||||
}
|
||||
panic!("{method} was not answered during the held prepare phase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() {
|
||||
let (mut harness, client) = harness();
|
||||
let (gate, started, release, finished) = open_worker::prepare_gate();
|
||||
harness.state_mut().open_prepare_gate = Some(gate);
|
||||
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-open-worker-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, fixture(2)).expect("write test PDF");
|
||||
let open_reply = request(&mut harness, &client, "ui.open", json!({ "path": path.to_string_lossy() }));
|
||||
assert_eq!(open_reply["pending"], true);
|
||||
started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier");
|
||||
|
||||
let state = request(&mut harness, &client, "ui.state", json!({}));
|
||||
assert_eq!(state["documents"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(state["documents"][0]["name"], "existing.pdf");
|
||||
assert_eq!(state["pending_opens"].as_array().map(Vec::len), Some(1));
|
||||
request(&mut harness, &client, "ui.command", json!({ "id": "page.insert_blank" }));
|
||||
assert_eq!(harness.state().session.get(harness.state().views[0].id).expect("existing tab").info.pages.len(), 4);
|
||||
assert_eq!(harness.state().views.len(), 1, "prepared state is not visible in the session");
|
||||
|
||||
release.send(()).expect("release prepare barrier");
|
||||
finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation");
|
||||
harness.step();
|
||||
let app = harness.state();
|
||||
assert_eq!(app.views.len(), 2, "one prepared document commits exactly once");
|
||||
assert_eq!(app.session.get(app.views[1].id).expect("new tab").info.pages.len(), 2);
|
||||
assert_eq!(app.active, Some(1));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
@@ -199,6 +199,25 @@ impl PdfCraftApp {
|
||||
let Some(store) = self.recovery.clone() else { return };
|
||||
for key in keys {
|
||||
let Some(meta) = self.recoverable.iter().find(|m| &m.key == key).cloned() else { continue };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.ctx.is_some() && !self.run_inline {
|
||||
self.startup_superseded = true;
|
||||
let result = self.schedule_open(crate::OpenRequest {
|
||||
name: meta.name.clone(),
|
||||
path: None,
|
||||
stamp: None,
|
||||
input: crate::open_worker::Input::Recovery(store.clone(), key.clone()),
|
||||
password: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: Some(meta.clone()),
|
||||
then: None,
|
||||
});
|
||||
if let Err(error) = result {
|
||||
self.notify_fmt("Couldn't recover {name}: {e}", &[("name", &meta.name), ("e", &error)]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let bytes = match store.read(key) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user