feat(app): first-launch prompt to set Open CAD Studio as default for .dwg/.dxf
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
This commit is contained in:
parent
e24447a70d
commit
a7d85fe148
7 changed files with 457 additions and 3 deletions
|
|
@ -33,7 +33,10 @@ truck-shapeops = "0.4"
|
||||||
rustc-hash = "2"
|
rustc-hash = "2"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[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]
|
[patch.crates-io]
|
||||||
# Tracks HakanSeven12/acadrust main, which carries the hatch
|
# Tracks HakanSeven12/acadrust main, which carries the hatch
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,12 @@ pub(super) struct OpenCADStudio {
|
||||||
/// New-release notification window — opened on startup when the
|
/// New-release notification window — opened on startup when the
|
||||||
/// GitHub releases API reports a newer version than this build.
|
/// GitHub releases API reports a newer version than this build.
|
||||||
update_notice_window: Option<window::Id>,
|
update_notice_window: Option<window::Id>,
|
||||||
|
/// First-launch "make Open CAD Studio the default for .dwg/.dxf?" prompt
|
||||||
|
/// window. Shown once, gated on `default_assoc_prompted`.
|
||||||
|
assoc_prompt_window: Option<window::Id>,
|
||||||
|
/// 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"),
|
/// 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.
|
/// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned.
|
||||||
update_notice_version: Option<String>,
|
update_notice_version: Option<String>,
|
||||||
|
|
@ -1045,6 +1051,13 @@ pub enum Message {
|
||||||
UpdateCheckResult(Option<crate::update_check::UpdateInfo>),
|
UpdateCheckResult(Option<crate::update_check::UpdateInfo>),
|
||||||
/// User dismissed the update-notice window.
|
/// User dismissed the update-notice window.
|
||||||
UpdateNoticeClose,
|
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<String, String>),
|
||||||
/// User clicked the "Open release page" button — opens the GitHub
|
/// User clicked the "Open release page" button — opens the GitHub
|
||||||
/// release URL in the OS default browser and closes the notice.
|
/// release URL in the OS default browser and closes the notice.
|
||||||
UpdateNoticeOpenRelease,
|
UpdateNoticeOpenRelease,
|
||||||
|
|
@ -1354,6 +1367,8 @@ impl OpenCADStudio {
|
||||||
shortcuts_window: None,
|
shortcuts_window: None,
|
||||||
about_window: None,
|
about_window: None,
|
||||||
update_notice_window: None,
|
update_notice_window: None,
|
||||||
|
assoc_prompt_window: None,
|
||||||
|
default_assoc_prompted: false,
|
||||||
update_notice_version: None,
|
update_notice_version: None,
|
||||||
update_notice_body: None,
|
update_notice_body: None,
|
||||||
clipboard: Vec::new(),
|
clipboard: Vec::new(),
|
||||||
|
|
@ -1581,9 +1596,24 @@ impl OpenCADStudio {
|
||||||
.filter(|p| !p.as_os_str().to_string_lossy().starts_with('-'))
|
.filter(|p| !p.as_os_str().to_string_lossy().starts_with('-'))
|
||||||
.map(|p| Task::done(Message::OpenRecent(p)))
|
.map(|p| Task::done(Message::OpenRecent(p)))
|
||||||
.unwrap_or_else(Task::none);
|
.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<Message> = 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,
|
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 {
|
if Some(window_id) == state.update_notice_window {
|
||||||
return "Update Available".into();
|
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 {
|
if Some(window_id) == state.unsaved_dialog_window {
|
||||||
return "Unsaved Changes".into();
|
return "Unsaved Changes".into();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ pub struct UserSettings {
|
||||||
pub otrack: bool,
|
pub otrack: bool,
|
||||||
/// Active snap modes, in `SNAP_ORDER`.
|
/// Active snap modes, in `SNAP_ORDER`.
|
||||||
pub snap_modes: Vec<SnapType>,
|
pub snap_modes: Vec<SnapType>,
|
||||||
|
/// 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 {
|
impl Default for UserSettings {
|
||||||
|
|
@ -91,6 +95,7 @@ impl Default for UserSettings {
|
||||||
SnapType::Intersection,
|
SnapType::Intersection,
|
||||||
SnapType::Nearest,
|
SnapType::Nearest,
|
||||||
],
|
],
|
||||||
|
default_assoc_prompted: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +129,7 @@ impl UserSettings {
|
||||||
"grid" => s.show_grid = val == "1",
|
"grid" => s.show_grid = val == "1",
|
||||||
"osnap" => s.snap_enabled = val == "1",
|
"osnap" => s.snap_enabled = val == "1",
|
||||||
"otrack" => s.otrack = val == "1",
|
"otrack" => s.otrack = val == "1",
|
||||||
|
"default_assoc_prompted" => s.default_assoc_prompted = val == "1",
|
||||||
"snap_modes" => {
|
"snap_modes" => {
|
||||||
let modes: Vec<SnapType> =
|
let modes: Vec<SnapType> =
|
||||||
val.split(',').filter_map(|t| snap_from_id(t.trim())).collect();
|
val.split(',').filter_map(|t| snap_from_id(t.trim())).collect();
|
||||||
|
|
@ -149,7 +155,7 @@ impl UserSettings {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let body = format!(
|
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.dyn_input),
|
||||||
b(self.ortho),
|
b(self.ortho),
|
||||||
b(self.polar),
|
b(self.polar),
|
||||||
|
|
@ -157,6 +163,7 @@ impl UserSettings {
|
||||||
b(self.show_grid),
|
b(self.show_grid),
|
||||||
b(self.snap_enabled),
|
b(self.snap_enabled),
|
||||||
b(self.otrack),
|
b(self.otrack),
|
||||||
|
b(self.default_assoc_prompted),
|
||||||
modes,
|
modes,
|
||||||
);
|
);
|
||||||
let _ = std::fs::write(path, body);
|
let _ = std::fs::write(path, body);
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ impl OpenCADStudio {
|
||||||
snap_enabled: self.snapper.snap_enabled,
|
snap_enabled: self.snapper.snap_enabled,
|
||||||
otrack: self.snapper.otrack_enabled,
|
otrack: self.snapper.otrack_enabled,
|
||||||
snap_modes: super::settings::UserSettings::modes_from(self.snapper.enabled.iter()),
|
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.snap_enabled = s.snap_enabled;
|
||||||
self.snapper.otrack_enabled = s.otrack;
|
self.snapper.otrack_enabled = s.otrack;
|
||||||
self.snapper.enabled = s.snap_modes.iter().copied().collect();
|
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,
|
/// 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<Message> {
|
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||||
match msg {
|
match msg {
|
||||||
Message::Tick(t) => {
|
Message::Tick(t) => {
|
||||||
|
|
@ -1613,6 +1622,12 @@ impl OpenCADStudio {
|
||||||
if self.update_notice_window == Some(id) {
|
if self.update_notice_window == Some(id) {
|
||||||
self.update_notice_window = None;
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5647,6 +5662,36 @@ impl OpenCADStudio {
|
||||||
Task::none()
|
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) => {
|
Message::PageSetupWidthEdit(s) => {
|
||||||
self.page_setup_w = s;
|
self.page_setup_w = s;
|
||||||
Task::none()
|
Task::none()
|
||||||
|
|
|
||||||
106
src/app/view.rs
106
src/app/view.rs
|
|
@ -473,6 +473,9 @@ impl OpenCADStudio {
|
||||||
let body = self.update_notice_body.as_deref().unwrap_or("");
|
let body = self.update_notice_body.as_deref().unwrap_or("");
|
||||||
return crate::ui::update_notice::view_window(latest, body);
|
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 {
|
if Some(window_id) == self.unsaved_dialog_window {
|
||||||
let tab_name = match &self.pending_close {
|
let tab_name = match &self.pending_close {
|
||||||
Some(super::PendingClose::Tab(idx)) => self
|
Some(super::PendingClose::Tab(idx)) => self
|
||||||
|
|
@ -3461,6 +3464,109 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
|
||||||
.into()
|
.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 ──────────────────────────────────────────────────
|
// ── Start / Welcome page ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Renders in place of the model-space viewport when the active tab is the
|
// Renders in place of the model-space viewport when the active tab is the
|
||||||
|
|
|
||||||
259
src/io/file_association.rs
Normal file
259
src/io/file_association.rs
Normal file
|
|
@ -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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
#[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<String, String> {
|
||||||
|
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<u16> = 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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String> = 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// All file reading/writing goes through acadrust.
|
// All file reading/writing goes through acadrust.
|
||||||
// Default save format: DWG (AC1032 / R2018+).
|
// Default save format: DWG (AC1032 / R2018+).
|
||||||
|
|
||||||
|
pub mod file_association;
|
||||||
pub mod obj;
|
pub mod obj;
|
||||||
pub mod pdf_export;
|
pub mod pdf_export;
|
||||||
pub mod plot_style;
|
pub mod plot_style;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue