Skip to main content

phasm_core/stego/
progress.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Global decode progress tracking.
6//!
7//! Uses atomics so it is safe to call from rayon worker threads.
8//! When the `wasm` feature is enabled, an optional JS callback is invoked
9//! on each `advance()` to drive a real-time progress bar via Web Worker
10//! `postMessage`.
11
12use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
13
14use super::error::StegoError;
15
16static STEP: AtomicU32 = AtomicU32::new(0);
17static TOTAL: AtomicU32 = AtomicU32::new(0);
18static CANCELLED: AtomicBool = AtomicBool::new(false);
19
20/// Reset progress to 0 and set the total step count.
21/// Also resets the cancellation flag so a fresh decode starts clean.
22pub fn init(total: u32) {
23    CANCELLED.store(false, Ordering::Relaxed);
24    STEP.store(0, Ordering::Relaxed);
25    TOTAL.store(total, Ordering::Relaxed);
26    notify();
27}
28
29/// Set (or update) the total without resetting the current step.
30/// Used by pipeline code that discovers the real total mid-flight
31/// (e.g. after counting delta candidates).
32pub fn set_total(total: u32) {
33    TOTAL.store(total, Ordering::Relaxed);
34    notify();
35}
36
37/// Request cancellation of the current decode operation.
38///
39/// The decode pipeline checks this flag at natural loop boundaries and
40/// returns `Err(StegoError::Cancelled)` when set.
41pub fn cancel() {
42    CANCELLED.store(true, Ordering::Relaxed);
43}
44
45/// Returns `true` if cancellation has been requested.
46pub fn is_cancelled() -> bool {
47    CANCELLED.load(Ordering::Relaxed)
48}
49
50/// Check for cancellation and return an error if requested.
51///
52/// Call this at natural loop boundaries in the decode pipeline to allow
53/// early termination without waiting for the full operation to complete.
54pub fn check_cancelled() -> Result<(), StegoError> {
55    if is_cancelled() {
56        Err(StegoError::Cancelled)
57    } else {
58        Ok(())
59    }
60}
61
62/// Advance progress by one step and notify the callback (if set).
63/// Step is capped at total to avoid displaying values like "84/15".
64/// When total is 0 (indeterminate), step still advances freely so that
65/// the UI can show activity; `init()` with the real total will follow.
66pub fn advance() {
67    let total = TOTAL.load(Ordering::Relaxed);
68    if total == 0 {
69        // Indeterminate phase — advance freely, UI should not display X/Y yet.
70        STEP.fetch_add(1, Ordering::Relaxed);
71    } else {
72        // Cap at total-1 so the bar never hits 100% before finish().
73        let _ = STEP.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |s| {
74            if s + 1 < total { Some(s + 1) } else { Some(s) }
75        });
76    }
77    notify();
78}
79
80/// Read the current (step, total) progress.
81pub fn get() -> (u32, u32) {
82    (STEP.load(Ordering::Relaxed), TOTAL.load(Ordering::Relaxed))
83}
84
85/// Advance progress by `n` steps.  Convenience wrapper that calls
86/// [`advance`] in a loop.
87pub fn advance_by(n: u32) {
88    for _ in 0..n {
89        advance();
90    }
91}
92
93/// Mark progress as complete (step = total) and notify.
94pub fn finish() {
95    let t = TOTAL.load(Ordering::Relaxed);
96    STEP.store(t, Ordering::Relaxed);
97    notify();
98}
99
100// ---------------------------------------------------------------------------
101// WASM callback (only compiled with the `wasm` feature)
102// ---------------------------------------------------------------------------
103
104#[cfg(feature = "wasm")]
105mod wasm_cb {
106    use std::cell::RefCell;
107
108    thread_local! {
109        static CALLBACK: RefCell<Option<js_sys::Function>> = RefCell::new(None);
110    }
111
112    pub fn set(cb: Option<js_sys::Function>) {
113        CALLBACK.with(|c: &RefCell<Option<js_sys::Function>>| *c.borrow_mut() = cb);
114    }
115
116    pub fn notify(step: u32, total: u32) {
117        CALLBACK.with(|c: &RefCell<Option<js_sys::Function>>| {
118            if let Some(ref f) = *c.borrow() {
119                let _ = f.call2(
120                    &wasm_bindgen::JsValue::NULL,
121                    &wasm_bindgen::JsValue::from(step),
122                    &wasm_bindgen::JsValue::from(total),
123                );
124            }
125        });
126    }
127}
128
129/// Set (or clear) the WASM progress callback. Only available with the `wasm` feature.
130#[cfg(feature = "wasm")]
131pub fn set_wasm_callback(cb: Option<js_sys::Function>) {
132    wasm_cb::set(cb);
133}
134
135#[cfg(feature = "wasm")]
136fn notify() {
137    let (s, t) = get();
138    wasm_cb::notify(s, t);
139}
140
141#[cfg(not(feature = "wasm"))]
142fn notify() {
143    // No-op on native — iOS/Android poll via FFI.
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn cancel_flag_propagates() {
152        // Reset state
153        init(10);
154        assert!(!is_cancelled());
155        assert!(check_cancelled().is_ok());
156
157        // Request cancellation
158        cancel();
159        assert!(is_cancelled());
160
161        // check_cancelled should return Err(Cancelled)
162        let err = check_cancelled().unwrap_err();
163        assert!(
164            matches!(err, StegoError::Cancelled),
165            "expected Cancelled, got {err:?}"
166        );
167
168        // Reset clears the cancel flag
169        init(5);
170        assert!(!is_cancelled());
171        assert!(check_cancelled().is_ok());
172    }
173}