forked from 1b-agent/opencadstudio-upstream
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:
7 files changed
+457
-3
No files matched your search
+34
-1
@@ -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<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"),
|
||||
/// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned.
|
||||
update_notice_version: Option<String>,
|
||||
@@ -1045,6 +1051,13 @@ pub enum Message {
|
||||
UpdateCheckResult(Option<crate::update_check::UpdateInfo>),
|
||||
/// 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<String, String>),
|
||||
/// 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<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,
|
||||
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();
|
||||
}
|
||||
|
||||
+8
-1
@@ -70,6 +70,10 @@ pub struct UserSettings {
|
||||
pub otrack: bool,
|
||||
/// Active snap modes, in `SNAP_ORDER`.
|
||||
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 {
|
||||
@@ -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<SnapType> =
|
||||
val.split(',').filter_map(|t| snap_from_id(t.trim())).collect();
|
||||
@@ -149,7 +155,7 @@ impl UserSettings {
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
|
||||
@@ -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<Message> {
|
||||
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()
|
||||
|
||||
+106
@@ -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
|
||||
|
||||
Reference in new issue
Block a user