Skip to main content

surfer/
main.rs

1#![cfg_attr(not(target_arch = "wasm32"), deny(unused_crate_dependencies))]
2#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
4#[cfg(not(target_arch = "wasm32"))]
5mod main_impl {
6    use camino::Utf8PathBuf;
7    use clap::Parser;
8    use emath::Vec2;
9    use eyre::Result;
10    use eyre::WrapErr as _;
11    use libsurfer::{
12        EGUI_CONTEXT, StartupParams, SystemState,
13        batch_commands::read_command_file,
14        file_watcher::FileWatcher,
15        logs,
16        message::Message,
17        run_egui,
18        wave_source::{WaveSource, string_to_wavesource},
19    };
20    use tracing::error;
21
22    #[derive(clap::Subcommand)]
23    enum Commands {
24        #[cfg(not(target_arch = "wasm32"))]
25        /// starts surfer in headless mode so that a user can connect to it
26        Server {
27            /// port on which server will listen
28            #[clap(long)]
29            port: Option<u16>,
30            /// IP address to bind the server to
31            #[clap(long)]
32            bind_address: Option<String>,
33            /// token used by the client to authenticate to the server
34            #[clap(long)]
35            token: Option<String>,
36            /// waveform file that we want to serve
37            #[arg(long)]
38            file: String,
39        },
40    }
41
42    #[derive(clap::Parser, Default)]
43    #[command(version = concat!(env!("CARGO_PKG_VERSION"), " (git: ", env!("VERGEN_GIT_DESCRIBE"), ")"), about)]
44    struct Args {
45        /// Waveform file in VCD, FST, or GHW format.
46        wave_file: Option<String>,
47        /// Path to a file containing 'commands' to run after a waveform has been loaded.
48        /// The commands are the same as those used in the command line interface inside the program.
49        /// Commands are separated by lines or ;. Empty lines are ignored. Line comments starting with
50        /// `#` are supported
51        /// NOTE: This feature is not permanent, it will be removed once a solid scripting system
52        /// is implemented.
53        #[clap(long, short, verbatim_doc_comment)]
54        command_file: Option<Utf8PathBuf>,
55        /// Alias for --`command_file` to support `VUnit`
56        #[clap(long)]
57        script: Option<Utf8PathBuf>,
58
59        #[clap(long, short)]
60        /// Load previously saved state file
61        state_file: Option<Utf8PathBuf>,
62
63        #[clap(long, action)]
64        /// Port for WCP to connect to
65        wcp_initiate: Option<u16>,
66
67        #[command(subcommand)]
68        command: Option<Commands>,
69    }
70
71    impl Args {
72        pub fn command_file(&self) -> Option<&Utf8PathBuf> {
73            match (&self.command_file, &self.script) {
74                (Some(_), Some(_)) => {
75                    error!("At most one of --command_file and --script can be used");
76                    None
77                }
78                (Some(cf), None) => Some(cf),
79                (None, Some(sc)) => Some(sc),
80                (None, None) => None,
81            }
82        }
83    }
84
85    #[allow(dead_code)] // NOTE: Only used in desktop version
86    fn startup_params_from_args(args: Args) -> StartupParams {
87        let startup_commands = args
88            .command_file()
89            .map(read_command_file)
90            .unwrap_or_default();
91        StartupParams {
92            waves: args.wave_file.map(|s| string_to_wavesource(&s)),
93            wcp_initiate: args.wcp_initiate,
94            startup_commands,
95        }
96    }
97
98    #[cfg(not(target_arch = "wasm32"))]
99    pub(crate) fn main() -> Result<()> {
100        use egui::Pos2;
101        use libsurfer::state::UserState;
102        #[cfg(feature = "wasm_plugins")]
103        use libsurfer::translation::wasm_translator::discover_wasm_translators;
104        simple_eyre::install()?;
105
106        logs::start_logging()?;
107
108        std::panic::set_hook(Box::new(panic_handler));
109
110        // https://tokio.rs/tokio/topics/bridging
111        // We want to run the gui in the main thread, but some long running tasks like
112        // loading VCDs should be done asynchronously. We can't just use std::thread to
113        // do that due to wasm support, so we'll start a tokio runtime
114        let runtime = tokio::runtime::Builder::new_current_thread()
115            .worker_threads(1)
116            .enable_all()
117            .build()
118            .unwrap();
119
120        // parse arguments
121        let args = Args::parse();
122        #[cfg(not(target_arch = "wasm32"))]
123        if let Some(Commands::Server {
124            port,
125            bind_address,
126            token,
127            file,
128        }) = args.command
129        {
130            let config = SystemState::new()?.user.config;
131
132            // Use CLI override if provided, otherwise use config setting
133            let bind_addr = bind_address.unwrap_or(config.server.bind_address);
134            let port = port.unwrap_or(config.server.port);
135
136            let res = runtime.block_on(surver::surver_main(port, bind_addr, token, &[file], None));
137            return res;
138        }
139
140        let _enter = runtime.enter();
141
142        std::thread::spawn(move || {
143            runtime.block_on(async {
144                loop {
145                    tokio::time::sleep(tokio::time::Duration::from_hours(1)).await;
146                }
147            });
148        });
149
150        let state_file = args.state_file.clone();
151        let startup_params = startup_params_from_args(args);
152        let waves = startup_params.waves.clone();
153
154        let state = match &state_file {
155            Some(file) => std::fs::read_to_string(file)
156                .with_context(|| format!("Failed to read state from {file}"))
157                .and_then(|content| {
158                    ron::from_str::<UserState>(&content)
159                        .with_context(|| format!("Failed to decode state from {file}"))
160                })
161                .map(SystemState::from)
162                .map(|mut s| {
163                    s.user.state_file = Some(file.into());
164                    s
165                })
166                .or_else(|e| {
167                    error!("Failed to read state file. Opening fresh session\n{e:#?}");
168                    SystemState::new()
169                })?,
170            None => SystemState::new()?,
171        }
172        .with_params(startup_params);
173
174        #[cfg(feature = "wasm_plugins")]
175        {
176            // Not using batch commands here as we want to start processing wasm plugins
177            // as soon as we start up, no need to wait for the waveform to load
178            let sender = state.channels.msg_sender.clone();
179            for message in discover_wasm_translators() {
180                if let Err(e) = sender.send(message) {
181                    error!("Failed to send message: {e}");
182                }
183            }
184        }
185        // install a file watcher that emits a `SuggestReloadWaveform` message
186        // whenever the user-provided file changes.
187        let _watcher = match waves {
188            Some(WaveSource::File(path)) => {
189                let sender = state.channels.msg_sender.clone();
190                FileWatcher::new(&path, move || {
191                    if let Err(e) = sender.send(Message::SuggestReloadWaveform) {
192                        error!("Message ReloadWaveform did not send:\n{e}");
193                    }
194                    // Force refresh UI to process messages. Otherwise, it is
195                    // deferred until a UI event occurs (like mouseover)
196                    if let Some(ctx) = EGUI_CONTEXT.read().unwrap().as_ref() {
197                        ctx.request_repaint();
198                    }
199                })
200                .inspect_err(|err| error!("Cannot set up the file watcher:\n{err}"))
201                .ok()
202            }
203            _ => None,
204        };
205
206        // Load icon using png crate
207        let icon_bytes = include_bytes!("../assets/com.gitlab.surferproject.surfer.png");
208        let decoder = png::Decoder::new(std::io::Cursor::new(&icon_bytes[..]));
209        let mut reader = decoder.read_info().expect("Failed to read PNG info");
210        let mut icon_data = vec![
211            0;
212            reader
213                .output_buffer_size()
214                .expect("Failed to calculate PNG buffer size")
215        ];
216        let info = reader
217            .next_frame(&mut icon_data)
218            .expect("Failed to decode PNG");
219
220        let options = eframe::NativeOptions {
221            viewport: egui::ViewportBuilder::default()
222                .with_app_id("org.surfer-project.surfer")
223                .with_title("Surfer")
224                .with_icon(egui::viewport::IconData {
225                    rgba: icon_data,
226                    width: info.width,
227                    height: info.height,
228                })
229                .with_inner_size(Vec2::new(
230                    state.user.config.layout.window_width as f32,
231                    state.user.config.layout.window_height as f32,
232                ))
233                .with_position(Pos2::new(
234                    state.user.config.layout.window_x_position as f32,
235                    state.user.config.layout.window_y_position as f32,
236                )),
237            ..Default::default()
238        };
239
240        eframe::run_native("Surfer", options, Box::new(|cc| Ok(run_egui(cc, state)?))).unwrap();
241
242        Ok(())
243    }
244
245    fn panic_handler(info: &std::panic::PanicHookInfo) {
246        let backtrace = std::backtrace::Backtrace::force_capture();
247
248        eprintln!();
249        eprintln!("Surfer crashed due to a panic 😞");
250        eprintln!("Please report this issue at https://gitlab.com/surfer-project/surfer/-/issues");
251        eprintln!();
252        eprintln!("Some notes on reports:");
253        eprintln!(
254            "We are happy about any reports, but it makes it much easier for us to fix issues if you:",
255        );
256        eprintln!(" - Include the information below");
257        eprintln!(" - Try to reproduce the issue to give us steps on how to reproduce the issue");
258        eprintln!(" - Include (minimal) waveform file and state file you used");
259        eprintln!("   (you can upload those confidentially, for the surfer team only)");
260        eprintln!();
261
262        let location = info.location().unwrap();
263        let msg = if let Some(msg) = info.payload().downcast_ref::<&str>() {
264            (*msg).to_string()
265        } else if let Some(msg) = info.payload().downcast_ref::<String>() {
266            msg.clone()
267        } else {
268            "<panic message not a string>".to_owned()
269        };
270
271        eprintln!(
272            "Surfer version: {} (git: {})",
273            env!("CARGO_PKG_VERSION"),
274            env!("VERGEN_GIT_DESCRIBE"),
275        );
276        eprintln!(
277            "thread '{}' ({:?}) panicked at {}:{}:{:?}",
278            std::thread::current().name().unwrap_or("unknown"),
279            std::thread::current().id(),
280            location.file(),
281            location.line(),
282            location.column(),
283        );
284        eprintln!("  {msg}");
285        eprintln!();
286        eprintln!("backtrace:");
287        eprintln!("{backtrace}");
288    }
289
290    #[cfg(test)]
291    mod tests {
292        use super::*;
293
294        #[test]
295        fn command_file_prefers_single_sources() {
296            // Only --command_file
297            let args = Args::parse_from(["surfer", "--command-file", "C:/tmp/cmds.sucl"]);
298            let cf = args.command_file().unwrap();
299            assert!(cf.ends_with("cmds.sucl"));
300
301            // Only --script
302            let args = Args::parse_from(["surfer", "--script", "C:/tmp/scr.sucl"]);
303            let cf = args.command_file().unwrap();
304            assert!(cf.ends_with("scr.sucl"));
305        }
306
307        #[test]
308        fn command_file_conflict_returns_none() {
309            let args = Args::parse_from([
310                "surfer",
311                "--command-file",
312                "C:/tmp/cmds.sucl",
313                "--script",
314                "C:/tmp/scr.sucl",
315            ]);
316            assert!(args.command_file().is_none());
317        }
318    }
319}
320
321#[cfg(target_arch = "wasm32")]
322mod main_impl {
323    use libsurfer::logs;
324    use libsurfer::wasm_api::WebHandle;
325    use wasm_bindgen::JsCast;
326
327    // Calling main is not the intended way to start surfer, instead, it should be
328    // started by `wasm_api::WebHandle`
329    pub(crate) fn main() -> eyre::Result<()> {
330        simple_eyre::install()?;
331
332        logs::start_logging()?;
333
334        let document = web_sys::window()
335            .expect("No window")
336            .document()
337            .expect("No document");
338        let canvas = document
339            .get_element_by_id("the_canvas_id")
340            .expect("Failed to find the_canvas_id")
341            .dyn_into::<web_sys::HtmlCanvasElement>()
342            .expect("the_canvas_id was not a HtmlCanvasElement");
343
344        wasm_bindgen_futures::spawn_local(async {
345            let wh = WebHandle::new();
346            wh.start(canvas).await.expect("Failed to start surfer");
347        });
348
349        Ok(())
350    }
351}
352
353fn main() -> eyre::Result<()> {
354    main_impl::main()
355}