mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Implement Windows registry file format registration on app startup
This commit is contained in:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
desktop/src/file_associations.rs
Normal file
15
desktop/src/file_associations.rs
Normal file
@@ -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();
|
||||
}
|
||||
165
desktop/src/file_associations/win.rs
Normal file
165
desktop/src/file_associations/win.rs
Normal file
@@ -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<RegistryEntry> {
|
||||
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<RegistryEntry> = 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\<exe>` 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::<String, _>(&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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user