//! FieldCraft desktop app. //! //! Usage: `fieldcraft [--sample [NAME]] [--control PORT] [FILE…]` (files open as if dropped on the //! window; NAME is a sample of `fieldcraft_engine::sample`, `parts` by default) //! //! `--sample` opens a sample model (`fieldcraft_engine::sample`). `--control PORT` (or //! `FIELDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"engine.execute","params":{"command":"model.info","params":{}}}`. See //! `fieldcraft_ui_egui::control` and docs/control-protocol.md. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] // Unsafe code is denied (Cargo.toml): only the Windows exception filter allows it (native_crash.rs). mod control_server; #[cfg(windows)] mod native_crash; mod terminate; mod update; mod usage; use fieldcraft_engine::autosave::{self, Autosaver}; use fieldcraft_engine::{Session, crash, logfile}; use fieldcraft_ui_egui::{Filter, ModelApp, Services}; const USAGE: &str = "usage: fieldcraft [--sample [NAME]] [--control PORT] [FILE…] (NAME: a sample, docs/examples.md; FILE: .fieldcraft, .solidcraft or .3mf)"; const DEPTH_BITS: u8 = 24; /// The window draws its own caption buttons where the OS ones would sit apart from the app bar. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); const MSAA: u16 = 4; /// The reverse-DNS application id: the Wayland app_id / X11 class, matched to the packaged /// `.desktop` entry (packaging/linux), and the macOS bundle id (packaging/macos/Info.plist.in). const APP_ID: &str = "io.github.jyviko.fieldcraft"; /// The window and Dock icon: the 256 px export of the application icon (assets/app-icon). const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png"); /// The application icon for the window; eframe's default icon if the PNG cannot be decoded. fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(ICON_PNG).unwrap_or_else(|e| { log::warn!("app icon: {e}"); egui::IconData::default() }) } /// How often unsaved changes are autosaved for recovery (seconds). const AUTOSAVE_SECONDS: f64 = 30.0; struct App { app: ModelApp, /// Autosave for crash recovery (`None` when the recovery folder is unavailable). autosaver: Option, /// App time of the last autosave check. autosaved_at: f64, /// Frames drawn so far, up to 2 (the second means the first was presented: the app is up). frames: u8, /// Sends the anonymous usage events, when this run may (docs/privacy.md). usage: Option, } impl App { fn autosave(&mut self) { if let Some(a) = &mut self.autosaver && let Err(e) = a.save(&self.app.session) { log::warn!("autosave: {e}"); } } } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.frames < 2 { self.frames += 1; // A second frame right away, which means the first was presented: the app is up. ctx.request_repaint(); if self.frames == 2 { crash::phase(crash::RUNNING); } } self.app.logic(ctx); if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS { self.autosaved_at = self.app.now; self.autosave(); } if self.autosaver.is_some() { ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS)); } } fn on_exit(&mut self) { if let Some(a) = self.autosaver.take() && let Err(e) = a.close(&self.app.session) { log::warn!("autosave: {e}"); } self.app.usage_closing(); if let Some(u) = self.usage.take() { u.finish(); } // An update the user chose to install: swapped in now that the window has closed. if let Some(staged) = self.app.take_staged_update() { update::finish(&staged); } log::info!("FieldCraft closed"); crash::end_session(); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.app.ui(ui); } } /// Native file pickers and the recent-files file, for the UI. fn services() -> Services { let dialog = |filters: &[Filter]| filters.iter().fold(rfd::FileDialog::new(), |d, f| d.add_filter(f.name, f.extensions)); Services { pick_open: Some(Box::new(move |filters: &[Filter]| dialog(filters).pick_file().map(|p| p.to_string_lossy().to_string()))), pick_save: Some(Box::new(move |name: &str, filters: &[Filter]| { dialog(filters).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })), // Beside the recovery folder in the per-user data folder. recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))), prefs: fieldcraft_io::recovery::default_dir() .and_then(|d| d.parent().map(|p| fieldcraft_ui_egui::files::PrefsStore::file(p.join("prefs.json")))), open_url: Some(Box::new(open_url)), } } /// Open a web link in the default browser, with the system's own opener: `open` (macOS), /// `xdg-open` (Linux and the BSDs), `explorer` (Windows). The link is one argument, never /// passed through a shell. Only https links are opened. fn open_url(url: &str) -> Result<(), String> { if !url.starts_with("https://") { return Err(format!("not a web link: {}", url.chars().take(80).collect::())); } let opener = if cfg!(target_os = "macos") { "open" } else if cfg!(windows) { "explorer" } else { "xdg-open" }; let mut child = std::process::Command::new(opener).arg(url).spawn().map_err(|e| format!("{opener}: {e}"))?; // Reaped off the window thread; its exit status says nothing (`explorer` exits with 1 even // when it opened the link). std::thread::spawn(move || { let _ = child.wait(); }); Ok(()) } /// Crash reports and the log file (docs/crash-reports.md): reports of earlier runs that crashed /// come back, for the window to offer. Both live in the app's data folder (a temporary one in /// runs driven by automation); `FIELDCRAFT_CRASH_DIR` moves the crash folder. fn start_reporting() -> Vec { // First the crash reports: an unclean exit's report takes the dead run's lines from the log. let crashed = crash::default_dir().map(|dir| crash::install(dir, crash::Build::current())).unwrap_or_default(); match fieldcraft_io::recovery::data_dir().map(|d| logfile::install(&d.join("logs"), log::LevelFilter::Info)) { Some(Ok(path)) => eprintln!("fieldcraft: log {}", path.display()), Some(Err(e)) => eprintln!("fieldcraft: no log file: {e}"), None => {} } let b = crash::Build::current(); log::info!( "FieldCraft {} started ({}), crash reports in {}", b.version, b.version_line(), crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default() ); crashed } /// egui-wgpu is about to start a step of getting the graphics device and the window's surface /// up: the crash journal keeps it, and the adapter once it is picked (#299). fn startup_step(step: &'static str, detail: &str) { if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN { crash::set_environment(&format!("Renderer: {detail}")); } crash::phase(step); } /// Command-line options. #[derive(Debug, Default, PartialEq)] struct Args { /// The sample to open (`--sample [NAME]`). sample: Option, control: Option, /// Files to open (models, designs, tagged meshes). files: Vec, } fn parse_args(args: impl IntoIterator, env_port: Option) -> Result, String> { let mut out = Args { control: env_port.and_then(|p| p.trim().parse().ok()), ..Default::default() }; let mut args = args.into_iter().peekable(); while let Some(a) = args.next() { match a.as_str() { "--sample" => { // An optional sample name follows. let name = args.peek().filter(|a| fieldcraft_engine::sample::find(a).is_some()).cloned(); if name.is_some() { args.next(); } out.sample = Some(name.unwrap_or_else(|| "parts".into())); } "--control" => { let port = args.next().ok_or("--control needs a port")?; out.control = Some(port.parse().map_err(|_| format!("--control: `{port}` is not a port number"))?); } "--version" | "-V" => { println!("fieldcraft {}", fieldcraft_ui_egui::VERSION); return Ok(None); } "--help" | "-h" => { println!("{USAGE}"); return Ok(None); } other if other.starts_with('-') => return Err(format!("unknown argument `{other}`\n{USAGE}")), file => out.files.push(file.to_string()), } } Ok(Some(out)) } fn main() -> eframe::Result { // The lattice Boltzmann GPU backend, for `solve.fluid` with `backend: "gpu"`. fieldcraft_lbm_gpu::register(); let args = match parse_args(std::env::args().skip(1), std::env::var("FIELDCRAFT_CONTROL_PORT").ok()) { Ok(Some(a)) => a, Ok(None) => return Ok(()), Err(e) => { eprintln!("fieldcraft: {e}"); std::process::exit(2); } }; if args.control.is_some() { // A run driven by automation keeps its autosaves (and recent files and preferences, which // live beside them), its crash reports and its log in a temporary folder, never the user's. fieldcraft_io::recovery::use_automation_dir(); } let crashed = start_reporting(); // A crash in native code (a graphics driver) says where it happened: the exception and the // module on Windows, and on every system the startup step the app was in. #[cfg(windows)] native_crash::install(); eframe::egui_wgpu::set_startup_hook(startup_step); crash::phase("window"); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() // OneBSpace (OneCAD Simulation): the window title names the fork (the APP_ID, which // the packaged .desktop entries and the bundle id match, stays upstream). .with_title("OneCAD Simulation") .with_app_id(APP_ID) .with_icon(app_icon()) .with_inner_size([1400.0, 900.0]) .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) // The app bar is the title bar (as in the other Craft apps): on macOS under the // traffic lights, elsewhere with its own caption buttons and resize zones. .with_decorations(!CUSTOM_TITLEBAR) .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false), depth_buffer: DEPTH_BITS, multisampling: MSAA, ..Default::default() }; eframe::run_native( // OneBSpace (OneCAD Simulation): the app name in the window title. "OneCAD Simulation", options, Box::new(move |cc| { crash::phase("app setup"); let mut session = Session::default(); // The window's model: its commands go into the crash journal. session.crash_journal = crash::enabled(); let mut app = ModelApp::new(session).with_services(services()); // Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md). let usage = match usage::host(args.control.is_some()) { Some(host) => { app = app.with_usage(host); usage::start() } None => None, }; // Updates (docs/updates.md): checked at most daily when on, installed only on a click. fieldcraft_engine::enable_update_checks(true); app = app.with_updates(update::host(cc.egui_ctx.clone(), args.control.is_some())); app.custom_titlebar = CUSTOM_TITLEBAR; app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(rs) = &cc.wgpu_render_state { let info = rs.adapter.get_info(); log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples); crash::set_environment(&format!("Renderer: {:?} {} ({}), MSAA {}x", info.backend, info.name, info.driver, rs.msaa_samples)); crash::phase("viewport pipelines (shader compile)"); app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx); // The graphics driver is loaded now; it may have put its own exception filter // in front of ours. #[cfg(windows)] native_crash::reinstall(); } // `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248). let ctx = cc.egui_ctx.clone(); if let Err(e) = terminate::watch(move || { ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.request_repaint(); }) { log::warn!("termination signals: {e}"); } if let Some(port) = args.control { app = app.with_control(control_server::start(port, cc.egui_ctx.clone())); } if let Some(name) = &args.sample { app.open_sample(name); } for f in &args.files { app.open_path(f); } let autosaver = autosave::default_dir().and_then(|dir| { let listed = app.session.execute("file.recovery.list", &serde_json::json!({"dir": dir})); let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0); if recoverable > 0 { app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false); } Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok() }); app.announce_crashes(&crashed); crash::phase("first frame"); Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, frames: 0, usage })) }), ) } #[cfg(test)] mod tests { #[test] fn the_app_icon_decodes() { let icon = super::app_icon(); assert_eq!((icon.width, icon.height), (256, 256)); assert_eq!(icon.rgba.len(), 256 * 256 * 4); } use super::*; fn parse(a: &[&str], env: Option<&str>) -> Result, String> { parse_args(a.iter().map(|s| s.to_string()), env.map(str::to_string)) } #[test] fn arguments() { assert_eq!(parse(&[], None), Ok(Some(Args::default()))); assert_eq!( parse(&["--sample", "--control", "7070"], None), Ok(Some(Args { sample: Some("parts".into()), control: Some(7070), files: vec![] })) ); assert_eq!(parse(&[], Some("9000")), Ok(Some(Args { control: Some(9000), ..Default::default() }))); assert_eq!(parse(&[], Some("nope")), Ok(Some(Args::default()))); assert!(parse(&["--control"], None).is_err()); assert!(parse(&["--control", "99999"], None).is_err()); assert!(parse(&["--frobnicate"], None).is_err()); assert_eq!(parse(&["--sample", "impact"], None).map(|a| a.and_then(|a| a.sample)), Ok(Some("impact".into()))); let a = parse(&["--sample", "x.fieldcraft"], None).unwrap().unwrap(); assert_eq!((a.sample.as_deref(), a.files), (Some("parts"), vec!["x.fieldcraft".to_string()])); assert_eq!(parse(&["a.fieldcraft", "--sample"], None).map(|a| a.map(|a| a.files)), Ok(Some(vec!["a.fieldcraft".to_string()]))); assert_eq!(parse(&["--help"], None), Ok(None)); } }