feat(thumbnails): embed DWG previews and wire cross-platform file thumbnails

Round-trip a raster preview through the DWG's embedded preview slot and
surface it to the OS file managers, so drawings show their contents in
Explorer / Finder / Nautilus instead of a generic document icon.

OCS:
* io::thumbnail — rasterize the scene to a BMP DIB on save (embedded via
  acadrust's new Preview type) and read it back on open for the Start
  page recent list; `--dwg-thumbnail IN OUT SIZE` CLI extracts a badged
  PNG for external thumbnailers.
* io::file_association::install_thumbnailer — self-install the OS
  integration on startup: Linux writes a .thumbnailer + hicolor mimetype
  icons; Windows registers the IThumbnailProvider DLL under HKCU.

Shared core:
* crates/dwg-thumbnailer — lean (image-only) preview extractor + the
  `badge_dwg` full-width "DWG" band, used by every platform so the
  ribbon is single-sourced. Ships an rlib+staticlib.
* crates/dwg-thumbnailer-win — IThumbnailProvider COM in-proc server
  (cfg(windows), CI-built as a workspace member).
* macos/ — QuickLook thumbnail extension (Swift + C-ABI bridge to the
  core staticlib), assembled into a .appex.

Icons & packaging (single SVG source -> per-platform assets in CI):
* assets/mimetypes/image-vnd.{dwg,dxf}.svg — themed file icons.
* packaging: WiX ships dwg/dxf.ico + the thumbnail DLL and points the
  ProgIds at them; Info.plist gets CFBundleTypeIconFile + the QuickLook
  extension in Contents/PlugIns.
* release.yml — generate .ico (Windows) and .icns (macOS) from the SVGs,
  build the QuickLook .appex, and bundle everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-11 01:27:17 +03:00
commit ba3b3202e8
20 changed files with 1085 additions and 76 deletions

View file

@ -80,6 +80,26 @@ pub fn unregister_handler() -> Result<(), String> {
}
}
/// Register (or refresh) the OS file-manager integration for DWG/DXF, so files
/// show OCS thumbnails (embedded preview) or, when there is none, a distinct
/// OCS DWG/DXF icon. Idempotent, best-effort, silent. On Linux this writes a
/// freedesktop `.thumbnailer` (pointing at this binary's `--dwg-thumbnail` mode)
/// plus the mimetype icons under `$XDG_DATA_HOME`; on Windows it registers the
/// bundled thumbnail-provider DLL.
pub fn install_thumbnailer() {
#[cfg(target_os = "linux")]
{
let _ = linux_impl::install_thumbnailer();
let _ = linux_impl::install_mime_icons();
}
#[cfg(target_os = "windows")]
{
let _ = windows_impl::install_thumbnailer();
}
// macOS: the QuickLook thumbnail extension is bundled inside the .app and
// registered by LaunchServices automatically — nothing to do at runtime.
}
/// 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> {
@ -233,6 +253,16 @@ mod windows_impl {
RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr());
}
}
// Also drop the DWG thumbnail-provider registration.
for key in [
format!(r"Software\Classes\CLSID\{THUMB_CLSID}"),
format!(r"Software\Classes\.dwg\ShellEx\{THUMB_IID}"),
] {
let w = wide(&key);
unsafe {
RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr());
}
}
Ok(())
}
@ -276,6 +306,41 @@ mod windows_impl {
Ok(())
}
/// CLSID of the DWG thumbnail provider (must match `dwg-thumbnailer-win`).
const THUMB_CLSID: &str = "{8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}";
/// The shell interface id Explorer looks up under `.dwg\ShellEx`.
const THUMB_IID: &str = "{e357fccd-a995-4576-b01f-234630154e96}";
/// Register the bundled DWG thumbnail provider DLL under `HKCU` — the same
/// keys `regsvr32` would write, but per-user (no admin) and driven by this
/// app on startup. No-op if `dwg_thumbnailer_win.dll` isn't shipped next to
/// the exe. Idempotent (the values are stable across launches).
pub(super) fn install_thumbnailer() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let dll = exe
.parent()
.ok_or("no executable directory")?
.join("dwg_thumbnailer_win.dll");
if !dll.exists() {
return Ok(()); // provider DLL not bundled — nothing to register
}
let dll = dll.to_string_lossy();
let clsid_base = format!(r"Software\Classes\CLSID\{THUMB_CLSID}");
set_string(&clsid_base, None, "OpenCADStudio DWG Thumbnail Provider")?;
set_string(&format!(r"{clsid_base}\InprocServer32"), None, &dll)?;
set_string(
&format!(r"{clsid_base}\InprocServer32"),
Some("ThreadingModel"),
"Apartment",
)?;
set_string(
&format!(r"Software\Classes\.dwg\ShellEx\{THUMB_IID}"),
None,
THUMB_CLSID,
)?;
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> {
@ -372,6 +437,33 @@ mod linux_impl {
use super::APP_ID;
use std::path::{Path, PathBuf};
/// Write (idempotently) a `.thumbnailer` that points this binary's
/// `--dwg-thumbnail` mode at DWG MIME types, so file managers render the
/// embedded preview. `~/.local/bin` need not be on PATH — the entry uses the
/// binary's absolute path (or `$APPIMAGE`).
pub(super) fn install_thumbnailer() -> Result<(), String> {
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 dir = data_home()?.join("thumbnailers");
let path = dir.join(format!("{APP_ID}-dwg.thumbnailer"));
let contents = format!(
"[Thumbnailer Entry]\n\
TryExec={exec}\n\
Exec={exec} --dwg-thumbnail %i %o %s\n\
MimeType=image/vnd.dwg;image/x-dwg;application/x-dwg;application/acad;application/x-autocad;\n"
);
// Unchanged since last launch → skip the write.
if std::fs::read_to_string(&path).ok().as_deref() == Some(contents.as_str()) {
return Ok(());
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(&path, &contents).map_err(|e| e.to_string())?;
Ok(())
}
/// 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
@ -542,6 +634,38 @@ mod linux_impl {
Ok(())
}
/// Install the OCS DWG/DXF file-type icons into the user's hicolor theme
/// (`mimetypes/image-vnd.dwg.svg`, `...dxf.svg`), so file managers show a
/// distinct icon for DWG/DXF files that have no thumbnail. Idempotent.
pub(super) fn install_mime_icons() -> Result<(), String> {
static DWG_ICON: &[u8] = include_bytes!("../../assets/mimetypes/image-vnd.dwg.svg");
static DXF_ICON: &[u8] = include_bytes!("../../assets/mimetypes/image-vnd.dxf.svg");
let dir = data_home()?.join("icons/hicolor/scalable/mimetypes");
let mut changed = false;
for (name, bytes) in [
("image-vnd.dwg.svg", DWG_ICON),
("image-vnd.dxf.svg", DXF_ICON),
] {
let path = dir.join(name);
if std::fs::read(&path).ok().as_deref() == Some(bytes) {
continue;
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
changed = true;
}
if changed {
let hicolor = data_home()
.map(|d| d.join("icons/hicolor"))
.unwrap_or_default();
let _ = std::process::Command::new("gtk-update-icon-cache")
.args(["--force", "--quiet", &hicolor.to_string_lossy()])
.status();
}
Ok(())
}
fn data_home() -> Result<PathBuf, String> {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)