collective

Browse / github.com/storytold/photocraft / patch

Feat/file thumbnails

Feat/file thumbnails Adds Windows Explorer thumbnails for .psd, .psb, and native .pcraft files, updated to upstream main 59cb4c49b (PhotoCraft 0.16.0). The PSD COM provider, preference UI, and Windows packaging are based on @wer33321’s work in #7805. This contribution preserves that history and extends support to PSB and PhotoCraft documents. Changes * Display embedded previews from PSD and PSB files saved by PhotoCraft or Photoshop. * Read the embedded thumb.png from native PhotoCraft files without decoding document layers. * Register each file extension separately, preserving previous thumbnail providers and

Status
open
Score
0.00
Adopters
0 other operators
Author
IgorsSantana (imported from pull request #9660, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+1769 −9 in 41 files
Review
pending
Shared
2026-10-28
Id
1448f1443fbb144f6d1fd457

How it went

No other operator has reported using it yet.

Diff

raw · AGENTS.md Cargo.lock Cargo.toml apps/photocraft/Cargo.toml apps/photocraft/src/explorer_thumbnails.rs apps/photocraft/src/main.rs apps/photocraft/src/services.rs crates/engine/src/prefs.rs crates/engine/src/prefs/tests.rs crates/shell/Cargo.toml crates/shell/src/lib.rs crates/shell/src/pcraft.rs crates/shell/src/psd.rs crates/shell/src/registration.rs crates/shell/src/windows_thumbnail.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sr.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/prefs_ui.rs docs/architecture.md docs/development.md packaging/windows/package.ps1 packaging/windows/photocraft.wxs xtask/src/layers.rs

diff --git a/AGENTS.md b/AGENTS.md--- a/AGENTS.md+++ b/AGENTS.md@@ -27,7 +27,8 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written crates/   geom cms color raster      L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)   psd codecs                 L0 standalone format crates (no workspace deps; publishable)-  tablet                     L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate+  tablet                     L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); an isolated unsafe crate+  shell                      L0 standalone Windows Explorer PSD thumbnail provider (COM DLL, empty off Windows); an isolated unsafe crate   doc                        L1 document model (layers, masks, adjustments, effects, smart objects: pure data)   ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes   compose gpu format         L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format@@ -52,7 +53,7 @@ xtask/                       cargo xtask layers | wasm | ci | stats | corpus | t People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.  - **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.-- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.+- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. There are two exceptions, both isolated helper crates that set `unsafe_code = "deny"` crate-wide, put a `SAFETY:` comment on every block and allow `unsafe` only in the files that need it. `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop), and on Windows a pointer message hook, tested against real tablet events; only its `src/macos.rs` and `src/windows.rs` allow `unsafe`, its X11 path and all mapping code are safe. `photocraft-shell` (`crates/shell`, Windows only, empty elsewhere): the File Explorer thumbnail provider for PSD files is a COM server DLL (`photocraft_shell.dll`), which is raw FFI by nature; only `src/windows_thumbnail.rs` and `src/registration.rs` allow `unsafe`, its PSD parser is safe, and it never `unwrap`s or panics because a crash there takes Explorer down. Don't add `unsafe` anywhere else. - **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input. - **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic). - **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -4861,6 +4861,7 @@ dependencies = [  "photocraft-geom",  "photocraft-io",  "photocraft-raster",+ "photocraft-shell",  "photocraft-tablet",  "photocraft-ui-egui",  "resvg",@@ -5220,6 +5221,13 @@ dependencies = [  "zune-jpegxl", ] +[[package]]+name = "photocraft-shell"+version = "0.16.0"+dependencies = [+ "windows-sys 0.59.0",+]+ [[package]] name = "photocraft-tablet" version = "0.16.0"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" }+photocraft-shell = { path = "crates/shell" } photocraft-ipp = { path = "crates/ipp" }  # externaldiff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml--- a/apps/photocraft/Cargo.toml+++ b/apps/photocraft/Cargo.toml@@ -94,6 +94,9 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] } # GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles), # no dependencies of its own. Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "windows")'.dependencies]+# Explorer thumbnails for PSD files (the Windows COM server DLL is built from this crate; the app+# only calls its safe register/unregister functions).+photocraft-shell = { workspace = true } # Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal. xcap = "=0.9.8" winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }diff --git a/apps/photocraft/src/explorer_thumbnails.rs b/apps/photocraft/src/explorer_thumbnails.rsnew file mode 100644--- /dev/null+++ b/apps/photocraft/src/explorer_thumbnails.rs@@ -0,0 +1,104 @@+//! Windows only: keeps File Explorer's PSD/PSB/PhotoCraft thumbnails in step with Preferences › File Handling ›+//! Explorer Thumbnails (`fileHandling.explorerThumbnails`: Off, On with logo, On no logo; on with+//! logo by default).+//!+//! The thumbnail provider itself is `photocraft_shell.dll` (the `photocraft-shell` crate), shipped+//! next to `photocraft.exe`. Registering it only touches the current user's registry.++use std::path::Path;+use std::sync::atomic::{AtomicU8, Ordering};+use std::sync::{Mutex, PoisonError};++use photocraft_engine::prefs::ExplorerThumbnails;+use serde_json::Value;++/// What the registry was last set to by this process (0 = not yet).+static APPLIED: AtomicU8 = AtomicU8::new(0);+/// Serializes asynchronous startup registration with preference saves.+static REGISTRATION_LOCK: Mutex<()> = Mutex::new(());++/// A stable number for each mode, used to remember what was last applied.+fn code(mode: ExplorerThumbnails) -> u8 {+    match mode {+        ExplorerThumbnails::Off => 1,+        ExplorerThumbnails::WithLogo => 2,+        ExplorerThumbnails::NoLogo => 3,+    }+}++/// The preference in the preferences text; the default when the file or the value is missing.+fn wanted(text: &str) -> ExplorerThumbnails {+    let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);+    v.get("fileHandling").and_then(|f| f.get("explorerThumbnails")).and_then(Value::as_str).and_then(ExplorerThumbnails::parse).unwrap_or_default()+}++/// Only the normal per-user install writes the registry: a portable copy leaves nothing behind, a+/// `PHOTOCRAFT_CONFIG_DIR` run (tests, agents) must not touch the real user's settings, and a+/// 32-bit build's class registration is invisible to the 64-bit Explorer.+fn applies() -> bool {+    cfg!(target_pointer_width = "64") && crate::app_dirs::current().mode == crate::app_dirs::Mode::Platform+}++/// At launch: unless the preference is Off, register the thumbnails, except where another program+/// (Photoshop) already provides them. Turning them off is only ever done from the preferences.+/// Runs on its own thread: writing the registry and telling the shell must not delay the window.+pub fn startup(prefs_file: Option<&Path>) {+    if !applies() {+        return;+    }+    let prefs_file = prefs_file.map(Path::to_path_buf);+    let work = move || {+        let _guard = REGISTRATION_LOCK.lock().unwrap_or_else(PoisonError::into_inner);+        let mode = wanted(&prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default());+        if mode == ExplorerThumbnails::Off {+            match photocraft_shell::enable_explorer_thumbnails(false, true) {+                Ok(()) => APPLIED.store(code(mode), Ordering::Relaxed),+                Err(e) => log::debug!("Explorer thumbnails: {e}"),+            }+        } else if let Err(e) = photocraft_shell::ensure_explorer_thumbnails(mode == ExplorerThumbnails::WithLogo) {+            // Typically the DLL is not beside the executable (a development build).+            log::debug!("Explorer thumbnails: {e}");+        } else {+            APPLIED.store(code(mode), Ordering::Relaxed);+        }+    };+    if let Err(e) = std::thread::Builder::new().name("explorer-thumbnails".into()).spawn(work) {+        log::debug!("Explorer thumbnails: {e}");+    }+}++/// After the preferences were written: apply a change of the setting. The user's explicit choice+/// takes `.psd` over from other programs, and Off removes only PhotoCraft's own registration. A+/// change that failed is tried again with the next save.+pub fn after_save(text: &str) {+    if !applies() {+        return;+    }+    let _guard = REGISTRATION_LOCK.lock().unwrap_or_else(PoisonError::into_inner);+    let mode = wanted(text);+    if APPLIED.load(Ordering::Relaxed) == code(mode) {+        return;+    }+    match photocraft_shell::enable_explorer_thumbnails(mode != ExplorerThumbnails::Off, mode == ExplorerThumbnails::WithLogo) {+        Ok(()) => APPLIED.store(code(mode), Ordering::Relaxed),+        Err(e) => log::debug!("Explorer thumbnails: {e}"),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn on_with_logo_unless_the_preference_says_otherwise() {+        assert_eq!(wanted(""), ExplorerThumbnails::WithLogo);+        assert_eq!(wanted("not json"), ExplorerThumbnails::WithLogo);+        assert_eq!(wanted(r#"{"fileHandling": {"autosave": false}}"#), ExplorerThumbnails::WithLogo);+        assert_eq!(wanted(r#"{"fileHandling": {"explorerThumbnails": "off"}}"#), ExplorerThumbnails::Off);+        assert_eq!(wanted(r#"{"fileHandling": {"explorerThumbnails": "noLogo"}}"#), ExplorerThumbnails::NoLogo);+        assert_eq!(wanted(r#"{"fileHandling": {"explorerThumbnails": "withLogo"}}"#), ExplorerThumbnails::WithLogo);+        // The earlier boolean form and unknown values fall back to the default.+        assert_eq!(wanted(r#"{"fileHandling": {"explorerThumbnails": false}}"#), ExplorerThumbnails::WithLogo);+        assert_eq!(wanted(r#"{"fileHandling": {"explorerThumbnails": "sometimes"}}"#), ExplorerThumbnails::WithLogo);+    }+}diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs--- a/apps/photocraft/src/main.rs+++ b/apps/photocraft/src/main.rs@@ -39,6 +39,9 @@ mod caption_style; mod control_server; mod crash_guard; mod cursor;+// Windows: PSD thumbnails in File Explorer (the photocraft-shell DLL), toggled in the preferences.+#[cfg(target_os = "windows")]+mod explorer_thumbnails; mod file_dialog_probe; mod gpu_startup; #[cfg(target_os = "linux")]@@ -424,6 +427,9 @@ fn main() -> eframe::Result {     let wanted_adapter =         gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));     gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());+    // Preferences › File Handling › Explorer Thumbnails (Windows): on by default.+    #[cfg(target_os = "windows")]+    explorer_thumbnails::startup(services::prefs_file().as_deref());     // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about     // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.     if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs--- a/apps/photocraft/src/services.rs+++ b/apps/photocraft/src/services.rs@@ -689,7 +689,12 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->         clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),         load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),         photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),-        save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),+        save_prefs: Some(Box::new(|text: &str| {+            write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes())?;+            #[cfg(target_os = "windows")]+            crate::explorer_thumbnails::after_save(text);+            Ok(())+        })),         system_theme: crate::appearance::service(),         append_text: Some(Box::new(|path: &str, text: &str| {             use std::io::Write;diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs--- a/crates/engine/src/prefs.rs+++ b/crates/engine/src/prefs.rs@@ -86,6 +86,11 @@ choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always);+choice!(+    /// Windows: PSD/PSB/PhotoCraft thumbnails in File Explorer (the `photocraft-shell` DLL), with the file+    /// type's logo badge on them or without it. Stored on every platform, used only on Windows.+    ExplorerThumbnails { Off = "off", WithLogo = "withLogo", NoLogo = "noLogo" } default WithLogo+); choice!(QuickExportFormat { Png = "png", Jpg = "jpg", Gif = "gif", Webp = "webp", Exr = "exr" } default Png); choice!(ExportLocation { Ask = "ask", SameFolder = "sameFolder" } default Ask); choice!(ExportMetadata { None = "none", Copyright = "copyright", All = "all" } default Copyright);@@ -472,6 +477,9 @@ pub struct FileHandling {     pub exr_aces_cg: bool,     pub ask_before_saving_layered_tiff: bool,     pub maximize_psd_compatibility: Ask,+    /// Windows: PSD/PSB/PhotoCraft thumbnails in File Explorer, with or without the logo badge (registers+    /// `photocraft_shell.dll`). Shown only on Windows.+    pub explorer_thumbnails: ExplorerThumbnails,     /// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed.     pub rasterize_svg_groups_deeper_than: u32,     pub recent_file_count: u32,@@ -504,6 +512,7 @@ impl Default for FileHandling {             exr_aces_cg: false,             ask_before_saving_layered_tiff: true,             maximize_psd_compatibility: Ask::Always,+            explorer_thumbnails: ExplorerThumbnails::WithLogo,             rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32,             recent_file_count: 20,             recent_files: Vec::new(),@@ -1167,7 +1176,9 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[  /// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog? pub fn is_hidden(path: &str) -> bool {-    HIDDEN_UNTIL_IMPLEMENTED.contains(&path) || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path))+    HIDDEN_UNTIL_IMPLEMENTED.contains(&path)+        || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path))+        || (!cfg!(target_os = "windows") && WINDOWS_ONLY.contains(&path)) }  /// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They@@ -1183,6 +1194,9 @@ pub fn gpu_adapter_matches(name: &str, wanted: &str) -> bool {     words.peek().is_some() && words.all(|w| name.contains(&w.to_lowercase())) } +/// Preferences that only do something on Windows, handled like [`LINUX_ONLY`].+pub const WINDOWS_ONLY: &[&str] = &["fileHandling.explorerThumbnails"];+ /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> {     Some(match path {@@ -1199,6 +1213,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {         "historyLog.destination" => LogDestination::NAMES,         "historyLog.detail" => LogDetail::NAMES,         "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES,+        "fileHandling.explorerThumbnails" => ExplorerThumbnails::NAMES,         "export.quickExportFormat" => QuickExportFormat::NAMES,         "export.quickExportLocation" => ExportLocation::NAMES,         "export.metadata" => ExportMetadata::NAMES,diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs--- a/crates/engine/src/prefs/tests.rs+++ b/crates/engine/src/prefs/tests.rs@@ -604,6 +604,27 @@ fn linux_only_preferences_show_only_on_linux() {     assert!(LINUX_ONLY.iter().all(|p| free_text.contains(p) || choices(p).is_some()), "every Linux-only preference is a real one"); } +#[test]+fn explorer_thumbnails_is_a_windows_only_choice_defaulting_to_on_with_logo() {+    assert_eq!(Preferences::default().file_handling.explorer_thumbnails, ExplorerThumbnails::WithLogo);+    assert_eq!(is_hidden("fileHandling.explorerThumbnails"), !cfg!(target_os = "windows"));+    assert_eq!(choices("fileHandling.explorerThumbnails"), Some(&["off", "withLogo", "noLogo"][..]));+    let mut s = Session::new();+    for (value, want) in [("off", ExplorerThumbnails::Off), ("noLogo", ExplorerThumbnails::NoLogo), ("withLogo", ExplorerThumbnails::WithLogo)] {+        s.execute("prefs.set", json!({"path": "fileHandling.explorerThumbnails", "value": value})).unwrap();+        assert_eq!(s.prefs().file_handling.explorer_thumbnails, want);+    }+    assert!(s.execute("prefs.set", json!({"path": "fileHandling.explorerThumbnails", "value": "sometimes"})).is_err());+    assert_eq!(s.prefs().file_handling.explorer_thumbnails, ExplorerThumbnails::WithLogo);+    // Older preference files without the setting keep it on.+    let mut t = Session::new();+    t.load_prefs_json(r#"{"fileHandling": {"autosave": false}}"#).unwrap();+    assert_eq!(t.prefs().file_handling.explorer_thumbnails, ExplorerThumbnails::WithLogo);+    for path in WINDOWS_ONLY {+        assert!(s.execute("prefs.get", json!({"path": path})).is_ok(), "{path} is a real preference");+    }+}+ #[test] fn the_default_pressure_curve_is_linear() {     let c = PressureCurve::new(&Preferences::default().tools.pressure_curve);diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/shell/Cargo.toml@@ -0,0 +1,44 @@+[package]+name = "photocraft-shell"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "Windows Explorer thumbnail provider that shows the embedded preview of PSD, PSB and PhotoCraft files"++[lib]+# `cdylib` is the COM server Explorer loads (photocraft_shell.dll); `rlib` lets the app call+# `enable_explorer_thumbnails` and lets `cargo test` run the parser tests.+crate-type = ["cdylib", "rlib"]++# The second isolated `unsafe` helper crate (after `photocraft-tablet`): a COM server is raw FFI+# by nature.+# Every `unsafe` function and block follows the safety contract at the top of `windows_thumbnail.rs`+# (the four invariants it numbers) or carries a `SAFETY:` comment (`registration.rs`). The PSD parser+# (`psd.rs`), the only code that reads untrusted bytes, is safe.+# Every other crate keeps the workspace's `unsafe_code = "forbid"`.+[lints.rust]+unsafe_code = "deny"++[lints.clippy]+dbg_macro = "warn"+unwrap_used = "deny"+expect_used = "deny"+panic = "deny"+unimplemented = "deny"+todo = "deny"+unreachable = "deny"++# Already in Cargo.lock; pinned exactly: review the diff before bumping. Compiles to nothing on+# macOS, Linux and the web.+[target.'cfg(target_os = "windows")'.dependencies.windows-sys]+version = "=0.59.0"+features = [+    "Win32_Foundation",+    "Win32_Graphics_Gdi",+    "Win32_Graphics_GdiPlus",+    "Win32_Security",+    "Win32_System_Com",+    "Win32_System_LibraryLoader",+    "Win32_System_Registry",+    "Win32_UI_Shell",+]diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/shell/src/lib.rs@@ -0,0 +1,26 @@+//! Windows Explorer thumbnails for PSD, PSB and PhotoCraft files.+//!+//! Explorer asks a registered `IThumbnailProvider` COM server for the preview of each `.psd`. This+//! crate is that server: it reads the embedded thumbnail resource (1036, written by PhotoCraft's+//! "Image Previews" setting) straight out of the file and hands Explorer a bitmap, with no image+//! crates involved.+//!+//! On macOS, Linux and the web this crate is empty: everything below sits behind+//! `cfg(target_os = "windows")`.++// The DLL's COM entry points (`DllGetClassObject` and friends) are plain `#[no_mangle]` exports;+// MSVC's linker notes (LNK4104) that they could also be marked PRIVATE in a .def file, which is+// not needed.+#![allow(linker_messages)]++#[cfg(any(target_os = "windows", test))]+mod pcraft;+#[cfg(any(target_os = "windows", test))]+mod psd;+#[cfg(target_os = "windows")]+mod registration;+#[cfg(target_os = "windows")]+mod windows_thumbnail;++#[cfg(target_os = "windows")]+pub use registration::{enable_explorer_thumbnails, ensure_explorer_thumbnails};diff --git a/crates/shell/src/pcraft.rs b/crates/shell/src/pcraft.rsnew file mode 100644--- /dev/null+++ b/crates/shell/src/pcraft.rs@@ -0,0 +1,177 @@+//! Bounded reader for the STORE ZIP previews written by PhotoCraft.+//! Only the manifest and thumbnail prefix is read; layer/tile payloads are never decoded.+use crate::psd::Extent;++const MAX_PREFIX: usize = 16 * 1024 * 1024;+const MAX_PNG: usize = 4 * 1024 * 1024;++fn u16_at(b: &[u8], at: usize) -> Option<u16> {+    Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))+}+fn u32_at(b: &[u8], at: usize) -> Option<u32> {+    Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))+}++/// Locate the thumbnail in the native writer's manifest-first ZIP layout. Repacked/deflated+/// archives and ZIP64 deliberately fall back to an icon rather than decompressing arbitrary data.+struct Thumbnail {+    start: usize,+    end: usize,+    crc: u32,+}++fn locate(head: &[u8]) -> Option<(Extent, Option<Thumbnail>)> {+    let mut at = 0usize;+    for entry in 0..4 {+        let fixed_end = at.checked_add(30)?;+        if fixed_end > MAX_PREFIX {+            return None;+        }+        if head.len() < fixed_end {+            return Some((Extent::NeedAtLeast(fixed_end), None));+        }+        let h = head.get(at..fixed_end)?;+        if h.get(..4)? != b"PK\x03\x04" || u16_at(h, 6)? & !0x800 != 0 || u16_at(h, 8)? != 0 {+            return None;+        }+        let size = usize::try_from(u32_at(h, 18)?).ok()?;+        if size != usize::try_from(u32_at(h, 22)?).ok()? {+            return None;+        }+        let name_end = fixed_end.checked_add(usize::from(u16_at(h, 26)?))?;+        let data_at = name_end.checked_add(usize::from(u16_at(h, 28)?))?;+        let end = data_at.checked_add(size)?;+        if end > MAX_PREFIX {+            return None;+        }+        let Some(name) = head.get(fixed_end..name_end) else {+            return Some((Extent::NeedAtLeast(data_at), None));+        };+        if entry == 0 && name != b"manifest.json" {+            return None;+        }+        if name == b"thumb.png" {+            if size > MAX_PNG {+                return None;+            }+            return Some((Extent::Known(end), Some(Thumbnail { start: data_at, end, crc: u32_at(h, 14)? })));+        }+        if entry > 0 {+            return None;+        }+        if head.len() < end {+            return Some((Extent::NeedAtLeast(end.checked_add(30)?), None));+        }+        at = end;+    }+    None+}++pub fn thumbnail_extent(head: &[u8]) -> Option<Extent> {+    locate(head).map(|(extent, _)| extent)+}++pub fn find_thumbnail_png(head: &[u8]) -> Option<&[u8]> {+    let (_, Some(Thumbnail { start, end, crc })) = locate(head)? else {+        return None;+    };+    let png = head.get(start..end)?;+    if png.get(..8)? != b"\x89PNG\r\n\x1a\n" || png.get(8..16)? != b"\0\0\0\rIHDR" || crc32(png) != crc {+        return None;+    }+    let width = u32::from_be_bytes(png.get(16..20)?.try_into().ok()?);+    let height = u32::from_be_bytes(png.get(20..24)?.try_into().ok()?);+    if !(1..=2048).contains(&width) || !(1..=2048).contains(&height) {+        return None;+    }+    // The IHDR, including its CRC, must be complete before invoking the native decoder.+    png.get(..33)?;+    Some(png)+}++fn crc32(bytes: &[u8]) -> u32 {+    let mut crc = !0u32;+    for &b in bytes {+        crc ^= u32::from(b);+        for _ in 0..8 {+            crc = (crc >> 1) ^ (0xEDB8_8320 & (0u32.wrapping_sub(crc & 1)));+        }+    }+    !crc+}++#[cfg(test)]+mod tests {+    use super::*;+    fn entry(out: &mut Vec<u8>, name: &[u8], data: &[u8]) {+        let mut h = [0u8; 30];+        h[..4].copy_from_slice(b"PK\x03\x04");+        h[14..18].copy_from_slice(&crc32(data).to_le_bytes());+        h[18..22].copy_from_slice(&(data.len() as u32).to_le_bytes());+        h[22..26].copy_from_slice(&(data.len() as u32).to_le_bytes());+        h[26..28].copy_from_slice(&(name.len() as u16).to_le_bytes());+        out.extend(h);+        out.extend(name);+        out.extend(data);+    }+    fn sample(w: u32, h: u32) -> (Vec<u8>, Vec<u8>) {+        let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();+        png.extend(w.to_be_bytes());+        png.extend(h.to_be_bytes());+        png.extend([0u8; 9]);+        let mut zip = Vec::new();+        entry(&mut zip, b"manifest.json", b"{}");+        entry(&mut zip, b"thumb.png", &png);+        (zip, png)+    }+    #[test]+    fn native_thumbnail_does_not_require_tile_payloads_or_directory() {+        let (mut zip, png) = sample(256, 128);+        let end = zip.len();+        zip.extend([42; 1000]);+        assert_eq!(thumbnail_extent(&zip), Some(Extent::Known(end)));+        assert_eq!(find_thumbnail_png(&zip), Some(png.as_slice()));+    }+    #[test]+    fn partial_reads_request_progress_and_truncations_are_rejected() {+        let (zip, _) = sample(256, 128);+        for n in 0..zip.len() {+            assert!(find_thumbnail_png(&zip[..n]).is_none());+            let e = thumbnail_extent(&zip[..n]).unwrap();+            let (Extent::Known(end) | Extent::NeedAtLeast(end)) = e;+            assert!(end > n && end <= MAX_PREFIX);+        }+    }+    #[test]+    fn invalid_dimensions_sizes_flags_crc_and_formats_are_rejected() {+        for (w, h) in [(0, 1), (1, 0), (2049, 1), (1, u32::MAX)] {+            assert!(find_thumbnail_png(&sample(w, h).0).is_none());+        }+        let (zip, _) = sample(1, 1);+        for (at, b) in [(6, 1), (6, 8), (8, 8), (18, 255), (22, 255)] {+            let mut bad = zip.clone();+            bad[at] = b;+            assert!(find_thumbnail_png(&bad).is_none());+        }+        let mut bad = zip.clone();+        *bad.last_mut().unwrap() ^= 1;+        assert!(find_thumbnail_png(&bad).is_none());+        assert!(thumbnail_extent(&[0; 64]).is_none());+        assert_eq!(crc32(b"123456789"), 0xcbf43926);+    }+    #[test]+    fn corrupted_prefixes_never_panic() {+        let (zip, _) = sample(256, 128);+        let mut seed = 27u64;+        for _ in 0..20_000 {+            seed ^= seed << 13;+            seed ^= seed >> 7;+            seed ^= seed << 17;+            let mut bad = zip.clone();+            let at = seed as usize % bad.len();+            bad[at] = (seed >> 8) as u8;+            let _ = thumbnail_extent(&bad);+            let _ = find_thumbnail_png(&bad);+        }+    }+}diff --git a/crates/shell/src/psd.rs b/crates/shell/src/psd.rsnew file mode 100644--- /dev/null+++ b/crates/shell/src/psd.rs@@ -0,0 +1,319 @@+//! Allocation-free PSD parser that locates the embedded thumbnail.+//!+//! Layout (all integers big-endian): header (26 bytes) | color mode data (u32 length) | image+//! resources (u32 length) | ... Each image resource block is `8BIM`, a u16 id, a Pascal name+//! padded to an even size, a u32 size and the data padded to an even size.++/// Thumbnail resource written by Photoshop 5 and later (JFIF, RGB).+const RESOURCE_THUMBNAIL: u16 = 0x040C;+/// `format` value of a JPEG RGB thumbnail (0 would be raw RGB).+const FORMAT_JPEG_RGB: u32 = 1;+/// The fixed header (26 bytes) plus the length of the color mode data (4 bytes).+const FIXED_LEN: usize = 30;+/// Longest side, in pixels, of a thumbnail that is decoded. Photoshop writes 160 at most; the cap+/// keeps a hostile file from declaring a huge image that the decoder would allocate for.+const MAX_THUMBNAIL_SIDE: u16 = 2048;++/// Bounds-checked big-endian cursor over a byte slice. Never panics.+struct Reader<'a> {+    data: &'a [u8],+    pos: usize,+}++impl<'a> Reader<'a> {+    /// A reader over `data`, positioned at the start.+    fn new(data: &'a [u8]) -> Self {+        Self { data, pos: 0 }+    }++    /// The next `len` bytes, or `None` (without moving) when fewer remain.+    fn take(&mut self, len: usize) -> Option<&'a [u8]> {+        let end = self.pos.checked_add(len)?;+        let slice = self.data.get(self.pos..end)?;+        self.pos = end;+        Some(slice)+    }++    /// Reads one byte.+    fn u8(&mut self) -> Option<u8> {+        self.take(1)?.first().copied()+    }++    /// Reads a big-endian `u16`.+    fn u16(&mut self) -> Option<u16> {+        Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?))+    }++    /// Reads a big-endian `u32`.+    fn u32(&mut self) -> Option<u32> {+        Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?))+    }++    /// The bytes not read yet.+    fn rest(&self) -> &'a [u8] {+        self.data.get(self.pos..).unwrap_or(&[])+    }+}++/// The JFIF bytes of the embedded thumbnail, borrowed from `psd`.+///+/// `None` for anything that is not a well-formed PSD/PSB with a JPEG thumbnail. A truncated file+/// is fine as long as the thumbnail block itself is complete.+pub fn find_thumbnail_jpeg(psd: &[u8]) -> Option<&[u8]> {+    let mut r = Reader::new(psd);+    if r.take(4)? != b"8BPS" {+        return None;+    }+    let version = r.u16()?;+    if version != 1 && version != 2 {+        return None;+    }+    // Reserved (6), channels (2), height (4), width (4), depth (2), mode (2).+    r.take(20)?;++    let color_mode_len = usize::try_from(r.u32()?).ok()?;+    r.take(color_mode_len)?;++    let resources_len = usize::try_from(r.u32()?).ok()?;+    let rest = r.rest();+    let mut blocks = Reader::new(rest.get(..resources_len.min(rest.len()))?);++    loop {+        if blocks.take(4)? != b"8BIM" {+            return None;+        }+        let id = blocks.u16()?;+        let name_len = usize::from(blocks.u8()?);+        // The Pascal string (length byte + text) is padded to an even size.+        blocks.take(name_len + (1 + name_len) % 2)?;+        let size = usize::try_from(blocks.u32()?).ok()?;+        let data = blocks.take(size)?;+        if size % 2 == 1 {+            let _ = blocks.take(1); // The padding may be missing at the very end.+        }+        if id == RESOURCE_THUMBNAIL {+            return parse_thumbnail(data);+        }+    }+}++/// The JPEG inside a 1036 thumbnail resource block (after its 28-byte header), if it is a JFIF.+fn parse_thumbnail(block: &[u8]) -> Option<&[u8]> {+    let mut r = Reader::new(block);+    if r.u32()? != FORMAT_JPEG_RGB {+        return None;+    }+    // Width, height, width bytes, total size.+    r.take(16)?;+    let compressed = usize::try_from(r.u32()?).ok()?;+    // Bits per pixel and planes.+    r.take(4)?;++    let data = r.rest();+    let jpeg = if compressed > 0 { data.get(..compressed.min(data.len()))? } else { data };+    plausible_jpeg(jpeg).then_some(jpeg)+}++/// A JPEG whose own header declares a size worth decoding.+fn plausible_jpeg(jpeg: &[u8]) -> bool {+    let sides = 1..=MAX_THUMBNAIL_SIDE;+    matches!(jpeg_size(jpeg), Some((width, height)) if sides.contains(&width) && sides.contains(&height))+}++/// Width and height from the first start-of-frame marker of a JPEG, read without decoding it.+fn jpeg_size(jpeg: &[u8]) -> Option<(u16, u16)> {+    let mut r = Reader::new(jpeg);+    if r.u16()? != 0xFFD8 {+        return None;+    }+    loop {+        // A marker is 0xFF, any number of 0xFF fill bytes, then its code.+        if r.u8()? != 0xFF {+            return None;+        }+        let mut code = 0xFF;+        while code == 0xFF {+            code = r.u8()?;+        }+        match code {+            // Markers without a length: TEM and RST0-7.+            0x01 | 0xD0..=0xD7 => continue,+            // The end of the image or its data before any frame header.+            0xD9 | 0xDA => return None,+            _ => {}+        }+        let len = usize::from(r.u16()?).checked_sub(2)?;+        let body = r.take(len)?;+        // SOF0-15, except DHT (C4), JPG (C8) and DAC (CC).+        if matches!(code, 0xC0..=0xCF) && !matches!(code, 0xC4 | 0xC8 | 0xCC) {+            let mut frame = Reader::new(body);+            frame.u8()?; // Sample precision.+            let height = frame.u16()?;+            let width = frame.u16()?;+            return Some((width, height));+        }+    }+}++/// How much of the start of a file the image resources occupy.+#[derive(Debug, PartialEq, Eq)]+pub enum Extent {+    /// The image resources end at this offset.+    Known(usize),+    /// Too little of the file is known to tell; at least this many bytes are needed.+    NeedAtLeast(usize),+}++/// Where the image resources of the PSD/PSB starting with `head` end, so a caller reads no more of+/// the (possibly gigabytes large) file than that. `None` when it is not a PSD/PSB.+pub fn resources_extent(head: &[u8]) -> Option<Extent> {+    if head.len() < FIXED_LEN {+        let magic_ok = head.get(..4).is_none_or(|m| m == b"8BPS");+        return magic_ok.then_some(Extent::NeedAtLeast(FIXED_LEN));+    }+    let mut r = Reader::new(head);+    if r.take(4)? != b"8BPS" || !matches!(r.u16()?, 1 | 2) {+        return None;+    }+    // Reserved (6), channels (2), height (4), width (4), depth (2), mode (2).+    r.take(20)?;+    let color_mode_len = usize::try_from(r.u32()?).ok()?;+    let length_at = r.pos.checked_add(color_mode_len)?;+    let resources_at = length_at.checked_add(4)?;+    let Some(length) = head.get(length_at..resources_at) else {+        return Some(Extent::NeedAtLeast(resources_at));+    };+    let resources_len = usize::try_from(u32::from_be_bytes(length.try_into().ok()?)).ok()?;+    Some(Extent::Known(resources_at.checked_add(resources_len)?))+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A minimal PSD whose only image resource is a 1036 thumbnail around `jpeg`.+    fn sample(jpeg: &[u8]) -> Vec<u8> {+        let mut thumb = Vec::new();+        thumb.extend_from_slice(&1u32.to_be_bytes());+        thumb.extend_from_slice(&[0; 16]);+        thumb.extend_from_slice(&(jpeg.len() as u32).to_be_bytes());+        thumb.extend_from_slice(&[0, 24, 0, 1]);+        thumb.extend_from_slice(jpeg);++        let mut res = Vec::new();+        res.extend_from_slice(b"8BIM");+        res.extend_from_slice(&RESOURCE_THUMBNAIL.to_be_bytes());+        res.extend_from_slice(&[0, 0]); // empty, padded name+        res.extend_from_slice(&(thumb.len() as u32).to_be_bytes());+        res.extend_from_slice(&thumb);+        if thumb.len() % 2 == 1 {+            res.push(0);+        }++        let mut psd = b"8BPS\0\x01".to_vec();+        psd.extend_from_slice(&[0; 20]);+        psd.extend_from_slice(&0u32.to_be_bytes());+        psd.extend_from_slice(&(res.len() as u32).to_be_bytes());+        psd.extend_from_slice(&res);+        psd+    }++    /// The start of a JPEG: SOI, a SOF0 frame header of `width` x `height`, then EOI.+    fn jpeg(width: u16, height: u16) -> Vec<u8> {+        let mut j = vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8];+        j.extend_from_slice(&height.to_be_bytes());+        j.extend_from_slice(&width.to_be_bytes());+        j.extend_from_slice(&[3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1]);+        j.extend_from_slice(&[0xFF, 0xD9]);+        j+    }++    #[test]+    fn finds_thumbnail() {+        let jpeg = jpeg(160, 90);+        assert_eq!(find_thumbnail_jpeg(&sample(&jpeg)), Some(&jpeg[..]));+    }++    #[test]+    fn psb_uses_the_same_resource_layout_as_psd() {+        let jpeg = jpeg(160, 90);+        let mut psb = sample(&jpeg);+        psb[4..6].copy_from_slice(&2u16.to_be_bytes());+        assert_eq!(find_thumbnail_jpeg(&psb), Some(jpeg.as_slice()));+        assert_eq!(resources_extent(&psb), Some(Extent::Known(psb.len())));+    }++    #[test]+    fn rejects_garbage() {+        assert_eq!(find_thumbnail_jpeg(b""), None);+        assert_eq!(find_thumbnail_jpeg(b"8BPS"), None);+        assert_eq!(find_thumbnail_jpeg(b"not a psd at all, just text"), None);+        assert_eq!(find_thumbnail_jpeg(&sample(b"not a jpeg")), None);+    }++    #[test]+    fn rejects_jpegs_that_declare_an_unreasonable_size() {+        for (w, h) in [(0, 90), (160, 0), (2049, 90), (160, 2049), (65535, 65535)] {+            assert_eq!(find_thumbnail_jpeg(&sample(&jpeg(w, h))), None, "{w}x{h}");+        }+        assert!(find_thumbnail_jpeg(&sample(&jpeg(2048, 2048))).is_some());+        // Headers that never reach a frame, or whose lengths lie.+        assert_eq!(find_thumbnail_jpeg(&sample(&[0xFF, 0xD8, 0xFF, 0xD9])), None);+        assert_eq!(find_thumbnail_jpeg(&sample(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2])), None);+        assert_eq!(find_thumbnail_jpeg(&sample(&[0xFF, 0xD8, 0xFF, 0xE0, 0xFF, 0xFF, 1, 2])), None);+        assert_eq!(find_thumbnail_jpeg(&sample(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 1])), None);+    }++    #[test]+    fn truncation_never_panics_and_never_succeeds_early() {+        let jpeg = jpeg(160, 90);+        let psd = sample(&jpeg);+        // The last byte is padding, so the thumbnail is complete only up to len - 1.+        for n in 0..psd.len() - 1 {+            assert_eq!(find_thumbnail_jpeg(&psd[..n]), None, "prefix of {n} bytes");+        }+    }++    #[test]+    fn corrupted_files_never_panic() {+        let psd = sample(&jpeg(160, 90));+        // A small xorshift generator keeps the test deterministic without a dependency.+        let mut state = 0x2545_F491_4F6C_DD1Du64;+        let mut next = move || {+            state ^= state << 13;+            state ^= state >> 7;+            state ^= state << 17;+            state+        };+        for _ in 0..20_000 {+            let mut copy = psd.clone();+            for _ in 0..=(next() % 4) {+                let at = (next() as usize) % copy.len();+                copy[at] = next() as u8;+            }+            let _ = find_thumbnail_jpeg(&copy);+            let _ = resources_extent(&copy);+        }+    }++    #[test]+    fn extent_of_the_image_resources() {+        let psd = sample(&jpeg(160, 90));+        assert_eq!(resources_extent(&psd), Some(Extent::Known(psd.len())));+        // Enough of the start to read the lengths, then not enough, then nothing.+        assert_eq!(resources_extent(&psd[..FIXED_LEN + 4]), Some(Extent::Known(psd.len())));+        assert_eq!(resources_extent(&psd[..FIXED_LEN]), Some(Extent::NeedAtLeast(FIXED_LEN + 4)));+        assert_eq!(resources_extent(&psd[..10]), Some(Extent::NeedAtLeast(FIXED_LEN)));+        assert_eq!(resources_extent(b""), Some(Extent::NeedAtLeast(FIXED_LEN)));+        assert_eq!(resources_extent(b"nope"), None);+        assert_eq!(resources_extent(&[b'x'; 64]), None);+        // Color mode data in front of the resources moves where their length is.+        let mut with_color_mode = psd[..26].to_vec();+        with_color_mode.extend_from_slice(&768u32.to_be_bytes());+        assert_eq!(resources_extent(&with_color_mode), Some(Extent::NeedAtLeast(26 + 4 + 768 + 4)));+        // Lengths this large are only asked for, never allocated here (the reader caps them).+        let mut huge = psd[..FIXED_LEN].to_vec();+        huge[26..30].copy_from_slice(&u32::MAX.to_be_bytes());+        assert!(matches!(resources_extent(&huge), None | Some(Extent::NeedAtLeast(_))));+    }+}diff --git a/crates/shell/src/registration.rs b/crates/shell/src/registration.rsnew file mode 100644--- /dev/null+++ b/crates/shell/src/registration.rs@@ -0,0 +1,387 @@+//! Per-user registry registration and the public on/off switch.+//!+//! Everything is written under `HKEY_CURRENT_USER\Software\Classes`, the per-user view of+//! `HKEY_CLASSES_ROOT`, so no elevation is needed.+#![allow(unsafe_code)]++use std::ffi::OsString;+use std::os::windows::ffi::{OsStrExt, OsStringExt};+use std::path::{Path, PathBuf};+use std::ptr::{null, null_mut};++use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, ERROR_SUCCESS};+use windows_sys::Win32::System::LibraryLoader::{+    GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,+};+use windows_sys::Win32::System::Registry::{+    HKEY, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RRF_RT_REG_SZ, RegCloseKey, RegCreateKeyExW, RegDeleteKeyValueW,+    RegDeleteTreeW, RegGetValueW, RegSetValueExW,+};+use windows_sys::Win32::UI::Shell::{ASSOCSTR_PROGID, AssocQueryStringW, SHCNE_ASSOCCHANGED, SHCNF_IDLIST, SHChangeNotify};++/// CLSID of the PhotoCraft PSD thumbnail provider.+pub const CLSID_STR: &str = "{8D6B2D5C-3F41-4B7A-9C0E-5A17E2B94D63}";++/// CLSID registry values are case-insensitive, even when returned as strings.+fn is_our_clsid(value: &str) -> bool {+    value.eq_ignore_ascii_case(CLSID_STR)+}+const EXTENSIONS: [&str; 3] = [".psd", ".psb", ".pcraft"];+const THUMBNAIL_IID: &str = "{E357FCCD-A995-4576-B01F-234630154E96}";+fn handler_key(ext: &str) -> String {+    format!(r"{ext}\ShellEx\{THUMBNAIL_IID}")+}+fn user_handler_key(ext: &str) -> String {+    format!(r"Software\Classes\{}", handler_key(ext))+}+// Keep the original PSD backup location, including when upgrading the PSD-only provider.+fn state_key(ext: &str) -> String {+    if ext == ".psd" { class_key() } else { format!(r"{}\Formats\{ext}", class_key()) }+}+const DLL_NAME: &str = "photocraft_shell.dll";+const FRIENDLY_NAME: &str = "PhotoCraft Document Thumbnail Provider";+/// Windows draws the file type's icon as a small badge on thumbnails unless the file type has an+/// empty `TypeOverlay` value.+const OVERLAY_VALUE: &str = "TypeOverlay";+/// Value of our class key that remembers which key we put `TypeOverlay` on, so turning the badge+/// back on removes exactly that and nothing else.+const OVERLAY_KEY_VALUE: &str = "TypeOverlayKey";+/// The value of `TypeOverlay` before PhotoCraft hid the badge, if one existed.+const PREVIOUS_OVERLAY_VALUE: &str = "PreviousTypeOverlay";+/// Whether a `TypeOverlay` value existed before PhotoCraft hid the badge.+const HAD_PREVIOUS_OVERLAY_VALUE: &str = "HadPreviousTypeOverlay";+/// Value of our class key that remembers the `.psd` thumbnail handler this user had before, which+/// turning the thumbnails off puts back.+const PREVIOUS_HANDLER_VALUE: &str = "PreviousHandler";++/// Turns PSD thumbnails in Windows Explorer on or off for the current user, with or without the+/// file type's logo badge on them. This is the explicit switch: enabling takes `.psd` over from+/// any other program's thumbnail handler.+///+/// Enabling expects `photocraft_shell.dll` next to the running executable. Disabling removes only+/// registrations made by PhotoCraft and leaves other programs' handlers alone.+pub fn enable_explorer_thumbnails(enable: bool, show_logo: bool) -> Result<(), String> {+    if enable {+        let dll = sibling_dll()?;+        register(&dll, show_logo)+    } else {+        unregister()+    }+}++/// The default-on behaviour at startup: registers the thumbnails unless another program already+/// provides the `.psd` thumbnails (Photoshop, for one), which is only replaced by an explicit+/// [`enable_explorer_thumbnails`]. A registration pointing at a moved copy of the DLL is repaired.+pub fn ensure_explorer_thumbnails(show_logo: bool) -> Result<(), String> {+    let dll = sibling_dll()?;+    for ext in EXTENSIONS {+        match current_handler(ext) {+            Some(other) if !is_our_clsid(&other) => {}+            _ if is_registered_to(&dll, ext) && overlay_ok(show_logo, ext) => {}+            _ => register_extension(&dll, show_logo, ext)?,+        }+    }+    Ok(())+}++/// A handler may be registered on the extension or on its associated ProgID. Automatic+/// registration must preserve both; explicit takeover only writes our per-user extension key.+fn current_handler(ext: &str) -> Option<String> {+    read_default(HKEY_CLASSES_ROOT, &handler_key(ext)).or_else(|| prog_id(ext).and_then(|id| read_default(HKEY_CLASSES_ROOT, &handler_key(&id))))+}++/// `photocraft_shell.dll` in the folder of the running executable.+fn sibling_dll() -> Result<PathBuf, String> {+    let exe = std::env::current_exe().map_err(|e| format!("cannot locate the executable: {e}"))?;+    exe.parent().map(|dir| dir.join(DLL_NAME)).filter(|path| path.is_file()).ok_or_else(|| format!("{DLL_NAME} not found next to the executable"))+}++/// The registry path of this provider's COM class.+fn class_key() -> String {+    format!(r"Software\Classes\CLSID\{CLSID_STR}")+}++/// Is `.psd` already registered to this class, served from `dll`?+fn is_registered_to(dll: &Path, ext: &str) -> bool {+    read_default(HKEY_CURRENT_USER, &user_handler_key(ext)).as_deref().is_some_and(is_our_clsid)+        && read_default(HKEY_CURRENT_USER, &format!(r"{}\InprocServer32", class_key())).as_deref() == Some(&*dll.to_string_lossy())+}++/// Registers the COM class and associates it with `.psd`.+pub(crate) fn register(dll: &Path, show_logo: bool) -> Result<(), String> {+    for ext in EXTENSIONS {+        if !is_registered_to(dll, ext) || !overlay_ok(show_logo, ext) {+            register_extension(dll, show_logo, ext)?;+        }+    }+    Ok(())+}++fn register_extension(dll: &Path, show_logo: bool, ext: &str) -> Result<(), String> {+    let class_key = class_key();+    set_value(&class_key, None, FRIENDLY_NAME)?;+    let inproc = format!(r"{class_key}\InprocServer32");+    set_value(&inproc, None, &dll.to_string_lossy())?;+    set_value(&inproc, Some("ThreadingModel"), "Apartment")?;+    let class_key = state_key(ext);+    // Taking `.psd` over from a handler registered for this user: keep it, to give it back. When+    // there is none now (another program removed it), forget the one saved earlier.+    match read_default(HKEY_CURRENT_USER, &user_handler_key(ext)) {+        Some(handler) if is_our_clsid(&handler) => {}+        Some(previous) => set_value(&class_key, Some(PREVIOUS_HANDLER_VALUE), &previous)?,+        None => delete_value(&class_key, PREVIOUS_HANDLER_VALUE)?,+    }+    set_value(&user_handler_key(ext), None, CLSID_STR)?;+    apply_overlay(show_logo, ext)?;+    notify_shell();+    Ok(())+}++/// Removes the `.psd` association (only when it is ours), the badge setting and the COM class.+///+/// The association comes first: it is what makes Explorer load the provider, so a failure to put the+/// cosmetic badge setting back must not leave the provider enabled. When only the badge restore+/// fails, the class key (which remembers what to restore) is kept and the error is returned, so a+/// later call can finish the job.+pub(crate) fn unregister() -> Result<(), String> {+    for ext in EXTENSIONS {+        unregister_extension(ext)?;+    }+    delete_tree(&class_key())+}++fn unregister_extension(ext: &str) -> Result<(), String> {+    let class_key = state_key(ext);+    let ours = read_default(HKEY_CURRENT_USER, &user_handler_key(ext)).as_deref().is_some_and(is_our_clsid);+    let overlay_key = read_string(HKEY_CURRENT_USER, &class_key, Some(OVERLAY_KEY_VALUE));+    let registered = ours || overlay_key.is_some() || read_default(HKEY_CURRENT_USER, &format!(r"{class_key}\InprocServer32")).is_some();+    if ours {+        let removed = match read_string(HKEY_CURRENT_USER, &class_key, Some(PREVIOUS_HANDLER_VALUE)) {+            Some(previous) => set_value(&user_handler_key(ext), None, &previous),+            None => delete_tree(&user_handler_key(ext)),+        };+        removed?;+    }+    let overlay = match &overlay_key {+        Some(key) => restore_overlay(key, &class_key),+        None => Ok(()),+    };+    let result = overlay.and_then(|()| delete_value(&class_key, OVERLAY_KEY_VALUE));+    // Refreshing every icon is disruptive: only when something was actually removed.+    if registered {+        notify_shell();+    }+    result+}++/// The key that holds the badge setting: the file type (ProgID) Windows resolves for `.psd`, whose+/// icon is the badge, or the `.psd` key itself when there is none.+fn overlay_target(ext: &str) -> String {+    match prog_id(ext) {+        Some(id) => format!(r"Software\Classes\{id}"),+        None => format!(r"Software\Classes\{ext}"),+    }+}++/// The file type (ProgID) Windows resolves for `.psd`, if any.+fn prog_id(extension: &str) -> Option<String> {+    let ext = wide(extension);+    let mut buf = [0u16; 256];+    let mut len = buf.len() as u32;+    // SAFETY: `ext` is NUL-terminated and `len` is the exact capacity of `buf` in characters.+    let hr = unsafe { AssocQueryStringW(0, ASSOCSTR_PROGID, ext.as_ptr(), null(), buf.as_mut_ptr(), &mut len) };+    if hr != 0 {+        return None;+    }+    let text = buf.get(..(len as usize).min(buf.len()))?;+    let end = text.iter().position(|&c| c == 0).unwrap_or(text.len());+    String::from_utf16(text.get(..end)?).ok().filter(|id| !id.is_empty())+}++/// Hides the file type's logo badge on `.psd` thumbnails, or shows it again.+fn apply_overlay(show_logo: bool, ext: &str) -> Result<(), String> {+    let class_key = state_key(ext);+    let stored = read_string(HKEY_CURRENT_USER, &class_key, Some(OVERLAY_KEY_VALUE));+    if show_logo {+        if let Some(key) = stored {+            restore_overlay(&key, &class_key)?;+            delete_value(&class_key, OVERLAY_KEY_VALUE)?;+        }+        return Ok(());+    }+    let target = overlay_target(ext);+    if let Some(old) = stored.filter(|old| *old != target) {+        restore_overlay(&old, &class_key)?;+    }+    // Save what is there unless it is our own empty value; another program may have changed it+    // since we last hid the badge, and that is what turning this off must put back.+    let current = read_string(HKEY_CURRENT_USER, &target, Some(OVERLAY_VALUE));+    if read_string(HKEY_CURRENT_USER, &class_key, Some(OVERLAY_KEY_VALUE)).as_deref() != Some(&*target) || current.as_deref() != Some("") {+        match current {+            Some(previous) => {+                set_value(&class_key, Some(PREVIOUS_OVERLAY_VALUE), &previous)?;+                set_value(&class_key, Some(HAD_PREVIOUS_OVERLAY_VALUE), "1")?;+            }+            None => {+                delete_value(&class_key, PREVIOUS_OVERLAY_VALUE)?;+                set_value(&class_key, Some(HAD_PREVIOUS_OVERLAY_VALUE), "0")?;+            }+        }+    }+    // Record the key before changing it, so a failure in between never leaves an empty value+    // that nothing remembers to undo.+    set_value(&class_key, Some(OVERLAY_KEY_VALUE), &target)?;+    set_value(&target, Some(OVERLAY_VALUE), "")+}++/// Restores the previous `TypeOverlay` value, removing only the empty value PhotoCraft applied.+fn restore_overlay(key: &str, class_key: &str) -> Result<(), String> {+    if read_string(HKEY_CURRENT_USER, key, Some(OVERLAY_VALUE)).as_deref() == Some("") {+        match read_string(HKEY_CURRENT_USER, class_key, Some(HAD_PREVIOUS_OVERLAY_VALUE)).as_deref() {+            Some("1") => {+                if let Some(previous) = read_string(HKEY_CURRENT_USER, class_key, Some(PREVIOUS_OVERLAY_VALUE)) {+                    set_value(key, Some(OVERLAY_VALUE), &previous)?;+                }+            }+            Some("0") | None => delete_value(key, OVERLAY_VALUE)?,+            Some(_) => {}+        }+    }+    delete_value(class_key, PREVIOUS_OVERLAY_VALUE)?;+    delete_value(class_key, HAD_PREVIOUS_OVERLAY_VALUE)?;+    Ok(())+}++/// Does the badge setting in the registry match `show_logo`?+fn overlay_ok(show_logo: bool, ext: &str) -> bool {+    let stored = read_string(HKEY_CURRENT_USER, &state_key(ext), Some(OVERLAY_KEY_VALUE));+    if show_logo {+        return stored.is_none();+    }+    let target = overlay_target(ext);+    stored.as_deref() == Some(&*target) && read_string(HKEY_CURRENT_USER, &target, Some(OVERLAY_VALUE)).as_deref() == Some("")+}++/// Anchor whose address lies inside this DLL, used to look the module up.+static MODULE_ANCHOR: u8 = 0;++/// Path of the DLL containing this code (used by `DllRegisterServer`).+pub(crate) fn current_module_path() -> Option<PathBuf> {+    let mut module = null_mut();+    let mut buf = [0u16; 1024];+    let flags = GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT;+    // SAFETY: the address belongs to this module and `buf`'s length is passed along.+    let len = unsafe {+        if GetModuleHandleExW(flags, std::ptr::addr_of!(MODULE_ANCHOR).cast(), &mut module) == 0 {+            return None;+        }+        GetModuleFileNameW(module, buf.as_mut_ptr(), buf.len() as u32) as usize+    };+    if len == 0 || len >= buf.len() {+        return None;+    }+    Some(PathBuf::from(OsString::from_wide(buf.get(..len)?)))+}++/// `s` as a NUL-terminated UTF-16 string for Win32 calls.+fn wide(s: &str) -> Vec<u16> {+    std::ffi::OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()+}++/// `Ok` for `ERROR_SUCCESS`, otherwise an error naming the failed call and its Win32 code.+fn check(code: u32, what: &str) -> Result<(), String> {+    if code == ERROR_SUCCESS { Ok(()) } else { Err(format!("{what} failed (Win32 error {code})")) }+}++/// Creates `path` under HKCU if needed and sets its value `name` (the default value for `None`).+fn set_value(path: &str, name: Option<&str>, value: &str) -> Result<(), String> {+    let path_w = wide(path);+    let name_w = name.map(wide);+    let value_w = wide(value);+    let bytes = u32::try_from(value_w.len() * 2).map_err(|_| "registry value too long".to_owned())?;+    let name_ptr = name_w.as_ref().map_or(null(), |n| n.as_ptr());+    let mut key: HKEY = null_mut();+    // SAFETY: every pointer references a live, NUL-terminated buffer owned by this function, and+    // the key is closed on every path after it was opened.+    unsafe {+        check(+            RegCreateKeyExW(HKEY_CURRENT_USER, path_w.as_ptr(), 0, null(), REG_OPTION_NON_VOLATILE, KEY_WRITE, null(), &mut key, null_mut()),+            "RegCreateKeyExW",+        )?;+        let rc = RegSetValueExW(key, name_ptr, 0, REG_SZ, value_w.as_ptr().cast(), bytes);+        RegCloseKey(key);+        check(rc, "RegSetValueExW")+    }+}++/// Reads a key's default string value under `root`; `None` when missing or not a string.+fn read_default(root: HKEY, path: &str) -> Option<String> {+    read_string(root, path, None)+}++/// Reads the string value `name` (the default value for `None`) of `path` under `root`; `None`+/// when missing or not a string.+fn read_string(root: HKEY, path: &str, name: Option<&str>) -> Option<String> {+    let path_w = wide(path);+    let name_w = name.map(wide);+    let name_ptr = name_w.as_ref().map_or(null(), |n| n.as_ptr());+    // MAX_PATH-sized paths of the DLL fit with room to spare.+    let mut buf = [0u16; 1024];+    let mut size = std::mem::size_of_val(&buf) as u32;+    // SAFETY: `size` is the exact byte capacity of `buf`; the name pointers are NUL-terminated.+    let rc = unsafe { RegGetValueW(root, path_w.as_ptr(), name_ptr, RRF_RT_REG_SZ, null_mut(), buf.as_mut_ptr().cast(), &mut size) };+    if rc != ERROR_SUCCESS {+        return None;+    }+    let text = buf.get(..(size as usize / 2).min(buf.len()))?;+    let end = text.iter().position(|&c| c == 0).unwrap_or(text.len());+    String::from_utf16(text.get(..end)?).ok()+}++/// Deletes one value of an HKCU key; a value that is already gone is fine.+fn delete_value(path: &str, name: &str) -> Result<(), String> {+    let (path_w, name_w) = (wide(path), wide(name));+    // SAFETY: both strings are NUL-terminated and outlive the call.+    let rc = unsafe { RegDeleteKeyValueW(HKEY_CURRENT_USER, path_w.as_ptr(), name_w.as_ptr()) };+    if matches!(rc, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { Ok(()) } else { check(rc, "RegDeleteKeyValueW") }+}++/// Deletes an HKCU key and everything under it; a key that is already gone is fine.+fn delete_tree(path: &str) -> Result<(), String> {+    let path_w = wide(path);+    // SAFETY: `path_w` is NUL-terminated and outlives the call.+    let rc = unsafe { RegDeleteTreeW(HKEY_CURRENT_USER, path_w.as_ptr()) };+    if matches!(rc, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) { Ok(()) } else { check(rc, "RegDeleteTreeW") }+}++/// Tells Explorer that file associations changed, so it refreshes its thumbnails and icons.+fn notify_shell() {+    // SAFETY: null item pointers are valid for `SHCNE_ASSOCCHANGED`.+    unsafe { SHChangeNotify(SHCNE_ASSOCCHANGED as i32, SHCNF_IDLIST, null(), null()) };+}++#[cfg(test)]+mod tests {+    use super::{CLSID_STR, is_our_clsid};++    #[test]+    fn every_extension_has_its_own_handler_and_backup() {+        use super::{EXTENSIONS, handler_key, state_key, user_handler_key};+        let mut handlers = std::collections::HashSet::new();+        let mut backups = std::collections::HashSet::new();+        for ext in EXTENSIONS {+            assert!(handlers.insert(handler_key(ext)));+            assert!(backups.insert(state_key(ext)));+            assert!(user_handler_key(ext).starts_with(r"Software\Classes\"));+        }+        assert_eq!(handlers.len(), 3);+        assert_eq!(state_key(".psd"), super::class_key(), "retain PSD-only version's backup");+    }++    #[test]+    fn recognizes_case_variants_of_our_clsid() {+        assert!(is_our_clsid(CLSID_STR));+        assert!(is_our_clsid(&CLSID_STR.to_ascii_lowercase()));+        assert!(!is_our_clsid("{11111111-2222-3333-4444-555555555555}"));+    }+}diff --git a/crates/shell/src/windows_thumbnail.rs b/crates/shell/src/windows_thumbnail.rsnew file mode 100644--- /dev/null+++ b/crates/shell/src/windows_thumbnail.rs@@ -0,0 +1,524 @@+//! COM implementation of `IThumbnailProvider` and `IInitializeWithStream`.+//!+//! Safety policy: nothing here may take down `explorer.exe` (or the `dllhost.exe` surrogate that+//! hosts thumbnail handlers). There is no `unwrap`, `expect` or `panic!`, every COM entry point+//! runs inside [`guard`], and every failure is reported as `E_FAIL`, so Explorer falls back to the+//! default icon.+//!+//! # Safety contract+//!+//! Everything here is `unsafe` because a COM server is, by definition, raw FFI: Windows calls into+//! this DLL through function pointers and hands it raw pointers. The code relies on four+//! invariants, which the functions below refer to by number:+//!+//! 1. **Layout.** [`Provider`] and `Factory` are `#[repr(C)]` with their vtable pointer(s) first,+//!    so a COM interface pointer is a pointer into the object, and `from_thumb` recovers the+//!    object from the second interface with `offset_of!`. The vtables are `static`, so they never+//!    dangle.+//! 2. **Lifetime.** An object is freed only when its reference count reaches zero, by the single+//!    `Box::from_raw` in `provider_release` / `factory_release`. `LIVE_COUNT` mirrors live objects+//!    so `DllCanUnloadNow` never lets the DLL unload under a live object.+//! 3. **Caller pointers.** Every pointer that Windows passes in (`this`, `riid`, `ppv`, `phbmp`,+//!    `stream`, ...) is checked for null before use, and every output pointer is set to null first,+//!    so a failing call never leaves the caller with a stale value. Windows guarantees that+//!    non-null interface pointers are valid for the duration of the call.+//! 4. **Resources.** Each GDI+ object, GDI `HBITMAP`, and COM stream acquired in a function is+//!    released on every path out of it; the one `HBITMAP` that is returned is owned by Explorer.+//!+//! The PSD parsing ([`crate::psd`]) is safe code and is the only code that looks at untrusted bytes.+#![allow(unsafe_code, non_snake_case, unsafe_op_in_unsafe_fn)]++use std::ffi::c_void;+use std::mem::{offset_of, zeroed};+use std::panic::{AssertUnwindSafe, catch_unwind};+use std::ptr::{addr_of_mut, null, null_mut};+use std::sync::Mutex;+use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};++use windows_sys::Win32::Foundation::{+    CLASS_E_CLASSNOTAVAILABLE, CLASS_E_NOAGGREGATION, E_FAIL, E_NOINTERFACE, E_OUTOFMEMORY, E_POINTER, E_UNEXPECTED, S_FALSE, S_OK,+};+use windows_sys::Win32::Graphics::Gdi::{DeleteObject, HBITMAP};+use windows_sys::Win32::Graphics::GdiPlus::{+    GdipCreateBitmapFromScan0, GdipCreateBitmapFromStream, GdipCreateHBITMAPFromBitmap, GdipDeleteGraphics, GdipDisposeImage, GdipDrawImageRectI,+    GdipGetImageGraphicsContext, GdipGetImageHeight, GdipGetImageWidth, GdipSetInterpolationMode, GdipSetPixelOffsetMode, GdiplusShutdown, GdiplusStartup,+    GdiplusStartupInput, GpBitmap, InterpolationModeHighQualityBicubic, PixelOffsetModeHalf,+};+use windows_sys::Win32::UI::Shell::SHCreateMemStream;+use windows_sys::core::{GUID, HRESULT};++use crate::psd;+use crate::registration::{self};++/// CLSID of the PhotoCraft PSD thumbnail provider (same value as `registration::CLSID_STR`).+const CLSID: GUID = GUID::from_u128(0x8D6B2D5C_3F41_4B7A_9C0E_5A17E2B94D63);++/// Never read more than this from the start of a file: its image resources have to fit.+const MAX_PREFIX_BYTES: usize = 16 * 1024 * 1024;+/// The first read, enough for the header of nearly every file and often the whole resources.+const FIRST_READ_BYTES: usize = 4096;+/// `HRESULT_FROM_WIN32(ERROR_ALREADY_INITIALIZED)`, as `IInitializeWithStream` specifies.+const E_ALREADY_INITIALIZED: HRESULT = 0x800700B7u32 as HRESULT;+/// How much larger than the embedded preview an enlargement may be; beyond this it only blurs.+const MAX_ENLARGE: u32 = 4;+/// Most pixels an enlarged bitmap may have (16 MiB at 32 bpp), whatever size Explorer asks for.+const MAX_ENLARGED_PIXELS: u64 = 2048 * 2048;+/// GDI+ `PixelFormat32bppPARGB`.+const PIXEL_FORMAT_32BPP_PARGB: i32 = 0x000E_200B;+/// `WTS_ALPHATYPE`: the bitmap has no meaningful alpha channel.+const WTSAT_RGB: i32 = 1;++const IID_IUNKNOWN: GUID = GUID::from_u128(0x00000000_0000_0000_C000_000000000046);+const IID_ICLASSFACTORY: GUID = GUID::from_u128(0x00000001_0000_0000_C000_000000000046);+const IID_IINITIALIZE_WITH_STREAM: GUID = GUID::from_u128(0xB824B49D_22AC_4161_AC8A_9916E8FA3F7F);+const IID_ITHUMBNAIL_PROVIDER: GUID = GUID::from_u128(0xE357FCCD_A995_4576_B01F_234630154E96);++/// Live objects plus server locks; backs `DllCanUnloadNow`.+static LIVE_COUNT: AtomicI32 = AtomicI32::new(0);++/// Compares two GUIDs field by field.+fn guid_eq(a: &GUID, b: &GUID) -> bool {+    a.data1 == b.data1 && a.data2 == b.data2 && a.data3 == b.data3 && a.data4 == b.data4+}++/// Runs `f`, turning a panic into `E_FAIL` instead of unwinding into COM callers.+fn guard(f: impl FnOnce() -> HRESULT) -> HRESULT {+    catch_unwind(AssertUnwindSafe(f)).unwrap_or(E_FAIL)+}++// ---------------------------------------------------------------- vtables++#[repr(C)]+struct IUnknownVtbl {+    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,+}++/// Only the `Read` slot of `IStream` (inherited from `ISequentialStream`) is needed.+#[repr(C)]+struct IStreamVtbl {+    base: IUnknownVtbl,+    read: unsafe extern "system" fn(*mut c_void, *mut c_void, u32, *mut u32) -> HRESULT,+}++#[repr(C)]+struct InitializeWithStreamVtbl {+    base: IUnknownVtbl,+    initialize: unsafe extern "system" fn(*mut c_void, *mut c_void, u32) -> HRESULT,+}++#[repr(C)]+struct ThumbnailProviderVtbl {+    base: IUnknownVtbl,+    get_thumbnail: unsafe extern "system" fn(*mut c_void, u32, *mut HBITMAP, *mut i32) -> HRESULT,+}++#[repr(C)]+struct ClassFactoryVtbl {+    base: IUnknownVtbl,+    create_instance: unsafe extern "system" fn(*mut c_void, *mut c_void, *const GUID, *mut *mut c_void) -> HRESULT,+    lock_server: unsafe extern "system" fn(*mut c_void, i32) -> HRESULT,+}++static INIT_VTBL: InitializeWithStreamVtbl =+    InitializeWithStreamVtbl { base: IUnknownVtbl { query_interface: init_query_interface, add_ref: init_add_ref, release: init_release }, initialize };++static THUMB_VTBL: ThumbnailProviderVtbl =+    ThumbnailProviderVtbl { base: IUnknownVtbl { query_interface: thumb_query_interface, add_ref: thumb_add_ref, release: thumb_release }, get_thumbnail };++static FACTORY_VTBL: ClassFactoryVtbl = ClassFactoryVtbl {+    base: IUnknownVtbl { query_interface: factory_query_interface, add_ref: factory_add_ref, release: factory_release },+    create_instance: factory_create_instance,+    lock_server: factory_lock_server,+};++// --------------------------------------------------------------- provider++/// One COM object exposing two interfaces, each with its own vtable pointer.+#[repr(C)]+struct Provider {+    init_vtbl: *const InitializeWithStreamVtbl,+    thumb_vtbl: *const ThumbnailProviderVtbl,+    refs: AtomicU32,+    /// Start of the PSD file, filled by `Initialize`.+    data: Mutex<Option<Vec<u8>>>,+}++impl Provider {+    /// A new provider with a reference count of one.+    fn create() -> *mut Provider {+        LIVE_COUNT.fetch_add(1, Ordering::SeqCst);+        Box::into_raw(Box::new(Provider { init_vtbl: &INIT_VTBL, thumb_vtbl: &THUMB_VTBL, refs: AtomicU32::new(1), data: Mutex::new(None) }))+    }+}++/// The object behind an `IThumbnailProvider` interface pointer.+unsafe fn from_thumb(this: *mut c_void) -> *mut Provider {+    // SAFETY (invariant 1): `this` points at the `thumb_vtbl` field of a `Provider`.+    (this as *mut u8).sub(offset_of!(Provider, thumb_vtbl)) as *mut Provider+}++/// Shared `QueryInterface`: hands out the `IUnknown`, `IInitializeWithStream` or `IThumbnailProvider` vtable.+unsafe fn provider_query(p: *mut Provider, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    if ppv.is_null() {+        return E_POINTER;+    }+    *ppv = null_mut();+    if p.is_null() || riid.is_null() {+        return E_POINTER;+    }+    let riid = &*riid;+    if guid_eq(riid, &IID_IUNKNOWN) || guid_eq(riid, &IID_IINITIALIZE_WITH_STREAM) {+        *ppv = addr_of_mut!((*p).init_vtbl).cast();+    } else if guid_eq(riid, &IID_ITHUMBNAIL_PROVIDER) {+        *ppv = addr_of_mut!((*p).thumb_vtbl).cast();+    } else {+        return E_NOINTERFACE;+    }+    (*p).refs.fetch_add(1, Ordering::SeqCst);+    S_OK+}++/// Increments the provider's reference count and returns the new count.+unsafe fn provider_add_ref(p: *mut Provider) -> u32 {+    (*p).refs.fetch_add(1, Ordering::SeqCst) + 1+}++/// Decrements the reference count and frees the provider when it reaches zero.+unsafe fn provider_release(p: *mut Provider) -> u32 {+    let remaining = (*p).refs.fetch_sub(1, Ordering::SeqCst) - 1;+    if remaining == 0 {+        // SAFETY (invariant 2): the last reference is gone, so nothing else uses `p`; it came+        // from `Box::into_raw` in `Provider::create`.+        drop(Box::from_raw(p));+        LIVE_COUNT.fetch_sub(1, Ordering::SeqCst);+    }+    remaining+}++/// `IInitializeWithStream::QueryInterface`.+unsafe extern "system" fn init_query_interface(this: *mut c_void, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    guard(|| provider_query(this as *mut Provider, riid, ppv))+}+/// `IInitializeWithStream::AddRef`.+unsafe extern "system" fn init_add_ref(this: *mut c_void) -> u32 {+    provider_add_ref(this as *mut Provider)+}+/// `IInitializeWithStream::Release`.+unsafe extern "system" fn init_release(this: *mut c_void) -> u32 {+    provider_release(this as *mut Provider)+}+/// `IThumbnailProvider::QueryInterface`.+unsafe extern "system" fn thumb_query_interface(this: *mut c_void, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    guard(|| provider_query(from_thumb(this), riid, ppv))+}+/// `IThumbnailProvider::AddRef`.+unsafe extern "system" fn thumb_add_ref(this: *mut c_void) -> u32 {+    provider_add_ref(from_thumb(this))+}+/// `IThumbnailProvider::Release`.+unsafe extern "system" fn thumb_release(this: *mut c_void) -> u32 {+    provider_release(from_thumb(this))+}++/// `IInitializeWithStream::Initialize`: buffers the start of the file.+unsafe extern "system" fn initialize(this: *mut c_void, stream: *mut c_void, _mode: u32) -> HRESULT {+    guard(|| {+        let provider = this as *mut Provider;+        if provider.is_null() || stream.is_null() {+            return E_POINTER;+        }+        // A repeated call is refused before any I/O, as `IInitializeWithStream` specifies.+        match (*provider).data.lock() {+            Ok(slot) if slot.is_some() => return E_ALREADY_INITIALIZED,+            Ok(_) => {}+            Err(_) => return E_FAIL,+        }+        let Some(bytes) = read_prefix(stream) else {+            return E_FAIL;+        };+        match (*provider).data.lock() {+            Ok(mut slot) if slot.is_none() => {+                *slot = Some(bytes);+                S_OK+            }+            Ok(_) => E_ALREADY_INITIALIZED,+            Err(_) => E_FAIL,+        }+    })+}++/// Reads the start of `stream` that holds the PSD's image resources: only as much as the file's own+/// header says they occupy (never more than [`MAX_PREFIX_BYTES`]), so a multi-gigabyte document is+/// not read through to show a 160 px preview. `None` when it is not a PSD/PSB.+unsafe fn read_prefix(stream: *mut c_void) -> Option<Vec<u8>> {+    // SAFETY (invariant 3): `stream` is a non-null `IStream` handed over by Explorer, whose first+    // field is its vtable pointer; `Read` is called with a buffer at least `step` bytes long.+    let vtbl = *(stream as *mut *const IStreamVtbl);+    if vtbl.is_null() {+        return None;+    }+    let mut out = Vec::new();+    let mut chunk = vec![0u8; 64 * 1024];+    let mut want = FIRST_READ_BYTES;+    while out.len() < want {+        let step = (want - out.len()).min(chunk.len());+        let mut read = 0u32;+        let hr = ((*vtbl).read)(stream, chunk.as_mut_ptr().cast(), step as u32, &mut read);+        if hr < 0 {+            return None;+        }+        if read == 0 {+            break; // End of stream.+        }+        out.extend_from_slice(chunk.get(..(read as usize).min(step))?);+        if out.len() >= want {+            // Enough to see the header: now the exact amount the image resources need.+            let (psd::Extent::Known(end) | psd::Extent::NeedAtLeast(end)) =+                if out.starts_with(b"PK\x03\x04") { crate::pcraft::thumbnail_extent(&out)? } else { psd::resources_extent(&out)? };+            if end > MAX_PREFIX_BYTES {+                return None;+            }+            want = end;+        }+    }+    Some(out)+}++/// `IThumbnailProvider::GetThumbnail`: the embedded JFIF as an `HBITMAP` that Explorer owns.+unsafe extern "system" fn get_thumbnail(this: *mut c_void, cx: u32, phbmp: *mut HBITMAP, pdw_alpha: *mut i32) -> HRESULT {+    guard(|| {+        if this.is_null() || phbmp.is_null() || pdw_alpha.is_null() {+            return E_POINTER;+        }+        *phbmp = null_mut();+        let provider = &*from_thumb(this);+        let Ok(data) = provider.data.lock() else {+            return E_FAIL;+        };+        let Some(data) = data.as_deref() else {+            return E_UNEXPECTED; // `Initialize` was not called.+        };+        match psd::find_thumbnail_jpeg(data).or_else(|| crate::pcraft::find_thumbnail_png(data)).and_then(|image| jpeg_to_hbitmap(image, cx)) {+            Some(hbmp) => {+                *phbmp = hbmp;+                *pdw_alpha = WTSAT_RGB;+                S_OK+            }+            None => E_FAIL,+        }+    })+}++/// Redraws `bitmap` with its longest side at `cx` pixels (at most [`MAX_ENLARGE`] times the+/// original) using bicubic interpolation, within [`MAX_ENLARGED_PIXELS`]. Returns null when no enlargement is needed or possible,+/// in which case the caller uses the original; a non-null result is owned by the caller.+unsafe fn enlarge(bitmap: *mut GpBitmap, cx: u32) -> *mut GpBitmap {+    let (mut width, mut height) = (0u32, 0u32);+    if GdipGetImageWidth(bitmap.cast(), &mut width) != 0 || GdipGetImageHeight(bitmap.cast(), &mut height) != 0 {+        return null_mut();+    }+    let longest = width.max(height);+    let target = cx.min(longest.saturating_mul(MAX_ENLARGE));+    if longest == 0 || target <= longest {+        return null_mut();+    }+    let scale = |side: u32| ((u64::from(side) * u64::from(target)) / u64::from(longest)).max(1);+    if scale(width) * scale(height) > MAX_ENLARGED_PIXELS {+        return null_mut();+    }+    let (Ok(new_width), Ok(new_height)) = (i32::try_from(scale(width)), i32::try_from(scale(height))) else {+        return null_mut();+    };++    let mut result: *mut GpBitmap = null_mut();+    if GdipCreateBitmapFromScan0(new_width, new_height, 0, PIXEL_FORMAT_32BPP_PARGB, null(), &mut result) != 0 || result.is_null() {+        return null_mut();+    }+    let mut graphics = null_mut();+    let mut drawn = GdipGetImageGraphicsContext(result.cast(), &mut graphics) == 0 && !graphics.is_null();+    if drawn {+        // `Half` keeps the border pixels from fading out.+        drawn = GdipSetInterpolationMode(graphics, InterpolationModeHighQualityBicubic) == 0+            && GdipSetPixelOffsetMode(graphics, PixelOffsetModeHalf) == 0+            && GdipDrawImageRectI(graphics, bitmap.cast(), 0, 0, new_width, new_height) == 0;+    }+    if !graphics.is_null() {+        GdipDeleteGraphics(graphics);+    }+    if drawn {+        return result;+    }+    GdipDisposeImage(result.cast());+    null_mut()+}++/// Decodes a JPEG with GDI+, which ships with Windows, and enlarges it towards `cx` pixels when+/// it is smaller. The caller owns the returned `HBITMAP`.+// Invariant 4: `stream`, the GDI+ bitmaps, the GDI+ token and (on failure) `hbmp` are all released+// below on every path; only a successful `hbmp` leaves this function.+unsafe fn jpeg_to_hbitmap(jpeg: &[u8], cx: u32) -> Option<HBITMAP> {+    let stream = SHCreateMemStream(jpeg.as_ptr(), u32::try_from(jpeg.len()).ok()?);+    if stream.is_null() {+        return None;+    }++    let mut input: GdiplusStartupInput = zeroed();+    input.GdiplusVersion = 1;+    let mut token = 0usize;+    if GdiplusStartup(&mut token, &input, null_mut()) != 0 {+        release_stream(stream);+        return None;+    }++    let mut bitmap = null_mut();+    let mut hbmp: HBITMAP = null_mut();+    let mut ok = GdipCreateBitmapFromStream(stream, &mut bitmap) == 0 && !bitmap.is_null();+    let mut enlarged = null_mut();+    if ok {+        // Opaque white background; a JPEG has no alpha anyway. A preview smaller than the size+        // Explorer asks for is enlarged with a better filter than Explorer's own scaling.+        enlarged = enlarge(bitmap, cx);+        let source = if enlarged.is_null() { bitmap } else { enlarged };+        ok = GdipCreateHBITMAPFromBitmap(source, &mut hbmp, 0xFFFF_FFFF) == 0 && !hbmp.is_null();+    }+    if !enlarged.is_null() {+        GdipDisposeImage(enlarged.cast());+    }+    if !bitmap.is_null() {+        GdipDisposeImage(bitmap.cast());+    }+    GdiplusShutdown(token);+    release_stream(stream);+    if ok {+        return Some(hbmp);+    }+    if !hbmp.is_null() {+        DeleteObject(hbmp);+    }+    None+}++/// Releases the in-memory `IStream` that `jpeg_to_hbitmap` created for GDI+.+unsafe fn release_stream(stream: *mut c_void) {+    // SAFETY (invariants 3 and 4): `stream` is the non-null stream from `SHCreateMemStream`, still+    // holding the reference taken there; this releases it exactly once.+    let vtbl = *(stream as *mut *const IUnknownVtbl);+    if !vtbl.is_null() {+        ((*vtbl).release)(stream);+    }+}++// ---------------------------------------------------------- class factory++#[repr(C)]+struct Factory {+    vtbl: *const ClassFactoryVtbl,+    refs: AtomicU32,+}++/// `IClassFactory::QueryInterface`.+unsafe extern "system" fn factory_query_interface(this: *mut c_void, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    guard(|| {+        if ppv.is_null() || riid.is_null() {+            return E_POINTER;+        }+        *ppv = null_mut();+        if guid_eq(&*riid, &IID_IUNKNOWN) || guid_eq(&*riid, &IID_ICLASSFACTORY) {+            factory_add_ref(this);+            *ppv = this;+            S_OK+        } else {+            E_NOINTERFACE+        }+    })+}++/// `IClassFactory::AddRef`.+unsafe extern "system" fn factory_add_ref(this: *mut c_void) -> u32 {+    (*(this as *mut Factory)).refs.fetch_add(1, Ordering::SeqCst) + 1+}++/// `IClassFactory::Release`: frees the factory when the last reference goes.+unsafe extern "system" fn factory_release(this: *mut c_void) -> u32 {+    let factory = this as *mut Factory;+    let remaining = (*factory).refs.fetch_sub(1, Ordering::SeqCst) - 1;+    if remaining == 0 {+        // SAFETY (invariant 2): last reference; created by `Box::into_raw` in `DllGetClassObject`.+        drop(Box::from_raw(factory));+        LIVE_COUNT.fetch_sub(1, Ordering::SeqCst);+    }+    remaining+}++/// `IClassFactory::CreateInstance`: creates a provider and returns the requested interface.+unsafe extern "system" fn factory_create_instance(_this: *mut c_void, outer: *mut c_void, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    guard(|| {+        if ppv.is_null() {+            return E_POINTER;+        }+        *ppv = null_mut();+        if !outer.is_null() {+            return CLASS_E_NOAGGREGATION;+        }+        let provider = Provider::create();+        if provider.is_null() {+            return E_OUTOFMEMORY;+        }+        let hr = provider_query(provider, riid, ppv);+        provider_release(provider); // Drops the creation reference.+        hr+    })+}++/// `IClassFactory::LockServer`: keeps the DLL loaded while locked.+unsafe extern "system" fn factory_lock_server(_this: *mut c_void, lock: i32) -> HRESULT {+    LIVE_COUNT.fetch_add(if lock != 0 { 1 } else { -1 }, Ordering::SeqCst);+    S_OK+}++// ------------------------------------------------------------ DLL exports++/// COM entry point: hands out the class factory for [`CLSID`].+#[unsafe(no_mangle)]+pub unsafe extern "system" fn DllGetClassObject(rclsid: *const GUID, riid: *const GUID, ppv: *mut *mut c_void) -> HRESULT {+    guard(|| {+        if rclsid.is_null() || riid.is_null() || ppv.is_null() {+            return E_POINTER;+        }+        *ppv = null_mut();+        if !guid_eq(&*rclsid, &CLSID) {+            return CLASS_E_CLASSNOTAVAILABLE;+        }+        LIVE_COUNT.fetch_add(1, Ordering::SeqCst);+        let factory = Box::into_raw(Box::new(Factory { vtbl: &FACTORY_VTBL, refs: AtomicU32::new(1) }));+        let hr = factory_query_interface(factory.cast(), riid, ppv);+        factory_release(factory.cast());+        hr+    })+}++/// COM entry point: whether Windows may unload the DLL.+#[unsafe(no_mangle)]+pub extern "system" fn DllCanUnloadNow() -> HRESULT {+    if LIVE_COUNT.load(Ordering::SeqCst) <= 0 { S_OK } else { S_FALSE }+}++/// `regsvr32` entry point: registers the provider for the current user.+#[unsafe(no_mangle)]+pub extern "system" fn DllRegisterServer() -> HRESULT {+    guard(|| match registration::current_module_path() {+        Some(path) if registration::register(&path, true).is_ok() => S_OK,+        _ => E_FAIL,+    })+}++/// `regsvr32 /u` entry point: removes the registration.+#[unsafe(no_mangle)]+pub extern "system" fn DllUnregisterServer() -> HRESULT {+    guard(|| match registration::unregister() {+        Ok(()) => S_OK,+        Err(_) => E_FAIL,+    })+}diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -1777,6 +1777,9 @@ 	Note Tool	Nástroj poznámka 	Object Selection Tool	Nástroj výběr objektu 	Off	Vypnuto+	Explorer thumbnails	Miniatury v Průzkumníku+	On, with logo	Zapnuto, s logem+	On, no logo	Zapnuto, bez loga 	Open	Otevřít 	Orange	Oranžová 	Original Ratio	Původní poměrdiff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -1756,6 +1756,9 @@ 	Note Tool	Notiz-Werkzeug 	Object Selection Tool	Objektauswahl-Werkzeug 	Off	Aus+	Explorer thumbnails	Explorer-Miniaturansichten+	On, with logo	Ein, mit Logo+	On, no logo	Ein, ohne Logo 	Open	Öffnen 	Orange	Orange 	Original Ratio	Originalverhältnisdiff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv--- a/crates/ui-egui/src/i18n/el.tsv+++ b/crates/ui-egui/src/i18n/el.tsv@@ -396,6 +396,9 @@ 	Disable	Απενεργοποίηση 	On	Ενεργό 	Off	Ανενεργό+	Explorer thumbnails	Μικρογραφίες στην Εξερεύνηση αρχείων+	On, with logo	Ενεργό, με λογότυπο+	On, no logo	Ενεργό, χωρίς λογότυπο 	None	Κανένα 	All	Όλα 	Selected	Επιλεγμέναdiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -1765,6 +1765,9 @@ 	Note Tool	Herramienta Nota 	Object Selection Tool	Herramienta Selección de objetos 	Off	Desactivado+	Explorer thumbnails	Miniaturas en el Explorador+	On, with logo	Activado, con logotipo+	On, no logo	Activado, sin logotipo 	Open	Abrir 	Orange	Naranja 	Original Ratio	Relación originaldiff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -1739,6 +1739,9 @@ 	Note Tool	Outil Note 	Object Selection Tool	Outil Sélection d'objet 	Off	Désactivé+	Explorer thumbnails	Miniatures dans l'Explorateur+	On, with logo	Activé, avec logo+	On, no logo	Activé, sans logo 	Open	Ouvrir 	Orange	Orange 	Original Ratio	Proportions d'originediff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -1216,6 +1216,9 @@ 	Now drag the selection onto another area	Most húzd a kijelölést egy másik területre 	Object Selection found nothing there — try a tighter rectangle	Az Objektumkijelölés ott nem talált semmit – próbálj szűkebb téglalapot 	Off	Ki+	Explorer thumbnails	Indexképek a Fájlkezelőben+	On, with logo	Be, logóval+	On, no logo	Be, logó nélkül 	Off: the speed of the pointer sets the spacing	Kikapcsolva: a térközt a mutató sebessége határozza meg 	Opacity Jitter	Átlátszatlanság ingadozása 	Opacity:	Átlátszatlanság:diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv--- a/crates/ui-egui/src/i18n/id.tsv+++ b/crates/ui-egui/src/i18n/id.tsv@@ -1749,6 +1749,9 @@ 	Note Tool	Alat Catatan 	Object Selection Tool	Alat Object Selection 	Off	Mati+	Explorer thumbnails	Gambar mini di File Explorer+	On, with logo	Hidup, dengan logo+	On, no logo	Hidup, tanpa logo 	Open	Buka 	Orange	Oranye 	Original Ratio	Rasio Aslidiff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -1759,6 +1759,9 @@ 	Note Tool	Strumento Nota 	Object Selection Tool	Strumento Selezione oggetto 	Off	Disattivato+	Explorer thumbnails	Anteprime in Esplora file+	On, with logo	Attivato, con logo+	On, no logo	Attivato, senza logo 	Open	Apri 	Orange	Arancione 	Original Ratio	Proporzione originalediff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -1761,6 +1761,9 @@ 	Note Tool	注釈ツール 	Object Selection Tool	オブジェクト選択ツール 	Off	オフ+	Explorer thumbnails	エクスプローラーのサムネイル+	On, with logo	オン(ロゴあり)+	On, no logo	オン(ロゴなし) 	Open	開く 	Orange	オレンジ 	Original Ratio	元の縦横比diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv--- a/crates/ui-egui/src/i18n/ko.tsv+++ b/crates/ui-egui/src/i18n/ko.tsv@@ -1755,6 +1755,9 @@ 	Note Tool	메모 도구 	Object Selection Tool	오브젝트 선택 도구 	Off	끔+	Explorer thumbnails	파일 탐색기 미리보기+	On, with logo	켬, 로고 포함+	On, no logo	켬, 로고 없음 	Open	열기 	Orange	주황 	Original Ratio	원본 비율diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv--- a/crates/ui-egui/src/i18n/nl.tsv+++ b/crates/ui-egui/src/i18n/nl.tsv@@ -1737,6 +1737,9 @@ 	Note Tool	Notitiegereedschap 	Object Selection Tool	Objectselectiegereedschap 	Off	Uit+	Explorer thumbnails	Miniaturen in Verkenner+	On, with logo	Aan, met logo+	On, no logo	Aan, zonder logo 	Open	Openen 	Orange	Oranje 	Original Ratio	Oorspronkelijke verhoudingdiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -1771,6 +1771,9 @@ 	Note Tool	Narzędzie Notatka 	Object Selection Tool	Narzędzie Zaznaczanie obiektów 	Off	Wyłączone+	Explorer thumbnails	Miniatury w Eksploratorze plików+	On, with logo	Włączone, z logo+	On, no logo	Włączone, bez logo 	Open	Otwórz 	Orange	Pomarańczowy 	Original Ratio	Oryginalne proporcjediff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv--- a/crates/ui-egui/src/i18n/pt-br.tsv+++ b/crates/ui-egui/src/i18n/pt-br.tsv@@ -1733,6 +1733,9 @@ 	Note Tool	Ferramenta Anotação 	Object Selection Tool	Ferramenta Seleção de objeto 	Off	Desativado+	Explorer thumbnails	Miniaturas no Explorador de Arquivos+	On, with logo	Ativado, com logotipo+	On, no logo	Ativado, sem logotipo 	Open	Abrir 	Orange	Laranja 	Original Ratio	Proporção originaldiff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -1812,6 +1812,9 @@ 	Measure	Измерить 	Heal	Восстановление 	Off	Выкл+	Explorer thumbnails	Эскизы в Проводнике+	On, with logo	Вкл., с логотипом+	On, no logo	Вкл., без логотипа 	Symmetry Off	Без симметрии 	Heal Off	Восстановление выкл 	Heal On	Восстановление вклdiff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv--- a/crates/ui-egui/src/i18n/sr.tsv+++ b/crates/ui-egui/src/i18n/sr.tsv@@ -1710,6 +1710,9 @@ 	Note Tool	Alatka za beleške 	Object Selection Tool	Alatka za selekciju objekta 	Off	Isključeno+	Explorer thumbnails	Sličice u Istraživaču datoteka+	On, with logo	Uključeno, sa logotipom+	On, no logo	Uključeno, bez logotipa 	Open	Otvori 	Orange	Narandžasta 	Original Ratio	Originalni odnosdiff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv--- a/crates/ui-egui/src/i18n/tr.tsv+++ b/crates/ui-egui/src/i18n/tr.tsv@@ -1709,6 +1709,9 @@ 	Note Tool	Not Aracı 	Object Selection Tool	Nesne Seçme Aracı 	Off	Kapalı+	Explorer thumbnails	Dosya Gezgini küçük resimleri+	On, with logo	Açık, logolu+	On, no logo	Açık, logosuz 	Open	Aç 	Orange	Turuncu 	Original Ratio	Orijinal Orandiff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv--- a/crates/ui-egui/src/i18n/uk.tsv+++ b/crates/ui-egui/src/i18n/uk.tsv@@ -1788,6 +1788,9 @@ 	Measure	Виміряти 	Heal	Відновити 	Off	Вимкнено+	Explorer thumbnails	Ескізи в Провіднику+	On, with logo	Увімкнено, з логотипом+	On, no logo	Увімкнено, без логотипа 	Symmetry Off	Симетрію вимкнено 	Heal Off	Відновлення вимкнено 	Heal On	Відновлення увімкненоdiff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv--- a/crates/ui-egui/src/i18n/vi.tsv+++ b/crates/ui-egui/src/i18n/vi.tsv@@ -1177,6 +1177,9 @@ 	Apply the pen's tilt and rotation to the tip shape	Áp dụng độ nghiêng và góc xoay bút vào hình đầu cọ 	Control:	Điều khiển: 	Off	Tắt+	Explorer thumbnails	Hình thu nhỏ trong File Explorer+	On, with logo	Bật, có logo+	On, no logo	Bật, không có logo 	Fade	Giảm dần 	Fade steps	Số bước giảm dần 	Pen Pressure	Áp lực bútdiff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv--- a/crates/ui-egui/src/i18n/zh-hans.tsv+++ b/crates/ui-egui/src/i18n/zh-hans.tsv@@ -1805,6 +1805,9 @@ 	Note Tool	注释工具 	Object Selection Tool	对象选择工具 	Off	关闭+	Explorer thumbnails	资源管理器缩略图+	On, with logo	开启,带徽标+	On, no logo	开启,无徽标 	Open	打开 	Orange	橙色 	Original Ratio	原始比例diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv--- a/crates/ui-egui/src/i18n/zh-hant.tsv+++ b/crates/ui-egui/src/i18n/zh-hant.tsv@@ -1779,6 +1779,9 @@ 	Note Tool	註解工具 	Object Selection Tool	物件選取工具 	Off	關+	Explorer thumbnails	檔案總管縮圖+	On, with logo	開啟,含標誌+	On, no logo	開啟,無標誌 	Open	開啟 	Orange	橙色 	Original Ratio	原始比例diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs--- a/crates/ui-egui/src/prefs_ui.rs+++ b/crates/ui-egui/src/prefs_ui.rs@@ -1334,6 +1334,8 @@ fn choice_label(v: &str) -> String {         "ahd" => "AHD (adaptive homogeneity)".into(),         "mhc" => "Malvar–He–Cutler".into(),         "cpu" => "CPU (no GPU acceleration)".into(),+        "withLogo" => "On, with logo".into(),+        "noLogo" => "On, no logo".into(),         v => humanize(v),     } }diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -23,8 +23,9 @@ What exists on `main` today. The numbered sections below are the original design   Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves).   Coverage: [`file-format-parity.md`](file-format-parity.md). - **Not built (yet) from the plan:** the `ml` crate (no ML runtime; see gap G15), plug-in ABI v2.-- **Deviations from the plan below:** `photocraft-tablet` is the one crate allowed `unsafe`-  (AppKit pen events); `affinity`, `heif` and `raw` crates were added; the phased roadmap in §15+- **Deviations from the plan below:** `photocraft-tablet` (AppKit pen events) and `photocraft-shell`+  (the Windows Explorer PSD thumbnail provider, a COM DLL) are the two crates allowed `unsafe`;+  `affinity`, `heif` and `raw` crates were added; the phased roadmap in §15   is superseded by [`roadmap.md`](roadmap.md).  ---diff --git a/docs/development.md b/docs/development.md--- a/docs/development.md+++ b/docs/development.md@@ -126,6 +126,8 @@ On Linux, on a machine with several GPUs (say an eGPU, a discrete laptop GPU and  On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712). +On Windows, File Explorer shows a PSD/PSB's embedded preview (the 160 px image resource 1036 that Preferences › File Handling › Image Previews saves) as its thumbnail. That is `photocraft_shell.dll`, built from `crates/shell` (`cargo build --release -p photocraft-shell`) and shipped beside `photocraft.exe`: a COM `IThumbnailProvider` that reads only the start of the file with a small bounds-checked parser and decodes the JPEG with the GDI+ that ships with Windows. Preferences › File Handling › Explorer Thumbnails sets Off, On with logo (Windows' usual look, the default) or On without logo (hides the file-type badge). The app registers the DLL for the current user only (`HKCU\Software\Classes`, no elevation) at launch and when the preference changes, leaves `.psd` alone when another program such as Photoshop already provides its thumbnails until the user picks the preference explicitly, and does nothing in portable mode, with `PHOTOCRAFT_CONFIG_DIR`, or in a 32-bit build. `regsvr32 photocraft_shell.dll` and `regsvr32 /u` do the same by hand. Explorer caches thumbnails, so a file already shown may keep its old picture for a while. Code: `crates/shell`, `apps/photocraft/src/explorer_thumbnails.rs`.+ Handled windowing and app initialization errors clear this launch's startup marker, restoring any prior crash evidence. For example, a Linux launch without `DISPLAY` or `WAYLAND_DISPLAY` does not change the next launch's graphics backend. Renderer initialization errors and driver crashes still@@ -714,3 +716,12 @@ backend command without submitting; it writes a PDF only when `output` is explic without an output requires `dryRun: true`. Remembered print settings omit `output`, `dryRun` and `send`, so Print One Copy derives submission from its own output choice. Shared spooler transport files are automatically removed on Windows; CUPS needs none.+++### Extended document thumbnails++The integration branch also registers `.psb` and `.pcraft`. Photoshop-compatible PSD/PSB resource 1036 is read irrespective of the application that saved it. Native `.pcraft` files use the existing 256 px `thumb.png`, emitted directly after `manifest.json` in a STORE ZIP. The provider bounds its prefix read to 16 MiB and the PNG to 4 MiB/2048 px per side, validates the ZIP payload CRC, and never decodes document layers. Repacked/deflated native bundles, oversized manifests and files without embedded previews fall back to the icon. PNG transparency is composited onto white by GDI+.++Registration and saved previous handlers are separate for all three extensions. Automatic startup preserves other programs' handlers; an explicit preference change may replace them. Turning the feature off restores each previous per-user handler. Portable/custom-config runs do not register handlers. Re-save older files with Image Previews enabled and allow Explorer to refresh its thumbnail cache.++The Windows shell implementation comes from [wer33321's PR #7805](https://github.com/storytold/photocraft/pull/7805), extended here with PSB coverage and native PhotoCraft previews. Its isolated COM/registry `unsafe` exception is proposed for maintainer review; it is not an already-approved upstream policy change. Real Windows Explorer and installer install/upgrade/uninstall testing remain required for this extended version.diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1--- a/packaging/windows/package.ps1+++ b/packaging/windows/package.ps1@@ -4,7 +4,7 @@  .DESCRIPTION   Produces, in $env:DIST (default: dist/release):-    photocraft-<version>-windows-<arch>.msi            per-machine installer (WiX v5)+    photocraft-<version>-windows-<arch>.msi            per-machine installer (WiX v5); with photocraft_shell.dll (x64, arm64)     photocraft-<version>-windows-<arch>-portable.zip   photocraft.exe + photocraft-cli.exe + portable.txt    The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable@@ -47,6 +47,9 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg $MsiVersion = ($Version -split '-')[0]  $Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }+# The thumbnail provider is registered as a COM class in the per-user view of the registry that+# Explorer (64-bit) reads; a 32-bit build's registration lands in the 32-bit view, so x86 ships none.+$ShellDll = $Arch -ne 'x86' $Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' } New-Item -ItemType Directory -Force -Path $Dist | Out-Null@@ -64,6 +67,8 @@ if (-not $SkipBuild) {   # Fail the build (rather than warn) if the icon/VERSIONINFO can't be embedded.   $env:PHOTOCRAFT_REQUIRE_WINRES = '1'   Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p photocraft -p photocraft-cli --features heif --target $Target }+  # The File Explorer document thumbnail provider (a COM DLL the MSI installs beside photocraft.exe).+  if ($ShellDll) { Invoke-Native "cargo build photocraft-shell ($Target)" { cargo build --release --locked -p photocraft-shell --target $Target } } }  $Bin = Join-Path $TargetDir "$Target\release"@@ -84,12 +89,22 @@ foreach ($check in @(@('photocraft.exe', 2), @('photocraft-cli.exe', 3))) {   if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }   Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)" }+if ($ShellDll) {+  $h = Get-PeHeader (Join-Path $Bin 'photocraft_shell.dll')+  if ($h.Machine -ne $Machine) { throw "photocraft_shell.dll is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }+  Write-Output "ok photocraft_shell.dll: $Arch"+} $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage+$Signables = @((Join-Path $Stage 'photocraft.exe'), (Join-Path $Stage 'photocraft-cli.exe'))+if ($ShellDll) {+  Copy-Item (Join-Path $Bin 'photocraft_shell.dll') $Stage+  $Signables += Join-Path $Stage 'photocraft_shell.dll'+} -& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe')+& (Join-Path $PSScriptRoot 'sign.ps1') @Signables  # ---- font notices for the MSI ------------------------------------------------------------------ # The portable ZIP already carries these notices; stage the complete files before MSI creation too.@@ -146,12 +161,13 @@ New-InstallerArt (Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.story  $Msi = Join-Path $Dist "photocraft-$Version-windows-$Arch.msi" & (Join-Path $PSScriptRoot 'check-icons.ps1')+$ShellDefine = if ($ShellDll) { @('-d', "ShellDll=$(Join-Path $Stage 'photocraft_shell.dll')") } else { @() } Invoke-Native 'wix build' {   wix build (Join-Path $PSScriptRoot 'photocraft.wxs') -arch $Arch `     -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `     -culture en-US -loc (Join-Path $PSScriptRoot 'photocraft.en-us.wxl') `     -d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\photocraft.ico')" `-    -d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `+    -d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" @ShellDefine `     -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" `     -o $Msi }diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs--- a/packaging/windows/photocraft.wxs+++ b/packaging/windows/photocraft.wxs@@ -58,6 +58,9 @@       <?endif ?>       <ComponentGroupRef Id="PhotocraftFiles" />     </Feature>+<?ifdef ShellDll?>+    <CustomActionRef Id="UnregisterShellThumbnails" />+<?endif?>      <!-- The setup wizard: WixUI_InstallDir without its licence page (the code is MIT OR          Apache-2.0; there is nothing to accept). A later NewDialog on the same button wins. -->@@ -162,6 +165,30 @@       <Component Id="PhotocraftCli">         <File Id="PhotocraftCliExe" Source="$(var.BinDir)\photocraft-cli.exe" KeyPath="yes" />       </Component>+<?ifdef ShellDll?>++      <!-- File Explorer thumbnails for PSD, PSB and PhotoCraft files (crates/shell). This only puts the COM DLL beside+           photocraft.exe: the app registers it for each user (Preferences › File Handling ›+           Explorer Thumbnails), so the choice stays per user. Not built for x86 (package.ps1). -->+      <Component Id="PhotocraftShell">+        <File Id="PhotocraftShellDll" Source="$(var.ShellDll)" KeyPath="yes" />+      </Component>+<?endif?>     </ComponentGroup>   </Fragment>+<?ifdef ShellDll?>++  <Fragment>+    <!-- On uninstall, undo the uninstalling user's registration (`regsvr32 /u` calls the DLL's+         DllUnregisterServer) before the DLL is deleted: it puts back the .psd thumbnail handler+         and badge setting that PhotoCraft replaced. It runs as that user, so only their registry+         is touched; other users' registrations fall back to the default icon until they are+         changed. Not on upgrades: the new version keeps the same DLL path. -->+    <CustomAction Id="UnregisterShellThumbnails" Directory="INSTALLFOLDER" Execute="deferred" Impersonate="yes" Return="ignore"+        ExeCommand="&quot;[System64Folder]regsvr32.exe&quot; /s /u &quot;[#PhotocraftShellDll]&quot;" />+    <InstallExecuteSequence>+      <Custom Action="UnregisterShellThumbnails" Before="RemoveFiles" Condition="REMOVE=&quot;ALL&quot; AND NOT UPGRADINGPRODUCTCODE" />+    </InstallExecuteSequence>+  </Fragment>+<?endif?> </Wix>diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs--- a/xtask/src/layers.rs+++ b/xtask/src/layers.rs@@ -48,6 +48,8 @@ pub const TABLE: &[(&str, Class)] = &[     ("adobe-assets", Class::Standalone),     // Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).     ("tablet", Class::Standalone),+    // Windows Explorer PSD thumbnail provider (COM server DLL; the second isolated `unsafe` crate).+    ("shell", Class::Standalone),     ("doc", Class::Layer(1)),     ("ops", Class::Layer(2)),     ("paint", Class::Layer(2)),