From c8e78fd58a2ed35f995c63df0ec59604f12255a0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 6 May 2026 17:23:50 -0700 Subject: [PATCH] Implement Windows registry file format registration on app startup --- Cargo.lock | 11 ++ desktop/Cargo.toml | 1 + desktop/src/consts.rs | 7 ++ desktop/src/file_associations.rs | 15 +++ desktop/src/file_associations/win.rs | 165 +++++++++++++++++++++++++++ desktop/src/lib.rs | 3 + 6 files changed, 202 insertions(+) create mode 100644 desktop/src/file_associations.rs create mode 100644 desktop/src/file_associations/win.rs diff --git a/Cargo.lock b/Cargo.lock index dcc71e997b..3c1663a1e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2101,6 +2101,7 @@ dependencies = [ "window_clipboard", "windows 0.58.0", "winit", + "winreg", ] [[package]] @@ -7369,6 +7370,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "winres" version = "0.1.12" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 22851d7dd7..6d45cf415f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -70,6 +70,7 @@ windows = { version = "0.58.0", features = [ "Win32_UI_HiDpi", "Win32_UI_Shell", ] } +winreg = "0.56" # Mac-specific dependencies [target.'cfg(target_os = "macos")'.dependencies] diff --git a/desktop/src/consts.rs b/desktop/src/consts.rs index d51594ea16..e867eca6d6 100644 --- a/desktop/src/consts.rs +++ b/desktop/src/consts.rs @@ -1,4 +1,5 @@ pub(crate) const APP_NAME: &str = "Graphite"; +pub(crate) const APP_DESCRIPTION: &str = "Vector graphics editor and procedural design engine"; #[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) const APP_ID: &str = "art.graphite.Graphite"; @@ -11,6 +12,12 @@ pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron"; pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron"; pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents"; +// Document type identifiers, used by per-platform OS file-type registration. +// Keep these in sync with Mac's `Info.plist` in the `graphite-desktop-bundle` crate. +pub(crate) const DOCUMENT_TYPE_IDENTIFIER: &str = "art.graphite.document"; +pub(crate) const DOCUMENT_FRIENDLY_NAME: &str = "Graphite Document"; +pub(crate) const DOCUMENT_MIME_TYPE: &str = "application/graphite+json"; + // CEF configuration constants pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60; pub(crate) const CEF_MESSAGE_LOOP_MAX_ITERATIONS: usize = 10; diff --git a/desktop/src/file_associations.rs b/desktop/src/file_associations.rs new file mode 100644 index 0000000000..35c4078fcf --- /dev/null +++ b/desktop/src/file_associations.rs @@ -0,0 +1,15 @@ +//! Per-launch file type registration with the OS. +//! +//! Mac handles this declaratively via the bundle's `Info.plist` (see `desktop/bundle/src/mac.rs`), +//! so this module is a no-op there. Windows requires writing registry entries, which this module does +//! idempotently on each launch. It re-registers only when the executable's path has changed. + +// TODO: Linux support + +#[cfg(target_os = "windows")] +mod win; + +pub(crate) fn register_with_os() { + #[cfg(target_os = "windows")] + win::register(); +} diff --git a/desktop/src/file_associations/win.rs b/desktop/src/file_associations/win.rs new file mode 100644 index 0000000000..0c1f2a2141 --- /dev/null +++ b/desktop/src/file_associations/win.rs @@ -0,0 +1,165 @@ +//! Windows file-type registration. Writes per-user (`HKCU`) entries so no admin elevation is required. +//! The list of registry values we want to have set is described within [`registration_entries`]. +//! Each launch re-reads them all and only re-writes when at least one differs from the desired state. + +use crate::consts::{APP_DESCRIPTION, APP_NAME, DOCUMENT_FRIENDLY_NAME, DOCUMENT_MIME_TYPE, DOCUMENT_TYPE_IDENTIFIER}; +use crate::wrapper::FILE_EXTENSION; +use std::io; +use std::path::{Path, PathBuf}; +use windows::Win32::UI::Shell::{SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify}; +use winreg::RegKey; +use winreg::enums::HKEY_CURRENT_USER; + +/// Defensive fallback if `current_exe()` somehow returns a path with no filename component. +const DEFAULT_EXE_FILENAME: &str = "Graphite.exe"; + +/// Extensions Graphite claims as the primary handler. Double-clicking these in Explorer launches +/// Graphite (subject to the user's prior `UserChoice` association, which always wins). +/// Stored without the leading dot; [`registration_entries`] prepends it where Windows expects one. +const OWNED_EXTENSIONS: &[&str] = &[FILE_EXTENSION]; + +/// Extensions where Graphite appears in "Open with..." but does not displace any existing default handler. +/// Stored without the leading dot; [`registration_entries`] prepends it where Windows expects one. +const OPEN_WITH_EXTENSIONS: &[&str] = &["svg", "png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp"]; + +pub(super) fn register() { + let exe_path = match std::env::current_exe() { + Ok(path) => { + // `current_exe()` may return a path with a `\\?\` verbatim prefix. + // That prefix is technically valid in registry command strings but Explorer occasionally mishandles it, so strip it. + let path: &Path = &path; + let lossy = path.to_string_lossy(); + if let Some(stripped) = lossy.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } + } + Err(error) => { + tracing::error!("Failed to determine current executable path for OS file registration: {error}"); + return; + } + }; + + let exe_string = exe_path.to_string_lossy().into_owned(); + let exe_filename = exe_path.file_name().and_then(|name| name.to_str()).unwrap_or(DEFAULT_EXE_FILENAME).to_owned(); + + let command = format!("\"{exe_string}\" \"%1\""); + let icon = format!("\"{exe_string}\",0"); + + let entries = registration_entries(&exe_filename, &command, &icon); + + if registration_is_current(&entries) { + return; + } + + if let Err(error) = write_associations(&entries) { + tracing::error!("Failed to register Windows file associations: {error}"); + return; + } + + // Tell Explorer to refresh its association/icon caches so the new mapping is visible immediately without requiring a sign-out. + // This is what causes desktop icons to briefly flicker, which is why we gate it behind the `registration_is_current` check above. + unsafe { + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None); + } + + tracing::info!("Registered Windows file associations for {exe_string}"); +} + +struct RegistryEntry { + subkey: String, + /// An empty string refers to the key's default (unnamed) value. + value_name: String, + value_data: String, +} + +fn registration_entries(exe_filename: &str, command: &str, icon: &str) -> Vec { + let app_path = format!(r"Software\Classes\Applications\{exe_filename}"); + let app_command_path = format!(r"{app_path}\shell\open\command"); + let app_supported_path = format!(r"{app_path}\SupportedTypes"); + let progid_path = format!(r"Software\Classes\{DOCUMENT_TYPE_IDENTIFIER}"); + let progid_icon_path = format!(r"{progid_path}\DefaultIcon"); + let progid_command_path = format!(r"{progid_path}\shell\open\command"); + let capabilities_path = format!(r"Software\{APP_NAME}\Capabilities"); + let file_associations_path = format!(r"{capabilities_path}\FileAssociations"); + let mime_associations_path = format!(r"{capabilities_path}\MimeAssociations"); + + let mut entries: Vec = Vec::new(); + let mut push = |subkey: &str, value_name: &str, value_data: &str| { + entries.push(RegistryEntry { + subkey: subkey.to_owned(), + value_name: value_name.to_owned(), + value_data: value_data.to_owned(), + }) + }; + + // 1. The Application entry: + // What Windows uses to populate "Open with..." menus and `Applications\` lookups. + // Keyed by exe filename, so re-launching from a new directory writes a new command line under that key. + push(&app_path, "FriendlyAppName", APP_NAME); + push(&app_command_path, "", command); + for extension in OWNED_EXTENSIONS.iter().chain(OPEN_WITH_EXTENSIONS) { + push(&app_supported_path, &format!(".{extension}"), ""); + } + + // 2. The ProgID describing a Graphite document: + // Referenced by every extension below. + push(&progid_path, "", DOCUMENT_FRIENDLY_NAME); + push(&progid_path, "FriendlyTypeName", DOCUMENT_FRIENDLY_NAME); + push(&progid_icon_path, "", icon); + push(&progid_command_path, "", command); + + // 3. Owned extensions: + // Bind to the ProgID as a default fallback, attach MIME and perceived type metadata, and surface in "Open with..." via OpenWithProgids. + // Setting the default does not override an existing `UserChoice` that the user may have made via the OS UI. + for extension in OWNED_EXTENSIONS { + let extension_path = format!(r"Software\Classes\.{extension}"); + let extension_open_with_path = format!(r"{extension_path}\OpenWithProgids"); + push(&extension_path, "", DOCUMENT_TYPE_IDENTIFIER); + push(&extension_path, "Content Type", DOCUMENT_MIME_TYPE); + push(&extension_path, "PerceivedType", "document"); + push(&extension_open_with_path, DOCUMENT_TYPE_IDENTIFIER, ""); + } + + // 4. Alternate extensions: + // Only add the ProgID to OpenWithProgids so Graphite shows up under "Open with..." without claiming to be the default handler. + for extension in OPEN_WITH_EXTENSIONS { + let extension_open_with_path = format!(r"Software\Classes\.{extension}\OpenWithProgids"); + push(&extension_open_with_path, DOCUMENT_TYPE_IDENTIFIER, ""); + } + + // 5. Capabilities + RegisteredApplications: + // The modern (Win8+) scheme. Required for Graphite to appear in "Settings" > "Default apps" > "Choose defaults by app", + // and to be treated as a known app rather than a fresh suggestion in the "Open With" dialog. + push(&capabilities_path, "ApplicationName", APP_NAME); + push(&capabilities_path, "ApplicationDescription", APP_DESCRIPTION); + push(&capabilities_path, "ApplicationIcon", icon); + for extension in OWNED_EXTENSIONS.iter().chain(OPEN_WITH_EXTENSIONS) { + push(&file_associations_path, &format!(".{extension}"), DOCUMENT_TYPE_IDENTIFIER); + } + push(&mime_associations_path, DOCUMENT_MIME_TYPE, DOCUMENT_TYPE_IDENTIFIER); + push(r"Software\RegisteredApplications", APP_NAME, &capabilities_path); + + entries +} + +/// Returns `true` when every registered entry already matches what we'd write. +fn registration_is_current(entries: &[RegistryEntry]) -> bool { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + entries.iter().all(|entry| { + hkcu.open_subkey(&entry.subkey) + .ok() + .and_then(|key| key.get_value::(&entry.value_name).ok()) + .is_some_and(|existing| existing == entry.value_data) + }) +} + +fn write_associations(entries: &[RegistryEntry]) -> io::Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + for entry in entries { + let (key, _) = hkcu.create_subkey(&entry.subkey)?; + key.set_value(&entry.value_name, &entry.value_data)?; + } + Ok(()) +} diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 8183c8aacf..b4177b68dd 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -15,6 +15,7 @@ mod cef; mod cli; mod dirs; mod event; +mod file_associations; mod gpu_context; #[cfg(not(target_os = "macos"))] mod instance_ipc; @@ -81,6 +82,8 @@ pub fn start() { } }; + file_associations::register_with_os(); + dirs::app_tmp_dir_cleanup(); // TODO: Eventually remove this cleanup code for the old "browser" CEF directory