libsurfer/
state_file_io.rs1use 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#[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
30fn 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"))]
48fn state_file_dialog_extension() -> &'static str {
53 #[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")))]
65fn state_file_dialog_extension() -> &'static str {
70 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
82fn 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 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 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 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 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 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 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 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 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 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 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}