Skip to main content

libsurfer/
state_file_io.rs

1use camino::Utf8PathBuf;
2use eyre::WrapErr as _;
3use rfd::FileHandle;
4use tracing::error;
5
6#[cfg(not(target_arch = "wasm32"))]
7use crate::async_util::perform_async_work;
8use crate::channels::{checked_send, checked_send_many};
9#[cfg(any(
10    target_os = "macos",
11    all(target_arch = "wasm32", not(feature = "vscode"))
12))]
13use crate::file_dialog::STATE_FILE_FILTER_MACOS;
14#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
15use crate::file_dialog::vscode_open_dialog_with_filter;
16use crate::file_dialog::{FileFilter, STATE_FILE_FILTER};
17
18use crate::{
19    SystemState,
20    async_util::AsyncJob,
21    message::Message,
22    wave_source::{STATE_FILE_EXTENSION, WaveSource},
23};
24
25// JS bridge function defined in integration.js; used to post messages to the
26// VS Code extension host (where `showSaveFilePicker` is not available).
27#[cfg(all(target_arch = "wasm32", feature = "vscode"))]
28#[wasm_bindgen::prelude::wasm_bindgen]
29extern "C" {
30    fn surfer_notify_host(message_json: &str);
31}
32
33/// Normalizes a suggested file stem into a safe, non-empty value.
34///
35/// Returns `fallback` when the input is blank or contains characters that
36/// are broadly invalid in file names across supported platforms.
37pub(crate) fn sanitize_file_stem<'a>(stem: &'a str, fallback: &'a str) -> &'a str {
38    let trimmed = stem.trim_matches([' ', '.']);
39    if trimmed.is_empty() {
40        return fallback;
41    }
42
43    let has_illegal = trimmed
44        .chars()
45        .any(|c| matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'));
46
47    if has_illegal { fallback } else { trimmed }
48}
49
50#[cfg(not(target_arch = "wasm32"))]
51// Mac cannot handle multiple extensions in the file dialogs.
52fn state_file_filter() -> &'static FileFilter {
53    #[cfg(target_os = "macos")]
54    {
55        &STATE_FILE_FILTER_MACOS
56    }
57    #[cfg(not(target_os = "macos"))]
58    {
59        &STATE_FILE_FILTER
60    }
61}
62
63#[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
64// Mac cannot handle multiple extensions in the file dialogs.
65fn state_file_filter() -> &'static FileFilter {
66    if web_sys::window()
67        .and_then(|w| w.navigator().platform().ok())
68        .map(|p| p.starts_with("Mac"))
69        .unwrap_or(false)
70    {
71        &STATE_FILE_FILTER_MACOS
72    } else {
73        &STATE_FILE_FILTER
74    }
75}
76
77/// Extracts a display-friendly base name from a wave source.
78///
79/// For URLs, query and fragment parts are stripped before computing the stem.
80pub(crate) fn source_file_stem(source: &WaveSource) -> Option<&str> {
81    match source {
82        WaveSource::File(path) | WaveSource::DragAndDrop(Some(path)) => path.file_stem(),
83        WaveSource::Url(url) => {
84            let trimmed = url.split(['?', '#']).next().unwrap_or(url.as_str());
85            let filename = trimmed.rsplit('/').next()?;
86            let stem = filename.rsplit_once('.').map_or(filename, |(head, _)| head);
87            if stem.is_empty() { None } else { Some(stem) }
88        }
89        WaveSource::Data | WaveSource::DragAndDrop(None) | WaveSource::Cxxrtl(_) => None,
90    }
91}
92
93impl SystemState {
94    /// Builds the suggested state-file name used by save dialogs.
95    ///
96    /// Uses the loaded wave source stem when available and falls back to
97    /// `surfer_state.surf.ron` semantics when no stable stem can be derived.
98    fn default_state_file_name(&self) -> String {
99        let stem = self
100            .user
101            .waves
102            .as_ref()
103            .and_then(|waves| source_file_stem(&waves.source))
104            .map_or("surfer_state", |stem| {
105                sanitize_file_stem(stem, "surfer_state")
106            });
107
108        format!("{stem}.{STATE_FILE_EXTENSION}")
109    }
110
111    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
112    /// Opens a state file through the VS Code host bridge in wasm+vscode builds.
113    pub(crate) fn load_state_file(&mut self, path: Option<Utf8PathBuf>) {
114        if path.is_some() {
115            return;
116        }
117
118        vscode_open_dialog_with_filter("state_file", &STATE_FILE_FILTER);
119    }
120
121    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
122    /// Opens and decodes a state file in plain wasm/browser builds.
123    pub(crate) fn load_state_file(&mut self, path: Option<Utf8PathBuf>) {
124        if path.is_some() {
125            return;
126        }
127        let message = move |bytes: Vec<u8>| match ron::de::from_bytes(&bytes)
128            .context("Failed loading state file")
129        {
130            Ok(s) => vec![Message::LoadState(s, path)],
131            Err(e) => {
132                error!("Failed to load state: {e:#?}");
133                vec![]
134            }
135        };
136        self.file_dialog_open("Load state", state_file_filter(), message);
137    }
138
139    #[cfg(not(target_arch = "wasm32"))]
140    /// Loads a state file from disk on native builds.
141    ///
142    /// When `path` is `None`, this opens a file picker and loads the selected file.
143    pub(crate) fn load_state_file(&mut self, path: Option<Utf8PathBuf>) {
144        let messages = move |source: Utf8PathBuf| match std::fs::read(source.as_std_path()) {
145            Ok(bytes) => match ron::de::from_bytes(&bytes)
146                .context(format!("Failed loading {}", source.as_str()))
147            {
148                Ok(s) => vec![Message::LoadState(s, Some(source))],
149                Err(e) => {
150                    error!("Failed to load state: {e:#?}");
151                    vec![]
152                }
153            },
154            Err(e) => {
155                error!("Failed to load state file: {source:#?} {e:#?}");
156                vec![]
157            }
158        };
159        if let Some(path) = path {
160            let sender = self.channels.msg_sender.clone();
161            checked_send_many(&sender, messages(path));
162        } else {
163            self.file_dialog_open("Load state", state_file_filter(), messages);
164        }
165    }
166
167    #[cfg(not(target_arch = "wasm32"))]
168    /// Saves the current state to disk on native builds.
169    ///
170    /// When `path` is `None`, this opens a save dialog with a suggested filename.
171    pub(crate) fn save_state_file(&mut self, path: Option<Utf8PathBuf>) {
172        let Some(encoded) = self.encode_state() else {
173            return;
174        };
175
176        let messages = async move |destination: FileHandle| {
177            destination
178                .write(encoded.as_bytes())
179                .await
180                .map_err(|e| error!("Failed to write state to {destination:#?} {e:#?}"))
181                .ok();
182            let state_file = match Utf8PathBuf::from_path_buf(destination.path().to_path_buf()) {
183                Ok(p) => p,
184                Err(p) => {
185                    error!("File path '{}' contains invalid UTF-8", p.display());
186                    return vec![Message::AsyncDone(AsyncJob::SaveState)];
187                }
188            };
189            vec![
190                Message::SetStateFile(state_file),
191                Message::AsyncDone(AsyncJob::SaveState),
192            ]
193        };
194        if let Some(path) = path {
195            let sender = self.channels.msg_sender.clone();
196            perform_async_work(async move {
197                checked_send_many(&sender, messages(path.into_std_path_buf().into()).await);
198            });
199        } else {
200            self.file_dialog_save(
201                "Save state",
202                state_file_filter(),
203                Some(self.default_state_file_name()),
204                messages,
205            );
206        }
207    }
208
209    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
210    /// Saves state in wasm+vscode builds by sending it to the extension host.
211    ///
212    /// The webview cannot use `showSaveFilePicker`, so the host is responsible
213    /// for showing the dialog and writing bytes.
214    pub(crate) fn save_state_file(&mut self, _path: Option<Utf8PathBuf>) {
215        let Some(encoded) = self.encode_state() else {
216            return;
217        };
218        let file_name = self.default_state_file_name();
219
220        // In the VS Code webview, `showSaveFilePicker` is not available.
221        // Send the encoded state to the extension host via the JS bridge so
222        // the host can show a native VS Code save dialog and write the file.
223        let msg = serde_json::json!({
224            "command": "vscodeSaveStateFromWasm",
225            "data": encoded,
226            "fileName": file_name,
227        });
228        surfer_notify_host(&msg.to_string());
229    }
230
231    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
232    /// Saves state in plain wasm/browser builds via the browser save dialog.
233    pub(crate) fn save_state_file(&mut self, path: Option<Utf8PathBuf>) {
234        if path.is_some() {
235            return;
236        }
237        let Some(encoded) = self.encode_state() else {
238            return;
239        };
240        let messages = async move |destination: FileHandle| {
241            destination
242                .write(encoded.as_bytes())
243                .await
244                .map_err(|e| error!("Failed to write state to {destination:#?} {e:#?}"))
245                .ok();
246            vec![Message::AsyncDone(AsyncJob::SaveState)]
247        };
248        self.file_dialog_save(
249            "Save state",
250            state_file_filter(),
251            Some(self.default_state_file_name()),
252            messages,
253        );
254    }
255
256    /// Serializes the current user state into pretty-printed RON.
257    pub(crate) fn encode_state(&self) -> Option<String> {
258        let opt = ron::Options::default();
259
260        opt.to_string_pretty(&self.user, ron::ser::PrettyConfig::default())
261            .context("Failed to encode state")
262            .map_err(|e| error!("Failed to encode state. {e:#?}"))
263            .ok()
264    }
265
266    /// Decodes RON bytes and enqueues a `LoadState` message on success.
267    pub(crate) fn load_state_from_bytes(&mut self, bytes: &[u8]) {
268        match ron::de::from_bytes(bytes).context("Failed loading state from bytes") {
269            Ok(s) => {
270                let sender = self.channels.msg_sender.clone();
271                checked_send(&sender, Message::LoadState(s, None));
272            }
273            Err(e) => {
274                error!("Failed to load state: {e:#?}");
275            }
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::StartupParams;
284    use crate::wave_source::WaveSource;
285
286    #[test]
287    fn test_encode_state() {
288        let state = SystemState::new_default_config()
289            .unwrap()
290            .with_params(StartupParams::default());
291        let encoded = state.encode_state();
292        assert!(encoded.is_some());
293        let encoded = encoded.unwrap();
294        assert!(encoded.contains("show_about"));
295    }
296
297    #[test]
298    fn test_load_state_from_bytes() {
299        let mut state = SystemState::new_default_config()
300            .unwrap()
301            .with_params(StartupParams::default());
302        let encoded = state.encode_state().unwrap();
303        let bytes = encoded.as_bytes();
304
305        state.load_state_from_bytes(bytes);
306
307        let msg = state.channels.msg_receiver.try_recv().unwrap();
308        match msg {
309            Message::LoadState(..) => {}
310            _ => panic!("Expected LoadState message, got {:?}", msg),
311        }
312    }
313
314    #[test]
315    fn test_source_file_stem_from_file_and_url() {
316        let file = WaveSource::File("examples/counter.vcd".into());
317        assert_eq!(source_file_stem(&file), Some("counter"));
318
319        let url = WaveSource::Url("https://example.com/some/path/demo.fst?x=1#top".to_string());
320        assert_eq!(source_file_stem(&url), Some("demo"));
321    }
322
323    #[test]
324    fn test_source_file_stem_url_without_filename() {
325        let url = WaveSource::Url("https://example.com/some/path/".to_string());
326        assert_eq!(source_file_stem(&url), None);
327    }
328
329    #[test]
330    fn test_sanitize_file_stem() {
331        assert_eq!(sanitize_file_stem("counter", "surfer_state"), "counter");
332        assert_eq!(
333            sanitize_file_stem("  counter.  ", "surfer_state"),
334            "counter"
335        );
336        assert_eq!(sanitize_file_stem("", "surfer_state"), "surfer_state");
337        assert_eq!(sanitize_file_stem("...", "surfer_state"), "surfer_state");
338        assert_eq!(
339            sanitize_file_stem("bad:name", "surfer_state"),
340            "surfer_state"
341        );
342    }
343}