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" }
 
 # external
diff --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.rs
new 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.toml
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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.rs
new 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ěr
diff --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ältnis
diff --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 original
diff --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'origine
diff --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 Asli
diff --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 originale
diff --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 verhouding
diff --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 proporcje
diff --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 original
diff --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 odnos
diff --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 Oran
diff --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út
diff --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)),
