diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 1092ca9ae..23da24ce3 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1468,6 +1468,39 @@ impl OpenCADStudio { _ => self.command_line.push_error("Requires 0 or 1"), } } + "FILEASSOC" => { + let v = if self.file_assoc_enabled { 1 } else { 0 }; + self.command_line.push_output(&format!("FILEASSOC = {v}")); + } + cmd if cmd.starts_with("FILEASSOC ") => { + match cmd.trim_start_matches("FILEASSOC").trim() { + "1" => { + self.file_assoc_enabled = true; + self.persist_settings_if_changed(); + match crate::io::file_association::register_as_handler() { + Ok(()) => self.command_line.push_output( + "FILEASSOC set to 1 — registered as a .dwg/.dxf/.bak handler", + ), + Err(e) => self + .command_line + .push_error(&format!("FILEASSOC: registration failed: {e}")), + } + } + "0" => { + self.file_assoc_enabled = false; + self.persist_settings_if_changed(); + match crate::io::file_association::unregister_handler() { + Ok(()) => self + .command_line + .push_output("FILEASSOC set to 0 — unregistered as a file handler"), + Err(e) => self + .command_line + .push_error(&format!("FILEASSOC: unregister failed: {e}")), + } + } + _ => 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 70951ec2a..f01d19c68 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -250,6 +250,9 @@ pub(super) struct OpenCADStudio { /// 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, + /// When true (default), the app registers itself as a .dwg/.dxf/.bak file + /// handler on each launch. Toggle with the FILEASSOC command. + pub file_assoc_enabled: 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. @@ -1866,6 +1869,7 @@ impl OpenCADStudio { dyn_input: true, texteditmode: false, backup_on_save: true, + file_assoc_enabled: true, default_bg_color: None, default_paper_bg_color: None, awaiting_vports: false, @@ -2151,8 +2155,15 @@ impl OpenCADStudio { // portable .exe / AppImage that no installer has registered. Detached so // it never delays startup. std::thread::spawn(|| { - if let Err(e) = crate::io::file_association::register_as_handler() { - eprintln!("file association: handler registration failed: {e}"); + // Honour the FILEASSOC toggle: skip (re)registration when the user + // has turned file association off. + let enabled = settings::UserSettings::load() + .map(|s| s.file_assoc_enabled) + .unwrap_or(true); + if enabled { + if let Err(e) = crate::io::file_association::register_as_handler() { + eprintln!("file association: handler registration failed: {e}"); + } } }); let state = Self::new(); diff --git a/src/app/settings.rs b/src/app/settings.rs index ca8c5c694..9dc68a0a0 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -106,6 +106,9 @@ pub struct UserSettings { /// 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, + /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak + /// handler on every launch. Toggle with the FILEASSOC command. + pub file_assoc_enabled: 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 @@ -137,6 +140,7 @@ impl Default for UserSettings { plugin_repos: Vec::new(), texteditmode: false, backup_on_save: true, + file_assoc_enabled: true, bg_color: None, paper_bg_color: None, } @@ -182,6 +186,7 @@ impl UserSettings { } } "backup_on_save" => s.backup_on_save = val == "1", + "file_assoc_enabled" => s.file_assoc_enabled = val == "1", "disabled_plugins" => { s.disabled_plugins = val .split(',') @@ -223,7 +228,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={}\nbackup_on_save={}\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={}\nfile_assoc_enabled={}\nbg_color={}\npaper_bg_color={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -236,6 +241,7 @@ impl UserSettings { self.plugin_repos.join(","), self.texteditmode, b(self.backup_on_save), + b(self.file_assoc_enabled), 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 a9ddc090a..1f1189c35 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -41,6 +41,7 @@ impl OpenCADStudio { plugin_repos: self.plugin_repos.clone(), texteditmode: self.texteditmode, backup_on_save: self.backup_on_save, + file_assoc_enabled: self.file_assoc_enabled, bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), } @@ -60,6 +61,7 @@ impl OpenCADStudio { self.plugin_repos = s.plugin_repos.clone(); self.texteditmode = s.texteditmode; self.backup_on_save = s.backup_on_save; + self.file_assoc_enabled = s.file_assoc_enabled; 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 diff --git a/src/io/file_association.rs b/src/io/file_association.rs index 07309b58d..60880f54e 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -60,6 +60,27 @@ pub fn register_as_handler() -> Result<(), String> { } } +/// Remove this app's file-handler registration (the inverse of +/// [`register_as_handler`]) so it no longer claims .dwg/.dxf/.bak. Best-effort. +pub fn unregister_handler() -> Result<(), String> { + #[cfg(target_os = "windows")] + { + windows_impl::unregister_handler() + } + #[cfg(target_os = "linux")] + { + linux_impl::unregister_handler() + } + #[cfg(target_os = "macos")] + { + Ok(()) + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + Ok(()) + } +} + /// Try to make this app the default handler for .dwg and .dxf. Returns a short /// human-readable status string on success, or an error message on failure. pub async fn set_default_app() -> Result { @@ -189,10 +210,29 @@ mod windows_impl { } use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, + RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, }; + /// Remove the per-user keys this app created — undoes [`register_handler`]. + /// Best-effort: leaves the shared `.dwg/.dxf/.bak` OpenWithProgids values + /// (they point at now-deleted ProgIDs, which Windows simply ignores). + pub(super) fn unregister_handler() -> Result<(), String> { + for key in [ + r"Software\Classes\Applications\OpenCADStudio.exe", + r"Software\Classes\OpenCADStudio.DWG", + r"Software\Classes\OpenCADStudio.DXF", + r"Software\Classes\OpenCADStudio.BAK", + r"Software\Open CAD Studio", + ] { + let w = wide(key); + unsafe { + RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr()); + } + } + Ok(()) + } + fn wide(s: &str) -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() } @@ -395,6 +435,23 @@ mod linux_impl { Ok(()) } + /// Remove the .desktop entry and the CAD mime package, then refresh the + /// caches — undoes [`register_handler`]. Best-effort; missing files are fine. + pub(super) fn unregister_handler() -> Result<(), String> { + let apps = data_home()?.join("applications"); + let _ = std::fs::remove_file(apps.join(format!("{APP_ID}.desktop"))); + let _ = std::process::Command::new("update-desktop-database") + .arg(&apps) + .status(); + + let home = data_home()?; + let _ = std::fs::remove_file(home.join("mime/packages").join(format!("{APP_ID}-cad.xml"))); + let _ = std::process::Command::new("update-mime-database") + .arg(home.join("mime")) + .status(); + 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;