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_or("surfer_state", sanitize_file_stem);
110
111 format!("{stem}.{STATE_FILE_EXTENSION}")
112 }
113
114 #[cfg(all(target_arch = "wasm32", feature = "vscode"))]
115 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 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 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 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 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 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 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 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 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}