The empty design shown at launch, New on a design that is still empty, file.new and the blank design left when the last tab closes all take the Units of new designs preference. Opened files and the sample part keep their own units. Fixes #1205. Co-authored-by: raythurman2386 <raythurman2386@users.noreply.github.com>
692 lines
33 KiB
Rust
692 lines
33 KiB
Rust
//! SolidCraft desktop app.
|
|
//!
|
|
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
|
|
//!
|
|
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
|
|
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
|
//!
|
|
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
|
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
|
|
//!
|
|
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
|
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
|
//! See `solidcraft_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)]
|
|
#![deny(unsafe_code)]
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
|
mod app_icon;
|
|
mod control_server;
|
|
#[cfg(target_os = "windows")]
|
|
mod native_crash;
|
|
#[cfg(target_os = "macos")]
|
|
mod open_documents;
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
mod space_mouse;
|
|
mod startup;
|
|
|
|
use solidcraft_engine::{Session, io};
|
|
use solidcraft_ui_egui::{Services, SolveApp};
|
|
|
|
struct App {
|
|
solve: SolveApp,
|
|
/// The settings folder whose start marker the first frame clears (`startup::started`).
|
|
starting: Option<std::path::PathBuf>,
|
|
/// Frames begun; the second one means the first was drawn (startup is over).
|
|
frames: u32,
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
space_mouse: space_mouse::SpaceMouse,
|
|
}
|
|
|
|
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 {
|
|
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
|
if let Some(dir) = self.starting.take() {
|
|
startup::started(&dir);
|
|
}
|
|
}
|
|
}
|
|
// The design on screen is the one whose commands a crash report replays.
|
|
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
|
|
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
|
// designs first; the window closes once they are answered (`quit_requested`).
|
|
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
|
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
|
|
self.solve.open_path_async(&f);
|
|
}
|
|
self.solve.logic(ctx);
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
if !self.solve.file_picker_busy() {
|
|
self.space_mouse.tick(&mut self.solve, ctx);
|
|
}
|
|
if self.solve.quit_requested {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
}
|
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
|
self.solve.raw_input_hook(raw);
|
|
}
|
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
|
self.solve.ui(ui);
|
|
}
|
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
|
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
|
}
|
|
fn on_exit(&mut self) {
|
|
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
|
solidcraft_engine::crash::end_session();
|
|
// An update asked to restart into the new version or to run its installer.
|
|
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
|
|
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
|
|
{
|
|
log::error!("update: starting {}: {e}", program.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
|
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
|
let open_parent = parent.clone();
|
|
Services {
|
|
pick_open: Some(Box::new(move || {
|
|
let future = open_parent
|
|
.clone()
|
|
.add_filter(
|
|
"Designs, STEP, IGES and meshes",
|
|
&[
|
|
io::DESIGN_EXT,
|
|
io::LEGACY_DESIGN_EXT,
|
|
"json",
|
|
"step",
|
|
"stp",
|
|
"STEP",
|
|
"STP",
|
|
"igs",
|
|
"iges",
|
|
"IGS",
|
|
"IGES",
|
|
"3mf",
|
|
"3MF",
|
|
"stl",
|
|
"STL",
|
|
],
|
|
)
|
|
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
|
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
|
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
|
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
|
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
|
.add_filter("All files", &["*"])
|
|
.pick_file();
|
|
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
|
})),
|
|
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
|
|
let dialog = parent.clone().set_file_name(name);
|
|
let dialog = if exts == [io::DESIGN_EXT, "json"] {
|
|
if name.ends_with(".json") {
|
|
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
|
|
} else {
|
|
dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"])
|
|
}
|
|
} else {
|
|
dialog.add_filter("File", exts)
|
|
};
|
|
let future = dialog.save_file();
|
|
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
|
})),
|
|
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
|
http_get: Some(std::sync::Arc::new(http_get)),
|
|
http_download: Some(std::sync::Arc::new(http_download)),
|
|
show_in_folder: Some(Box::new(|path| {
|
|
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
|
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
|
"explorer"
|
|
} else if cfg!(target_os = "macos") {
|
|
"open"
|
|
} else {
|
|
"xdg-open"
|
|
});
|
|
command.arg(folder);
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
command.creation_flags(0x08000000);
|
|
}
|
|
let _ = command.spawn();
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
|
|
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
|
|
/// the version or anything about this machine, and the API's media type).
|
|
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
|
|
use solidcraft_ui_egui::update::FetchError;
|
|
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
|
|
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
|
|
}
|
|
|
|
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
|
|
/// `progress` returns false. The caller verifies the file.
|
|
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
|
|
use solidcraft_ui_egui::update::FetchError;
|
|
use std::io::{Read, Write};
|
|
let fail = |e: String| FetchError::Failed(e);
|
|
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
|
|
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
|
|
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
|
|
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
|
let mut buf = vec![0u8; 1 << 16];
|
|
let mut done = 0u64;
|
|
loop {
|
|
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
|
done += n as u64;
|
|
if !progress(done, total) {
|
|
return Err(fail("cancelled".into()));
|
|
}
|
|
}
|
|
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
|
|
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
|
|
use solidcraft_ui_egui::update::FetchError;
|
|
use std::time::Duration;
|
|
let tls = ureq::tls::TlsConfig::builder()
|
|
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
|
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
|
|
.build();
|
|
let agent: ureq::Agent = ureq::Agent::config_builder()
|
|
.tls_config(tls)
|
|
.https_only(true)
|
|
.http_status_as_error(false)
|
|
.timeout_global(Some(timeout))
|
|
.timeout_connect(Some(Duration::from_secs(5)))
|
|
.user_agent("SolidCraft-update-check")
|
|
.max_redirects(3)
|
|
.build()
|
|
.into();
|
|
let response = agent
|
|
.get(url)
|
|
.header("Accept", "application/vnd.github+json")
|
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
|
.call()
|
|
.map_err(|e| FetchError::Failed(e.to_string()))?;
|
|
let status = response.status().as_u16();
|
|
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
|
|
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
|
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
|
|
return Err(FetchError::RateLimited { retry_after_s });
|
|
}
|
|
if status != 200 {
|
|
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
|
|
fn settings_dir() -> Option<std::path::PathBuf> {
|
|
eframe::storage_dir("SolidCraft")
|
|
}
|
|
|
|
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
|
|
/// window opens).
|
|
fn graphics_path() -> Option<std::path::PathBuf> {
|
|
settings_dir().map(|d| d.join("graphics.json"))
|
|
}
|
|
|
|
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
|
|
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
|
|
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
|
|
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
|
|
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
|
|
/// are never changed.
|
|
fn adopt_legacy_settings() {
|
|
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
|
|
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
|
|
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(¤t);
|
|
if let Err(e) = adopt_settings(¤t, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
|
|
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
|
|
}
|
|
}
|
|
|
|
fn adopt_settings(
|
|
current: &std::path::Path,
|
|
old_flatpak: Option<&std::path::Path>,
|
|
legacy: &std::path::Path,
|
|
legacy_eframe: Option<&std::path::Path>,
|
|
) -> std::io::Result<()> {
|
|
let fresh = !current.exists();
|
|
if let Some(old) = old_flatpak {
|
|
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
|
|
}
|
|
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
|
|
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
|
|
std::fs::create_dir_all(current)?;
|
|
let new = current.join("app.ron");
|
|
if !new.exists() {
|
|
std::fs::copy(old, new)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `graphics.json`, read before the window opens.
|
|
fn graphics() -> Option<serde_json::Value> {
|
|
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
|
}
|
|
|
|
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
|
|
fn backend_preference() -> Option<String> {
|
|
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
|
}
|
|
|
|
/// Anti-aliasing samples from Preferences (4 when unset).
|
|
fn msaa() -> u16 {
|
|
graphics()
|
|
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
|
.and_then(|m| u16::try_from(m).ok())
|
|
.filter(|m| [1, 2, 4, 8].contains(m))
|
|
.unwrap_or(MSAA)
|
|
}
|
|
|
|
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
|
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
|
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
|
|
|
const DEPTH_BITS: u8 = 24;
|
|
/// The section cap marks where it shows in the stencil.
|
|
const STENCIL_BITS: u8 = 8;
|
|
const MSAA: u16 = 4;
|
|
|
|
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
|
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
|
|
#[cfg(target_os = "macos")]
|
|
let icon = egui::IconData::default();
|
|
#[cfg(not(target_os = "macos"))]
|
|
let icon =
|
|
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
|
Ok(egui::ViewportBuilder::default()
|
|
.with_title("SolidCraft")
|
|
.with_icon(icon)
|
|
.with_inner_size([1600.0, 1000.0])
|
|
.with_min_inner_size([960.0, 600.0])
|
|
.with_drag_and_drop(true)
|
|
.with_decorations(!CUSTOM_TITLEBAR)
|
|
.with_fullsize_content_view(true)
|
|
.with_titlebar_shown(false)
|
|
.with_title_shown(false)
|
|
.with_app_id(APP_ID))
|
|
}
|
|
|
|
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
|
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
|
|
|
fn main() -> eframe::Result {
|
|
adopt_legacy_settings();
|
|
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
|
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
|
let mut files = Vec::new();
|
|
let mut sample = false;
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(a) = args.next() {
|
|
match a.as_str() {
|
|
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
|
"--sample" => sample = true,
|
|
"--safe-mode" => safe_mode = true,
|
|
"--version" | "-V" => {
|
|
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
"--crash-log" => {
|
|
match solidcraft_engine::crash::default_dir() {
|
|
Some(dir) => {
|
|
println!("crash reports: {}", dir.display());
|
|
for r in solidcraft_engine::crash::list(&dir).iter().take(5) {
|
|
println!(" {} {} {}", r.id, r.kind, r.message);
|
|
}
|
|
println!("more: solidcraft-cli crashes");
|
|
}
|
|
None => println!("no crash folder on this system"),
|
|
}
|
|
return Ok(());
|
|
}
|
|
"--help" | "-h" => {
|
|
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
|
|
return Ok(());
|
|
}
|
|
_ => files.push(a),
|
|
}
|
|
}
|
|
// Settings that don't read are moved aside; launches that never got a window are counted,
|
|
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
|
|
let settings = settings_dir();
|
|
let mut notes: Vec<String> = Vec::new();
|
|
let mut failed_starts = 0;
|
|
if let Some(dir) = &settings {
|
|
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
|
|
failed_starts = startup::begin(dir);
|
|
}
|
|
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
|
|
safe_mode = true;
|
|
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
|
|
}
|
|
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
|
|
if safe_mode {
|
|
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
|
}
|
|
let samples = if safe_mode { MSAA } else { msaa() };
|
|
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
|
let crashed = solidcraft_engine::crash::default_dir()
|
|
.map(|dir| {
|
|
let build = solidcraft_engine::crash::Build {
|
|
version: solidcraft_ui_egui::help::VERSION,
|
|
commit: solidcraft_ui_egui::help::COMMIT,
|
|
built: solidcraft_ui_egui::help::BUILD_DATE,
|
|
};
|
|
solidcraft_engine::crash::install(dir, build)
|
|
})
|
|
.unwrap_or_default();
|
|
// 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(target_os = "windows")]
|
|
native_crash::install();
|
|
eframe::egui_wgpu::set_startup_hook(startup_step);
|
|
solidcraft_engine::crash::phase("window");
|
|
let options = eframe::NativeOptions {
|
|
viewport: app_viewport()?,
|
|
depth_buffer: DEPTH_BITS,
|
|
stencil_buffer: STENCIL_BITS,
|
|
multisampling: samples,
|
|
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
|
|
// and writes a scratch copy, leaving the user's settings as they were.
|
|
persistence_path: if safe_mode {
|
|
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
|
|
let _ = std::fs::remove_file(&scratch);
|
|
Some(scratch)
|
|
} else {
|
|
settings.as_ref().map(|d| d.join("app.ron"))
|
|
},
|
|
persist_window: !safe_mode,
|
|
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
|
|
..Default::default()
|
|
};
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
let mut options = options;
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
if prefer_x11(
|
|
std::env::var_os("DISPLAY").is_some(),
|
|
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
|
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
|
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
|
|
) {
|
|
options.event_loop_builder = Some(Box::new(|b| {
|
|
use winit::platform::x11::EventLoopBuilderExtX11;
|
|
b.with_x11();
|
|
}));
|
|
}
|
|
// Files opened from Finder and the Dock arrive as events, not arguments.
|
|
#[cfg(target_os = "macos")]
|
|
open_documents::install();
|
|
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let created_in_app = created.clone();
|
|
let starting = settings.clone();
|
|
let result = eframe::run_native(
|
|
"SolidCraft",
|
|
options,
|
|
Box::new(move |cc| {
|
|
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
solidcraft_engine::crash::phase("app setup");
|
|
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
|
let mut app = SolveApp::new(Session::default(), services(cc));
|
|
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
|
app.custom_titlebar = CUSTOM_TITLEBAR;
|
|
app.integrated_titlebar = cfg!(target_os = "macos");
|
|
app.cpu_meshing = safe_mode;
|
|
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
|
app.load_prefs(&p);
|
|
}
|
|
if let Some(rs) = &cc.wgpu_render_state {
|
|
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
|
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
|
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
|
// The graphics driver is loaded now; it may have put its own exception filter
|
|
// in front of ours.
|
|
#[cfg(target_os = "windows")]
|
|
native_crash::reinstall();
|
|
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
|
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
|
let info = rs.adapter.get_info();
|
|
solidcraft_engine::crash::set_environment(&format!(
|
|
"Renderer: {:?} {} ({}), MSAA {}x",
|
|
info.backend, info.name, info.driver, rs.msaa_samples
|
|
));
|
|
}
|
|
if let Some(port) = control_port {
|
|
let rx = control_server::start(port, cc.egui_ctx.clone());
|
|
app = app.with_control(rx);
|
|
}
|
|
if sample {
|
|
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
|
app.set_status(format!("sample: {e}"), true);
|
|
}
|
|
app.session.doc_mut().name = "Sample Plate".into();
|
|
app.session.mark_saved();
|
|
app.session.undo.clear();
|
|
}
|
|
// A launch from Finder: its files arrived before the window.
|
|
#[cfg(target_os = "macos")]
|
|
let files = {
|
|
open_documents::set_ui(&cc.egui_ctx);
|
|
[files, open_documents::take()].concat()
|
|
};
|
|
for f in &files {
|
|
app.open_path_async(f);
|
|
}
|
|
// No file and no sample: the empty design takes Units of new designs (#1205).
|
|
// An opened file and the sample part keep the units they were saved or built with.
|
|
if files.is_empty() && !sample {
|
|
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
|
}
|
|
// No design given: the start page (not when driven over the control channel, whose
|
|
// clients expect the design view; `ui.home` shows it there).
|
|
if files.is_empty() && !sample && control_port.is_none() {
|
|
let rev = app.session.revision;
|
|
app.home.show(rev);
|
|
}
|
|
// Autosave, and offer what crashed sessions left behind.
|
|
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
|
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
|
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
|
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
|
|
if safe_mode {
|
|
app.recoverable.clear();
|
|
}
|
|
}
|
|
// Tell the user about crashes since the last start (after the recovery offer is set up).
|
|
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
|
|
if !notes.is_empty() {
|
|
app.set_status(notes.join(" "), true);
|
|
}
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
|
solidcraft_engine::crash::phase("first frame");
|
|
Ok(Box::new(App {
|
|
solve: app,
|
|
starting,
|
|
frames: 0,
|
|
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
|
space_mouse,
|
|
}))
|
|
}),
|
|
);
|
|
control_server::remove_token_file();
|
|
match result {
|
|
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
|
|
r => r,
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
fn startup_step(step: &'static str, detail: &str) {
|
|
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
|
|
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
|
|
}
|
|
solidcraft_engine::crash::phase(step);
|
|
}
|
|
|
|
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
|
|
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
|
|
/// in use is started); `preference` is `graphics.json`'s `backend`.
|
|
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
|
|
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
|
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
|
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
|
|
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
|
|
eprintln!("SolidCraft graphics: starting {backends:?}");
|
|
setup.instance_descriptor.backends = backends;
|
|
}
|
|
if cpu {
|
|
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
|
}
|
|
}
|
|
options
|
|
}
|
|
|
|
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
|
|
/// the software renderer, else a message box saying what failed instead of a silent exit.
|
|
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
|
|
log::error!("the window could not open: {error}");
|
|
eprintln!("SolidCraft: the window could not open: {error}");
|
|
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
|
|
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
|
|
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
|
|
Ok(status) if status.success() => return Ok(()),
|
|
// The second try showed its own message.
|
|
Ok(_) => return Err(error),
|
|
Err(e) => log::error!("could not start again with the software renderer: {e}"),
|
|
}
|
|
}
|
|
let _ = rfd::MessageDialog::new()
|
|
.set_level(rfd::MessageLevel::Error)
|
|
.set_title("SolidCraft could not start")
|
|
.set_description(startup::no_window_message(&error.to_string(), settings))
|
|
.set_buttons(rfd::MessageButtons::Ok)
|
|
.show();
|
|
Err(error)
|
|
}
|
|
|
|
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
|
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
|
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
|
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
|
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
|
match wayland_override.map(str::trim) {
|
|
Some("0") => return display,
|
|
Some(_) => return false,
|
|
None => {}
|
|
}
|
|
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
|
|
display && !(niri && wayland_display)
|
|
}
|
|
|
|
#[cfg(all(test, unix, not(target_os = "macos")))]
|
|
mod backend_tests {
|
|
use super::prefer_x11;
|
|
|
|
#[test]
|
|
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
|
|
// XWayland whenever there's an X display, for file drag-and-drop.
|
|
assert!(prefer_x11(true, true, Some("GNOME"), None));
|
|
assert!(prefer_x11(true, false, None, None));
|
|
assert!(!prefer_x11(false, true, Some("KDE"), None));
|
|
// niri (alone or in a desktop list) under Wayland stays native.
|
|
assert!(!prefer_x11(true, true, Some("niri"), None));
|
|
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
|
// niri without a Wayland socket (nested X session) still uses X11.
|
|
assert!(prefer_x11(true, false, Some("niri"), None));
|
|
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
|
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
|
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
|
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
|
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod icon_tests {
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
|
|
let viewport = super::app_viewport().expect("viewport");
|
|
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
|
|
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[test]
|
|
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
|
let viewport = super::app_viewport().expect("viewport");
|
|
let icon = viewport.icon.expect("running window must have an icon");
|
|
assert_eq!((icon.width, icon.height), (64, 64));
|
|
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
|
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod msaa_tests {
|
|
#[test]
|
|
fn first_launch_adopts_the_previous_release_settings() {
|
|
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
let (current, legacy, legacy_eframe) = (d.join("solidcraft"), d.join("solvecraft"), d.join("ai.storyteller.solvecraft"));
|
|
std::fs::create_dir_all(&legacy).unwrap();
|
|
std::fs::create_dir_all(&legacy_eframe).unwrap();
|
|
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
|
|
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
|
|
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
|
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
|
|
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
|
|
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
|
|
// Later launches leave the current settings alone.
|
|
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
|
|
std::fs::remove_file(current.join("graphics.json")).unwrap();
|
|
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
|
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
|
|
assert!(!current.join("graphics.json").exists());
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_multisampling_uses_the_selected_backend_policy() {
|
|
use eframe::wgpu::Backend;
|
|
for requested in [1, 2, 4, 8] {
|
|
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
|
|
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
|
|
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
|
|
}
|
|
}
|
|
}
|
|
}
|