mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-27 19:48:12 +08:00
Implement Windows registry file format registration on app startup
This commit is contained in:
Generated
+11
@@ -2101,6 +2101,7 @@ dependencies = [
|
|||||||
"window_clipboard",
|
"window_clipboard",
|
||||||
"windows 0.58.0",
|
"windows 0.58.0",
|
||||||
"winit",
|
"winit",
|
||||||
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7369,6 +7370,16 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "winres"
|
name = "winres"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ windows = { version = "0.58.0", features = [
|
|||||||
"Win32_UI_HiDpi",
|
"Win32_UI_HiDpi",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
] }
|
] }
|
||||||
|
winreg = "0.56"
|
||||||
|
|
||||||
# Mac-specific dependencies
|
# Mac-specific dependencies
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) const APP_NAME: &str = "Graphite";
|
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"))]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
pub(crate) const APP_ID: &str = "art.graphite.Graphite";
|
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_PREFERENCES_FILE_NAME: &str = "preferences.ron";
|
||||||
pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents";
|
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
|
// CEF configuration constants
|
||||||
pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60;
|
pub(crate) const CEF_WINDOWLESS_FRAME_RATE: i32 = 60;
|
||||||
pub(crate) const CEF_MESSAGE_LOOP_MAX_ITERATIONS: usize = 10;
|
pub(crate) const CEF_MESSAGE_LOOP_MAX_ITERATIONS: usize = 10;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 cli;
|
||||||
mod dirs;
|
mod dirs;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod file_associations;
|
||||||
mod gpu_context;
|
mod gpu_context;
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
mod instance_ipc;
|
mod instance_ipc;
|
||||||
@@ -81,6 +82,8 @@ pub fn start() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
file_associations::register_with_os();
|
||||||
|
|
||||||
dirs::app_tmp_dir_cleanup();
|
dirs::app_tmp_dir_cleanup();
|
||||||
|
|
||||||
// TODO: Eventually remove this cleanup code for the old "browser" CEF directory
|
// TODO: Eventually remove this cleanup code for the old "browser" CEF directory
|
||||||
|
|||||||
Reference in New Issue
Block a user