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_or("surfer_state", sanitize_file_stem);
110
111        format!("{stem}.{STATE_FILE_EXTENSION}")
112    }
113
114    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
115    /// Opens a state file through the VS Code host bridge in wasm+vscode builds.
116    pub(crate) fn load_state_file(&mut self, path: Option<PathBuf>) {
117        if path.is_some() {
118            return;
119        }
120
121        let filter = (
122            format!("Surfer state files (*.{STATE_FILE_EXTENSION})"),
123            vec![STATE_FILE_EXTENSION.to_string()],
124        );
125        vscode_open_dialog_with_filter("state_file", &filter);
126    }
127
128    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
129    /// Opens and decodes a state file in plain wasm/browser builds.
130    pub(crate) fn load_state_file(&mut self, path: Option<PathBuf>) {
131        if path.is_some() {
132            return;
133        }
134        let message = move |bytes: Vec<u8>| match ron::de::from_bytes(&bytes)
135            .context("Failed loading state file")
136        {
137            Ok(s) => vec![Message::LoadState(s, path)],
138            Err(e) => {
139                error!("Failed to load state: {e:#?}");
140                vec![]
141            }
142        };
143        let ext = state_file_dialog_extension();
144        self.file_dialog_open(
145            "Load state",
146            (
147                format!("Surfer state files (*.{STATE_FILE_EXTENSION})"),
148                vec![ext.to_string()],
149            ),
150            message,
151        );
152    }
153
154    #[cfg(not(target_arch = "wasm32"))]
155    /// Loads a state file from disk on native builds.
156    ///
157    /// When `path` is `None`, this opens a file picker and loads the selected file.
158    pub(crate) fn load_state_file(&mut self, path: Option<PathBuf>) {
159        let messages = move |path: PathBuf| {
160            let source = if let Ok(p) = Utf8PathBuf::from_path_buf(path.clone()) {
161                p
162            } else {
163                let err = eyre::eyre!("File path '{}' contains invalid UTF-8", path.display());
164                error!("{err:#?}");
165                return vec![Message::Error(err)];
166            };
167
168            match std::fs::read(source.as_std_path()) {
169                Ok(bytes) => match ron::de::from_bytes(&bytes)
170                    .context(format!("Failed loading {}", source.as_str()))
171                {
172                    Ok(s) => vec![Message::LoadState(s, Some(path))],
173                    Err(e) => {
174                        error!("Failed to load state: {e:#?}");
175                        vec![Message::Error(e)]
176                    }
177                },
178                Err(e) => {
179                    error!("Failed to load state file: {path:#?} {e:#?}");
180                    vec![Message::Error(eyre::eyre!(
181                        "Failed to read state file '{}': {e}",
182                        path.display()
183                    ))]
184                }
185            }
186        };
187        if let Some(path) = path {
188            let sender = self.channels.msg_sender.clone();
189            checked_send_many(&sender, messages(path));
190        } else {
191            let ext = state_file_dialog_extension();
192            self.file_dialog_open(
193                "Load state",
194                (
195                    format!("Surfer state files (*.{STATE_FILE_EXTENSION})"),
196                    vec![ext.to_string()],
197                ),
198                messages,
199            );
200        }
201    }
202
203    #[cfg(not(target_arch = "wasm32"))]
204    /// Saves the current state to disk on native builds.
205    ///
206    /// When `path` is `None`, this opens a save dialog with a suggested filename.
207    pub(crate) fn save_state_file(&mut self, path: Option<PathBuf>) {
208        let Some(encoded) = self.encode_state() else {
209            return;
210        };
211
212        let messages = async move |destination: FileHandle| {
213            destination
214                .write(encoded.as_bytes())
215                .await
216                .map_err(|e| error!("Failed to write state to {destination:#?} {e:#?}"))
217                .ok();
218            vec![
219                Message::SetStateFile(destination.path().into()),
220                Message::AsyncDone(AsyncJob::SaveState),
221            ]
222        };
223        if let Some(path) = path {
224            let sender = self.channels.msg_sender.clone();
225            perform_async_work(async move {
226                checked_send_many(&sender, messages(path.into()).await);
227            });
228        } else {
229            let ext = state_file_dialog_extension();
230
231            self.file_dialog_save(
232                "Save state",
233                (
234                    format!("Surfer state files (*.{STATE_FILE_EXTENSION})"),
235                    vec![ext.to_string()],
236                ),
237                Some(self.default_state_file_name()),
238                messages,
239            );
240        }
241    }
242
243    #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
244    /// Saves state in wasm+vscode builds by sending it to the extension host.
245    ///
246    /// The webview cannot use `showSaveFilePicker`, so the host is responsible
247    /// for showing the dialog and writing bytes.
248    pub(crate) fn save_state_file(&mut self, _path: Option<PathBuf>) {
249        let Some(encoded) = self.encode_state() else {
250            return;
251        };
252        let file_name = self.default_state_file_name();
253
254        // In the VS Code webview, `showSaveFilePicker` is not available.
255        // Send the encoded state to the extension host via the JS bridge so
256        // the host can show a native VS Code save dialog and write the file.
257        let msg = serde_json::json!({
258            "command": "vscodeSaveStateFromWasm",
259            "data": encoded,
260            "fileName": file_name,
261        });
262        surfer_notify_host(&msg.to_string());
263    }
264
265    #[cfg(all(target_arch = "wasm32", not(feature = "vscode")))]
266    /// Saves state in plain wasm/browser builds via the browser save dialog.
267    pub(crate) fn save_state_file(&mut self, path: Option<PathBuf>) {
268        if path.is_some() {
269            return;
270        }
271        let Some(encoded) = self.encode_state() else {
272            return;
273        };
274        let messages = async move |destination: FileHandle| {
275            destination
276                .write(encoded.as_bytes())
277                .await
278                .map_err(|e| error!("Failed to write state to {destination:#?} {e:#?}"))
279                .ok();
280            vec![Message::AsyncDone(AsyncJob::SaveState)]
281        };
282        let ext = state_file_dialog_extension();
283        self.file_dialog_save(
284            "Save state",
285            (
286                format!("Surfer state files (*.{STATE_FILE_EXTENSION})"),
287                vec![ext.to_string()],
288            ),
289            Some(self.default_state_file_name()),
290            messages,
291        );
292    }
293
294    /// Serializes the current user state into pretty-printed RON.
295    pub(crate) fn encode_state(&self) -> Option<String> {
296        let opt = ron::Options::default();
297
298        opt.to_string_pretty(&self.user, ron::ser::PrettyConfig::default())
299            .context("Failed to encode state")
300            .map_err(|e| error!("Failed to encode state. {e:#?}"))
301            .ok()
302    }
303
304    /// Decodes RON bytes and enqueues a `LoadState` message on success.
305    pub(crate) fn load_state_from_bytes(&mut self, bytes: &[u8]) {
306        match ron::de::from_bytes(bytes).context("Failed loading state from bytes") {
307            Ok(s) => {
308                let sender = self.channels.msg_sender.clone();
309                checked_send(&sender, Message::LoadState(s, None));
310            }
311            Err(e) => {
312                error!("Failed to load state: {e:#?}");
313            }
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::StartupParams;
322    use crate::wave_source::WaveSource;
323
324    #[test]
325    fn test_encode_state() {
326        let state = SystemState::new_default_config()
327            .unwrap()
328            .with_params(StartupParams::default());
329        let encoded = state.encode_state();
330        assert!(encoded.is_some());
331        let encoded = encoded.unwrap();
332        assert!(encoded.contains("show_about"));
333    }
334
335    #[test]
336    fn test_load_state_from_bytes() {
337        let mut state = SystemState::new_default_config()
338            .unwrap()
339            .with_params(StartupParams::default());
340        let encoded = state.encode_state().unwrap();
341        let bytes = encoded.as_bytes();
342
343        state.load_state_from_bytes(bytes);
344
345        let msg = state.channels.msg_receiver.try_recv().unwrap();
346        match msg {
347            Message::LoadState(..) => {}
348            _ => panic!("Expected LoadState message, got {:?}", msg),
349        }
350    }
351
352    #[test]
353    fn test_source_file_stem_from_file_and_url() {
354        let file = WaveSource::File("examples/counter.vcd".into());
355        assert_eq!(source_file_stem(&file), Some("counter"));
356
357        let url = WaveSource::Url("https://example.com/some/path/demo.fst?x=1#top".to_string());
358        assert_eq!(source_file_stem(&url), Some("demo"));
359    }
360
361    #[test]
362    fn test_source_file_stem_url_without_filename() {
363        let url = WaveSource::Url("https://example.com/some/path/".to_string());
364        assert_eq!(source_file_stem(&url), None);
365    }
366
367    #[test]
368    fn test_sanitize_file_stem() {
369        assert_eq!(sanitize_file_stem("counter"), "counter");
370        assert_eq!(sanitize_file_stem("  counter.  "), "counter");
371        assert_eq!(sanitize_file_stem(""), "surfer_state");
372        assert_eq!(sanitize_file_stem("..."), "surfer_state");
373        assert_eq!(sanitize_file_stem("bad:name"), "surfer_state");
374    }
375}