Startup hardening: safe mode, a software-renderer retry, and bad settings files set aside (#1138)
Startup no longer stops at a bad settings file or a missing GPU: a settings file that fails to load is set aside, a failed GPU start retries with the software renderer, and a safe mode starts with defaults. Adds a troubleshooting page to the guide.
This commit is contained in:
1 parent
8f48544021
commit
2c754c430b
10 files changed
+445
-13
No files matched your search
Generated
+2
@@ -3457,8 +3457,10 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"quick-xml",
|
||||
"rfd",
|
||||
"ron",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
|
||||
@@ -27,6 +27,8 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
# Checks eframe's settings file (app.ron) before eframe reads it (src/startup.rs).
|
||||
ron = "0.12"
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
@@ -48,5 +50,8 @@ block2 = "0.6"
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pollster = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
@@ -1,6 +1,9 @@
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
|
||||
//! 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.
|
||||
@@ -20,18 +23,24 @@ mod control_server;
|
||||
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>,
|
||||
#[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 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
|
||||
@@ -306,7 +315,7 @@ const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
let samples = msaa();
|
||||
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;
|
||||
@@ -315,6 +324,7 @@ fn main() -> eframe::Result {
|
||||
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(());
|
||||
@@ -333,12 +343,30 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
|
||||
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| {
|
||||
@@ -355,8 +383,17 @@ fn main() -> eframe::Result {
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id.
|
||||
persistence_path: settings_dir().map(|d| d.join("app.ron")),
|
||||
// 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),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -376,15 +413,20 @@ fn main() -> eframe::Result {
|
||||
// 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_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
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 {
|
||||
@@ -429,20 +471,63 @@ fn main() -> eframe::Result {
|
||||
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);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
control_server::remove_token_file();
|
||||
result
|
||||
match result {
|
||||
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe).
|
||||
fn wgpu_options(cpu: bool) -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if cpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Getting the window open, whatever state the settings folder or the graphics device is in
|
||||
//! (docs/book/src/guide/troubleshooting.md).
|
||||
//!
|
||||
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
|
||||
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
|
||||
//! being dropped silently or stopping the start.
|
||||
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
|
||||
//! row the next launch starts in safe mode by itself.
|
||||
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
|
||||
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
|
||||
//! - **No usable graphics device**: one retry on the software renderer
|
||||
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The environment variable that starts safe mode (any value but `0` or empty).
|
||||
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
|
||||
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
|
||||
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
|
||||
/// Consecutive launches that died before the window was up, after which safe mode starts.
|
||||
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
|
||||
const MARKER: &str = "starting";
|
||||
|
||||
/// Whether an environment value switches something on.
|
||||
pub fn env_on(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
|
||||
}
|
||||
|
||||
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
|
||||
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
|
||||
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
|
||||
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
|
||||
let mut n = 1;
|
||||
while to.exists() && n < 100 {
|
||||
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
|
||||
n += 1;
|
||||
}
|
||||
std::fs::rename(path, &to)?;
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// Whether eframe's `app.ron` reads, with the preferences inside it.
|
||||
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
|
||||
}
|
||||
|
||||
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
|
||||
/// aside (the app then starts with defaults for it). Returns a line per file moved.
|
||||
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
|
||||
let path = dir.join(name);
|
||||
let Ok(bytes) = std::fs::read(&path) else { return };
|
||||
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
|
||||
if ok {
|
||||
return;
|
||||
}
|
||||
match back_up(&path) {
|
||||
Ok(to) => {
|
||||
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
|
||||
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
|
||||
}
|
||||
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
|
||||
}
|
||||
};
|
||||
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
|
||||
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
|
||||
/// died before their window was up.
|
||||
pub fn begin(dir: &Path) -> u32 {
|
||||
let marker = dir.join(MARKER);
|
||||
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
|
||||
if std::fs::create_dir_all(dir).is_ok() {
|
||||
let _ = std::fs::write(&marker, (before + 1).to_string());
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
/// The window is up: this launch did not fail to start.
|
||||
pub fn started(dir: &Path) {
|
||||
let _ = std::fs::remove_file(dir.join(MARKER));
|
||||
}
|
||||
|
||||
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
|
||||
/// the first one that can draw to `surface`.
|
||||
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
|
||||
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
|
||||
adapters
|
||||
.iter()
|
||||
.filter(fits)
|
||||
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
|
||||
.or_else(|| adapters.iter().find(fits))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
|
||||
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
|
||||
})
|
||||
}
|
||||
|
||||
/// The message shown when no window could be opened at all.
|
||||
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
|
||||
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
|
||||
format!(
|
||||
"SolidCraft could not open its window.\n\n{error}\n\n\
|
||||
The software renderer was tried too. Things to try:\n\
|
||||
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
|
||||
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
|
||||
- Start it once with `solidcraft --safe-mode`.\n\
|
||||
- Rename the settings folder: {folder}\n\n\
|
||||
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_read_from_the_environment() {
|
||||
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
|
||||
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
|
||||
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
|
||||
let d = scratch("check");
|
||||
// Good files (and an empty one) stay where they are.
|
||||
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
|
||||
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
|
||||
assert!(check_settings(&d, &keys).is_empty());
|
||||
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
|
||||
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
|
||||
for (name, text) in [
|
||||
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
|
||||
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
|
||||
("graphics.json", br#"{"msaa":"#.as_slice()),
|
||||
("graphics.json", b"\xff\xfe\x00".as_slice()),
|
||||
] {
|
||||
std::fs::write(d.join(name), text).unwrap();
|
||||
let notes = check_settings(&d, &keys);
|
||||
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
|
||||
assert!(!d.join(name).exists(), "{name} moved");
|
||||
let kept: Vec<_> =
|
||||
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
|
||||
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launches_that_never_got_a_window_are_counted_until_one_does() {
|
||||
let d = scratch("marker");
|
||||
assert_eq!(begin(&d), 0);
|
||||
assert_eq!(begin(&d), 1);
|
||||
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
|
||||
started(&d);
|
||||
assert_eq!(begin(&d), 0);
|
||||
// A damaged marker counts as none.
|
||||
std::fs::write(d.join(MARKER), "zz").unwrap();
|
||||
assert_eq!(begin(&d), 0);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_settings_folder_and_safe_mode() {
|
||||
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
|
||||
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
|
||||
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
|
||||
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
|
||||
if adapters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let picked = pick_cpu_adapter(&adapters, None).unwrap();
|
||||
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
|
||||
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,19 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
|
||||
None
|
||||
}
|
||||
|
||||
/// An autosave that failed to load goes back in the recovery list, so it can be discarded (or
|
||||
/// left for later) instead of coming back unasked.
|
||||
fn reoffer(app: &mut SolveApp, id: &str, dir: Option<&str>) {
|
||||
let dir = dir.map(std::path::PathBuf::from).or_else(|| app.autosave.as_ref().map(|a| a.dir.clone()));
|
||||
let Some(dir) = dir else { return };
|
||||
if app.recoverable.iter().any(|e| e.id == id) {
|
||||
return;
|
||||
}
|
||||
if let Some(e) = solidcraft_engine::recovery::orphans(&dir).into_iter().find(|e| e.id == id) {
|
||||
app.recoverable.insert(0, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// A recovered design is ours now: autosave it under this app's entry before the old one goes.
|
||||
fn recovered(app: &mut SolveApp, request: &Request) {
|
||||
let Kind::Recover { id, dir } = &request.kind else { return };
|
||||
@@ -448,7 +461,12 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
Err(e) => {
|
||||
request.answer(Err(e.clone()));
|
||||
app.set_status(e, true);
|
||||
if let Kind::Recover { id, dir } = &request.kind {
|
||||
reoffer(app, id, dir.as_deref());
|
||||
app.set_status(format!("could not recover {}: {e}. Discard it or choose Later", request.path), true);
|
||||
} else {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,9 +757,10 @@ fn tessellation(adapter: &wgpu::Adapter, device: &wgpu::Device, queue: &wgpu::Qu
|
||||
}
|
||||
|
||||
/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA
|
||||
/// settings. Without a stencil the section cap does not hide the inside of cut bodies.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget {
|
||||
let tess = tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER);
|
||||
/// settings. Without a stencil the section cap does not hide the inside of cut bodies. Without
|
||||
/// `gpu_meshing` (safe mode) bodies are meshed on the CPU.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32, gpu_meshing: bool) -> GpuTarget {
|
||||
let tess = if gpu_meshing { tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER) } else { None };
|
||||
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples, tess: tess.is_some() };
|
||||
let res = Resources::new(&rs.device, t, tess);
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
|
||||
@@ -290,6 +290,9 @@ pub struct SolveApp {
|
||||
pub custom_titlebar: bool,
|
||||
/// macOS: the traffic lights sit over the application bar's left end.
|
||||
pub integrated_titlebar: bool,
|
||||
/// Build bodies' display meshes on the CPU even where the GPU could (safe mode). Read by
|
||||
/// [`SolveApp::set_wgpu`].
|
||||
pub cpu_meshing: bool,
|
||||
pub frame_ms: f64,
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
|
||||
@@ -357,6 +360,7 @@ impl SolveApp {
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
cpu_meshing: false,
|
||||
frame_ms: 0.0,
|
||||
synthetic: Vec::new(),
|
||||
synthetic_mods: egui::Modifiers::default(),
|
||||
@@ -389,7 +393,7 @@ impl SolveApp {
|
||||
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
|
||||
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
|
||||
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples));
|
||||
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples, !self.cpu_meshing));
|
||||
self.msaa_supported = rs.msaa_supported.clone();
|
||||
let info = rs.adapter.get_info();
|
||||
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
|
||||
@@ -468,7 +472,7 @@ impl SolveApp {
|
||||
if let Some(t) = v.get("toolbar") {
|
||||
self.toolbar_custom.load(t);
|
||||
}
|
||||
if let Some(mut p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
|
||||
if let Some(mut p) = v.get("preferences").filter(|p| p.is_object()).map(prefs::lenient) {
|
||||
if !prefs::MSAA.contains(&p.msaa) {
|
||||
p.msaa = prefs::Prefs::default().msaa;
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ pub const MSAA: [u32; 4] = [1, 2, 4, 8];
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NavigatorStyle {
|
||||
/// Also what a navigator this version doesn't know reads as (`lenient`).
|
||||
#[default]
|
||||
Axes,
|
||||
Cube,
|
||||
@@ -1101,6 +1102,28 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Saved preferences, keeping every one that still reads. A value this version doesn't
|
||||
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
|
||||
/// its default on its own instead of discarding all of them.
|
||||
pub fn lenient(saved: &serde_json::Value) -> Prefs {
|
||||
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
|
||||
return p;
|
||||
}
|
||||
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
|
||||
for (k, v) in src.iter().take(1000) {
|
||||
// Keys this version doesn't have are ignored anyway.
|
||||
let Some(old) = o.get(k).cloned() else { continue };
|
||||
o.insert(k.clone(), v.clone());
|
||||
if serde_json::from_value::<Prefs>(serde_json::Value::Object(o.clone())).is_err() {
|
||||
log::warn!("preferences: `{k}` is not understood by this version; using its default");
|
||||
o.insert(k.clone(), old);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(cur).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set preferences from JSON (control channel); returns them all.
|
||||
pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let mut cur = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
|
||||
@@ -1151,6 +1174,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_value_this_version_does_not_understand_keeps_the_other_preferences() {
|
||||
use serde_json::json;
|
||||
// A navigator, a field of the wrong type and a key from another release.
|
||||
let saved = json!({"nav": "solidworks", "navigator_style": "hologram", "ui_scale": "big", "zoom_reverse": true, "from_the_future": 3});
|
||||
let p = super::lenient(&saved);
|
||||
assert_eq!(p.nav, "solidworks");
|
||||
assert!(p.zoom_reverse);
|
||||
assert_eq!(p.navigator_style, super::NavigatorStyle::Axes);
|
||||
assert_eq!(p.ui_scale, super::Prefs::default().ui_scale);
|
||||
assert_eq!(super::lenient(&json!("garbage")), super::Prefs::default());
|
||||
// Whole-app load: the other saved state is kept too, and nothing panics.
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.load_prefs(&json!({"grid": false, "preferences": saved}).to_string());
|
||||
assert!(!app.ui.show_grid && app.preferences.nav == "solidworks" && app.preferences.zoom_reverse);
|
||||
for junk in ["", "{", "[]", "null", "{\"preferences\": 7, \"recent\": {}, \"shortcuts\": 1, \"toolbar\": \"x\"}"] {
|
||||
crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()).load_prefs(junk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_prefs_fall_back_to_the_legacy_key() {
|
||||
let only_old = |k: &str| (k == super::LEGACY_STORAGE_KEY).then(|| "old".to_string());
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [Import and export](guide/files.md)
|
||||
- [Keyboard shortcuts](guide/shortcuts.md)
|
||||
- [Updates and privacy](guide/updates.md)
|
||||
- [Troubleshooting: SolidCraft won't start](guide/troubleshooting.md)
|
||||
- [Driving SolidCraft with an AI agent (MCP)](guide/mcp.md)
|
||||
|
||||
# Developer guide
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Troubleshooting: SolidCraft won't start
|
||||
|
||||
## 0.10.0 closes a few seconds after it starts (Windows)
|
||||
|
||||
SolidCraft 0.10.0 crashes at startup on Windows PCs that draw with Direct3D 12 and the system
|
||||
shader compiler. That covers older Intel graphics, PCs without a graphics driver (the
|
||||
"Microsoft Basic Display Adapter"), virtual machines and Remote Desktop sessions. **Install
|
||||
0.10.1 or later**, which fixes it. Versions before 0.10.0 don't have this problem.
|
||||
|
||||
## Start in safe mode
|
||||
|
||||
Safe mode starts with default settings (your own settings stay as they are, for the next normal
|
||||
start), skips the "Recover unsaved design?" offer and draws with the software renderer.
|
||||
|
||||
- Windows: run `solidcraft.exe --safe-mode` from a terminal in the install folder, or add
|
||||
` --safe-mode` to the end of a shortcut's *Target*.
|
||||
- macOS: `open -a SolidCraft --args --safe-mode`
|
||||
- Linux: `solidcraft --safe-mode` (Flatpak: `flatpak run ai.storyteller.solidcraft --safe-mode`)
|
||||
|
||||
Setting `SOLIDCRAFT_SAFE_MODE=1` does the same. From 0.10.2, if two launches in a row stop
|
||||
before the window opens, the next one starts in safe mode by itself. `SOLIDCRAFT_RENDERER=cpu`
|
||||
uses only the software renderer and keeps your settings.
|
||||
|
||||
## Reset the settings
|
||||
|
||||
SolidCraft keeps its settings and its data in the folders below. All installed versions share
|
||||
them. No version changes anything outside them: no system settings, drivers or registry
|
||||
entries. Quit SolidCraft, then **rename** (don't delete) the folders. SolidCraft starts with
|
||||
defaults and makes new ones. Rename them back to restore your settings, or send them to us
|
||||
with a bug report.
|
||||
|
||||
| System | Settings (`app.ron`, `graphics.json`) | Data (`recovery`, `crashes`, `control`, thumbnails) |
|
||||
|---|---|---|
|
||||
| Windows | `%APPDATA%\SolidCraft` | `%LOCALAPPDATA%\SolidCraft` |
|
||||
| macOS | `~/Library/Application Support/SolidCraft` | (the same folder) |
|
||||
| Linux | `~/.local/share/solidcraft` | (the same folder); thumbnails in `$XDG_CACHE_HOME/SolidCraft` or `/tmp/SolidCraft` |
|
||||
| Linux (Flatpak) | `~/.var/app/ai.storyteller.solidcraft/data/solidcraft` | (the same folder) |
|
||||
|
||||
On Windows, paste `%APPDATA%` or `%LOCALAPPDATA%` into the Explorer address bar to open the
|
||||
folder. Releases before the rename used `SolveCraft` folders next to these; SolidCraft only
|
||||
reads them, so you can leave them alone.
|
||||
|
||||
- The **`recovery`** folder holds autosaves of designs that were never saved. Rename the data
|
||||
folder rather than deleting it, so you can still open those autosaves (`.solidcraft` files)
|
||||
with File > Open.
|
||||
- The **`crashes`** folder holds crash reports. Please attach the newest one to a bug report.
|
||||
|
||||
From 0.10.2, SolidCraft also checks the settings files at start. A file it can't read is renamed
|
||||
to `app.ron.bad-<time>` or `graphics.json.bad-<time>`, and SolidCraft starts with defaults
|
||||
instead.
|
||||
|
||||
## "No graphics adapter" or nothing happens at all
|
||||
|
||||
SolidCraft needs Direct3D 12 or Vulkan on Windows, Metal on macOS, and Vulkan or OpenGL on
|
||||
Linux. Without a graphics driver it falls back to the system's software renderer (WARP on
|
||||
Windows, llvmpipe or lavapipe on Linux), which is slow but works. From 0.10.2, if no window can
|
||||
be opened at all, SolidCraft tries the software renderer once and otherwise shows a message
|
||||
saying what failed (earlier versions closed without a word). Things to try:
|
||||
|
||||
- Install the graphics driver from the GPU maker (Intel, AMD, NVIDIA) rather than Windows Update.
|
||||
- In a virtual machine, turn on 3D acceleration. Over Remote Desktop, try it on the machine itself.
|
||||
- Run `solidcraft --crash-log` to list recent crash reports, and attach the newest one to an
|
||||
issue.
|
||||
|
||||
## A design saved by 0.10 won't open in an older version
|
||||
|
||||
Designs saved by a newer SolidCraft use a newer file format. Older versions refuse them with
|
||||
"this design was saved by a newer SolidCraft" and don't change the file. Open them with the
|
||||
current version.
|
||||
Reference in new issue
Block a user