From 962268e16e4a7d04998931c33dbf8a530e292ff7 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 29 Jun 2026 12:49:02 +0300 Subject: [PATCH] feat(io): .bak backup-on-save + open .bak as a supported type (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save: overwriting an existing file first copies it to a sibling .bak (default on; toggle with the ISAVEBAK command, persisted in settings). - Open: .bak is a first-class type — the Open dialog lists it and the loader detects DWG vs DXF from the file's leading magic bytes (the extension hides the real format). - File association registers .bak too, every launch (so users who associated earlier get it automatically): Windows via the registry; Linux via a high-priority shared-mime-info magic that resolves DWG/DXF content under the .bak extension, with the generic *.bak glob claimed only once the app is the default DWG handler — so it never hijacks other apps' backups. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/styleprops.rs | 19 +++++++++ src/app/mod.rs | 4 ++ src/app/settings.rs | 8 +++- src/app/update/file.rs | 8 ++++ src/app/update/mod.rs | 3 +- src/io/file_association.rs | 73 ++++++++++++++++++++++++++++++++++ src/io/mod.rs | 37 ++++++++++++++++- 7 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 486663bfd..1092ca9ae 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1449,6 +1449,25 @@ impl OpenCADStudio { .push_error("Requires 0 OR 1 OR MULTIPLE OR SINGLE"); } } + "ISAVEBAK" => { + let v = if self.backup_on_save { 1 } else { 0 }; + self.command_line.push_output(&format!("ISAVEBAK = {v}")); + } + cmd if cmd.starts_with("ISAVEBAK ") => { + match cmd.trim_start_matches("ISAVEBAK").trim() { + "0" => { + self.backup_on_save = false; + self.persist_settings_if_changed(); + self.command_line.push_output("ISAVEBAK set to 0"); + } + "1" => { + self.backup_on_save = true; + self.persist_settings_if_changed(); + self.command_line.push_output("ISAVEBAK set to 1"); + } + _ => self.command_line.push_error("Requires 0 or 1"), + } + } "PDSIZE" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( diff --git a/src/app/mod.rs b/src/app/mod.rs index 1167c082d..70951ec2a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -247,6 +247,9 @@ pub(super) struct OpenCADStudio { dyn_input: bool, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// When true (default), saving over an existing file first writes a `.bak` + /// copy of it for recovery (#205). Toggle with the ISAVEBAK command. + pub backup_on_save: bool, /// Persisted default viewport background, restored from settings and applied /// to every drawing tab (new and opened) so a chosen background survives /// restarts (#188). `None` = the built-in dark-grey / off-white defaults. @@ -1862,6 +1865,7 @@ impl OpenCADStudio { show_grid: false, dyn_input: true, texteditmode: false, + backup_on_save: true, default_bg_color: None, default_paper_bg_color: None, awaiting_vports: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index e5f834fe2..ca8c5c694 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -103,6 +103,9 @@ pub struct UserSettings { pub plugin_repos: Vec, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// When true, saving over an existing file first copies it to a sibling + /// `.bak` so a faulty or accidental save can be recovered (#205). + pub backup_on_save: bool, /// Persisted viewport background colours (0–255 RGB); `None` = app default /// (dark grey model / off-white paper). Applied to every drawing tab on /// launch and to tabs opened later, so a chosen background survives restarts @@ -133,6 +136,7 @@ impl Default for UserSettings { disabled_plugins: Vec::new(), plugin_repos: Vec::new(), texteditmode: false, + backup_on_save: true, bg_color: None, paper_bg_color: None, } @@ -177,6 +181,7 @@ impl UserSettings { s.texteditmode = v; } } + "backup_on_save" => s.backup_on_save = val == "1", "disabled_plugins" => { s.disabled_plugins = val .split(',') @@ -218,7 +223,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbg_color={}\npaper_bg_color={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nbg_color={}\npaper_bg_color={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -230,6 +235,7 @@ impl UserSettings { self.disabled_plugins.join(","), self.plugin_repos.join(","), self.texteditmode, + b(self.backup_on_save), rgb_to_str(self.bg_color), rgb_to_str(self.paper_bg_color), ); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c13b408d6..a9ddc090a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -40,6 +40,7 @@ impl OpenCADStudio { }, plugin_repos: self.plugin_repos.clone(), texteditmode: self.texteditmode, + backup_on_save: self.backup_on_save, bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), } @@ -58,6 +59,7 @@ impl OpenCADStudio { self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); self.texteditmode = s.texteditmode; + self.backup_on_save = s.backup_on_save; self.default_bg_color = s.bg_color.map(u3_to_f4); self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4); // Push the restored background onto every drawing tab that exists now @@ -536,6 +538,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; // A direct Save preserves the document's current version. + if self.backup_on_save { + crate::io::write_backup(&path); + } match crate::io::save(&self.tabs[i].scene.document, &path) { Ok(()) => { self.command_line @@ -597,6 +602,9 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(not(target_arch = "wasm32"))] { let path = self.save_dialog_folder.join(&filename); + if self.backup_on_save { + crate::io::write_backup(&path); + } match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { Ok(()) => { self.command_line diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 803d09c58..8f67c69ba 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -315,9 +315,10 @@ impl OpenCADStudio { async { let handle = rfd::AsyncFileDialog::new() .set_title("Select External Reference File") - .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) + .add_filter("CAD Files", &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"]) .add_filter("DWG Files", &["dwg", "DWG"]) .add_filter("DXF Files", &["dxf", "DXF"]) + .add_filter("Backup Files", &["bak", "BAK"]) .pick_file() .await; match handle { diff --git a/src/io/file_association.rs b/src/io/file_association.rs index 4aff996ed..07309b58d 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -272,12 +272,15 @@ mod windows_impl { // appear in the "Open with → Choose another app" picker for them. set_string(&format!(r"{APP_BASE}\SupportedTypes"), Some(".dwg"), "")?; set_string(&format!(r"{APP_BASE}\SupportedTypes"), Some(".dxf"), "")?; + // `.bak` copies hold verbatim DWG/DXF content and open the same way. + set_string(&format!(r"{APP_BASE}\SupportedTypes"), Some(".bak"), "")?; // ── ProgIDs (per-user mirror of the MSI's) ────────────────────────── // The Capabilities entries below point at these, and they must resolve // to a real open command for "set as default" to apply them. register_progid(&exe, "OpenCADStudio.DWG", "DWG Drawing")?; register_progid(&exe, "OpenCADStudio.DXF", "DXF Drawing")?; + register_progid(&exe, "OpenCADStudio.BAK", "CAD Backup")?; // Also offer the ProgIDs in each extension's Open-with list. set_string( r"Software\Classes\.dwg\OpenWithProgids", @@ -289,6 +292,11 @@ mod windows_impl { Some("OpenCADStudio.DXF"), "", )?; + set_string( + r"Software\Classes\.bak\OpenWithProgids", + Some("OpenCADStudio.BAK"), + "", + )?; // ── Capabilities + RegisteredApplications ─────────────────────────── // Mirrors the MSI's DefaultPrograms component, but per-user, so the @@ -304,6 +312,7 @@ mod windows_impl { )?; set_string(&format!(r"{CAP}\FileAssociations"), Some(".dwg"), "OpenCADStudio.DWG")?; set_string(&format!(r"{CAP}\FileAssociations"), Some(".dxf"), "OpenCADStudio.DXF")?; + set_string(&format!(r"{CAP}\FileAssociations"), Some(".bak"), "OpenCADStudio.BAK")?; set_string( r"Software\RegisteredApplications", Some("Open CAD Studio"), @@ -325,6 +334,11 @@ mod linux_impl { /// Skipped when a system package already registered the app, so we don't /// shadow a distro-provided desktop entry. pub(super) fn register_handler() -> Result<(), String> { + // High-priority CAD magic so DWG/DXF content (including `.bak` copies) + // resolves to image/vnd.dwg / image/vnd.dxf even though the freedesktop + // `*.bak` glob would otherwise tag it application/x-trash (#205). + install_cad_mime(); + // A system install already lists us — leave it alone. let data_dirs = std::env::var("XDG_DATA_DIRS") .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); @@ -381,6 +395,65 @@ mod linux_impl { Ok(()) } + /// Install a high-priority shared-mime-info magic rule so DWG/DXF *content* + /// is recognised regardless of extension. Without it a `.bak` copy matches + /// the freedesktop `*.bak` glob (application/x-trash) and never reaches us; + /// the magic (priority 90) beats that glob so DWG/DXF backups open with the + /// app. Best-effort + idempotent (skips the slow DB refresh when unchanged). + fn install_cad_mime() { + let Ok(home) = data_home() else { return }; + let pkgs = home.join("mime/packages"); + let xml_path = pkgs.join(format!("{APP_ID}-cad.xml")); + // The `*.bak` glob is what makes file managers (GIO/Nautilus key off the + // extension, not content) open DWG/DXF backups with us — but it would + // mislabel every OTHER app's `.bak` files too. So only claim it once the + // user has actually made us the default for DWG (i.e. granted the file + // association). The content magic below is always safe. + let bak_glob = if is_default_for_dwg() { + " \n" + } else { + "" + }; + let xml = format!( + r#" + + +{bak_glob} + + +{bak_glob} + + +"# + ); + if std::fs::read_to_string(&xml_path).ok().as_deref() == Some(xml.as_str()) { + return; + } + if std::fs::create_dir_all(&pkgs).is_ok() && std::fs::write(&xml_path, &xml).is_ok() { + let _ = std::process::Command::new("update-mime-database") + .arg(home.join("mime")) + .status(); + } + } + + /// Whether the user has already made us the default app for DWG drawings + /// (i.e. granted the file association). Used to decide whether to claim the + /// generic `*.bak` extension for DWG/DXF backups (#205). + fn is_default_for_dwg() -> bool { + let cfg = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))); + let Some(cfg) = cfg else { return false }; + let Ok(body) = std::fs::read_to_string(cfg.join("mimeapps.list")) else { + return false; + }; + let target = format!("{APP_ID}.desktop"); + body.lines().any(|l| { + let l = l.trim(); + l.starts_with("image/vnd.dwg=") && l.contains(&target) + }) + } + /// Write the app SVG icon into the user's hicolor icon theme so that the /// named `Icon={APP_ID}` entry in the .desktop file resolves correctly in /// file managers and the "Open with" picker. Skipped when the icon is diff --git a/src/io/mod.rs b/src/io/mod.rs index 9f1772f0b..f540c9538 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -38,9 +38,10 @@ const PHASE_FINALIZING: u8 = 3; pub async fn pick_open_path() -> Option<(PathBuf, u64)> { let handle = rfd::AsyncFileDialog::new() .set_title("Open CAD file") - .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) + .add_filter("CAD Files", &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"]) .add_filter("DWG Files", &["dwg", "DWG"]) .add_filter("DXF Files", &["dxf", "DXF"]) + .add_filter("Backup Files", &["bak", "BAK"]) .add_filter("All Files", &["*"]) .pick_file() .await?; @@ -147,13 +148,36 @@ pub fn load_bytes(name: &str, bytes: Vec) -> Result { } /// Load a DWG or DXF file directly from a path (auto-detect by extension). +/// Peek at a file's leading bytes to tell a DWG (version tag "AC10xx") from a +/// DXF. Used for `.bak` copies, whose extension hides the real format. +fn sniff_dwg_or_dxf(path: &Path) -> String { + use std::io::Read; + let mut buf = [0u8; 6]; + if let Ok(mut f) = std::fs::File::open(path) { + let _ = f.read(&mut buf); + } + if buf.starts_with(b"AC10") { + "dwg".to_string() + } else { + "dxf".to_string() + } +} + pub fn load_file(path: &Path) -> Result { let ext = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); - match ext.as_str() { + // A `.bak` backup holds a verbatim DWG or DXF copy — detect the real format + // from the file's leading bytes rather than the `.bak` extension. + let effective = if ext == "bak" { + sniff_dwg_or_dxf(path) + } else { + ext.clone() + }; + + match effective.as_str() { "dwg" => { let mut doc = DwgReader::from_file(path) .map_err(|e| e.to_string())? @@ -473,6 +497,15 @@ pub fn dropped_on_save_count( n } +/// Before overwriting `path`, copy the existing file to a sibling `.bak` +/// so a faulty or accidental save can be recovered (#205). Best-effort: a +/// failed backup never blocks the save itself. +pub fn write_backup(path: &std::path::Path) { + if path.exists() { + let _ = std::fs::copy(path, path.with_extension("bak")); + } +} + // ── Plot Style Table ────────────────────────────────────────────────────── /// Show a file-open dialog and load the selected CTB or STB file.