From 556989dbdb5626f04e54becd1d5897ef0e1c7989 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:24:36 +0000 Subject: [PATCH 1/6] feat(installer): add finish screen with launch-app checkbox to Windows MSI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WixUI_Minimal UI to the WiX installer so users see a proper Welcome → Progress → Finish wizard. The finish screen includes a pre-ticked "Launch Open CAD Studio" checkbox; clicking Finish with it checked starts the application immediately after the installer closes. Also updates the light.exe invocation in release.yml to include -ext WixUIExtension, which is required to link the WixUI dialogs. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- .github/workflows/release.yml | 2 +- packaging/windows/main.wxs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9075158e9..3b7aac55f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: $wxsPath ` -out $wixObj if ($LASTEXITCODE -ne 0) { throw "candle failed" } - & $light $wixObj -out $msiPath + & $light $wixObj -ext WixUIExtension -out $msiPath if ($LASTEXITCODE -ne 0) { throw "light failed" } - name: Sign MSI installer (Azure Trusted Signing) diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index b48d2ebd5..9e4e417e4 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. @@ -122,5 +122,35 @@ + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed + + From fd949b74c798d0cfc98275d58778b16155435759 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:28:04 +0000 Subject: [PATCH 2/6] refactor(installer): split installer UI into its own ui.wxs Moves the WixUI_Minimal UI and finish-screen launch checkbox out of main.wxs into a dedicated ui.wxs fragment, referenced from main.wxs via . Keeps the package/feature definition and the UI concerns in separate files. Updates the release workflow to compile both .wxs files (candle now writes per-source .wixobj into a directory) and link both objects. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- .github/workflows/release.yml | 17 +++++++++---- packaging/windows/main.wxs | 31 ++---------------------- packaging/windows/ui.wxs | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 packaging/windows/ui.wxs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b7aac55f..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 -ext WixUIExtension -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/packaging/windows/main.wxs b/packaging/windows/main.wxs index 9e4e417e4..5487c2b7a 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -122,35 +122,8 @@ - - - - - - - - - - WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed - + + 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 + + + From e24447a70d78bd13248c7428edf893ecdbd11a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:30:49 +0000 Subject: [PATCH 3/6] feat(installer): register Open CAD Studio as a default-app candidate for .dwg/.dxf Adds a DefaultPrograms component publishing Capabilities and a RegisteredApplications entry under HKLM. This makes Open CAD Studio appear in Settings > Default Apps and the shell "Open with" list, so users can set it as the default handler for .dwg and .dxf files. Modern Windows guards the actual default behind a per-user UserChoice hash that an installer cannot forge, so this is the supported path to becoming the default rather than silently overwriting the association. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- packaging/windows/main.wxs | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index 5487c2b7a..9183c8bd8 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -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,6 +166,7 @@ + From a7d85fe14869bf4c2d3a69759032f9ed50d4ccd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:50:41 +0000 Subject: [PATCH 4/6] 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; From 5ba7101022f836df9439e4e409f2e2eedc1bf120 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:00:18 +0000 Subject: [PATCH 5/6] feat(app): silently register as a .dwg/.dxf handler so portable builds show in "Open with" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed builds already register a handler (MSI ProgIDs, the Linux .desktop MimeType, the macOS Info.plist), but the portable .exe and the AppImage register nothing, so they never appear in the OS "Open with" list. Add a best-effort, idempotent runtime registration that runs silently on a detached thread at startup: * Windows — writes HKCU\Software\Classes\Applications\OpenCADStudio.exe (open command + FriendlyAppName + SupportedTypes .dwg/.dxf), which is what lists the exe in "Open with → Choose another app". * Linux — writes a user-level .desktop pointing at the running binary ($APPIMAGE when set) and refreshes the desktop database. Skipped when a system package already provides the desktop entry, so we never shadow a distro install. * macOS — no-op; .app bundles auto-register with LaunchServices. Unlike the default-app prompt this changes no defaults and shows no UI; it only makes the binary discoverable as a handler. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- Cargo.toml | 9 +- src/app/mod.rs | 9 ++ src/io/file_association.rs | 163 +++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eaf68054c..b3164cb90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,11 @@ truck-shapeops = "0.4" rustc-hash = "2" [target.'cfg(target_os = "windows")'.dependencies] -# 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"] } +# 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/src/app/mod.rs b/src/app/mod.rs index 3d7c6f4d8..126f77f8b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1569,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, diff --git a/src/io/file_association.rs b/src/io/file_association.rs index 61cdebc92..d786ad1d5 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -28,6 +28,37 @@ #[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 { @@ -155,6 +186,72 @@ mod windows_impl { 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(()) + } + + /// Register the running .exe under HKCU\…\Applications so it shows up in the + /// shell's "Open with" list for .dwg / .dxf. The MSI does this machine-wide + /// for installed builds; this covers the portable .exe. + 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(); + const BASE: &str = r"Software\Classes\Applications\OpenCADStudio.exe"; + + set_string( + &format!(r"{BASE}\shell\open\command"), + None, + &format!("\"{exe}\" \"%1\""), + )?; + set_string(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"{BASE}\SupportedTypes"), Some(".dwg"), "")?; + set_string(&format!(r"{BASE}\SupportedTypes"), Some(".dxf"), "")?; + Ok(()) + } } // ── Linux ────────────────────────────────────────────────────────────────── @@ -162,6 +259,72 @@ mod windows_impl { #[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"); From db607cca1f36e342ae91fbc7b14ac6a10543c347 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 10:54:34 +0000 Subject: [PATCH 6/6] fix(windows): register HKCU ProgIDs + Capabilities for portable .exe default-app support For MSI installs the installer already writes HKLM Capabilities + RegisteredApplications, so the "Set as default" dialog works. For portable .exe builds those registry keys are never created, so the IApplicationAssociationRegistrationUI dialog silently does nothing. register_handler() now also writes: - HKCU\Software\Classes\Applications\OpenCADStudio.exe (Open-with list) - HKCU\Software\Classes\OpenCADStudio.DWG / .DXF (ProgIDs + icon + open cmd) - HKCU\Software\Classes\.dwg|.dxf\OpenWithProgids - HKCU\Software\Open CAD Studio\Capabilities (AssociationToasts shown) - HKCU\Software\RegisteredApplications (Windows Default Programs) This makes the "Yes, set as default" button work correctly for portable builds on all supported Windows versions. https://claude.ai/code/session_01DFVSKgViFaxEjZD2KsTdtt --- src/io/file_association.rs | 79 ++++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/io/file_association.rs b/src/io/file_association.rs index d786ad1d5..722da5202 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -232,24 +232,79 @@ mod windows_impl { Ok(()) } - /// Register the running .exe under HKCU\…\Applications so it shows up in the - /// shell's "Open with" list for .dwg / .dxf. The MSI does this machine-wide - /// for installed builds; this covers the portable .exe. - 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(); - const BASE: &str = r"Software\Classes\Applications\OpenCADStudio.exe"; - + /// 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"), + &format!(r"{base}\shell\open\command"), None, &format!("\"{exe}\" \"%1\""), )?; - set_string(BASE, Some("FriendlyAppName"), "Open CAD Studio")?; + 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"{BASE}\SupportedTypes"), Some(".dwg"), "")?; - set_string(&format!(r"{BASE}\SupportedTypes"), Some(".dxf"), "")?; + 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(()) } }