forked from 1b-agent/opencadstudio-upstream
Merge pull request #84 from HakanSeven12/claude/windows-msi-finish-screen-13as49
Add platform-specific file association registration for .dwg/.dxf
This commit is contained in:
10 files changed
+794
-9
No files matched your search
@@ -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)
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
candle.exe -arch x64 -dVersion=<x.y.z> -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 @@
|
||||
</ProgId>
|
||||
</Component>
|
||||
|
||||
<!--
|
||||
Default Programs registration. The ProgId/Extension rows above
|
||||
register the handlers, but modern Windows (8+) guards the actual
|
||||
default via a per-user UserChoice hash an installer can't forge.
|
||||
Publishing Capabilities + a RegisteredApplications entry is the
|
||||
supported way in: it makes Open CAD Studio appear in Settings >
|
||||
Default Apps and the "Open with" list, so .dwg / .dxf can be set
|
||||
to it. The keys are HKLM-wide, so the shell picks them up on its
|
||||
next association query without a forced refresh.
|
||||
-->
|
||||
<Component Id='DefaultPrograms' Guid='6f3e9b2a-1c4d-4f8a-9b7e-2d5a8c1f0e63' Win64='yes'>
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='Software\Open CAD Studio\Capabilities'
|
||||
Name='ApplicationName'
|
||||
Type='string'
|
||||
Value='Open CAD Studio'
|
||||
KeyPath='yes' />
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='Software\Open CAD Studio\Capabilities'
|
||||
Name='ApplicationDescription'
|
||||
Type='string'
|
||||
Value='2D/3D CAD application for DWG and DXF drawings.' />
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='Software\Open CAD Studio\Capabilities\FileAssociations'
|
||||
Name='.dwg'
|
||||
Type='string'
|
||||
Value='OpenCADStudio.DWG' />
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='Software\Open CAD Studio\Capabilities\FileAssociations'
|
||||
Name='.dxf'
|
||||
Type='string'
|
||||
Value='OpenCADStudio.DXF' />
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='Software\RegisteredApplications'
|
||||
Name='Open CAD Studio'
|
||||
Type='string'
|
||||
Value='Software\Open CAD Studio\Capabilities' />
|
||||
</Component>
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
@@ -119,8 +166,12 @@
|
||||
|
||||
<Feature Id='Complete' Title='Open CAD Studio' Level='1'>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='DefaultPrograms' />
|
||||
<ComponentRef Id='ProgramMenuDirComp' />
|
||||
</Feature>
|
||||
|
||||
<!-- Installer UI + finish-screen launch checkbox (see ui.wxs). -->
|
||||
<UIRef Id='OpenCADStudioUI' />
|
||||
|
||||
</Product>
|
||||
</Wix>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version='1.0' encoding='windows-1252'?>
|
||||
<!--
|
||||
Installer UI for the Windows MSI, kept separate from the package/feature
|
||||
definition in main.wxs. Linked in via `<UIRef Id='OpenCADStudioUI' />`
|
||||
there; both files are compiled together by candle and linked by light
|
||||
(which needs `-ext WixUIExtension` for the WixUI_Minimal dialog set).
|
||||
|
||||
Provides:
|
||||
* WixUI_Minimal wizard (Welcome → Progress → Finish).
|
||||
* A pre-ticked "Launch Open CAD Studio" checkbox on the finish screen
|
||||
that starts the freshly installed exe when the user clicks Finish.
|
||||
-->
|
||||
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
|
||||
<Fragment>
|
||||
<!--
|
||||
Finish-screen "Launch Open CAD Studio" checkbox.
|
||||
WIXUI_EXITDIALOGOPTIONALCHECKBOX=1 pre-ticks the checkbox; the Publish
|
||||
below fires the launch action when the user clicks Finish with it checked.
|
||||
-->
|
||||
<Property Id='WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT' Value='Launch Open CAD Studio' />
|
||||
<Property Id='WIXUI_EXITDIALOGOPTIONALCHECKBOX' Value='1' />
|
||||
|
||||
<!--
|
||||
Start the installed exe after the wizard closes. FileKey points at the
|
||||
File defined in main.wxs (light resolves the cross-fragment reference).
|
||||
asyncNoWait lets the installer exit cleanly while the app starts.
|
||||
-->
|
||||
<CustomAction
|
||||
Id='LaunchApplication'
|
||||
FileKey='OpenCADStudioEXE'
|
||||
ExeCommand=''
|
||||
Execute='immediate'
|
||||
Return='asyncNoWait'
|
||||
Impersonate='yes' />
|
||||
|
||||
<UI Id='OpenCADStudioUI'>
|
||||
<UIRef Id='WixUI_Minimal' />
|
||||
<Publish
|
||||
Dialog='ExitDialog'
|
||||
Control='Finish'
|
||||
Event='DoAction'
|
||||
Value='LaunchApplication'>WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed</Publish>
|
||||
</UI>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
+43
-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(),
|
||||
@@ -1554,6 +1569,15 @@ impl OpenCADStudio {
|
||||
/// opens the primary application window.
|
||||
fn boot() -> (Self, Task<Message>) {
|
||||
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<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 +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();
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
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<u16> {
|
||||
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::<u16>()) 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\<progid>`) 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<PathBuf, String> {
|
||||
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<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.
|
||||
// Default save format: DWG (AC1032 / R2018+).
|
||||
|
||||
pub mod file_association;
|
||||
pub mod obj;
|
||||
pub mod pdf_export;
|
||||
pub mod plot_style;
|
||||
|
||||
Reference in new issue
Block a user