Skip to main content

libsurfer/
state_file_io.rs

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