ImageGlass Agent Skills & AI Rules

GitHub

A fast, open-source, modern image viewer for 90+ formats โ€“ including WEBP, GIF, SVG, AVIF, JXL, HEIC and more โ€“ built for smooth browsing across Windows,...

RAW Rules
2 Discovered Rules โ€ข ~12,030 Estimated Tokens
Quick:
Loading skill content...

source - Claude Agent Guidelines & System Prompt

source/CLAUDE.md claude-rule ~11,979 tokens
Raw
# ImageGlass v10

## Project Overview
ImageGlass v10 is a complete rewrite in **C# with .NET 11**, using **Avalonia 12** for cross-platform image viewing and **SkiaSharp 4.152.x** for high-performance rendering. The primary targets are **Windows, macOS, Linux** on x64 and ARM64 with AOT (Ahead-of-Time) publishing enabled.

## Repository Layout
- **`/source`** โ€” v10 source code (the active codebase; this `CLAUDE.md` lives here). All development happens here.
- **`/v9`** โ€” legacy v9 source code (WinForms/.NET, archived).

> **Default to v10 (`/source`).** Do not read, modify, or reference the v9 source in `/v9` unless the user explicitly asks about v9 or requests porting/comparison work.

**Key Projects:**
- `ImageGlass.Lib`: Core library (net11.0) โ€” UI controls, rendering, codecs, themes, localization, settings
- `ImageGlass.Win32`: Windows desktop app (net11.0-windows10.0.19041.0) โ€” entry point with Win32 APIs and platform-specific services
- `ImageGlass.Linux`: Linux desktop app (net11.0) โ€” entry point with Linux-specific services
- `ImageGlass.Mac`: macOS desktop app (net11.0) โ€” entry point with macOS-specific services

---

## Critical Focus Areas (Always Prioritize)

### 1. AOT/Trim Safety
- **All platform projects** (Win32, Linux, Mac) use `PublishAot=true` and `PublishTrimmed=true` with trim analyzer enabled.
- **Win32 project** additionally uses `DisableRuntimeMarshalling=true` and CsWin32 code generation.
- Avoid reflection-based serialization; use custom JSON converters in `Common/Types/JsonTypeConverters/`.
- Test AOT builds: `dotnet publish ImageGlass.Win32 -c Release` produces a self-contained, trimmed executable.

### 2. Thread-Safety (No Data Races)
- Use `Lock` for protecting shared state (not `object` locks).
- Use `InterlockedBool` instead of plain `bool` for concurrent flags.
- Use `ConcurrentDictionary` for thread-safe collections (`PhotoManager` index).
- Always check `IsDisposed` before accessing resources in multi-threaded contexts.
- Example: `PhotoManager` uses `Lock _lock` to protect `_items`, `_dict`, and `_cachedIndexes`.

### 3. Memory Leak Prevention
- Use `try/finally` or `using` statements for all `SKObject` acquisitions.
- Override `OnDisposing()` in `PhDisposable` subclasses to clean up unmanaged resources.
- Example: `MipmapTileCache` evicts with `RequestDispose()` (never `Dispose()`) so an in-flight render lease stays valid.
- Always dispose `SKImageRef` leases when done; use `KeepAlive()` pattern in `MipmapTileCache`.

### 4. Memory Usage Control
- Respect cache limits: `MipmapTileCache` caps 100 tiles; `PhotoManager_Caching` honors `Config.MaxMemoryCacheInMb`, `Config.MaxFileSizeCacheInMb`, `Config.MaxDimensionCache`.
- LRU eviction is mandatory in tile and photo caches to prevent unbounded growth.
- **Hard decode ceiling**: no `SKBitmap` or raster `SKImage` may exceed `int.MaxValue` bytes, whatever the free RAM. That is ~536 MP at 4 bpp, ~268 MP at `RgbaF16`, ~134 MP at `RgbaF32`. There is no bypass: `SKImage.FromPixels` from an `SKData`, a raw pointer, or an `SKPixmap` all fail at the same boundary, so host-owned-buffer tricks do not help. Both codecs shrink to fit instead of failing (see "Oversized image decode").
- Mark a finished bitmap `SetImmutable()` before `SKImage.FromBitmap` (`SkiaCodec.ToSKImageNoCopy`), otherwise Skia duplicates the entire pixel buffer and doubles peak memory.
- Profile memory with large images and GIF animations to ensure caches don't leak.

### 5. Cross-Platform Design
- **Interfaces first** in `ImageGlass.Lib/Common/ServiceProviders/*`: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, etc.
- **Implementations** per platform:
  - `ImageGlass.Win32/Common/ServiceProviders/*`: `Win32FileSearchProvider`, `Win32ShellProvider`, etc.
  - `ImageGlass.Linux/Common/ServiceProviders/*`: `LinuxPrintProvider`, `LinuxShellProvider`, etc.
  - `ImageGlass.Mac/Common/ServiceProviders/*`: `MacPrintProvider`, `MacShellProvider`, etc.
- Register service providers in each platform's `Program.cs` before app bootstrap.
- Avoid platform-specific code in library; use dependency injection via `Core.API`, `Core.FileSearchProvider`, etc.

---

## Architecture Highlights

### Core Application Layers
1. **Core Static Hub** (`Core.cs`, `Core_Events.cs`): Central event dispatcher and singleton registry
   - `Core.AppInstance`: App lifecycle & unique instance enforcement
   - `Core.Config`: Global configuration state
   - `Core.Photos`: Photo collection and file searching
   - Events: `LanguageChanged`, `ThemeChanged`, `PhotoUnloaded`, `PhotoSaved`, `ColorProfileChanged`
   - Service provider slots: `ShellProvider`, `PreviewProvider`, `FileSearchProvider`, `PrintProvider`, `API`

2. **Service Providers** (Plug-in Pattern)
   - Platform-agnostic interfaces in `ImageGlass.Lib/Common/ServiceProviders/*`
   - Platform implementations:
     - Win32: `ImageGlass.Win32/Common/ServiceProviders/*`
     - Linux: `ImageGlass.Linux/Common/ServiceProviders/*`
     - macOS: `ImageGlass.Mac/Common/ServiceProviders/*`
   - Shared concrete providers in library: `PhotoPreviewProvider`, `SlideshowProvider`, `UpdateProvider`
   - Examples: `IFileSearchProvider`, `IShellProvider`, `IPrintProvider`, `IWindowColorProfileProvider`, `IPhotoPreviewProvider`
   - Registered at startup in each platform's `Program.cs`; initialized in `App.xaml.cs`

3. **Photo & Codec Abstraction**
   - `PhotoManager`: Thread-safe collection with `AvaloniaList<Photo>` + `ConcurrentDictionary` index
   - Codecs: `SkiaCodec` (fast SkiaSharp pipeline) and `MagickCodec` (Magick.NET fallback for obscure formats)
   - Async loading via `PhotoLoadingOptions` + frame animation support via `SkiaAnimator`/`AnimatorImpl`
   - Caching: `PhotoManager_Caching.cs` uses spiral pattern, LRU eviction, memory budgets

