diff --git a/Cargo.lock b/Cargo.lock index 2f57f01..d18de53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,7 @@ dependencies = [ "log", "muda", "rfd", + "serde", "serde_json", "winit", "winresource", diff --git a/README.md b/README.md index 6884a15..5c7d4c1 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,24 @@ The file stops growing at 16 MiB. `--version` writes no file. | Variable | Effect | |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | +| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. +### Graphics backend + +The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A +graphics driver that faults takes the process down before any Rust code can catch it, so the +backend is chosen before the window exists, and a start that never showed a frame is remembered: +`gpu.json` in the settings directory records the backend being tried until the first frame has +been presented. A record left behind means that backend crashed (or the app was killed) at +start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows; +Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every +backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to +start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an +integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is +the Windows default. The code is `apps/designcraft/src/gpu.rs`. + ### Web ```sh diff --git a/ROADMAP.md b/ROADMAP.md index a73d64e..eb05508 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index eaacdd3..416b074 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -14,6 +14,7 @@ designcraft-engine = { workspace = true } designcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } egui = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs new file mode 100644 index 0000000..e80f566 --- /dev/null +++ b/apps/designcraft/src/gpu.rs @@ -0,0 +1,396 @@ +//! Which wgpu backend draws the window, and the fallback when one of them crashes at start-up. +//! +//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites the +//! interface, so every backend shows the same pixels and the one that never crashes is the right +//! one. wgpu's default backend set (`PRIMARY | GL`) enumerates Vulkan first, and on an AMD +//! hybrid-graphics laptop (a Radeon RX 6800M beside an integrated Radeon, two versions of +//! `amdvlk64.dll` loaded into the process) AMD's Vulkan driver faulted with an access violation +//! at the first swapchain present. A fault inside a driver can't be caught (the crash guard never +//! sees it), so the backend is chosen before the window exists: on Windows DirectX 12 first (the +//! backend browsers' WebGPU uses), then Vulkan, then OpenGL; on Linux Vulkan, then OpenGL; on +//! macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, `vulkan`, `gl`, `metal`, or a +//! comma-separated list — overrides the choice and turns the fallback off. +//! +//! The fallback: before the window is created, `gpu.json` in the settings directory records the +//! backend being tried; once a frame has been presented, that entry is cleared. An entry still +//! there at the next start means the previous start never got that far — the driver crashed, or +//! the app was killed meanwhile — so that backend joins `failed`, the next candidate is tried and +//! the status bar says so. When every candidate has failed the list starts over, and so does a +//! new DesignCraft version (drivers and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing. + +use std::path::{Path, PathBuf}; + +use eframe::wgpu; +use serde::{Deserialize, Serialize}; + +/// The record's file name inside the settings directory. +pub const FILE: &str = "gpu.json"; + +/// A graphics backend wgpu can draw the window with. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Dx12, + Vulkan, + Metal, + Gl, +} + +impl Backend { + /// The name the status bar and the log use. + pub fn name(self) -> &'static str { + match self { + Backend::Dx12 => "DirectX 12", + Backend::Vulkan => "Vulkan", + Backend::Metal => "Metal", + Backend::Gl => "OpenGL", + } + } + + /// wgpu's bit for this backend. + pub fn bit(self) -> wgpu::Backends { + match self { + Backend::Dx12 => wgpu::Backends::DX12, + Backend::Vulkan => wgpu::Backends::VULKAN, + Backend::Metal => wgpu::Backends::METAL, + Backend::Gl => wgpu::Backends::GL, + } + } + + /// The backend of the adapter wgpu picked; `None` for the web and no-op backends. + pub fn of(backend: wgpu::Backend) -> Option { + match backend { + wgpu::Backend::Dx12 => Some(Backend::Dx12), + wgpu::Backend::Vulkan => Some(Backend::Vulkan), + wgpu::Backend::Metal => Some(Backend::Metal), + wgpu::Backend::Gl => Some(Backend::Gl), + _ => None, + } + } +} + +/// The backends to try on this platform, best first. +pub fn candidates() -> &'static [Backend] { + if cfg!(windows) { + &[Backend::Dx12, Backend::Vulkan, Backend::Gl] + } else if cfg!(target_os = "macos") { + &[Backend::Metal] + } else { + &[Backend::Vulkan, Backend::Gl] + } +} + +/// What `gpu.json` holds. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// The DesignCraft version that wrote the record; another version starts afresh. + #[serde(default)] + pub version: String, + /// The backend of a start that has not presented a frame yet. + #[serde(default)] + pub trying: Option, + /// Backends whose start never presented a frame. + #[serde(default)] + pub failed: Vec, +} + +/// The backend for a start, from [`choose`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + pub backend: Backend, + /// The record to write before the window is created. + pub record: Record, + /// The backend the previous start never finished with, when this start moves away from it. + pub switched_from: Option, +} + +/// Pick the backend for this start from the candidates (best first) and the previous start's +/// record; `None` without candidates. +pub fn choose(candidates: &[Backend], previous: &Record, version: &str) -> Option { + let first = *candidates.first()?; + let same_version = previous.version == version; + let mut failed: Vec = if same_version { previous.failed.clone() } else { Vec::new() }; + failed.retain(|b| candidates.contains(b)); + let unfinished = previous.trying.filter(|_| same_version); + if let Some(b) = unfinished + && !failed.contains(&b) + { + failed.push(b); + } + let backend = match candidates.iter().copied().find(|b| !failed.contains(b)) { + Some(b) => b, + // Every candidate has failed: start over rather than never starting. + None => { + failed.clear(); + first + } + }; + let switched_from = unfinished.filter(|b| *b != backend); + Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from }) +} + +/// The record in `path`; a missing or unreadable file is an empty record. +pub fn read(path: &Path) -> Record { + std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default() +} + +/// Write `record` to `path` (creating the directory). +pub fn write(path: &Path, record: &Record) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?; + std::fs::write(path, bytes) +} + +/// A start in progress: the backend chosen for it and the record that says so until a frame has +/// been presented. +#[derive(Debug)] +pub struct Startup { + pub backend: Backend, + pub switched_from: Option, + /// `gpu.json`, when the record is persisted. + path: Option, + record: Record, +} + +impl Startup { + /// Choose the backend for this start and record it in `path` (nothing is recorded without + /// one). `None` when `WGPU_BACKEND` is set — wgpu follows it and the fallback is off — or the + /// platform has no candidates. + pub fn begin(path: Option, version: &str) -> Option { + if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) { + log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off"); + return None; + } + Self::begin_unforced(path, version) + } + + fn begin_unforced(path: Option, version: &str) -> Option { + let previous = path.as_deref().map(read).unwrap_or_default(); + let choice = choose(candidates(), &previous, version)?; + if let Some(p) = &path + && let Err(e) = write(p, &choice.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + match choice.switched_from { + Some(from) => log::warn!( + "graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})", + from.name(), + choice.backend.name(), + choice.record.failed + ), + None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()), + } + Some(Startup { backend: choice.backend, switched_from: choice.switched_from, path, record: choice.record }) + } + + /// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it + /// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers + /// (AMD's) crash while the instance is created (#167), before any fallback could help, so + /// there OpenGL is only ever tried as its own candidate. + pub fn backends(&self) -> wgpu::Backends { + let mut backends = self.backend.bit(); + if !cfg!(windows) && !self.record.failed.contains(&Backend::Gl) { + backends |= wgpu::Backends::GL; + } + backends + } + + /// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend + /// that is actually drawing. + pub fn started_with(&mut self, backend: Backend) { + if self.record.trying == Some(backend) { + return; + } + self.backend = backend; + self.record.trying = Some(backend); + self.save(); + } + + /// A frame has been presented (or the app exited normally): this start did not crash. + pub fn presented(&mut self) { + if self.record.trying.take().is_some() { + self.save(); + } + } + + /// The status-bar line for a start that moved away from a backend that crashed. + pub fn status_line(&self) -> Option { + let from = self.switched_from?; + Some(format!( + "Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).", + from.name(), + self.backend.name(), + from.name() + )) + } + + fn save(&self) { + if let Some(p) = &self.path + && let Err(e) = write(p, &self.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const V: &str = "0.5.0"; + const WIN: &[Backend] = &[Backend::Dx12, Backend::Vulkan, Backend::Gl]; + + fn temp_path(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d.join(FILE) + } + + fn record(trying: Option, failed: &[Backend]) -> Record { + Record { version: V.into(), trying, failed: failed.to_vec() } + } + + #[test] + fn every_platform_has_candidates_without_duplicates() { + let c = candidates(); + assert!(!c.is_empty()); + for (i, b) in c.iter().enumerate() { + assert!(!c[..i].contains(b), "{b:?} twice in {c:?}"); + } + if cfg!(windows) { + assert_eq!(c[0], Backend::Dx12, "DirectX 12 is the Windows default (AMD's Vulkan driver faulted at the first present)"); + assert!(!c.contains(&Backend::Metal)); + } + if cfg!(target_os = "macos") { + assert_eq!(c, &[Backend::Metal]); + } + } + + #[test] + fn a_fresh_start_takes_the_first_candidate() { + let c = choose(WIN, &Record::default(), V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + assert!(choose(&[], &Record::default(), V).is_none()); + } + + #[test] + fn a_start_that_never_presented_moves_to_the_next_candidate() { + let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V).unwrap(); + assert_eq!(c.backend, Backend::Vulkan); + assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Dx12])); + assert_eq!(c.switched_from, Some(Backend::Dx12)); + + let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Vulkan]); + assert_eq!(c.switched_from, Some(Backend::Vulkan)); + } + + #[test] + fn a_finished_start_keeps_the_failed_list_and_switches_silently() { + // `trying` was cleared by a presented frame; the next start still avoids what failed. + let c = choose(WIN, &record(None, &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.backend, Backend::Vulkan); + assert_eq!(c.record.failed, &[Backend::Dx12]); + assert_eq!(c.switched_from, None); + // The same backend failing again is listed once. + let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.record.failed, &[Backend::Dx12]); + } + + #[test] + fn when_every_candidate_failed_the_list_starts_over() { + let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12, Backend::Vulkan]), V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record.failed, &[]); + assert_eq!(c.switched_from, Some(Backend::Gl)); + } + + #[test] + fn another_version_or_platform_forgets_what_failed() { + let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] }; + let c = choose(WIN, &old, V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + // A backend that is no candidate here (a record copied from another machine) is dropped. + let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Vulkan]); + } + + #[test] + fn the_record_round_trips_and_garbage_reads_as_empty() { + let path = temp_path("roundtrip"); + assert_eq!(read(&path), Record::default()); + let r = record(Some(Backend::Vulkan), &[Backend::Dx12]); + write(&path, &r).expect("write"); + assert_eq!(read(&path), r); + let text = std::fs::read_to_string(&path).expect("read"); + assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}"); + std::fs::write(&path, b"{not json").expect("garbage"); + assert_eq!(read(&path), Record::default()); + // Unknown fields and missing ones are tolerated. + std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial"); + assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] }); + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn a_start_records_its_backend_until_a_frame_is_presented() { + let path = temp_path("startup"); + let mut s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); + let first = candidates()[0]; + assert_eq!(s.backend, first); + assert_eq!(read(&path).trying, Some(first)); + assert!(s.backends().contains(first.bit())); + // OpenGL is the in-process fallback, except on Windows where it is only its own candidate. + assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl); + assert_eq!(s.status_line(), None); + + // wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one. + s.started_with(Backend::Gl); + assert_eq!(read(&path).trying, Some(Backend::Gl)); + s.presented(); + assert_eq!(read(&path), record(None, &[])); + s.presented(); + assert_eq!(read(&path), record(None, &[])); + + // The next start after one that never presented: switches, says so, and leaves OpenGL + // out of the set once it failed. + write(&path, &record(Some(Backend::Gl), &[])).expect("write"); + let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); + if candidates().len() > 1 { + assert_eq!(s.switched_from, Some(Backend::Gl)); + let line = s.status_line().expect("status"); + assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); + assert!(!s.backends().contains(wgpu::Backends::GL)); + } else { + assert_eq!(s.switched_from, None); + } + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { + let mut s = Startup::begin_unforced(None, V).expect("candidates"); + s.started_with(Backend::Gl); + s.presented(); + // A file where the directory should be: every write fails, nothing panics. + let blocked = temp_path("blocked"); + let dir = blocked.parent().expect("dir"); + std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp"); + std::fs::write(dir, "a file where the directory should be").expect("block"); + let mut s = Startup::begin_unforced(Some(blocked.clone()), V).expect("candidates"); + s.started_with(Backend::Gl); + s.presented(); + let _ = std::fs::remove_file(dir); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index fa413e3..c306062 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -5,10 +5,14 @@ //! `--control ` (or `DESIGNCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `designcraft_ui_egui::control` for the methods. +//! +//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window +//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it. #![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)] mod control_server; +mod gpu; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -16,33 +20,54 @@ mod native_menu; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; -struct App(DesignApp, #[cfg(target_os = "macos")] Option); +struct App { + app: DesignApp, + #[cfg(target_os = "macos")] + menu: Option, + /// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`. + startup: Option, + /// Frames begun so far. + frames: u32, +} impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The third call: two frames went through `ui`, so the first was presented and the driver + // survived it (a fault at the first present is what the fallback in `gpu` is for). + if self.frames >= 2 + && let Some(s) = self.startup.as_mut() + { + s.presented(); + self.startup = None; + } + self.frames = self.frames.saturating_add(1); #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(&mut self.0)); + if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { + self.menu = Some(native_menu::NativeMenu::install(&mut self.app)); } - if let Some(m) = &mut self.1 { - m.poll(&mut self.0, ctx); + if let Some(m) = &mut self.menu { + m.poll(&mut self.app, ctx); } } - self.0.logic(ctx); + self.app.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.app.ui(ui); } fn on_exit(&mut self) { - save_prefs(&self.0); + // A normal exit before the third frame is not a crash. + if let Some(s) = self.startup.as_mut() { + s.presented(); + } + save_prefs(&self.app); } } -/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here. +/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here. fn config_dir() -> Option { if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) @@ -56,12 +81,17 @@ fn config_dir() -> Option { } } +/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). +fn no_prefs() -> bool { + std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() +} + fn prefs_path() -> Option { config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { - if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() { + if no_prefs() { return; } if let Some(p) = prefs_path() @@ -80,7 +110,7 @@ fn load_prefs(app: &mut DesignApp) { } fn save_prefs(app: &DesignApp) { - if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() { + if no_prefs() { return; } if let Some(p) = prefs_path() { @@ -241,6 +271,14 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); } + // The graphics backend, decided before the window exists: a driver that faults takes the + // process down before any Rust code can catch it (see `gpu`). + let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); + if let Some(s) = &gpu + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = s.backends(); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. @@ -255,11 +293,24 @@ fn main() -> eframe::Result { "DesignCraft", options, Box::new(move |cc| { + let mut gpu = gpu; + if let Some(rs) = &cc.wgpu_render_state { + // What a bug report needs to know about the graphics driver. + let info = rs.adapter.get_info(); + log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info); + if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) { + s.started_with(backend); + } + } let mut session = Session::new(); // Crash recovery: reopen what a previous run left unsaved, then keep it current. session.recovery_dir = designcraft_engine::recovery::default_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); + if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { + app.status(line); + } + // Recovered documents matter more than the graphics note; both are in the log. if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) { app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" })); } @@ -277,11 +328,13 @@ fn main() -> eframe::Result { eprintln!("designcraft: {f}: {e}"); } } - Ok(Box::new(App( + Ok(Box::new(App { app, #[cfg(target_os = "macos")] - None, - ))) + menu: None, + startup: gpu, + frames: 0, + })) }), ) }