From a7d85fe14869bf4c2d3a69759032f9ed50d4ccd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:50:41 +0000 Subject: [PATCH] feat(app): first-launch prompt to set Open CAD Studio as default for .dwg/.dxf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the first launch that hasn't answered it yet, the app shows a small dialog offering to become the default handler for .dwg and .dxf files. The choice is persisted (default_assoc_prompted) so the prompt never reappears. The platform plumbing lives in io/file_association.rs: * Windows — opens the OS's own per-app default-programs dialog via IApplicationAssociationRegistrationUI::LaunchAdvancedAssociationUI, passing the "Open CAD Studio" RegisteredApplications name the MSI registers. Modern Windows guards the actual default behind a UserChoice hash, so this is the supported consent path. * Linux — xdg-mime default against the installed .desktop file, whose MimeType entries already cover image/vnd.dwg and image/vnd.dxf. * macOS — LSSetDefaultRoleHandlerForContentType binds the DWG/DXF UTIs declared in Info.plist to the app bundle id. The work runs on a dedicated thread (the Windows dialog is modal) with the result delivered back through a oneshot channel and surfaced in the command line. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- Cargo.toml | 5 +- src/app/mod.rs | 35 ++++- src/app/settings.rs | 9 +- src/app/update.rs | 45 +++++++ src/app/view.rs | 106 +++++++++++++++ src/io/file_association.rs | 259 +++++++++++++++++++++++++++++++++++++ src/io/mod.rs | 1 + 7 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 src/io/file_association.rs diff --git a/Cargo.toml b/Cargo.toml index 180a88985..eaf68054c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,10 @@ truck-shapeops = "0.4" rustc-hash = "2" [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +# Win32_System_Com is needed for the COM calls behind "Set as default app" +# (CoCreateInstance → IApplicationAssociationRegistrationUI), which opens +# Windows' own per-app default-programs dialog. +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com"] } [patch.crates-io] # Tracks HakanSeven12/acadrust main, which carries the hatch diff --git a/src/app/mod.rs b/src/app/mod.rs index f68b0eb14..3d7c6f4d8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -292,6 +292,12 @@ pub(super) struct OpenCADStudio { /// New-release notification window — opened on startup when the /// GitHub releases API reports a newer version than this build. update_notice_window: Option, + /// First-launch "make Open CAD Studio the default for .dwg/.dxf?" prompt + /// window. Shown once, gated on `default_assoc_prompted`. + assoc_prompt_window: Option, + /// Whether the one-time default-association prompt has already been shown. + /// Persisted via [`settings::UserSettings`] so it survives restarts. + default_assoc_prompted: bool, /// Tag of the latest available release (without the leading "v"), /// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned. update_notice_version: Option, @@ -1045,6 +1051,13 @@ pub enum Message { UpdateCheckResult(Option), /// User dismissed the update-notice window. UpdateNoticeClose, + /// First-launch default-association prompt: user accepted — register this + /// app as the default handler for .dwg / .dxf. + AssocPromptYes, + /// First-launch default-association prompt: user declined (or "not now"). + AssocPromptNo, + /// Result of the platform default-association call. + AssocResult(Result), /// User clicked the "Open release page" button — opens the GitHub /// release URL in the OS default browser and closes the notice. UpdateNoticeOpenRelease, @@ -1354,6 +1367,8 @@ impl OpenCADStudio { shortcuts_window: None, about_window: None, update_notice_window: None, + assoc_prompt_window: None, + default_assoc_prompted: false, update_notice_version: None, update_notice_body: None, clipboard: Vec::new(), @@ -1581,9 +1596,24 @@ impl OpenCADStudio { .filter(|p| !p.as_os_str().to_string_lossy().starts_with('-')) .map(|p| Task::done(Message::OpenRecent(p))) .unwrap_or_else(Task::none); + // One-time prompt offering to make Open CAD Studio the default app for + // .dwg / .dxf. Shown only on the first launch that hasn't answered it + // yet; the flag is persisted so we never ask twice. + let assoc_prompt: Task = if s.default_assoc_prompted { + Task::none() + } else { + let (id, open) = window::open(window::Settings { + size: iced::Size::new(440.0, 210.0), + resizable: false, + level: window::Level::AlwaysOnTop, + ..Default::default() + }); + s.assoc_prompt_window = Some(id); + open.map(|_| Message::Noop) + }; ( s, - Task::batch([open_main, check_update, focus_cmd, cli_open]), + Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]), ) } } @@ -1634,6 +1664,9 @@ pub fn run() -> iced::Result { if Some(window_id) == state.update_notice_window { return "Update Available".into(); } + if Some(window_id) == state.assoc_prompt_window { + return "Set Default Application".into(); + } if Some(window_id) == state.unsaved_dialog_window { return "Unsaved Changes".into(); } diff --git a/src/app/settings.rs b/src/app/settings.rs index 5793f9ab0..4f4596e35 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -70,6 +70,10 @@ pub struct UserSettings { pub otrack: bool, /// Active snap modes, in `SNAP_ORDER`. pub snap_modes: Vec, + /// Whether the one-time "make Open CAD Studio the default for .dwg/.dxf?" + /// prompt has already been shown. Set once the user answers (either way), + /// so we never nag again on subsequent launches. + pub default_assoc_prompted: bool, } impl Default for UserSettings { @@ -91,6 +95,7 @@ impl Default for UserSettings { SnapType::Intersection, SnapType::Nearest, ], + default_assoc_prompted: false, } } } @@ -124,6 +129,7 @@ impl UserSettings { "grid" => s.show_grid = val == "1", "osnap" => s.snap_enabled = val == "1", "otrack" => s.otrack = val == "1", + "default_assoc_prompted" => s.default_assoc_prompted = val == "1", "snap_modes" => { let modes: Vec = val.split(',').filter_map(|t| snap_from_id(t.trim())).collect(); @@ -149,7 +155,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\nsnap_modes={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -157,6 +163,7 @@ impl UserSettings { b(self.show_grid), b(self.snap_enabled), b(self.otrack), + b(self.default_assoc_prompted), modes, ); let _ = std::fs::write(path, body); diff --git a/src/app/update.rs b/src/app/update.rs index fdcdd3987..5e11fd210 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -85,6 +85,7 @@ impl OpenCADStudio { snap_enabled: self.snapper.snap_enabled, otrack: self.snapper.otrack_enabled, snap_modes: super::settings::UserSettings::modes_from(self.snapper.enabled.iter()), + default_assoc_prompted: self.default_assoc_prompted, } } @@ -98,6 +99,7 @@ impl OpenCADStudio { self.snapper.snap_enabled = s.snap_enabled; self.snapper.otrack_enabled = s.otrack; self.snapper.enabled = s.snap_modes.iter().copied().collect(); + self.default_assoc_prompted = s.default_assoc_prompted; } /// Write preferences to disk only when they differ from the last write, @@ -110,6 +112,13 @@ impl OpenCADStudio { } } + /// Record that the one-time default-association prompt has been answered and + /// flush it to disk, so the dialog never reappears on later launches. + fn mark_assoc_prompted(&mut self) { + self.default_assoc_prompted = true; + self.persist_settings_if_changed(); + } + fn update_inner(&mut self, msg: Message) -> Task { match msg { Message::Tick(t) => { @@ -1613,6 +1622,12 @@ impl OpenCADStudio { if self.update_notice_window == Some(id) { self.update_notice_window = None; } + // Closing the default-association prompt via the window chrome + // counts as answering it — never nag again. + if self.assoc_prompt_window == Some(id) { + self.assoc_prompt_window = None; + self.mark_assoc_prompted(); + } Task::none() } @@ -5647,6 +5662,36 @@ impl OpenCADStudio { Task::none() } } + Message::AssocPromptYes => { + self.mark_assoc_prompted(); + let close = if let Some(id) = self.assoc_prompt_window.take() { + window::close(id) + } else { + Task::none() + }; + let run = Task::perform( + crate::io::file_association::set_default_app(), + Message::AssocResult, + ); + Task::batch([close, run]) + } + Message::AssocPromptNo => { + self.mark_assoc_prompted(); + if let Some(id) = self.assoc_prompt_window.take() { + window::close(id) + } else { + Task::none() + } + } + Message::AssocResult(result) => { + match result { + Ok(msg) => self.command_line.push_info(&msg), + Err(err) => self + .command_line + .push_error(&format!("Could not set default app: {err}")), + } + Task::none() + } Message::PageSetupWidthEdit(s) => { self.page_setup_w = s; Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index 19a6a61f1..ba1b06b07 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -473,6 +473,9 @@ impl OpenCADStudio { let body = self.update_notice_body.as_deref().unwrap_or(""); return crate::ui::update_notice::view_window(latest, body); } + if Some(window_id) == self.assoc_prompt_window { + return default_assoc_dialog_window(); + } if Some(window_id) == self.unsaved_dialog_window { let tab_name = match &self.pending_close { Some(super::PendingClose::Tab(idx)) => self @@ -3461,6 +3464,109 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> { .into() } +/// First-launch prompt offering to register Open CAD Studio as the default +/// handler for .dwg / .dxf. "Yes" runs the platform association call; "Not now" +/// just dismisses. Either answer flips the persisted `default_assoc_prompted` +/// flag so the dialog never reappears. +fn default_assoc_dialog_window() -> Element<'static, Message> { + const BG: Color = Color { + r: 0.18, + g: 0.18, + b: 0.20, + a: 1.0, + }; + const BORDER_COL: Color = Color { + r: 0.38, + g: 0.38, + b: 0.42, + a: 1.0, + }; + const TEXT_COL: Color = Color { + r: 0.90, + g: 0.90, + b: 0.90, + a: 1.0, + }; + const DIM_COL: Color = Color { + r: 0.62, + g: 0.62, + b: 0.66, + a: 1.0, + }; + const BTN_YES: Color = Color { + r: 0.20, + g: 0.46, + b: 0.80, + a: 1.0, + }; + const BTN_YHOV: Color = Color { + r: 0.26, + g: 0.55, + b: 0.92, + a: 1.0, + }; + const BTN_NO: Color = Color { + r: 0.28, + g: 0.28, + b: 0.30, + a: 1.0, + }; + const BTN_NHOV: Color = Color { + r: 0.36, + g: 0.36, + b: 0.40, + a: 1.0, + }; + + let btn = |label: &'static str, msg: Message, base: Color, hov: Color| { + button(text(label).size(13).color(TEXT_COL)) + .on_press(msg) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => hov, + _ => base, + })), + text_color: TEXT_COL, + border: Border { + color: BORDER_COL, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([6, 18]) + }; + + container( + column![ + text("Make Open CAD Studio your default CAD app?") + .size(15) + .color(TEXT_COL), + iced::widget::Space::new().height(10), + text("Open .dwg and .dxf drawings in Open CAD Studio by default. You can change this later in your system settings.") + .size(12) + .color(DIM_COL), + iced::widget::Space::new().height(22), + row![ + iced::widget::Space::new().width(Fill), + btn("Not now", Message::AssocPromptNo, BTN_NO, BTN_NHOV), + iced::widget::Space::new().width(8), + btn("Yes, set as default", Message::AssocPromptYes, BTN_YES, BTN_YHOV), + ] + .align_y(iced::Center), + ] + .spacing(0), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .center(Fill) + .padding([24, 28]) + .into() +} + // ── Start / Welcome page ────────────────────────────────────────────────── // // Renders in place of the model-space viewport when the active tab is the diff --git a/src/io/file_association.rs b/src/io/file_association.rs new file mode 100644 index 000000000..61cdebc92 --- /dev/null +++ b/src/io/file_association.rs @@ -0,0 +1,259 @@ +//! "Make Open CAD Studio the default for .dwg / .dxf" — the platform-specific +//! plumbing behind the one-time first-launch prompt (see `app::update`'s +//! `AssocPrompt*` handlers). +//! +//! Each OS guards default file associations differently, so there is no single +//! cross-platform call: +//! +//! * Windows — the actual default is protected by a per-user UserChoice hash +//! an app cannot forge. The supported path is to open the OS's own +//! per-app default-programs dialog via +//! `IApplicationAssociationRegistrationUI::LaunchAdvancedAssociationUI`, +//! passing the RegisteredApplications name the MSI registered +//! ("Open CAD Studio"). The user confirms there. +//! * Linux — `xdg-mime default` writes the association into the user's +//! `mimeapps.list`; no separate consent step. The .desktop file already +//! declares the matching `MimeType=` entries. +//! * macOS — LaunchServices' `LSSetDefaultRoleHandlerForContentType` binds +//! the DWG/DXF UTIs (declared in the bundle's Info.plist) to this app's +//! bundle id. +//! +//! The work runs on a dedicated thread because the Windows dialog is modal and +//! would otherwise block the iced executor; the result is delivered back +//! through a oneshot channel that the async wrapper awaits. + +/// Reverse-DNS bundle / app id, shared by the macOS handler binding and the +/// Linux desktop-file name. Matches `CFBundleIdentifier` in packaging/Info.plist +/// and the installed `*.desktop` basename. +#[cfg(any(target_os = "macos", target_os = "linux"))] +const APP_ID: &str = "io.github.HakanSeven12.OpenCadStudio"; + +/// 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 { + let (tx, rx) = iced::futures::channel::oneshot::channel(); + std::thread::Builder::new() + .name("set-default-app".into()) + .spawn(move || { + let _ = tx.send(set_default_app_blocking()); + }) + .map_err(|e| format!("could not start the default-app helper: {e}"))?; + rx.await + .unwrap_or_else(|_| Err("the default-app helper was cancelled".to_string())) +} + +fn set_default_app_blocking() -> Result { + #[cfg(target_os = "windows")] + { + windows_impl::set_default() + } + #[cfg(target_os = "linux")] + { + linux_impl::set_default() + } + #[cfg(target_os = "macos")] + { + macos_impl::set_default() + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + Err("Setting the default application isn't supported on this platform.".to_string()) + } +} + +// ── Windows ──────────────────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +mod windows_impl { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use windows_sys::core::{GUID, HRESULT}; + use windows_sys::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }; + + // CLSID_ApplicationAssociationRegistrationUI + // {1968106D-F3B5-44CF-890E-116FCB9ECEF1} + const CLSID_APP_ASSOC_UI: GUID = GUID { + data1: 0x1968106D, + data2: 0xF3B5, + data3: 0x44CF, + data4: [0x89, 0x0E, 0x11, 0x6F, 0xCB, 0x9E, 0xCE, 0xF1], + }; + // IID_IApplicationAssociationRegistrationUI + // {1F76A169-F994-40AC-8FC8-0959E8874710} + const IID_APP_ASSOC_UI: GUID = GUID { + data1: 0x1F76A169, + data2: 0xF994, + data3: 0x40AC, + data4: [0x8F, 0xC8, 0x09, 0x59, 0xE8, 0x87, 0x47, 0x10], + }; + + // Hand-rolled vtable for IApplicationAssociationRegistrationUI (IUnknown + + // its single method). windows-sys ships raw COM, so we drive the interface + // through the vtable directly rather than depend on generated wrappers. + #[repr(C)] + struct IAppAssocUiVtbl { + query_interface: + unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + launch_advanced_association_ui: + unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + } + #[repr(C)] + struct IAppAssocUi { + vtbl: *const IAppAssocUiVtbl, + } + + // Must match the RegisteredApplications value name in packaging/windows/main.wxs. + const APP_REGISTRY_NAME: &str = "Open CAD Studio"; + + pub(super) fn set_default() -> Result { + unsafe { + let co = CoInitializeEx(std::ptr::null(), COINIT_APARTMENTTHREADED); + // S_OK (0) / S_FALSE (1) mean we initialised COM and must balance it + // with CoUninitialize. Any other value: another init already owns the + // thread's apartment, so we leave it alone. + let owns_com = co == 0 || co == 1; + + let result = (|| { + let mut obj: *mut c_void = std::ptr::null_mut(); + let hr = CoCreateInstance( + &CLSID_APP_ASSOC_UI, + std::ptr::null_mut(), + CLSCTX_INPROC_SERVER, + &IID_APP_ASSOC_UI, + &mut obj, + ); + if hr < 0 || obj.is_null() { + return Err(format!( + "could not open the Windows default-apps dialog (0x{:08X})", + hr as u32 + )); + } + let this = obj as *mut IAppAssocUi; + let name: Vec = std::ffi::OsStr::new(APP_REGISTRY_NAME) + .encode_wide() + .chain(Some(0)) + .collect(); + let hr = ((*(*this).vtbl).launch_advanced_association_ui)(obj, name.as_ptr()); + ((*(*this).vtbl).release)(obj); + if hr < 0 { + return Err(format!( + "the Windows default-apps dialog returned an error (0x{:08X})", + hr as u32 + )); + } + Ok("Opened the Windows default-apps dialog — tick .dwg / .dxf there.".to_string()) + })(); + + if owns_com { + CoUninitialize(); + } + result + } + } +} + +// ── Linux ────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::APP_ID; + + pub(super) fn set_default() -> Result { + let desktop = format!("{APP_ID}.desktop"); + let status = std::process::Command::new("xdg-mime") + .args(["default", &desktop, "image/vnd.dwg", "image/vnd.dxf"]) + .status() + .map_err(|e| format!("could not run xdg-mime: {e}"))?; + if status.success() { + Ok("Open CAD Studio is now the default for .dwg and .dxf files.".to_string()) + } else { + Err(format!("xdg-mime exited with {status}")) + } + } +} + +// ── macOS ────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +mod macos_impl { + use super::APP_ID; + use std::ffi::c_void; + + #[repr(C)] + struct __CFString { + _private: [u8; 0], + } + type CFStringRef = *const __CFString; + type CFAllocatorRef = *const c_void; + type OSStatus = i32; + type LSRolesMask = u32; + + const KCFSTRING_ENCODING_UTF8: u32 = 0x0800_0100; + const KLS_ROLES_ALL: LSRolesMask = 0xFFFF_FFFF; + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + static kCFAllocatorDefault: CFAllocatorRef; + fn CFStringCreateWithBytes( + alloc: CFAllocatorRef, + bytes: *const u8, + num_bytes: isize, + encoding: u32, + is_external_representation: u8, + ) -> CFStringRef; + fn CFRelease(cf: *const c_void); + } + + // LaunchServices lives under the CoreServices umbrella framework. + #[link(name = "CoreServices", kind = "framework")] + extern "C" { + fn LSSetDefaultRoleHandlerForContentType( + in_content_type: CFStringRef, + in_role: LSRolesMask, + in_handler_bundle_id: CFStringRef, + ) -> OSStatus; + } + + fn cfstr(s: &str) -> CFStringRef { + unsafe { + CFStringCreateWithBytes( + kCFAllocatorDefault, + s.as_ptr(), + s.len() as isize, + KCFSTRING_ENCODING_UTF8, + 0, + ) + } + } + + pub(super) fn set_default() -> Result { + let bundle = cfstr(APP_ID); + if bundle.is_null() { + return Err("could not build the bundle-id string".to_string()); + } + // UTIs declared in packaging/Info.plist's CFBundleDocumentTypes. + let mut last_err: Option = None; + for uti in ["com.autodesk.dwg", "com.autodesk.dxf"] { + let ct = cfstr(uti); + if ct.is_null() { + last_err = Some("could not build the content-type string".to_string()); + continue; + } + let st = unsafe { LSSetDefaultRoleHandlerForContentType(ct, KLS_ROLES_ALL, bundle) }; + unsafe { CFRelease(ct as *const c_void) }; + if st != 0 { + last_err = Some(format!("LaunchServices error {st} for {uti}")); + } + } + unsafe { CFRelease(bundle as *const c_void) }; + match last_err { + None => Ok("Open CAD Studio is now the default for .dwg and .dxf files.".to_string()), + Some(e) => Err(e), + } + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index a1be8ca8e..e83c09713 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -3,6 +3,7 @@ // All file reading/writing goes through acadrust. // Default save format: DWG (AC1032 / R2018+). +pub mod file_association; pub mod obj; pub mod pdf_export; pub mod plot_style;