4. **Viewer & Rendering** (`ViewerControl`)
   - Partial classes split by concern: `ViewerControl_Render.cs`, `ViewerControl_ZoomAndPan.cs`, `ViewerControl_Events.cs`, `ViewerControl_Animation.cs`, `ViewerControl_NavButtons.cs`, etc.
   - High-performance mipmap tile caching: `MipmapTileCache` with LRU eviction for large images (8192ร—8192+)
   - Gesture recognizers: `PhPanGestureRecognizer`, `PhPinchGestureRecognizer` accumulate points for smooth interaction
   - SkiaSharp rendering in `ViewerControl_Render.cs` + `PhotoRenderer.cs`
   - **Navigation buttons**: `NavButtonsOverlay` (separate `PhControl` overlay) renders left/right arrow buttons on hover; uses `RequestAnimationFrame` for frame-rate-independent slide+fade animation; click detection on pointer release (does not interfere with panning); config-bound via `EnableNavButtonsProperty` StyledProperty
   - **Image info overlay**: `ImageInfoOverlayControl` (a non-hit-testable `PhOverlay` in the viewer panel) shows the `Config.ImageInfoTags` items in a bar at the top. `MainWindow.Status_Changed` pushes `AppStatusInfo.GetItems()` into it (the app name is left out). One setting, `ImageInfoOverlayMode`: `Hidden`, `Always`/`AlwaysAutoHide` (every window), `Borderless`/`BorderlessAutoHide` (only while the title bar is hidden, `ImageInfoOverlayControl.IsTitleBarHidden`: full screen or frameless); auto-hide tracks the pointer on the window with a hysteresis zone and a delayed hide, and a running slideshow (`Config.EnableSlideshow`) makes every visible mode auto-hide. It is set in Settings > Layout only, with no menu item or hotkey. `CoveredHeight` is how far the bar reaches, which `MainWindowView` insets the in-app message by

---

## Code Organization & Naming Conventions

### Folder Structure
```
ImageGlass.Lib/
โ”œโ”€โ”€ Common/
โ”‚   โ”œโ”€โ”€ Actions/                   # Action definitions
โ”‚   โ”œโ”€โ”€ Commands/                  # Command definitions
โ”‚   โ”œโ”€โ”€ AppThemes/                 # Theme system: IgTheme, IgThemeColors, AppThemeColors, IgThemeMetadata
โ”‚   โ”œโ”€โ”€ BHelper/                   # Static helper methods (see "Reusing Shared Code"): Color, Format, General, JsonEx, Path, ProcessHelper, Sound, Task
โ”‚   โ”œโ”€โ”€ Extensions/                # Extension methods (see "Reusing Shared Code"): Color_Exts, DrawingContext_Exts, ISolidColorBrush_Exts, Point_Exts, Rect_Exts, Size_Exts, SKObject_Exts
โ”‚   โ”œโ”€โ”€ Localization/              # Lang.cs, LangId enum
โ”‚   โ”œโ”€โ”€ Photoing/                  # Photo management, codecs, animators
โ”‚   โ”‚   โ”œโ”€โ”€ Animators/             # SkiaAnimator, AnimatorImpl
โ”‚   โ”‚   โ”œโ”€โ”€ Codecs/                # Codec pipeline
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ MagickCodecs/      # Magick.NET fallback codec
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ Registry/          # CodecRegistry
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ SkiaCodecs/        # SkiaSharp fast-path codec
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ SvgCodecs/         # SVG (vector) codec
โ”‚   โ”‚   โ”œโ”€โ”€ Manager/               # PhotoManager & PhotoManager_*.cs (search, watcher, caching)
โ”‚   โ”‚   โ””โ”€โ”€ Photos/                # Photo, PhotoMetadata, PhotoColorProfile, etc.
โ”‚   โ”œโ”€โ”€ Printing/                  # Print layouts, layout engine, page renderer, PDF writer, PrintSession (see "Printing")
โ”‚   โ”œโ”€โ”€ ServiceProviders/          # Interface contracts + shared providers
โ”‚   โ”‚   โ”œโ”€โ”€ AppAPIs/               # App API provider interfaces
โ”‚   โ”‚   โ”œโ”€โ”€ FileSearchService/     # File search service abstractions
โ”‚   โ”‚   โ”œโ”€โ”€ Update/                # Update provider abstractions
โ”‚   โ”‚   โ”œโ”€โ”€ I*.cs                  # Interfaces (IFileSearchProvider, IShellProvider, etc.)
โ”‚   โ”‚   โ”œโ”€โ”€ PhotoPreviewProvider.cs
โ”‚   โ”‚   โ”œโ”€โ”€ SlideshowProvider.cs
โ”‚   โ”‚   โ””โ”€โ”€ UpdateProvider.cs
โ”‚   โ””โ”€โ”€ Types/                     # Base classes + shared types: PhReactive, PhDisposable, InterlockedBool, Resx, Const, Hotkey, SKImageRef, Enums, Dir
โ”‚       โ””โ”€โ”€ JsonTypeConverters/    # AOT-safe JSON converters (see "Reusing Shared Code")
โ”œโ”€โ”€ Plugins/                       # Native plugin host: PluginRegistry, codec proxies
โ”œโ”€โ”€ Settings/                      # Config.cs, Config_Static.cs, ConfigMetadata.cs
โ”œโ”€โ”€ Tools/                         # Built-in & external tool framework
โ”‚   โ”œโ”€โ”€ Builtins/                  # ColorPicker, CropImage, FrameNav, ImageResizer, LosslessCompression
โ”‚   โ””โ”€โ”€ External/                  # External tool process management
โ”œโ”€โ”€ UI/
โ”‚   โ”œโ”€โ”€ BaseControls/              # Ph* controls (see "Designing UI"): PhControl, PhButton, PhTextBox, PhTextBlock, PhToolButton, PhMenuItem, PhHotkeyPicker, PhGridSplitter, PhVirtualizingUniformPanel, PhCommandPreview, PhSearchBoxControl, PhTableControl
โ”‚   โ”œโ”€โ”€ Gallery/                   # Gallery browsing UI (GalleryControl)
โ”‚   โ”œโ”€โ”€ Styles/                    # App-wide XAML styles + resource dictionaries (see "Designing UI"); merged in App.axaml
โ”‚   โ”œโ”€โ”€ Toolbar/                   # Toolbar UI and model (ToolbarControl)
โ”‚   โ”œโ”€โ”€ Viewer/                    # ViewerControl (partial classes by feature)
โ”‚   โ”‚   โ”œโ”€โ”€ Checkerboard/          # Transparency checkerboard background
โ”‚   โ”‚   โ”œโ”€โ”€ NavButtons/            # NavButtonsOverlay, NavButtonsInfo, NavButtonClickedEventArgs
โ”‚   โ”‚   โ”œโ”€โ”€ Renderer/              # MipmapTileCache, PhotoRenderer
โ”‚   โ”‚   โ”œโ”€โ”€ Selection/             # Selection tools
โ”‚   โ”‚   โ””โ”€โ”€ ZoomAndPan/            # Gesture recognizers, zoom/pan math
โ”‚   โ””โ”€โ”€ Windowing/                 # PhWindow, ModalWindow, DialogWindow, PhColorPickerDialog
โ”œโ”€โ”€ ViewModels/                    # MainWindowViewModel, MainWindowModel, SettingsViewModel
โ””โ”€โ”€ Windows/                       # Top-level windows: MainWindow, AboutWindow, SettingsWindow, UpdateWindow, ExportFramesWindow
    โ”œโ”€โ”€ Main/                      # MainWindow_View partial
    โ”œโ”€โ”€ Print/                     # PrintWindow, its view, preview and layout tiles
    โ””โ”€โ”€ Settings/                  # Settings UI: Controls/, Pages/, Windows/

ImageGlass.Win32/
โ”œโ”€โ”€ Program.cs                     # Entry point, service registration, AOT bootstrap
โ”œโ”€โ”€ Properties/                    # PublishProfiles
โ”œโ”€โ”€ Windows/                       # MainWindow32.cs (Win32-specific window)
โ””โ”€โ”€ Common/
    โ”œโ”€โ”€ ServiceProviders/          # Win32 implementations (Win32FileSearchProvider, etc.)
    โ””โ”€โ”€ WinAPI/                    # P/Invoke wrappers (Win32*.cs files)

ImageGlass.Linux/
โ”œโ”€โ”€ Program.cs                     # Entry point, service registration
โ””โ”€โ”€ Common/
    โ””โ”€โ”€ ServiceProviders/          # Linux implementations (LinuxPrintProvider, etc.)

ImageGlass.Mac/
โ”œโ”€โ”€ Program.cs                     # Entry point, service registration
โ””โ”€โ”€ Common/
    โ”œโ”€โ”€ Sandbox/                   # macOS sandbox helpers
    โ””โ”€โ”€ ServiceProviders/          # macOS implementations (MacPrintProvider, etc.)
```

