mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Startup: survive damaged settings, font folders, recovery folders and small texture limits
- ui.json / prefs.json are read through a new settings module: regular files only, 4 MiB cap, parse errors logged, values sanitized (a saved zoom of 1e30 aborted start-up, 1e4 panicked in egui); writes are atomic. - The canvas and the control protocol clamp stray zooms; the status bar shows the zoom in use. - The canvas texture is cut to the GPU's max texture side (zoom 400% on a 2048 limit panicked). - The system font scan opens only regular files of sane size (a FIFO named *.ttf in ~/.fonts hung start-up), with depth and entry caps. - AutoRecover skips non-regular entries and reopens at most 32 per start, newest first. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,24 +52,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut SlideApp) {
|
||||
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path()
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(ui) = serde_json::from_slice::<deckcraft_ui_egui::UiState>(&bytes)
|
||||
{
|
||||
let Some(dir) = config_dir() else { return };
|
||||
let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir);
|
||||
if let Some(ui) = ui {
|
||||
app.ui = ui;
|
||||
}
|
||||
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(prefs) = serde_json::from_slice::<deckcraft_engine::Prefs>(&bytes)
|
||||
{
|
||||
if let Some(prefs) = prefs {
|
||||
app.session.prefs = prefs;
|
||||
}
|
||||
}
|
||||
@@ -78,14 +70,8 @@ fn save_prefs(app: &SlideApp) {
|
||||
if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
|
||||
let _ = std::fs::write(p.with_file_name("prefs.json"), bytes);
|
||||
}
|
||||
if let Some(dir) = config_dir() {
|
||||
deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,29 @@ impl Default for Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// The preferences with every value in a range the editor can use: `prefs.json` is a file
|
||||
/// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo
|
||||
/// limit) must not break editing or exhaust memory. Out-of-range numbers take their
|
||||
/// defaults or the nearest usable value.
|
||||
pub fn sanitized(mut self) -> Prefs {
|
||||
let d = Prefs::default();
|
||||
let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default };
|
||||
self.grid_spacing =
|
||||
if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing };
|
||||
self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset);
|
||||
self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT);
|
||||
self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes);
|
||||
if self.author.chars().count() > 256 {
|
||||
self.author = self.author.chars().take(256).collect();
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The most undo steps [`Prefs::undo_limit`] can keep.
|
||||
pub const MAX_UNDO_LIMIT: usize = 10_000;
|
||||
|
||||
/// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
|
||||
@@ -25,6 +25,20 @@ pub fn default_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("deckcraft/recovery"))
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
/// full of entries can't stall start-up.
|
||||
pub const MAX_REOPEN: usize = 32;
|
||||
/// Largest entry read back.
|
||||
const MAX_ENTRY_BYTES: u64 = 2 << 30;
|
||||
const MAX_META_BYTES: u64 = 64 << 10;
|
||||
|
||||
/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an
|
||||
/// entry belongs would block start-up forever.
|
||||
fn read_regular(p: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
let m = std::fs::metadata(p).ok()?;
|
||||
(m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten()
|
||||
}
|
||||
|
||||
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
@@ -78,11 +92,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = std::fs::read_to_string(p.with_extension("json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or(json!({}));
|
||||
out.push((uid, meta));
|
||||
@@ -92,12 +108,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
}
|
||||
|
||||
/// Reopen every entry as an unsaved presentation and remove the entries (this session writes its
|
||||
/// own). Unreadable entries are left in place. Returns the opened documents' indices.
|
||||
/// own), the newest [`MAX_REOPEN`] first. Unreadable entries and those past the cap are left in place. Returns the opened documents' indices.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
let mut opened = Vec::new();
|
||||
for (uid, meta) in list(dir) {
|
||||
let mut entries = list(dir);
|
||||
entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid)));
|
||||
for (uid, meta) in entries {
|
||||
if opened.len() >= MAX_REOPEN {
|
||||
break;
|
||||
}
|
||||
let (doc, _) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue };
|
||||
let Ok(mut d) = deckcraft_format::load(&bytes) else { continue };
|
||||
deckcraft_format::repair(&mut d);
|
||||
let path = meta.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
@@ -162,4 +183,34 @@ mod tests {
|
||||
assert_eq!(v.as_array().map(Vec::len), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked
|
||||
/// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn hostile_recovery_folders_dont_stall_start_up() {
|
||||
let dir = temp("recovery-hostile");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
save(&s, &dir).unwrap();
|
||||
let (good, _) = files(&dir, s.doc().unwrap().uid);
|
||||
for i in 1..=(MAX_REOPEN as u64 + 3) {
|
||||
std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap();
|
||||
}
|
||||
for name in ["5.deckcraft", "6.json"] {
|
||||
let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status();
|
||||
}
|
||||
std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let d = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut s2 = Session::new();
|
||||
let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0));
|
||||
});
|
||||
let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung");
|
||||
assert_eq!(n, MAX_REOPEN);
|
||||
assert_eq!(list(&dir).len(), 5, "the rest wait for the next start");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Folder depth below a font folder that the system scan descends.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCAN_DEPTH: usize = 12;
|
||||
/// Directory entries the system scan looks at in all.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCAN_ENTRIES: usize = 200_000;
|
||||
/// The largest font file read (the biggest CJK collections are a few hundred MB).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const MAX_FONT_FILE: u64 = 512 << 20;
|
||||
|
||||
/// A regular file of a size a font can have.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn font_file_ok(meta: &std::fs::Metadata) -> bool {
|
||||
meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
/// Empty on wasm.
|
||||
pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
@@ -804,24 +820,41 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut found = Vec::new();
|
||||
let mut stack = self.font_dirs.clone();
|
||||
let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
let mut entries = 0usize;
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
// A link to `/` or a vast tree mustn't turn start-up into a disk crawl.
|
||||
entries += 1;
|
||||
if entries > MAX_SCAN_ENTRIES {
|
||||
log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries");
|
||||
stack.clear();
|
||||
break;
|
||||
}
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
// Follows links: what matters is what the entry leads to.
|
||||
let Ok(meta) = std::fs::metadata(&p) else { continue };
|
||||
if meta.is_dir() {
|
||||
if depth < MAX_SCAN_DEPTH {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
// Only regular files: opening a FIFO named like a font blocks until something
|
||||
// writes to it (start-up hung), and a device never ends.
|
||||
if !font_file_ok(&meta) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
found.push(CatalogEntry { family, style, path: p.clone() });
|
||||
}
|
||||
@@ -845,7 +878,9 @@ impl FontDb {
|
||||
};
|
||||
let mut any = false;
|
||||
for p in paths {
|
||||
if let Ok(data) = std::fs::read(&p) {
|
||||
if std::fs::metadata(&p).is_ok_and(|m| font_file_ok(&m))
|
||||
&& let Ok(data) = std::fs::read(&p)
|
||||
{
|
||||
any |= self.add_font(data) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,4 +299,30 @@ mod tests {
|
||||
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
|
||||
}
|
||||
}
|
||||
|
||||
/// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them
|
||||
/// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and
|
||||
/// still finds the real font beside them.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_font_scan_skips_fifos_folders_and_link_loops() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap();
|
||||
let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success());
|
||||
std::fs::create_dir(dir.join("folder.ttf")).unwrap();
|
||||
std::fs::write(dir.join("empty.otf"), b"").unwrap();
|
||||
std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let scan_dir = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let db = FontDb::with_font_dirs(vec![scan_dir]);
|
||||
let _ = tx.send(db.load_system_fonts());
|
||||
});
|
||||
let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung");
|
||||
assert!(n >= 1, "the real font was found");
|
||||
assert!(fifo || n >= 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
||||
let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32);
|
||||
let margin = 28.0;
|
||||
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
|
||||
let scale = app.ui.zoom.unwrap_or(fit);
|
||||
// A zoom from outside the editor's own controls (settings, the control protocol) is kept in range.
|
||||
let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end()));
|
||||
let size = vec2(sw * scale, sh * scale);
|
||||
let mut r = Rect::from_center_size(avail.center(), size);
|
||||
if size.x > avail.width() {
|
||||
|
||||
@@ -229,7 +229,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
};
|
||||
}
|
||||
if let Some(z) = p.get("zoom") {
|
||||
app.ui.zoom = z.as_f64().map(|v| v as f32);
|
||||
app.ui.zoom = z
|
||||
.as_f64()
|
||||
.map(|v| v as f32)
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end()));
|
||||
}
|
||||
if let Some(d) = p.get("dark").and_then(Value::as_bool) {
|
||||
let _ = app.run("view.dark", json!({"on": d}));
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod menus;
|
||||
pub mod panes;
|
||||
pub mod ribbon;
|
||||
pub mod selpane;
|
||||
pub mod settings;
|
||||
pub mod show;
|
||||
pub mod sorter;
|
||||
pub mod status;
|
||||
@@ -131,6 +132,41 @@ impl Default for UiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor's zoom range (1 = 100 %).
|
||||
pub const ZOOM_RANGE: std::ops::RangeInclusive<f32> = 0.1..=4.0;
|
||||
|
||||
impl UiState {
|
||||
/// The state with every value usable: `ui.json` is a file on disk, and a damaged or
|
||||
/// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the
|
||||
/// renderer for a texture larger than memory at start-up; panel sizes, scales and names are
|
||||
/// brought into range the same way, and the recent list is cut to its usual length.
|
||||
pub fn sanitized(mut self) -> UiState {
|
||||
let d = UiState::default();
|
||||
let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default };
|
||||
self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end()));
|
||||
self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom);
|
||||
self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale);
|
||||
self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height);
|
||||
self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width);
|
||||
self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width);
|
||||
let short = |s: &String| s.len() <= 64;
|
||||
if !short(&self.tab) {
|
||||
self.tab = d.tab;
|
||||
}
|
||||
if !short(&self.format_tab) {
|
||||
self.format_tab = d.format_tab;
|
||||
}
|
||||
self.pane = self.pane.filter(short);
|
||||
self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0'));
|
||||
self.recent.dedup();
|
||||
self.recent.truncate(RECENT_MAX);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// How many recent files File › Open and Home list.
|
||||
pub const RECENT_MAX: usize = 12;
|
||||
|
||||
/// UI-only commands (id, label, shortcut, params).
|
||||
pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("view.normal", "Normal", None, "{}"),
|
||||
@@ -575,7 +611,7 @@ impl SlideApp {
|
||||
self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(12);
|
||||
self.ui.recent.truncate(RECENT_MAX);
|
||||
self.ui.view = ViewMode::Normal;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json`
|
||||
//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a
|
||||
//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts
|
||||
//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a
|
||||
//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing
|
||||
//! goes through a temporary file and a rename, so a crash mid-write leaves the old file.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use deckcraft_engine::Prefs;
|
||||
|
||||
use crate::UiState;
|
||||
|
||||
pub const UI_FILE: &str = "ui.json";
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
/// Larger settings files are not settings.
|
||||
pub const MAX_BYTES: u64 = 4 << 20;
|
||||
|
||||
/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`.
|
||||
fn read<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_BYTES {
|
||||
log::warn!("{}: not a settings file; using the defaults", path.display());
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}; using the defaults", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved UI state and preferences in `dir` (each `None` when missing or unusable).
|
||||
pub fn load(dir: &Path) -> (Option<UiState>, Option<Prefs>) {
|
||||
(read::<UiState>(&dir.join(UI_FILE)).map(UiState::sanitized), read::<Prefs>(&dir.join(PREFS_FILE)).map(Prefs::sanitized))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file beside it.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
|
||||
/// Save both files into `dir` (created when missing). Failures are logged, not fatal.
|
||||
pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("{}: {e}; settings not saved", dir.display());
|
||||
return;
|
||||
}
|
||||
for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] {
|
||||
let path = dir.join(name);
|
||||
if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) {
|
||||
log::warn!("{}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_settings_files_give_the_defaults() {
|
||||
let d = dir("damaged");
|
||||
let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"];
|
||||
for bytes in cases {
|
||||
std::fs::write(d.join(UI_FILE), bytes).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), bytes).unwrap();
|
||||
let (ui, prefs) = load(&d);
|
||||
// Prefs has no `zoom`, so that case is a valid (default) Prefs.
|
||||
assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes));
|
||||
assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
// A folder where a file should be, and an oversized file.
|
||||
std::fs::remove_file(d.join(UI_FILE)).unwrap();
|
||||
std::fs::create_dir(d.join(UI_FILE)).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap();
|
||||
assert!(matches!(load(&d), (None, None)));
|
||||
// Nothing there at all.
|
||||
assert!(matches!(load(&d.join("missing")), (None, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_values_are_brought_into_range() {
|
||||
let d = dir("ranges");
|
||||
let recent: Vec<String> = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect();
|
||||
let ui = serde_json::json!({
|
||||
"zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30,
|
||||
"thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500),
|
||||
"recent": recent,
|
||||
});
|
||||
let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)});
|
||||
std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap();
|
||||
std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap();
|
||||
let (ui, prefs) = load(&d);
|
||||
let (ui, prefs) = (ui.unwrap(), prefs.unwrap());
|
||||
assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end()));
|
||||
assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0));
|
||||
assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0);
|
||||
assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None));
|
||||
assert_eq!(ui.recent.len(), crate::RECENT_MAX);
|
||||
assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing);
|
||||
assert_eq!(prefs.duplicate_offset, 1000.0);
|
||||
assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT);
|
||||
assert_eq!(prefs.recovery_minutes, 0.1);
|
||||
assert_eq!(prefs.author.chars().count(), 256);
|
||||
// A zero or negative zoom means "fit".
|
||||
std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap();
|
||||
assert_eq!(load(&d).0.unwrap().zoom, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_round_trips_and_leaves_no_temporary_file() {
|
||||
let d = dir("save");
|
||||
let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() };
|
||||
let prefs = Prefs { author: "Ann".into(), ..Default::default() };
|
||||
save(&d.join("nested"), &ui, &prefs);
|
||||
let (u, p) = load(&d.join("nested"));
|
||||
assert_eq!(u.unwrap().zoom, Some(1.5));
|
||||
assert_eq!(p.unwrap().author, "Ann");
|
||||
let names: Vec<String> =
|
||||
std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}");
|
||||
// A settings folder that is a file: nothing saved, no panic.
|
||||
std::fs::write(d.join("blocked"), "file").unwrap();
|
||||
save(&d.join("blocked"), &ui, &prefs);
|
||||
}
|
||||
|
||||
/// The window opens with the worst `ui.json` values once they are sanitized; a zoom of
|
||||
/// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up.
|
||||
#[test]
|
||||
fn the_window_opens_with_hostile_saved_state() {
|
||||
let hostile = UiState {
|
||||
zoom: Some(1.0e30),
|
||||
notes_height: 1.0e30,
|
||||
thumbs_width: -1.0e30,
|
||||
pane: Some("format".into()),
|
||||
pane_width: 1.0e30,
|
||||
..Default::default()
|
||||
};
|
||||
for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] {
|
||||
let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());
|
||||
// The second case is not sanitized: the canvas keeps a stray zoom in range itself.
|
||||
app.ui = ui;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut crate::SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() {
|
||||
app.ui.zoom = None;
|
||||
}
|
||||
let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round();
|
||||
let pct = (app.canvas_scale * 100.0).round();
|
||||
let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click()));
|
||||
if lbl.clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("zoom"));
|
||||
|
||||
@@ -116,7 +116,9 @@ impl Textures {
|
||||
Some((i, d)) if i == index => estimate.union(d),
|
||||
_ => estimate,
|
||||
};
|
||||
let want = deckcraft_render::pasteboard_region(size, scale, reach, visible);
|
||||
// The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui.
|
||||
let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32;
|
||||
let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side);
|
||||
let key = Key {
|
||||
slide: Arc::as_ptr(&slide) as usize,
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
@@ -142,7 +144,8 @@ impl Textures {
|
||||
// Text spilling out of its box can reach further than the boxes said. Once drawn, the
|
||||
// region it calls for is known (and is next frame's key): render again only if it shows
|
||||
// more than this render covered.
|
||||
let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key };
|
||||
let key =
|
||||
Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key };
|
||||
if !region.contains(&key.want) {
|
||||
region = key.want;
|
||||
img = render(region).0;
|
||||
@@ -213,3 +216,33 @@ impl Textures {
|
||||
Some(tex)
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits.
|
||||
pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region {
|
||||
fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) {
|
||||
if *len > max {
|
||||
let hi = *start as i64 + (*len - max) as i64;
|
||||
*start = (vis as i64).clamp(*start as i64, hi) as i32;
|
||||
*len = max;
|
||||
}
|
||||
}
|
||||
axis(&mut r.x, &mut r.w, visible.x, max);
|
||||
axis(&mut r.y, &mut r.h, visible.y, max);
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canvas_textures_stay_within_the_texture_limit() {
|
||||
let r = Region { x: -100, y: -50, w: 5000, h: 1000 };
|
||||
let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048);
|
||||
assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000));
|
||||
// Visible past the far end: the cut is pinned there.
|
||||
let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048);
|
||||
assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048));
|
||||
assert_eq!(fit_texture(r, r, 8192), r);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user