forked from 1b-agent/fieldcraft-upstream
The CPU stays the default, so every existing result and fixture is bit-identical; "gpu" steps on the device FIELDCRAFT_GPU picks through the registered backend, and is an error when the build has none. The desktop app and the CLI (hence the MCP server) register fieldcraft-lbm-gpu at start; the summary names what stepped. Hostile values are errors. Part of #529.
366 lines
16 KiB
Rust
366 lines
16 KiB
Rust
//! 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<Autosaver>,
|
||
/// 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<usage::Sender>,
|
||
}
|
||
|
||
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::<String>()));
|
||
}
|
||
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<crash::Summary> {
|
||
// 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<String>,
|
||
control: Option<u16>,
|
||
/// Files to open (models, designs, tagged meshes).
|
||
files: Vec<String>,
|
||
}
|
||
|
||
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Result<Option<Args>, 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()
|
||
.with_title("FieldCraft")
|
||
.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(
|
||
"FieldCraft",
|
||
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<Option<Args>, 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));
|
||
}
|
||
}
|