### Naming Rules
- **Classes inheriting from Control/UserControl**: Use `PhControl` base (e.g., `ToolbarControl : PhControl`)
- **View Models**: Suffix with `ViewModel` (e.g., `MainWindowViewModel : PhReactive`)
- **Async methods**: Always suffix with `Async`
- **Codecs/Providers**: Prefix with platform/source (e.g., `SkiaCodec`, `Win32FileSearchProvider`, `MagickCodec`)
- **Partial class files**: Name by concern (e.g., `ViewerControl_Render.cs`, `PhotoManager_Caching.cs`, `AppAPIProvider_Hotkeys.cs`)

---

## Reusing Shared Code (Always Check Before Writing New)

Before adding a helper, converter, or extension, check these folders; the utility you need likely already exists.

### Type Extensions: `Common/Extensions/`
Extension methods on framework types. Prefer these over re-implementing:
- `Color_Exts`: `ToBrush()`, `WithAlpha()`, `NoAlpha()`, `Blend()`, `WithBrightness()`, `IsLight()`, `BlackOrWhite()` / `InvertBlackOrWhite()`, `ToHex()`, `ToRgbaString()`, `ToCmyk()` / `ToCmykString()`, `ToHslString()`, `ToHsvString()`, `ToCIELAB()` / `ToCIELABString()`
- `ISolidColorBrush_Exts`, `DrawingContext_Exts`, `Point_Exts`, `Rect_Exts`, `Size_Exts`: Avalonia geometry & drawing helpers
- `SKObject_Exts`: `IsDisposed()` and other SkiaSharp guards

### Helper Methods: `Common/BHelper/` (static `BHelper` partial class)
- `Color.cs`: `ColorFromHex()`
- `Format.cs`: `FormatSize()`, `FormatDateTime()`, `SimplifyFractions()`, star-rating formatting
- `General.cs`: `OS`, `GenerateWrappedIndexes()` (spiral cache order), `ComputeIndexInRange()`, `ResizeRatio()`, `GetInAppError()`
- `Path.cs`: `ConfigDir()` / `BaseDir()`, `ResolvePath()`, `CheckPath()`, `OpenUrlAsync()`, `OpenFilePath()` / `OpenFolderPath()`, `DeleteFile()`
  - `ResolvePath()` is the **single** public path normalizer: it unwraps `ig://`, strips quotes, expands env vars, follows `.lnk` and macOS `.app`, then makes the result absolute. Never add a second normalizer beside it; if a caller needs only part of that, make the missing piece a `private` helper *inside* `ResolvePath` rather than a parallel public API.
- `ProcessHelper.cs`: `RunExeAsync()` / `RunExeCmd()`, `RunSync()`, `ExitApp()`
- `Sound.cs`: `PlayNotificationSoundAsync()` (extracts the bundled `Assets/Sounds/notification.wav` to `_temp`, then plays it via NetCoreAudio on Windows/macOS, or a probed CLI player on Linux)
- `JsonEx.cs`: `CreateJsonOptions()`, `ReadJsonFromFile()` / `WriteJsonToFileAsync()` (AOT-safe via `JsonTypeInfo<T>`); both go through `FileIO.cs`, so every JSON file is multi-instance safe
- `FileIO.cs`: `OpenReadShared()` / `WriteAllTextSharedAsync()`, the multi-instance-safe file primitives (see "Concurrent file writes")
- `Task.cs`: `Debounce()`, `GcCollect()`

### JSON Converters: `Common/Types/JsonTypeConverters/`
AOT-safe converters for `Config` serialization. Reuse an existing one, or add a new converter here; never use reflection-based serialization.
- `JsonStringEnumSafeConverter<T>` (always use for enums; never the built-in `JsonStringEnumConverter`)
- `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`, `JsonArrayToRectConverter` / `JsonArrayToPixelRectConverter`, `JsonArrayToDoubleConverter` / `JsonArrayToIntConverter`, `JsonDateTimeConverter`, `JsonHashSetToStringConverter`, `JsonObservableCollectionToStringConverter`

---

## Designing UI

When building or restyling UI, reuse the app's existing controls, design tokens, styles, icons, and fonts instead of raw Avalonia controls or hardcoded values.

