libsurfer/
state_file_io.rs1use 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#[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
33pub(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"))]
51fn 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")))]
64fn 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
77pub(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 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 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 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 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 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 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 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 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 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 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}