Skip to main content

zipatch_rs/apply/
observer.rs

1//! Progress and cancellation hooks for the apply layer.
2//!
3//! [`ApplyObserver`] is the user-visible instrumentation surface for a long-
4//! running [`apply_patch`](crate::ApplyConfig::apply_patch) call. It is designed
5//! around two concrete needs from the downstream `gaveloc-patcher` consumer:
6//!
7//! - **Smooth UI progress** — a desktop launcher needs to drive a progress
8//!   bar while applying multi-GB FFXIV patches. Per-chunk events plus a
9//!   running byte counter are enough to compute "X of Y bytes applied"
10//!   without ever buffering the full patch in memory.
11//! - **User-initiated cancellation** — a single [`SqpkFile`] `AddFile`
12//!   chunk can carry hundreds of megabytes of DEFLATE blocks. Chunk-
13//!   boundary checks are not enough; cancellation must be observable
14//!   *inside* the long-running block loop. The cheap
15//!   [`ApplyObserver::should_cancel`] predicate is polled between blocks.
16//!
17//! # Trait, not closure
18//!
19//! The observer is a **trait** rather than a closure so that callers can
20//! own state across event methods (e.g. a UI handle, an [`mpsc::Sender`],
21//! a cancellation token) without juggling shared captures. Every method has
22//! a no-op default, so implementors only override what they care about.
23//!
24//! There is **no** blanket impl for closures. The trait carries
25//! [`ApplyObserver::should_cancel`] alongside
26//! [`ApplyObserver::on_chunk_applied`], and a closure can only carry one of
27//! the two — silently filling in the "never cancel" default would disable
28//! the cancellation path the consumer almost certainly wants. Implementors
29//! pass a struct (typically owning an [`AtomicBool`](std::sync::atomic::AtomicBool)
30//! cancellation flag) and override exactly the methods they need.
31//!
32//! # Default observer
33//!
34//! [`ApplyConfig`](crate::ApplyConfig) defaults to a no-op observer when
35//! none is configured. Parsing-only consumers
36//! ([`ZiPatchReader`](crate::ZiPatchReader) without
37//! [`apply_patch`](crate::ApplyConfig::apply_patch)) pay nothing — the observer
38//! is exclusively an apply-layer concept.
39//!
40//! [`SqpkFile`]: crate::chunk::sqpk::SqpkFile
41//! [`mpsc::Sender`]: std::sync::mpsc::Sender
42
43use crate::newtypes::ChunkTag;
44use std::ops::ControlFlow;
45
46/// One chunk-applied event delivered to an [`ApplyObserver`].
47///
48/// Fired after each top-level chunk's apply has completed successfully. The
49/// `index` field is the zero-based ordinal of the chunk in the patch stream,
50/// counting every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader) (the
51/// internal `EOF_` terminator is not yielded and is not counted). The `kind`
52/// is the 4-byte ASCII wire tag of the chunk, which lets the consumer
53/// categorise events without needing to match on the [`Chunk`](crate::Chunk)
54/// enum directly. `bytes_read` is the running total of bytes consumed from
55/// the patch stream up to and including this chunk's frame (length prefix,
56/// tag, body, and CRC32).
57///
58/// # Stability
59///
60/// The struct is `#[non_exhaustive]`. New fields may be added in future minor
61/// versions (per-chunk elapsed time, decompressed byte counts, etc.).
62/// Construct in tests via the [`ChunkEvent::new`] associated function rather
63/// than struct-literal syntax.
64#[non_exhaustive]
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ChunkEvent {
67    /// Zero-based index of the chunk within the patch stream.
68    ///
69    /// Counts every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader),
70    /// in stream order. The internal `EOF_` terminator is not counted.
71    pub index: usize,
72    /// 4-byte ASCII wire tag of the chunk (e.g. [`ChunkTag::SQPK`],
73    /// [`ChunkTag::ADIR`]).
74    ///
75    /// `EOF_` will never appear here because the reader consumes that chunk
76    /// internally. New tags introduced by Square Enix would surface as
77    /// [`ParseError::UnknownChunkTag`](crate::ParseError::UnknownChunkTag)
78    /// before any event fires for them.
79    pub kind: ChunkTag,
80    /// Running total of bytes consumed from the patch stream, measured from
81    /// the start of the patch file (including the 12-byte magic prefix).
82    ///
83    /// Monotonically non-decreasing across successive events; equal to the
84    /// stream position immediately after the chunk's CRC32 was read. Useful
85    /// for driving a `bytes_applied / total_patch_size` progress bar.
86    pub bytes_read: u64,
87}
88
89impl ChunkEvent {
90    /// Construct a [`ChunkEvent`] from its component fields.
91    ///
92    /// Primarily intended for unit-test fixtures. Production code receives
93    /// events from the apply driver and does not need to construct them.
94    #[must_use]
95    pub fn new(index: usize, kind: ChunkTag, bytes_read: u64) -> Self {
96        Self {
97            index,
98            kind,
99            bytes_read,
100        }
101    }
102
103    /// Returns the chunk's [`kind`](Self::kind) tag as a `&str` if it is
104    /// valid UTF-8.
105    ///
106    /// All wire tags defined by the `ZiPatch` format are 4-byte ASCII
107    /// (`SQPK`, `ADIR`, `APLY`, …), so this returns `Some(&str)` in
108    /// practice. The fallible variant exists because the underlying field is
109    /// 4 raw bytes, which by itself does not constrain the contents to
110    /// UTF-8 — a forward-compatible future tag or a corrupt event constructed
111    /// in tests could in principle carry non-ASCII bytes.
112    ///
113    /// # Example
114    ///
115    /// ```
116    /// use zipatch_rs::ChunkEvent;
117    /// use zipatch_rs::newtypes::ChunkTag;
118    /// let ev = ChunkEvent::new(0, ChunkTag::SQPK, 96);
119    /// assert_eq!(ev.kind_str(), Some("SQPK"));
120    ///
121    /// let bad = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0, 0]), 0);
122    /// assert_eq!(bad.kind_str(), None);
123    /// ```
124    #[must_use]
125    pub fn kind_str(&self) -> Option<&str> {
126        self.kind.as_str()
127    }
128}
129
130/// Hook trait for observing apply-time progress and signalling cancellation.
131///
132/// All methods
133/// have no-op defaults so implementors override only what they need.
134///
135/// # Threading
136///
137/// An observer is borrowed mutably by the apply driver for the lifetime of
138/// the [`apply_patch`](crate::ApplyConfig::apply_patch) call. There is no
139/// internal synchronisation: implementors that need to forward events to
140/// another thread should do so via channels they own.
141///
142/// The trait has `Send + Sync` supertrait bounds so a boxed observer can
143/// be constructed on one thread and driven on another — the typical UI
144/// pattern is to construct the observer (often around an
145/// [`mpsc::Sender`](std::sync::mpsc::Sender) or an
146/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag) on the
147/// UI thread, hand it to an [`ApplyConfig`](crate::ApplyConfig), and ship
148/// the context to an apply worker. `Sync` costs nothing for the realistic
149/// implementations (channel senders, atomics, `Arc<Mutex<_>>`) and lets the
150/// observer be shared by reference if a downstream consumer ever needs to.
151///
152/// # Async usage
153///
154/// Both [`Self::on_chunk_applied`] and [`Self::should_cancel`] run inline
155/// with the apply loop and are intentionally synchronous — see the
156/// crate-level "Async usage" section for the rationale. The cancellation
157/// poll in particular is called once per `SqpkFile` `AddFile` block and
158/// must be cheap (an atomic-bool load is the canonical implementation),
159/// which makes it a poor fit for `async` even hypothetically.
160///
161/// Async consumers wrap the whole apply call in
162/// `tokio::task::spawn_blocking` and use an
163/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag the
164/// async side can flip from a `tokio::select!` arm or a cancellation
165/// token. Per-chunk events that need to reach an async UI go through a
166/// channel whose `Sender` lives inside [`Self::on_chunk_applied`] and
167/// whose `Receiver` is polled from the async task.
168///
169/// # Example
170///
171/// ```no_run
172/// use std::ops::ControlFlow;
173/// use zipatch_rs::{ApplyConfig, ApplyObserver, ChunkEvent, open_patch};
174///
175/// struct Progress {
176///     total: u64,
177///     applied: u64,
178/// }
179///
180/// impl ApplyObserver for Progress {
181///     fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
182///         self.applied = ev.bytes_read;
183///         println!("progress: {}/{}", self.applied, self.total);
184///         ControlFlow::Continue(())
185///     }
186/// }
187///
188/// let mut ctx = ApplyConfig::new("/opt/ffxiv/game")
189///     .with_observer(Progress { total: 12_345_678, applied: 0 });
190/// let reader = open_patch("patch.patch").unwrap();
191/// ctx.apply_patch(reader).unwrap();
192/// ```
193pub trait ApplyObserver: Send + Sync {
194    /// Called after each top-level chunk has been applied successfully.
195    ///
196    /// Returning [`ControlFlow::Break`] aborts the apply loop immediately;
197    /// the apply call returns [`ApplyError::Cancelled`].
198    ///
199    /// Not invoked when the chunk's apply itself fails — the error
200    /// propagates from [`apply_patch`](crate::ApplyConfig::apply_patch) without
201    /// firing this method. The event is therefore a "chunk succeeded"
202    /// signal, not a "chunk attempted" one.
203    ///
204    /// The default implementation does nothing and continues.
205    ///
206    /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
207    fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
208        let _ = ev;
209        ControlFlow::Continue(())
210    }
211
212    /// Polled inside long-running chunks to check for user cancellation.
213    ///
214    /// Implementors should make this method **cheap** — it is called once
215    /// per block inside the
216    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile` loop and on
217    /// every iteration of any future fine-grained loop the apply layer adds.
218    /// A simple atomic-bool load is the recommended implementation.
219    ///
220    /// Polled before each block within long-running chunks (currently only
221    /// `SQPK F` `AddFile`). Once a block's I/O has started, it completes —
222    /// cancellation takes effect at the next block boundary, not mid-write.
223    /// This means the last block of any chunk always finishes once started.
224    ///
225    /// Returning `true` causes the current apply operation to abort at the
226    /// next checkpoint with [`ApplyError::Cancelled`].
227    ///
228    /// The default implementation always returns `false`.
229    ///
230    /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
231    fn should_cancel(&mut self) -> bool {
232        false
233    }
234}
235
236/// No-op observer used by [`ApplyConfig`](crate::ApplyConfig) when none
237/// is configured.
238///
239/// Public because [`ApplyConfig::with_observer`](crate::ApplyConfig::with_observer)
240/// is generic over `impl ApplyObserver + 'static` and callers occasionally need
241/// to name the default in `Box<dyn ApplyObserver>`-typed fields. All trait
242/// methods use the trait defaults.
243#[derive(Debug, Default, Clone, Copy)]
244pub struct NoopObserver;
245
246impl ApplyObserver for NoopObserver {}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    // --- Thread-safety bounds ---
253
254    #[test]
255    fn boxed_observer_is_send_and_sync() {
256        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
257        assert_send_sync::<dyn ApplyObserver>();
258        assert_send_sync::<Box<dyn ApplyObserver>>();
259        let boxed: Box<dyn ApplyObserver> = Box::new(NoopObserver);
260        std::thread::spawn(move || {
261            let _ = boxed;
262        })
263        .join()
264        .unwrap();
265    }
266
267    // --- NoopObserver ---
268
269    #[test]
270    fn noop_observer_on_chunk_applied_always_continues() {
271        let mut obs = NoopObserver;
272        let ev = ChunkEvent::new(0, ChunkTag::ADIR, 32);
273        assert_eq!(
274            obs.on_chunk_applied(ev),
275            ControlFlow::Continue(()),
276            "NoopObserver must never break"
277        );
278    }
279
280    #[test]
281    fn noop_observer_should_cancel_always_false() {
282        let mut obs = NoopObserver;
283        // Call multiple times; must stay false — never flip to true.
284        for _ in 0..5 {
285            assert!(
286                !obs.should_cancel(),
287                "NoopObserver must never request cancellation"
288            );
289        }
290    }
291
292    // --- ChunkEvent ---
293
294    #[test]
295    fn chunk_event_new_stores_all_fields_exactly() {
296        let ev = ChunkEvent::new(7, ChunkTag::SQPK, 1024);
297        assert_eq!(ev.index, 7, "index field mismatch");
298        assert_eq!(ev.kind, ChunkTag::SQPK, "kind field mismatch");
299        assert_eq!(ev.bytes_read, 1024, "bytes_read field mismatch");
300    }
301
302    #[test]
303    fn chunk_event_clone_and_eq_are_consistent() {
304        let ev = ChunkEvent::new(3, ChunkTag::ADIR, 512);
305        let cloned = ev;
306        assert_eq!(
307            ev, cloned,
308            "ChunkEvent must be Copy/Eq with field-wise equality"
309        );
310    }
311
312    #[test]
313    fn chunk_event_kind_str_returns_some_for_ascii_tag() {
314        let ev = ChunkEvent::new(0, ChunkTag::ADIR, 0);
315        assert_eq!(ev.kind_str(), Some("ADIR"));
316        let ev = ChunkEvent::new(0, ChunkTag::SQPK, 0);
317        assert_eq!(ev.kind_str(), Some("SQPK"));
318    }
319
320    #[test]
321    fn chunk_event_kind_str_returns_none_for_invalid_utf8() {
322        let ev = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0xFD, 0xFC]), 0);
323        assert_eq!(ev.kind_str(), None, "non-UTF-8 tag bytes must produce None");
324    }
325}