### 1. Controls: prefer `UI/` over raw Avalonia
- **Base controls** (`UI/BaseControls/`, all inherit `PhControl`): `PhButton`, `PhTextBox`, `PhTextBlock`, `PhToolButton`, `PhMenuItem`, `PhHotkeyPicker`, `PhGridSplitter`, `PhVirtualizingUniformPanel`, `PhCommandPreview`, `PhTableControl` (read-only data table: aligned auto-fit columns + hover/focus-revealed icon-button actions; use for settings tables), `PhOverlay` (floating surface in the in-app message style: set `IsShown` to fade it in or out, `TransitionDirection` picks the side it slides in from, `Auto` fades in place; place it with `Margin`/`Padding`/alignment; `MessageOverlayControl`, `ImageInfoOverlayControl` and `MotionPhotoOverlayControl` derive from it).
- **Windows & dialogs** (`UI/Windowing/`): inherit `PhWindow`; for modal/dialog flows use `ModalWindow` / `DialogWindow`; use `PhColorPickerDialog` for color picking.
- **Feature controls**: `ViewerControl` (`UI/Viewer/`), `GalleryControl` (`UI/Gallery/`), `ToolbarControl` (`UI/Toolbar/`).

### 2. Colors, brushes & styles: never hardcode
- **Themed resources via `Resx` / `ResxId`** (`Common/Types/Resx.cs`): resolve in XAML with `{DynamicResource <ResxId-name>}`, or in code with `Resx.Get<T>(ResxId.X)` / `Resx.CreateBinding(ResxId.X)`. The resource name equals the `ResxId` enum name. These keys are populated at runtime by the `Update*` methods in `Core.cs` (`UpdateBaseResources`, `UpdateAppThemedColorResources`, `UpdateViewerBackgroundBrushResource`, `UpdateAccentColorResources`; see `Core.cs:387-635`). To add a new themed value: add a `ResxId` entry and set it inside the matching `Update*` method.
- **Brush vs Color**: `ResxId` keys ending in `Brush` resolve to an `IBrush`; keys ending in `Color` resolve to a `Color`. Use the `*Color` variant where a `Color` is required (e.g. `GradientStop.Color`).
- **Theme & situational colors** (`Common/AppThemes/AppThemeColors.cs`): the static source of truth for text success/warning/danger, situational backgrounds, and window backgrounds. The `Update*` methods derive `Resx` resources from these, so reuse them rather than duplicating hex literals. `IgThemeColors.cs` is the per-theme-pack color model.
- **Shared styles & tokens** (`UI/Styles/`, merged in `App.axaml`): control styles (`ButtonStyle`, `TextBoxStyle`, `ComboBoxStyle`, `CheckBoxStyle`, `RadioButtonStyle`, `MenuStyle`, `SliderStyle`, `ListBoxItemStyle`, `ControlStyle`) and resource dictionaries (`CommonResources`, `MenuResources`, `ComboBoxResources`, `ScrollBarResources`, `IconResources`). Reuse tokens from `CommonResources.axaml`: `ControlCornerRadius` (7), `OverlayCornerRadius` (10), `PhAccentFill` / `PhAccentFillPointerOver` / `PhAccentFillPressed`, and the shared disabled palette (`PhControlFillDisabled`, `PhControlGlyphDisabled`, `PhControlBorderDisabled`, `PhControlContentOpacityDisabled`, `PhSvgCssDisabled`).

### 3. Icons: `UI/Styles/IconResources.axaml`
Reuse the `StreamGeometry` icon resources via `{StaticResource IconName}`: `IconSearch`, `IconSettings`, `IconSave`, `IconSaveAs`, `IconCrop`, `IconCopy`, `IconReset`, `IconClose`, `IconEllipsis`, `IconArrowPrevious` / `IconArrowNext` / `IconArrowLeft` / `IconArrowRight`, `IconPlay`, `IconPause`, `IconImageForward`, `IconLivePhoto`, `IconFolderOpen`. In code-behind, resolve the same geometries with `Resx.GetIcon(ResxIconId.X)` (enum name equals the resource key). Add new icons here as `StreamGeometry` and add a matching `ResxIconId` entry in `Resx.cs`. Platform stock icons (window/system bitmaps) come from `Resx.GetStockIcon(StockIconId.X)` / `Resx.GetDefaultWindowIcon()`.

### 4. Fonts: `Common/Types/Const.cs:81-104`
- **Font family**: `Const.FONT_CODE` (OS-specific monospace stack for code / credits / metadata text).
- **Font sizes**: `Const.FONT_SIZE_BODY`, `FONT_SIZE_TITLE`, `FONT_SIZE_SUBTITLE`, `FONT_SIZE_SMALL`. Reference in XAML via `{x:Static types:Const.FONT_SIZE_SMALL}` (with `xmlns:types="using:ImageGlass.Common.Types"`); do not hardcode sizes.

---

## Critical Base Classes & Patterns

### 1. Reactive Programming โ€” `PhReactive`
- All ViewModels inherit from `PhReactive : INotifyPropertyChanged`
- Thread-safe property change notifications using `Lock` (not `object`)
- Use with Avalonia compiled bindings: `{CompiledBinding PropertyName}`
- Example:
  ```csharp
  public class MainWindowViewModel : PhReactive
  {
      public string Title
      {
          get; set
          {
              if (field.Equals(value)) return;
              field = value;
              OnPropertyChanged();
          }
      } = BHelper.AppName;
  }
  ```

### 2. Resource Management โ€” `PhDisposable`
- Inherit when managing unmanaged resources (SKImage, file handles, etc.)
- Override `OnDisposing()` (called on Dispose, not destructor)
- Uses `InterlockedBool _isDisposed` for thread-safe disposal checks
- Use with `await using` in async contexts
- Example:
  ```csharp
  public sealed class MipmapTileCache : PhDisposable
  {
      protected override void OnDisposing()
      {
          lock (_lock)
          {
              foreach (var bitmap in _tiles.Values)
              {
                  bitmap.Dispose();
              }
              _tiles.Clear();
          }
      }
  }
  ```

### 3. Thread-Safe Operations โ€” `InterlockedBool`
- Use instead of `bool` for concurrent state flags
- Atomic read/write using `Volatile` and `Interlocked` operations
- Example: `_isPreviewing`, `_isFirstDraw`, `_isDisposed` in ViewerControl
- Check with `.Value` property or implicit conversion

### 4. SkiaSharp Safety โ€” `IsDisposed()` Extension
- Always check `SKObject.IsDisposed()` before using SkiaSharp objects
- Defined in `ImageGlass.Common.Extensions.SKObject_Exts`
- Returns `true` if object is `null` or `Handle == IntPtr.Zero`
- Example:
  ```csharp
  if (_skImage.IsDisposed()) { _skImage = null; }
  ```

---

## Critical Workflows & Commands

### Build
```powershell
# Restore and build
dotnet build

# Publish AOT-enabled binary (ImageGlass.Win32)
dotnet publish ImageGlass.Win32 -c Release -o ./bin/publish

# Run from source
dotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]
```

