diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9075158e9..f661ca86a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,17 +128,24 @@ jobs: # filename. $exePath = (Resolve-Path target\release\OpenCADStudio.exe).Path $iconPath = (Resolve-Path packaging\windows\AppIcon.ico).Path - $wxsPath = (Resolve-Path packaging\windows\main.wxs).Path - $wixObj = Join-Path $PWD "packaging\windows\main.wixobj" + # main.wxs holds the package/feature; ui.wxs holds the installer UI + # and finish-screen launch checkbox. Both compile together. + $mainWxs = (Resolve-Path packaging\windows\main.wxs).Path + $uiWxs = (Resolve-Path packaging\windows\ui.wxs).Path + # candle writes one .wixobj per source into this directory (the + # trailing slash tells it -out is a folder, not a single file). + $objDir = Join-Path $PWD "packaging\windows\" $msiPath = Join-Path $PWD "OpenCADStudio.msi" & $candle -arch x64 ` "-dVersion=$version" ` "-dSource=$exePath" ` "-dIcon=$iconPath" ` - $wxsPath ` - -out $wixObj + $mainWxs $uiWxs ` + -out $objDir if ($LASTEXITCODE -ne 0) { throw "candle failed" } - & $light $wixObj -out $msiPath + $mainObj = Join-Path $objDir "main.wixobj" + $uiObj = Join-Path $objDir "ui.wixobj" + & $light $mainObj $uiObj -ext WixUIExtension -out $msiPath if ($LASTEXITCODE -ne 0) { throw "light failed" } - name: Sign MSI installer (Azure Trusted Signing) diff --git a/Cargo.toml b/Cargo.toml index 180a88985..b3164cb90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,11 @@ 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 — COM behind "Set as default app" +# (IApplicationAssociationRegistrationUI dialog). +# Win32_System_Registry — HKCU registration that lists the app under +# "Open with" for the portable .exe. +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Foundation"] } [patch.crates-io] # Tracks HakanSeven12/acadrust main, which carries the hatch diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index b48d2ebd5..9183c8bd8 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -6,7 +6,7 @@ candle.exe -arch x64 -dVersion= -dSource=target\release\OpenCADStudio.exe \ -dIcon=packaging\windows\AppIcon.ico packaging\windows\main.wxs \ -out packaging\windows\main.wixobj - light.exe packaging\windows\main.wixobj -out OpenCADStudio.msi + light.exe packaging\windows\main.wixobj -ext WixUIExtension -out OpenCADStudio.msi Provides: * Per-machine install under Program Files\Open CAD Studio. @@ -14,6 +14,9 @@ * File associations for .dwg and .dxf via a single ProgID; both route through `OpenCADStudio.exe "%1"` so the OS hands the file path to the app as argv[1] (consumed in OpenCADStudio::boot). + * Default Programs registration (Capabilities + RegisteredApplications) + so the app shows up in Settings > Default Apps and can be set as the + default handler for .dwg / .dxf. * Standard Add/Remove Programs entry with icon. * MajorUpgrade — installing a newer MSI replaces the old version. --> @@ -98,6 +101,50 @@ + + + + + + + + + @@ -119,8 +166,12 @@ + + + + diff --git a/packaging/windows/ui.wxs b/packaging/windows/ui.wxs new file mode 100644 index 000000000..de89b4bc1 --- /dev/null +++ b/packaging/windows/ui.wxs @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed + + + diff --git a/src/app/mod.rs b/src/app/mod.rs index f68b0eb14..126f77f8b 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(), @@ -1554,6 +1569,15 @@ impl OpenCADStudio { /// opens the primary application window. fn boot() -> (Self, Task) { use helpers::build_window_icon; + // Silently (re)register this binary as a .dwg/.dxf handler so it appears + // in the OS "Open with" list. Best-effort and idempotent; covers the + // 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}"); + } + }); let state = Self::new(); let (id, open_task) = window::open(window::Settings { maximized: true, @@ -1581,9 +1605,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 +1673,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..722da5202 --- /dev/null +++ b/src/io/file_association.rs @@ -0,0 +1,477 @@ +//! "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"; + +/// Silently register this app as *a* handler (not necessarily the default) for +/// .dwg / .dxf, so it appears in the OS "Open with" list. Unlike +/// [`set_default_app`], this changes no defaults and shows no UI — it just makes +/// the running binary discoverable as a handler, which the installers already +/// do for installed builds but the portable .exe / AppImage otherwise lack. +/// +/// Idempotent and best-effort; safe to call on every launch. Runs synchronously +/// (registry / small file writes), so callers should invoke it off the UI +/// thread. +pub fn register_as_handler() -> Result<(), String> { + #[cfg(target_os = "windows")] + { + windows_impl::register_handler() + } + #[cfg(target_os = "linux")] + { + linux_impl::register_handler() + } + #[cfg(target_os = "macos")] + { + // .app bundles are registered with LaunchServices automatically when + // first launched or moved (it scans Info.plist's CFBundleDocumentTypes), + // so there is nothing to do at runtime. + 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 { + 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 + } + } + + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, + KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, + }; + + fn wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + } + + // Create `subkey` under HKCU and write `data` into `value` (None = the key's + // default value), as a REG_SZ string. + fn set_string(subkey: &str, value: Option<&str>, data: &str) -> Result<(), String> { + let subkey_w = wide(subkey); + let mut hkey: HKEY = std::ptr::null_mut(); + let rc = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + subkey_w.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut hkey, + std::ptr::null_mut(), + ) + }; + if rc != 0 { + return Err(format!("RegCreateKeyExW failed ({rc}) for {subkey}")); + } + let name_w = value.map(wide); + let name_ptr = name_w.as_ref().map_or(std::ptr::null(), |v| v.as_ptr()); + let data_w = wide(data); + // cbData counts bytes including the trailing NUL that `wide` appended. + let cb = (data_w.len() * std::mem::size_of::()) as u32; + let rc = unsafe { + RegSetValueExW(hkey, name_ptr, 0, REG_SZ, data_w.as_ptr() as *const u8, cb) + }; + unsafe { RegCloseKey(hkey) }; + if rc != 0 { + return Err(format!("RegSetValueExW failed ({rc}) for {subkey}")); + } + Ok(()) + } + + /// Create a per-user ProgID (`HKCU\Software\Classes\`) with an icon + /// and an open command, mirroring one of the MSI's per-machine ProgIDs. + fn register_progid(exe: &str, progid: &str, description: &str) -> Result<(), String> { + let base = format!(r"Software\Classes\{progid}"); + set_string(&base, None, description)?; + set_string(&format!(r"{base}\DefaultIcon"), None, &format!("\"{exe}\",0"))?; + set_string( + &format!(r"{base}\shell\open\command"), + None, + &format!("\"{exe}\" \"%1\""), + )?; + Ok(()) + } + + /// Register the running .exe (per-user, HKCU) so the portable build matches + /// what the MSI provides machine-wide: + /// * an `Applications\OpenCADStudio.exe` entry → listed under "Open with"; + /// * ProgIDs + a Capabilities / RegisteredApplications block → the app is + /// a default-app candidate, so the in-app "set as default" prompt's OS + /// dialog (LaunchAdvancedAssociationUI "Open CAD Studio") finds it. + pub(super) fn register_handler() -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let exe = exe.to_string_lossy().to_string(); + + // ── "Open with" exe listing ───────────────────────────────────────── + const APP_BASE: &str = r"Software\Classes\Applications\OpenCADStudio.exe"; + set_string( + &format!(r"{APP_BASE}\shell\open\command"), + None, + &format!("\"{exe}\" \"%1\""), + )?; + set_string(APP_BASE, Some("FriendlyAppName"), "Open CAD Studio")?; + // Listing the extensions under SupportedTypes is what makes the app + // 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"), "")?; + + // ── 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")?; + // Also offer the ProgIDs in each extension's Open-with list. + set_string( + r"Software\Classes\.dwg\OpenWithProgids", + Some("OpenCADStudio.DWG"), + "", + )?; + set_string( + r"Software\Classes\.dxf\OpenWithProgids", + Some("OpenCADStudio.DXF"), + "", + )?; + + // ── Capabilities + RegisteredApplications ─────────────────────────── + // Mirrors the MSI's DefaultPrograms component, but per-user, so the + // portable build is a Default-Apps candidate too. The value name in + // RegisteredApplications must equal the name passed to + // LaunchAdvancedAssociationUI ("Open CAD Studio"). + const CAP: &str = r"Software\Open CAD Studio\Capabilities"; + set_string(CAP, Some("ApplicationName"), "Open CAD Studio")?; + set_string( + CAP, + Some("ApplicationDescription"), + "2D/3D CAD application for DWG and DXF drawings.", + )?; + set_string(&format!(r"{CAP}\FileAssociations"), Some(".dwg"), "OpenCADStudio.DWG")?; + set_string(&format!(r"{CAP}\FileAssociations"), Some(".dxf"), "OpenCADStudio.DXF")?; + set_string( + r"Software\RegisteredApplications", + Some("Open CAD Studio"), + r"Software\Open CAD Studio\Capabilities", + )?; + Ok(()) + } +} + +// ── Linux ────────────────────────────────────────────────────────────────── + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::APP_ID; + use std::path::{Path, PathBuf}; + + /// Write a user-level .desktop file pointing at the running binary, then + /// refresh the MIME cache so file managers list us under "Open with". + /// 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> { + // 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()); + let already_system = data_dirs.split(':').filter(|d| !d.is_empty()).any(|d| { + Path::new(d) + .join("applications") + .join(format!("{APP_ID}.desktop")) + .exists() + }); + if already_system { + return Ok(()); + } + + // Prefer the AppImage path ($APPIMAGE) — current_exe() points inside the + // transient mount and isn't a stable command to launch later. + let exec = std::env::var_os("APPIMAGE") + .map(PathBuf::from) + .or_else(|| std::env::current_exe().ok()) + .ok_or("could not determine the executable path")?; + let exec = exec.to_string_lossy(); + + let apps = data_home()?.join("applications"); + let desktop_path = apps.join(format!("{APP_ID}.desktop")); + + let contents = format!( + "[Desktop Entry]\n\ + Name=Open CAD Studio\n\ + Comment=A CAD application for 2D/3D drawing and design\n\ + Exec={exec} %f\n\ + Icon={APP_ID}\n\ + Terminal=false\n\ + Type=Application\n\ + Categories=Graphics;Engineering;\n\ + MimeType=image/vnd.dwg;image/vnd.dxf;\n\ + Keywords=CAD;DWG;DXF;drawing;design;\n\ + StartupWMClass=OpenCADStudio\n" + ); + + // Nothing changed since last launch → skip the write and the (slow) + // desktop-database refresh. + if std::fs::read_to_string(&desktop_path).ok().as_deref() == Some(contents.as_str()) { + return Ok(()); + } + + std::fs::create_dir_all(&apps).map_err(|e| e.to_string())?; + std::fs::write(&desktop_path, &contents).map_err(|e| e.to_string())?; + // Best-effort: refresh the MIME→app cache. Absent on minimal systems. + let _ = std::process::Command::new("update-desktop-database") + .arg(&apps) + .status(); + Ok(()) + } + + fn data_home() -> Result { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .ok_or_else(|| "neither XDG_DATA_HOME nor HOME is set".to_string()) + } + + 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;