Files
OneCAD-Simulation/apps/fieldcraft/src/main.rs
T
jyvikoandClaude Opus 5.5 7dd50a3ca4 App: run update jobs in the background and swap the release in after closing (#231)
The desktop app checks, downloads, verifies and stages on its own threads for the window, and
once the window has closed (autosave closed) swaps a staged release in and starts it. Staging
folders an earlier update left are removed at start-up. Automatic checks stay off under
--control automation and CI unless FIELDCRAFT_UPDATE_HOST names a test server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00

327 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)]
#![forbid(unsafe_code)]
mod control_server;
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,
/// The first frame was drawn (the crash journal says the app is up).
running: bool,
/// 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.running {
self.running = true;
crash::activity("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()
);
crash::activity("startup");
crashed
}
/// 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 {
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();
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| {
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);
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
}
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);
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, 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));
}
}