### Debug
- Open solution in Visual Studio 2026
- Set `ImageGlass.Win32` as startup project
- **Breakpoints work normally in C# code**
- **Avalonia Hot Reload**: Available with `--debug` flag (limited to XAML/styles)
- **Profiling**: Use Visual Studio's memory profiler for cache leak detection

### Testing
- No dedicated test projects in v10 (focus on functional validation)
- Manual testing via `dotnet run`
- Test command-line parsing with sample images
- Profile memory usage with large images and GIF animations

### Key C# Version & Langversion
- **LangVersion**: `Preview` (C# 15 features available)
- **Features**: `runtime-async=on` in all projects (a .NET 11 preview, NativeAOT-supported): `async Task`/`ValueTask` methods compile to runtime-async methods instead of `<X>d__N` state machines; `async void` and async iterators still use the compiler's. Remove it from all four projects to fall back.
- **Nullable**: `enable` across projects
- **AllowUnsafeBlocks**: `true` (for P/Invoke, SkiaSharp interop)
- **PublishAot**: `true` in all platform projects (Win32, Linux, Mac)
- **PublishTrimmed**: `true` in all projects (library + platform)
- **PublishSingleFile** + **SelfContained** + **PublishReadyToRun**: `true` in all platform projects
- **DisableRuntimeMarshalling**: `true` in Win32 only (AOT-safe marshalling)

---

## Key Integration Points

### 1. Photo Loading Pipeline
```
App.Initialize() 
  โ†’ Core.InitializeAsync() 
    โ†’ PhotoManager.Add/SearchAsync()
    โ†’ ViewerControl.LoadPhotoAsync(photoPath)
      โ†’ Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)
      โ†’ shrink to fit the pixel ceiling if needed โ†’ Photo.DecodeScale
      โ†’ PhotoRenderer.RenderAsync()
      โ†’ AnimatorImpl for GIF/WEBP animation
    โ†’ PhotoLoading/PhotoAnimatorFrameChanged events
    โ†’ MipmapTileCache.Create() if image > 8192ร—8192
```

### Oversized image decode
- Images past the `int.MaxValue`-byte ceiling are decoded smaller rather than refused.
- **Skia** (`SkiaCodec.GetDecodableImageInfo`): steps down through the codec's native scales, JPEG eighths largest-first. Non-JPEG codecs cannot scale and throw a readable `NotSupportedException`.
- **Magick** (`SkiaCodec.FromMagick`): resizes in place to fit, so it lands closer to the ceiling than Skia can. Callers must be handing over an image they are about to discard.
- The reduction flows `SkiaDecoderOutput`/`MagickCodecAdapter` โ†’ `CodecDecodeResult.DecodeScale` โ†’ `Photo.DecodeScale`, and `AppStatusInfo.Dimension` shows it.
- `Photo.Size` and `ViewerControl.BitmapSize` follow the DECODED size so zoom, selection, crop, and the color picker stay consistent; only `Photo.Metadata.Width/Height` keep the true file dimensions.

### Multi-size icons (ICO/CUR)
- A multi-size `.ico`/`.cur` opens on its largest frame (then deepest color, then first). `MagickCodec` picks it into `PhotoMetadata.DefaultFrameIndex` from the frames it already pinged, and the metadata `Width/Height` describe that frame so the preview and its zoom match the final image.
- **Built-in Magick only.** The index is in Magick's frame order (the ICO directory order), and WIC numbers ICO frames differently, so `Photo.GetDefaultFrameIndex()` returns 0 unless the decode codec is `CodecRegistry.FallbackDecodeCodec`. Plugin-decoded icons open on frame 0; no ABI change.
- `ReadOptions.FrameIndex` is the frame a load opens on: `LoadAsync__` re-points it once metadata is loaded, and both `FromConfig` resets (`ViewPhotoAsync`, the prefetch) pass `GetDefaultFrameIndex()`, because a cached photo is shown with no decode and Save As writes `ReadOptions.FrameIndex`.
- `ApplyDecodeResult` must record the decoded frame in `_frameIndex`: the viewer asks `GetFrameAsync(Math.Max(0, FrameIndex))`, so a missing index decodes and shows frame 0 again.

### 2. Theme/Localization Changes
- Change language: `Core.LanguageChanged?.Invoke()`
- Change theme: `Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...))`
- UI elements auto-refresh via bindings + event subscribers

### 3. Win32 Service Registration
Occurs in `Program.cs` before Avalonia app setup (Linux/Mac have equivalent registrations):
- `FileSearchProvider` (shell enumeration in the view order of File Explorer or a supported third-party file manager; see "Explorer sort order & the foreground shell")
- `ShellProvider` (context menu, file properties, Windows Share API)
- `PrintProvider` (GDI printing through the spooler; Print Pictures as the system dialog; see "Printing")
- `ColorProfileProvider` (Monitor color profile retrieval via Win32 APIs)
- `PreviewProvider` (thumbnail cache via Windows shell)

### 4. Configuration Persistence
- `Config.cs` handles JSON serialization with custom converters
- Converters in `Common/Types/JsonTypeConverters/` (e.g., `JsonStringToHotkeyConverter`, `JsonArrayToZoomFactorConverter`)
- Cached in `AppData\Local\ImageGlass\`, or the startup dir in portable mode (see below)
- Async save via `Config.SaveAsync()`

### Concurrent file writes (`BHelper.WriteAllTextSharedAsync`)
- Every instance writes the **same** `igconfig.json`, and closing a taskbar group closes them all at once, so a plain `File.WriteAllTextAsync` loses the race with a sharing violation. All JSON writes therefore stage into `<path>.<pid>.tmp` and rename over the target, guarded by a per-path `SemaphoreSlim` (two windows of one process) plus a bounded retry.
- **`File.Move(overwrite: true)` is only atomic while no one holds the target for writing.** Held, it degrades into an in-place copy, and a concurrent reader then sees a truncated file. That is why `OpenReadShared` asks for `FileShare.Read | FileShare.Delete`: `Delete` lets the rename proceed under an open handle, and withholding `Write` is what keeps the swap a rename. Measured over ~166k reads: `ReadWrite` gave 878 truncated reads, `Read` gave 0.
- **`File.Replace` is not the fix**, despite being the usual advice: it leaves `~RF*.TMP` litter, and the target name briefly does not exist mid-call, which makes a concurrent reader *and* a concurrent writer fail with `FileNotFoundException`.
- `Config.SaveAsync` never throws: it returns `false` and records `Config.SavingException`, because it is reached from `async void` close handlers and a fire-and-forget save in `SettingsWindow.OnClosing`, where an escaping exception is an unhandled-exception dialog on a window that is already closing. Every path the **user** explicitly triggered still reports the failure (`ModalWindow.ShowSettingsSaveErrorAsync`, or a throw out of `IG_ApplySettings`); only the two close paths stay silent, and `QuickSetupWindow` additionally refuses to restart, since the restart would reload the unwritten file and discard every choice.
- Last writer wins is unchanged and intended: each instance persists its own whole `Config`.

### Portable mode (`ConfigMode`)
- A `.igportable` marker file (`Const.PORTABLE_MARKER_FILE`) in the **startup dir** moves the whole config dir there: `BHelper.ConfigPath` returns `BasePath` instead of `%LocalAppData%\ImageGlass`, so `igconfig.json`, `_cache`, `_logs`, `_plugins`, `_lang`, `_themes` all live next to the exe and the folder can be relocated without losing settings.
- `ConfigMode` only answers **which mode** (`IsPortable`, resolved once per process on first access); `BHelper.ConfigPath` stays the single accessor that turns that into a path, so every existing `ConfigDir()` caller follows automatically. Never expose a second config-path property, and never re-read the marker at a new call site.
- The marker must be resolved before anything touches the config dir, so `App.InitializeAppInstance` forces it (trace mark `InitInstance:portableMode`) ahead of `LicenseService.LoadActive` and `Config.Load`.
- **A marker in a non-writable startup dir is fatal, never a fallback.** `ConfigMode.PortableError` holds the raw exception from a single create-probe (a per-process temp file, never the marker itself), `App.OnFrameworkInitializationCompleted` shows it (real message + `GetExceptionDetails`) and quits before creating the main window, and `Config.SaveAsync` refuses to write. Falling back to `%LocalAppData%` would hide the portable settings behind a second config.
- `ConfigDir()` swallows directory-creation failures on purpose: a read-only config dir must not throw out of a path getter, or startup crashes before it can report the real problem.
- Shipped by the **ZIP packer only** (`__assets/win/script-pack-win-zip.ps1`, `-NoPortable` to omit). Never add it to `__assets/__app`: the MSIX payload folder is read-only, so a packaged build would refuse to start.

### Linux self-update (`LinuxUpdateProvider`)
- Same shared pipeline as the sideload MSIX (Pro, download-now/install-on-click, sha256), with artifacts `linux-<arch>-appimage` and `linux-<arch>-flatpak`. The tarball stays check-only.
- **AppImage: two renames in the image's own folder, never an in-place write.** The running session reads its FUSE mount through the old inode, so a truncating write corrupts it. The path is kept, so launchers and associations still resolve. Supported only when that folder is writable.
- **The relaunch is also a probe.** If the new process exits non-zero within `RELAUNCH_PROBE_MS`, the old image is renamed back. An AppImage runs on the host glibc, so a release that raises the floor would otherwise leave an install that cannot even report the failure. A session started with `--appimage-extract-and-run` relaunches with `APPIMAGE_EXTRACT_AND_RUN=1`, or a FUSE-less host fails a healthy build.
- **Flatpak: bundle installs only.** A bundle's origin (`imageglass-origin`) has no URL, so `flatpak update` answers "Nothing to do."; the new `.flatpak` goes through `flatpak install --bundle` on the host instead. An install from a remote with a URL (Flathub, or the dev install `flatpak-builder --install` makes) stays check-only: a bundle over it keeps that origin but deploys a commit the remote lacks, and the next `flatpak update` can downgrade it.
- Host `flatpak` commands target the installation the app runs from, read from `/.flatpak-info` `app-path` (`--system` for `/var/lib/flatpak`, else `--user` plus `FLATPAK_USER_DIR`), and need `--talk-name=org.freedesktop.Flatpak`. **Never pass `--noninteractive`**: it also suppresses the polkit prompt a system installation needs. Exit code 0 proves nothing (`Nothing to do.` is 0), so success means the deployed commit changed.
- The new instance must start on the host (`flatpak-spawn --host flatpak run`); a child inside the sandbox dies with it. Both formats release `Core.AppInstance` first, or the new process hands its launch to the exiting one, and they clear the pending update before relaunching, or the old instance's close-time save re-arms it.
- The install-while-alive helpers (`RunInstallThenExitAsync`, `RelaunchAsync`, `ForgetInstalledUpdateAsync`, `RunCommandAsync`) live in the base `UpdateProvider`, shared with macOS.

### macOS self-update (`MacUpdateProvider`)
- Same shared pipeline, artifact `mac-<arch>-dmg` (only arm64 is published, so an Intel build stays check-only). Supported only when running as `<name>.app/Contents/MacOS/<exe>` with the bundle and its folder passing `access(W_OK)`: a loose dev binary, a Gatekeeper-translocated copy, the DMG volume itself, or a non-admin's `/Applications` stay check-only.
- **The DMG is mounted privately (`hdiutil attach -nobrowse -readonly -mountpoint $TMPDIR/โ€ฆ`), its root `.app` is `ditto`'d beside the running bundle, then swapped in by two renames**, never copied over: this session keeps reading its files through the renamed bundle, and the path (Dock, Finder, associations) is unchanged. A renamed bundle still validates, so the hidden `.<name>.app.<pid>.new` sibling is checked in place.
- **The staged copy must satisfy the running app's own designated requirement** (`MacCodeSignApi`, Security.framework with the `codesign --verify --deep --strict` flags): same identifier, Developer ID, same team. `HttpClient` downloads carry no quarantine, so Gatekeeper never assesses the new app; this check stands in for it. An ad-hoc dev build has no team to pin and only demands a valid signature. A rejected package is discarded.
- The relaunch starts the new binary directly and doubles as the probe (`RELAUNCH_PROBE_MS`); an early non-zero exit renames the old bundle back. `IsMeteredConnection` is not implemented on macOS (always `false`).

### Photo transitions (`UI/Viewer/Transitions/`)
- **Effects are written only in gl-transitions GLSL** (`vec4 transition(vec2 uv)` with `getFromColor`/`getToColor`/`progress`/`ratio`), built-ins included. SkSL is a backend detail: `GlTransitionsAdapter` translates GLSL to an `SKRuntimeEffect` (GPU through Avalonia's Skia lease, CPU raster without a `GRContext`, AOT-safe since the source is a string). Avalonia plans to move from Skia to Impeller, so never let SkSL leak into an effect file; an Impeller port replaces the adapter, not the effects.
- **The adapter's inputs are a frozen contract**: only add, never rename or remove, or every shipped and user effect breaks. On top of gl-transitions it adds `resolution` (logical px) and `navigationDirection` (1 forward, -1 backward); it is not `direction`, which real gl-transitions effects already declare as a parameter. `PhotoRenderer` sets an input only if the shader declares it, so a new input is backward compatible. The adapter also renames a bare `half`, a plain GLSL name that SkSL reserves as a type; add any other SkSL-only keyword the same way if an effect trips over it. `ToSkSL` normalizes line endings before any regex: git `autocrlf` checks effect files out as CRLF, a multiline `$` matches only before `
`, and the default-value regex once silently skipped every CRLF file, so Wipe/Dissolve/Circle ran with their parameters at 0 (only in published builds, whose payload came from the CRLF working tree).
- No effect is compiled in. An effect is a `*.igtransition.glsl` file (id = the name before that suffix; any other file is ignored); only Fade, Circle, Dissolve, Wipe and Push ship, in `__assets/__app/_transitions`. The rest live in `__assets/transitions` (not packaged) for download from https://imageglass.org/effects, the "Get more effectsโ€ฆ" link. `TransitionEffects` reads the config dir `_transitions` first, then the base dir one, and the first file of an id wins, so a user file overrides a shipped one.
- **After any SkiaSharp upgrade, check every shipped effect by hand** (select it in Settings, then navigate): a broken effect never crashes the app, it silently stops playing, and Settings only shows compile errors, not an effect that compiles but renders wrong.
- Only `AppAPIProvider.ViewByStep__` creates a `TransitionRequest` (skipped while `IsQuickBrowsing`); it travels in `PhotoLoadingOptions.Transition`, and `SetPhotoAsync` must call `BeginTransition` **before** `UnloadPhoto`, because the old frame is recorded as an `SKPicture` whose native refs keep the old images alive after unload.
- The viewer holds the old frame until the new photo has something to draw (`MAX_TRANSITION_HOLD_MS`), which also hides the preview flash. `PhotoRenderer.RenderTransition` then renders both sides (image layer only; the checkerboard stays the viewer's own layer beneath) into per-frame offscreen surfaces and blends them through the shader; per-frame surfaces keep every GPU object inside the render call, never across threads.
- Every path must complete the request (`TransitionRequest.Complete`): the slideshow awaits `Completion` before counting the next interval (`SlideshowProvider.WaitForAdvanceAsync`, bounded by `MAX_ADVANCE_WAIT_MS`). A new navigation snaps the running transition, and one still holding hands its old frame over (`TakeFromFrame`).
- **A mirror (projector) plays the same transition through its own viewport.** `BeginTransition` raises `TransitionStarting` inside the source lock, before `UnloadPhoto`, so each mirror records its own old frame (`CaptureMirrorFrame`) while the old images still exist, then runs its own clock. It plays a **copy** of the request, because only the source's request may tell the slideshow the effect ended. A mirror's `_transition` is guarded by the source's lock (`TransitionLock`), the one its renderer draws under; a second lock there would deadlock against the render thread.

### Full screen & the windowed layout
- Full screen is destructive: it writes the *hidden* toolbar/gallery and clears frameless/window fit straight into `Config`. So the layout to come back to lives in one `WindowLayoutSnapshot` (`AppAPIProvider._preFullScreenLayout`), captured by `MainWindow.CaptureWindowLayout()` **before** anything else in the enter branch runs.
- Invariant: `Config.ShowToolbar` / `ShowGallery` / `EnableFrameless` / `EnableWindowFit` / `EnableMainWindowMaximized` / `MainWindowBounds` always describe the **windowed** layout; `EnableFullScreen` is the one orthogonal flag. `SaveConfigOnClosingAsync` enforces it by persisting `Core.API.PreFullScreenLayout` instead of the live values whenever a session ends in full screen.
- Startup must enter through `IG_ToggleFullScreen(true)`, never a raw `WindowState = FullScreen`: the raw assignment captures nothing (so leaving full screen restored hardcoded defaults) and skips the `ShowToolbarInFullscreen`/`ShowGalleryInFullscreen` hide policy. Full screen is also checked **before** window fit, since both can now be saved as `true`.
- Any new path that reaches `WindowState.FullScreen` must capture the snapshot (see `StartSlideshow__`) and null it out when it leaves (see `StopSlideshow__`), or the close-time save persists a stale layout.
- The close handler reads `RestorableWindowState`, not `WindowState`: closing a *minimized* window would otherwise record `EnableFullScreen`/`EnableMainWindowMaximized` as false.
- Leaving full screen defers the window-fit recompute through `Dispatcher.UIThread.Post`, because `PhControl.IsContentVisible` posts the real visibility change and `ApplyWindowFitMode` measures `Toolbar.Bounds` / `Gallery.Bounds`.

### 5. Photo Caching Strategy
- **Tier 1**: Current photo in viewer (always loaded)
- **Tier 2**: Neighboring photos preloaded via `PhotoManager.RequestCacheAround()`
- **Tier 3**: LRU eviction when memory budget exhausted
- Spiral pattern: right-1, left-1, right-2, left-2, ... to balance browsing directions
- **A decoded photo must never be reachable only through `_cachedIndexes`.** `RunCacheAroundAsync` registers an index the moment `LoadAsync` returns, *before* the cancel check, because a photo that is decoded but untracked escapes both `EstimateCachedMemory` and eviction, and is then never freed for the process lifetime. Rapid navigation cancels a pass on every step, so this is the common path, not the rare one; it once held an entire 80-photo folder (3.6 GB) against a 1000 MB budget.
- **Eviction is authoritative over the whole list, not over the tracked set.** `UnloadPhotosOutside(keepIndexes, excludePhoto)` walks every item and unloads any `PhotoState.Loaded` photo outside the keep set, whatever decoded it, so the pass self-heals regardless of how a photo became loaded. `centerIndex` and `CurrentIndex` are always kept (the viewer owns the shown photo). `ClearCache(excludePhoto)` is the same sweep with an empty keep set, which is what makes a plugin unload safe: an orphaned photo still holding a plugin buffer would otherwise outlive `NativeLibrary.Free`.
- **The budget accumulates from the spiral, not from `_cachedIndexes`.** The pass seeds `usedMemory` with the current photo alone and adds each already-decoded photo as the spiral re-visits it; seeding from the tracked set double-counts every re-adopted photo and breaks the loop early.
- **A `LoadAsync` that returns without raising `Loaded` is a blank viewer, not a no-op.** `SetPhotoAsync` disposes the old image up front, and nothing else re-invalidates, so a silent return leaves the photo black until the user navigates away and back. Hence: loads are serialized per `Photo` by `_loadGate` (the prefetch pass and the viewer both load the same file, and cancelling the one in flight is what produced the silent return); a cache hit dispatches `Loaded` through `DispatchLoadedAsync` instead of just returning; `_cancelEpoch` still drops a load cancelled while queued; `ViewerControl.RecoverIfNothingRenderedAsync` is the backstop for every remaining path, and `PhotoRenderer.ReturnFirstDrawClaim` hands back a first-draw claim whose frame never rendered.
- **`UnloadPhotosOutside(..., keepCurrent: true)` re-reads `CurrentIndex` per item**, because the sweep is long enough for the user to navigate onto an index the keep set was built without. `ClearCache` deliberately leaves it off; it must be able to free every photo before a plugin unload.
- **The pass also color-manages each neighbour ahead of its view** (`Photo.PrepareDisplayFrameAsync`, keyed by a `DisplayColorPass` holding every input of the pass), so a cached view skips the HDR tone map and the monitor-profile conversion; the budget pays for the extra frame (`Photo.DisplayFrameBytes`). The viewer's `TakeDisplayFrameAsync` also **claims** the frame, after which the viewer may free it, so no cache pass reads it again; both run under the photo's `_displayFrameGate`, which `OnDisposing` holds while it frees the bitmap. Any new background reader of `Photo.Bitmap` needs that gate, and multi-frame photos are skipped because a page switch frees the frame. `ViewPhotoAsync` cancels the running pass first, so a navigation's load never competes with the old neighbourhood.

### File search & captured file metadata
- `FileSearchProvider.EnumerateFileEntries` captures `FileInfo` (size, timestamps, attributes) during enumeration into a `FileSearchEntry`, and `PhotoManager.Add(IEnumerable<FileSearchEntry>)` builds `Photo`/`PhotoMetadata` from it, so no per-file re-stat. On Windows those fields come free from `WIN32_FIND_DATA`; on Unix they cost one `stat`, still fewer than before.
- `PhotoMetadata(FileSearchEntry)` deliberately skips `SetFilePath__` (which re-stats). It is a **partial** fill: `IsOutdated()` stays true until a codec loads real image metadata, so the capture *defers* the codec metadata load rather than replacing it.
- Enumeration emits `FileInfo.FullName`, i.e. absolute paths, and `PhotoManager.IndexOf` is an exact-string `ConcurrentDictionary` lookup (`OrdinalIgnoreCase`). Every path entering `StartLoadingFiles` must therefore pass through `BHelper.ResolvePath` or the init photo never resolves to a list index (gallery shows nothing selected, navigation starts from -1).
- `FileSearchingEventArgs.Results` is a materialized `IReadOnlyList`, and filtering/sorting now run on the background thread instead of lazily on the UI thread during `Add`.
- The **Win32** override publishes progress through `Dispatcher.UIThread.Post` with a token re-check *inside* the posted lambda, which is what serializes a stale batch against `PhotoManager.Clear()`. The base provider (Linux/macOS) still invokes `progressFn` directly on the worker, so that race remains open there.

### Explorer sort order & the foreground shell (EggShell)
- **The list has three possible sources, tried in order** by `Win32FileSearchProvider.SearchAsync`: the foreground view captured at launch (`IShellProvider.ForegroundShell`, when `CanUseForegroundShell()` allows it), then, with `EnableExplorerSortOrder`, an opening view that shows the folder (`EggShell.FindOpeningView(dir, includeDesktop: true)`), then plain enumeration sorted by `ImageLoadingOrder`. A view only decides which files are listed and in what order: `FindFiles_WithShell` still enumerates the folder for metadata and drops folders, hidden files and unsupported extensions.
- **`CanUseForegroundShell()` gates the foreground view.** A search result (a `search-ms:` path or a saved `.search-ms`) always uses it, since that list exists nowhere else; a plain folder only when it is the opened file's own folder *and* `EnableExplorerSortOrder` is on. The foreground window is read once in `App.OnFrameworkInitializationCompleted` (and again for a single dropped file), before the main window shows, so a launch from a terminal or script sees that window, not the file manager.
- **ImageGlass only ever sees EggShell's `ShellAppView`**: `GetTabViewPath()` and `GetTabViewItems()` (full paths in view order; `null` when unreadable, which falls back to a plain search of that folder). File Explorer and the desktop are `ExplorerView`; each supported third-party file manager is an internal EggShell class behind `IShellApp`. Support for another file manager goes into EggShell (one `IShellApp` + view registered in `EggShell._shellApps`), never into ImageGlass.
- **Third-party file managers have no folder view in the Explorer window list**, so EggShell reads each one through its own app-specific interface. That lookup is not free: some of them answer through a helper process, which costs tens of milliseconds per query.
- **Views wrap cross-process COM proxies, so use them on the UI thread**: step 1 and the per-folder search post `FindFiles_WithShell` to the UI thread, and the step-2 lookup runs inside `Dispatcher.UIThread.InvokeAsync`.
- **A shell failure must never fault `SearchAsync`.** `StartLoadingFiles` discards its task, so an exception there becomes an empty photo list plus an unobserved-task dialog; a third-party file manager whose shell browser answers `QueryActiveShellView` with `E_NOTIMPL` caused exactly that. The step-2 lookup catches everything and falls back to the plain search, and EggShell's view methods catch every exception: a third-party shell can fail with any HRESULT, and the generated COM stubs map the common ones to non-`COMException` types (`E_NOTIMPL` -> `NotImplementedException`, `E_NOINTERFACE` -> `InvalidCastException`).
- EggShell is a separate repo shipped as the `D2Phap.EggShell-x64` / `-ARM64` packages (pinned in `Directory.Packages.props`), so an EggShell API change compiles in ImageGlass only once the new package is published.

### Gallery thumbnail pipeline (`Photo.LoadThumbnailAsync`)
- Tiers in order, all skipped when `useCache` is false: in-memory `GalleryThumbnail`, `ThumbnailDiskCache`, `IPhotoPreviewProvider.TryGetCachedThumbnailAsync` (platform cache), then `GetThumbnailAsync` (decodes).
- Both cache tiers run **before** `LoadMetadataAsync`; that ordering is the whole point, since neither may read the source file. `ThumbnailDiskCache.TryGetAsync` therefore validates against the `Metadata.FileLastWriteTimeUtc` captured at enumeration, not a fresh stat, so an out-of-band edit is corrected by the file watcher rather than by the cache check.
- `_galleryThumbnailRequestSize` keys the in-memory thumbnail to the size it was produced for, so a DPI or `Config.ThumbnailSize` change reloads instead of serving a stale size.
- **Shrink whatever a cache returns** to `thumbSize` via `SkiaCodec.ScaleDown` before `ToWritableBitmap`. The Windows Shell hands back whole cache tiers (96/256/768/1920), and retaining one costs MBs of gallery memory per photo. Keep the *original* cached image as the size authority for `IsPreviewLargeEnough` and the too-small check.
- **Never nest a process-wide gate inside this call.** The caller already holds a `_thumbnailThrottleLock` slot, so a second semaphore parks threads while they hold a slot and collapses the gallery to one photo at a time.
- **The throttle is sized by the cache budget, not the core count**: `clamp(CacheMaxMemoryInMb / MB_PER_THUMBNAIL_SLOT, 4, clamp(ProcessorCount-1, 4, 16))`, lazily so it reads the loaded config. A cache miss decodes the full image before shrinking it, so each slot can transiently hold one full-resolution bitmap; CPU-only sizing let 16 concurrent 4K decodes outweigh the entire photo-cache budget on a many-core machine.
- `TryGetCachedThumbnailAsync` must not decode: